diff --git a/ghost/ghost/src/core/activitypub/tell-the-world.service.test.ts b/ghost/ghost/src/core/activitypub/tell-the-world.service.test.ts new file mode 100644 index 0000000000..aec7cc41b5 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/tell-the-world.service.test.ts @@ -0,0 +1,51 @@ +import assert from 'assert'; +import {Activity} from './activity.entity'; +import {Actor} from './actor.entity'; +import {TheWorld} from './tell-the-world.service'; +import {URI} from './uri.object'; +import nock from 'nock'; + +describe('TheWorld', function () { + describe('deliverActivity', function () { + beforeEach(function () { + nock.disableNetConnect(); + }); + afterEach(function () { + nock.enableNetConnect(); + }); + it('Can deliver the activity to the inbox of the desired recipient', async function () { + const service = new TheWorld(new URL('https://base.com')); + + const actor = Actor.create({ + username: 'Testing' + }); + + const toFollow = new URI('https://main.ghost.org/activitypub/actor/deadbeefdeadbeefdeadbeef'); + + const activity = new Activity({ + type: 'Follow', + activity: null, + actor: actor.actorId, + object: { + id: toFollow + }, + to: toFollow + }); + + const actorFetch = nock('https://main.ghost.org') + .get('/activitypub/actor/deadbeefdeadbeefdeadbeef') + .reply(200, { + inbox: 'https://main.ghost.org/activitypub/inbox/deadbeefdeadbeefdeadbeef' + }); + + const activityDelivery = nock('https://main.ghost.org') + .post('/activitypub/inbox/deadbeefdeadbeefdeadbeef') + .reply(201, {}); + + await service.deliverActivity(activity, actor); + + assert(actorFetch.isDone(), 'Expected actor to be fetched'); + assert(activityDelivery.isDone(), 'Expected activity to be delivered'); + }); + }); +}); diff --git a/ghost/ghost/src/core/activitypub/tell-the-world.service.ts b/ghost/ghost/src/core/activitypub/tell-the-world.service.ts new file mode 100644 index 0000000000..25a7f4c58f --- /dev/null +++ b/ghost/ghost/src/core/activitypub/tell-the-world.service.ts @@ -0,0 +1,61 @@ +import {Inject} from '@nestjs/common'; +import {Activity} from './activity.entity'; +import {Actor} from './actor.entity'; + +export class TheWorld { + constructor( + @Inject('ActivityPubBaseURL') private readonly url: URL + ) {} + + async deliverActivity(activity: Activity, actor: Actor): Promise { + const recipients = await this.getRecipients(activity); + for (const recipient of recipients) { + const data = await this.fetchForActor(recipient.href, actor); + if ('inbox' in data && typeof data.inbox === 'string') { + const inbox = new URL(data.inbox); + await this.sendActivity(inbox, activity, actor); + } + } + } + + private async sendActivity(to: URL, activity: Activity, from: Actor) { + const request = new Request(to.href, { + method: 'POST', + headers: { + 'Content-Type': 'application/ld+json' + }, + body: JSON.stringify(activity.getJSONLD(this.url)) + }); + const signedRequest = await from.sign(request, this.url); + await fetch(signedRequest); + } + + private async getRecipients(activity: Activity): Promise{ + const json = activity.getJSONLD(this.url); + const recipients = []; + if (json.to) { + recipients.push(new URL(json.to)); + } + return recipients; + } + + private async fetchForActor(uri: string, actor: Actor) { + const request = new Request(uri, { + headers: { + Accept: 'application/ld+json' + } + }); + + const signedRequest = await actor.sign(request, this.url); + + const result = await fetch(signedRequest); + + const json = await result.json(); + + if (typeof json !== 'object' || json === null) { + throw new Error('Could not read data'); + } + + return json; + } +}