From df1774d8e93dbb0f572e0c0b6d7d68a11169a758 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Wed, 15 May 2024 12:23:45 +0700 Subject: [PATCH] Supported Ghost2Ghost Follow/Accept ref https://linear.app/tryghost/issue/MOM-108 Apologies to my future self and maintainers if you come across this commit. This is a bit of a mega commit because we need to cut corners somewhere and it came down to commit atomicity or tests/code quality. The main changes here are a bunch of tests, as well as some scaffolding for Inbox handling of Activities and delivery of Activities. The structure is not final at all - and we have logic split across services which isn't ideal - but thsi will do for now as we play around and discover the structure through building. --- .../src/common/libraries.defintitions.ts | 1 + .../src/common/types/settings-cache.type.ts | 2 +- .../core/activitypub/activity.entity.test.ts | 55 ++++++ .../src/core/activitypub/activity.entity.ts | 88 ++++++++++ .../src/core/activitypub/activity.event.ts | 10 +- .../src/core/activitypub/activity.object.ts | 27 --- .../core/activitypub/activity.repository.ts | 6 + .../core/activitypub/activity.service.test.ts | 129 ++++++++++++++ .../activitypub/activitypub.service.test.ts | 115 +++++++++++++ .../core/activitypub/activitypub.service.ts | 44 +++++ .../src/core/activitypub/actor.entity.test.ts | 157 ++++++++++++++++-- .../src/core/activitypub/actor.entity.ts | 113 +++++++++++-- .../src/core/activitypub/article.object.ts | 5 + .../core/activitypub/inbox.service.test.ts | 75 +++++++++ .../src/core/activitypub/inbox.service.ts | 25 +++ .../core/activitypub/jsonld.service.test.ts | 122 ++++++++++++++ ghost/ghost/src/core/activitypub/types.ts | 18 +- .../ghost/src/core/activitypub/uri.object.ts | 11 ++ .../src/core/activitypub/webfinger.service.ts | 2 + .../activity.repository.in-memory.ts | 18 ++ .../in-memory/actor.repository.in-memory.ts | 3 +- .../activitypub.controller.test.ts | 36 ++++ .../controllers/activitypub.controller.ts | 39 +++++ .../activitypub.controller.test.ts | 120 +++++++++++++ .../controllers/activitypub.controller.ts | 48 +++++- .../controllers/webfinger.controller.test.ts | 38 +++++ .../src/listeners/activity.listener.test.ts | 50 ++++++ .../ghost/src/listeners/activity.listener.ts | 15 ++ .../nestjs/filters/global-exception.filter.ts | 6 +- .../src/nestjs/modules/activitypub.module.ts | 21 ++- .../src/nestjs/modules/admin-api.module.ts | 12 +- 31 files changed, 1345 insertions(+), 66 deletions(-) create mode 100644 ghost/ghost/src/core/activitypub/activity.entity.test.ts create mode 100644 ghost/ghost/src/core/activitypub/activity.entity.ts delete mode 100644 ghost/ghost/src/core/activitypub/activity.object.ts create mode 100644 ghost/ghost/src/core/activitypub/activity.repository.ts create mode 100644 ghost/ghost/src/core/activitypub/activity.service.test.ts create mode 100644 ghost/ghost/src/core/activitypub/activitypub.service.test.ts create mode 100644 ghost/ghost/src/core/activitypub/activitypub.service.ts create mode 100644 ghost/ghost/src/core/activitypub/inbox.service.test.ts create mode 100644 ghost/ghost/src/core/activitypub/inbox.service.ts create mode 100644 ghost/ghost/src/core/activitypub/jsonld.service.test.ts create mode 100644 ghost/ghost/src/core/activitypub/uri.object.ts create mode 100644 ghost/ghost/src/db/in-memory/activity.repository.in-memory.ts create mode 100644 ghost/ghost/src/http/admin/controllers/activitypub.controller.test.ts create mode 100644 ghost/ghost/src/http/admin/controllers/activitypub.controller.ts create mode 100644 ghost/ghost/src/http/frontend/controllers/activitypub.controller.test.ts create mode 100644 ghost/ghost/src/http/frontend/controllers/webfinger.controller.test.ts create mode 100644 ghost/ghost/src/listeners/activity.listener.test.ts create mode 100644 ghost/ghost/src/listeners/activity.listener.ts diff --git a/ghost/ghost/src/common/libraries.defintitions.ts b/ghost/ghost/src/common/libraries.defintitions.ts index afc8627392..b0a9045a2a 100644 --- a/ghost/ghost/src/common/libraries.defintitions.ts +++ b/ghost/ghost/src/common/libraries.defintitions.ts @@ -1 +1,2 @@ declare module '@tryghost/errors'; +declare module '@tryghost/domain-events'; diff --git a/ghost/ghost/src/common/types/settings-cache.type.ts b/ghost/ghost/src/common/types/settings-cache.type.ts index fe028d2e56..cfa10f1a47 100644 --- a/ghost/ghost/src/common/types/settings-cache.type.ts +++ b/ghost/ghost/src/common/types/settings-cache.type.ts @@ -1,7 +1,7 @@ export type Settings = { ghost_public_key: string; ghost_private_key: string; - testing: boolean; + title: string; }; export interface SettingsCache { diff --git a/ghost/ghost/src/core/activitypub/activity.entity.test.ts b/ghost/ghost/src/core/activitypub/activity.entity.test.ts new file mode 100644 index 0000000000..404c4a77af --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activity.entity.test.ts @@ -0,0 +1,55 @@ +import assert from 'assert'; +import {Activity} from './activity.entity'; +import {URI} from './uri.object'; + +describe('Activity', function () { + describe('fromJSONLD', function () { + it('Can construct an entity from JSONLD with various id types', async function () { + const input = { + id: new URI('https://site.com/activity'), + type: 'Follow', + actor: { + id: 'https://site.com/actor' + }, + object: 'https://site.com/object' + }; + + const activity = Activity.fromJSONLD(input); + assert(activity); + }); + + it('Will throw for unknown types', async function () { + const input = { + id: new URI('https://site.com/activity'), + type: 'Unknown', + actor: { + id: 'https://site.com/actor' + }, + object: 'https://site.com/object' + }; + + assert.throws(() => { + Activity.fromJSONLD(input); + }); + }); + + it('Will throw for missing actor,object or type', async function () { + const input = { + id: new URI('https://site.com/activity'), + type: 'Unknown', + actor: { + id: 'https://site.com/actor' + }, + object: 'https://site.com/object' + }; + + for (const prop of ['actor', 'object', 'type']) { + const modifiedInput = Object.create(input); + delete modifiedInput[prop]; + assert.throws(() => { + Activity.fromJSONLD(modifiedInput); + }); + } + }); + }); +}); diff --git a/ghost/ghost/src/core/activitypub/activity.entity.ts b/ghost/ghost/src/core/activitypub/activity.entity.ts new file mode 100644 index 0000000000..86c2e28381 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activity.entity.ts @@ -0,0 +1,88 @@ +import {Entity} from '../../common/entity.base'; +import {ActivityPub} from './types'; +import {URI} from './uri.object'; + +type ActivityData = { + activity: URI | null; + type: ActivityPub.ActivityType; + actor: URI; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + object: {id: URI, [x: string]: any}; + to: URI | null; +} + +function getURI(input: unknown) { + if (input instanceof URI) { + return input; + } + if (typeof input === 'string') { + return new URI(input); + } + if (typeof input !== 'object' || input === null) { + throw new Error(`Could not create URI from ${JSON.stringify(input)}`); + } + if ('id' in input && typeof input.id === 'string') { + return new URI(input.id); + } + throw new Error(`Could not create URI from ${JSON.stringify(input)}`); +} + +function checkKeys(keys: T[], obj: object): Record { + for (const key of keys) { + if (!(key in obj)) { + throw new Error(`Missing key ${key}`); + } + } + return obj as Record; +} + +export class Activity extends Entity { + get type() { + return this.attr.type; + } + + getObject() { + return this.attr.object; + } + + get actorId() { + return this.attr.actor; + } + + get objectId() { + return this.attr.object.id; + } + + get activityId() { + return this.attr.activity; + } + + getJSONLD(url: URL): ActivityPub.Activity { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + id: this.activityId?.getValue(url) || null, + type: this.attr.type, + actor: { + type: 'Person', + id: this.actorId.getValue(url), + username: `@index@${this.actorId.hostname}` + }, + object: this.objectId.getValue(url), + to: this.attr.to?.getValue(url) || null + }; + } + + static fromJSONLD(json: object) { + const parsed = checkKeys(['type', 'actor', 'object'], json); + if (typeof parsed.type !== 'string' || !['Create', 'Follow', 'Accept'].includes(parsed.type)) { + throw new Error(`Unknown type ${parsed.type}`); + } + return new Activity({ + activity: 'id' in json ? getURI(json.id) : null, + type: parsed.type as ActivityPub.ActivityType, + actor: getURI(parsed.actor), + object: {id: getURI(parsed.object)}, + to: 'to' in json ? getURI(json.to) : null + }); + } +} diff --git a/ghost/ghost/src/core/activitypub/activity.event.ts b/ghost/ghost/src/core/activitypub/activity.event.ts index b9232dfd46..47a370efaf 100644 --- a/ghost/ghost/src/core/activitypub/activity.event.ts +++ b/ghost/ghost/src/core/activitypub/activity.event.ts @@ -1,12 +1,14 @@ import {BaseEvent} from '../../common/event.base'; -import {Activity} from './activity.object'; +import {Activity} from './activity.entity'; +import {Actor} from './actor.entity'; type ActivityEventData = { - activity: Activity + activity: Activity, + actor: Actor } export class ActivityEvent extends BaseEvent { - static create(activity: Activity) { - return new ActivityEvent({activity}); + static create(activity: Activity, actor: Actor) { + return new ActivityEvent({activity, actor}); } } diff --git a/ghost/ghost/src/core/activitypub/activity.object.ts b/ghost/ghost/src/core/activitypub/activity.object.ts deleted file mode 100644 index 8dbc2b8265..0000000000 --- a/ghost/ghost/src/core/activitypub/activity.object.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {Actor} from './actor.entity'; -import {Article} from './article.object'; -import {ActivityPub} from './types'; - -type ActivityData = { - type: ActivityPub.ActivityType; - actor: Actor; - object: Article -} - -export class Activity { - constructor(private readonly attr: ActivityData) {} - - getJSONLD(url: URL): ActivityPub.Activity { - const actor = this.attr.actor.getJSONLD(url); - const object = this.attr.object.getJSONLD(url); - - return { - '@context': 'https://www.w3.org/ns/activitystreams', - id: null, - type: 'Create', - summary: `${actor.name} created an ${object.type.toLowerCase()}.`, - actor: actor.id, - object: object.id - }; - } -} diff --git a/ghost/ghost/src/core/activitypub/activity.repository.ts b/ghost/ghost/src/core/activitypub/activity.repository.ts new file mode 100644 index 0000000000..cc83545f80 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activity.repository.ts @@ -0,0 +1,6 @@ +import {Activity} from './activity.entity'; + +export interface ActivityRepository { + getOne(id: URL): Promise + save(activity: Activity): Promise +} diff --git a/ghost/ghost/src/core/activitypub/activity.service.test.ts b/ghost/ghost/src/core/activitypub/activity.service.test.ts new file mode 100644 index 0000000000..accc89da85 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activity.service.test.ts @@ -0,0 +1,129 @@ +import ObjectID from 'bson-objectid'; +import {ActivityService} from './activity.service'; +import {Actor} from './actor.entity'; +import assert from 'assert'; + +describe('ActivityService', function () { + describe('#createArticleForPost', function () { + it('Adds a Create activity for an Article Object to the default actors Outbox', async function () { + const actor = Actor.create({username: 'testing'}); + const mockActorRepository = { + async getOne() { + return actor; + }, + async save() {} + }; + const mockPostRepository = { + async getOne(id: ObjectID) { + return { + id: id, + title: 'Testing', + slug: 'testing', + html: '

Testing stuff..

', + visibility: 'public' + }; + } + }; + const service = new ActivityService( + mockActorRepository, + mockPostRepository + ); + + const postId = new ObjectID(); + + await service.createArticleForPost(postId); + + const found = actor.outbox.find(activity => activity.type === 'Create'); + + assert.ok(found); + }); + + it('Does not add a Create activity for non public posts', async function () { + const actor = Actor.create({username: 'testing'}); + const mockActorRepository = { + async getOne() { + return actor; + }, + async save() {} + }; + const mockPostRepository = { + async getOne(id: ObjectID) { + return { + id: id, + title: 'Testing', + slug: 'testing', + html: '

Testing stuff..

', + visibility: 'private' + }; + } + }; + const service = new ActivityService( + mockActorRepository, + mockPostRepository + ); + + const postId = new ObjectID(); + + await service.createArticleForPost(postId); + + const found = actor.outbox.find(activity => activity.type === 'Create'); + + assert.ok(!found); + }); + + it('Throws when post is not found', async function () { + const actor = Actor.create({username: 'testing'}); + const mockActorRepository = { + async getOne() { + return actor; + }, + async save() {} + }; + const mockPostRepository = { + async getOne() { + return null; + } + }; + const service = new ActivityService( + mockActorRepository, + mockPostRepository + ); + + const postId = new ObjectID(); + + await assert.rejects(async () => { + await service.createArticleForPost(postId); + }, /Post not found/); + }); + + it('Throws when actor is not found', async function () { + const mockActorRepository = { + async getOne() { + return null; + }, + async save() {} + }; + const mockPostRepository = { + async getOne(id: ObjectID) { + return { + id: id, + title: 'Testing', + slug: 'testing', + html: '

Testing stuff..

', + visibility: 'private' + }; + } + }; + const service = new ActivityService( + mockActorRepository, + mockPostRepository + ); + + const postId = new ObjectID(); + + await assert.rejects(async () => { + await service.createArticleForPost(postId); + }, /Actor not found/); + }); + }); +}); diff --git a/ghost/ghost/src/core/activitypub/activitypub.service.test.ts b/ghost/ghost/src/core/activitypub/activitypub.service.test.ts new file mode 100644 index 0000000000..e7e715d4c6 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activitypub.service.test.ts @@ -0,0 +1,115 @@ +import assert from 'assert'; +import {ActivityPubService} from './activitypub.service'; +import {ActorRepository} from './actor.repository'; +import {WebFingerService} from './webfinger.service'; +import {Actor} from './actor.entity'; +import Sinon from 'sinon'; +import {URI} from './uri.object'; + +describe('ActivityPubService', function () { + describe('#follow', function () { + it('Throws if it cannot find the default actor', async function () { + const mockWebFingerService: WebFingerService = { + async finger() { + return {}; + } + } as unknown as WebFingerService; + const mockActorRepository = { + async getOne() { + return null; + } + } as unknown as ActorRepository; + + const service = new ActivityPubService( + mockWebFingerService, + mockActorRepository + ); + + await assert.rejects(async () => { + await service.follow('@egg@ghost.org'); + }, /Could not find default actor/); + }); + + it('Follows the actor and saves', async function () { + const mockWebFingerService: WebFingerService = { + finger: Sinon.stub().resolves({ + id: 'https://example.com/user-to-follow' + }) + } as unknown as WebFingerService; + const actor = Actor.create({username: 'testing'}); + const mockActorRepository = { + getOne: Sinon.stub().resolves(actor), + save: Sinon.stub().resolves() + }; + + const service = new ActivityPubService( + mockWebFingerService, + mockActorRepository + ); + + const followStub = Sinon.stub(actor, 'follow'); + await service.follow('@egg@ghost.org'); + + assert(followStub.calledWithMatch({ + id: new URI('https://example.com/user-to-follow'), + username: '@egg@ghost.org' + })); + + assert(mockActorRepository.save.calledWith(actor)); + }); + }); + + describe('#getFollowing', function () { + it('Throws if the default actor is not found', async function () { + const mockWebFingerService: WebFingerService = { + finger: Sinon.stub().resolves({ + id: 'https://example.com/user-to-follow' + }) + } as unknown as WebFingerService; + const mockActorRepository = { + getOne: Sinon.stub().resolves(null), + save: Sinon.stub().resolves() + }; + + const service = new ActivityPubService( + mockWebFingerService, + mockActorRepository + ); + + await assert.rejects(async () => { + await service.getFollowing(); + }, /Could not find default actor/); + }); + + it('Returns a list of the default actors following', async function () { + const mockWebFingerService: WebFingerService = { + finger: Sinon.stub().resolves({ + id: 'https://example.com/user-to-follow' + }) + } as unknown as WebFingerService; + const actor = Actor.create({ + username: 'testing', + following: [{ + id: new URI('https://site.com/user'), + username: '@person@site.com' + }] + }); + const mockActorRepository = { + getOne: Sinon.stub().resolves(actor), + save: Sinon.stub().resolves() + }; + + const service = new ActivityPubService( + mockWebFingerService, + mockActorRepository + ); + + const result = await service.getFollowing(); + + assert.deepEqual(result, [{ + id: 'https://site.com/user', + username: '@person@site.com' + }]); + }); + }); +}); diff --git a/ghost/ghost/src/core/activitypub/activitypub.service.ts b/ghost/ghost/src/core/activitypub/activitypub.service.ts new file mode 100644 index 0000000000..3b28d37cec --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activitypub.service.ts @@ -0,0 +1,44 @@ +import {Inject} from '@nestjs/common'; +import {ActorRepository} from './actor.repository'; +import {WebFingerService} from './webfinger.service'; +import {URI} from './uri.object'; + +export class ActivityPubService { + constructor( + private readonly webfinger: WebFingerService, + @Inject('ActorRepository') private readonly actors: ActorRepository + ) {} + + async follow(username: string): Promise { + const json = await this.webfinger.finger(username); + const actor = await this.actors.getOne('index'); + + if (!actor) { + throw new Error('Could not find default actor'); + } + + const actorToFollow = { + id: new URI(json.id), + username + }; + + actor.follow(actorToFollow); + + await this.actors.save(actor); + } + + async getFollowing(): Promise<{id: string, username?: string}[]> { + const actor = await this.actors.getOne('index'); + + if (!actor) { + throw new Error('Could not find default actor'); + } + + return actor.following.map((x) => { + return { + id: x.id.href, + username: x.username + }; + }); + } +} diff --git a/ghost/ghost/src/core/activitypub/actor.entity.test.ts b/ghost/ghost/src/core/activitypub/actor.entity.test.ts index e3f97fb6b0..ab4564ef4c 100644 --- a/ghost/ghost/src/core/activitypub/actor.entity.test.ts +++ b/ghost/ghost/src/core/activitypub/actor.entity.test.ts @@ -2,23 +2,58 @@ import crypto from 'node:crypto'; import {Actor} from './actor.entity'; import {HTTPSignature} from './http-signature.service'; import assert from 'node:assert'; +import {URI} from './uri.object'; +import {ActivityEvent} from './activity.event'; +import {Activity} from './activity.entity'; +import {Article} from './article.object'; +import ObjectID from 'bson-objectid'; describe('Actor', function () { + describe('getters', function () { + describe('displayName', function () { + it('Uses displayName, but falls back to username', function () { + const hasDisplayName = Actor.create({ + username: 'username', + displayName: 'displayName' + }); + + const doesnaeHaveDisplayName = Actor.create({ + username: 'username' + }); + + assert.equal(hasDisplayName.displayName, 'displayName'); + assert.equal(doesnaeHaveDisplayName.displayName, 'username'); + }); + }); + }); + + describe('#createArticle', function () { + it('Adds an activity to the outbox', function () { + const actor = Actor.create({username: 'username'}); + + const article = Article.fromPost({ + id: new ObjectID(), + title: 'Post Title', + slug: 'post-slug', + html: '

Hello world

', + visibility: 'public' + }); + + actor.createArticle(article); + + const found = actor.outbox.find((value) => { + return value.type === 'Create'; + }); + + assert.ok(found); + }); + }); + describe('#sign', function () { it('returns a request with a valid Signature header', async function () { - const keypair = crypto.generateKeyPairSync('rsa', { - modulusLength: 512 - }); const baseUrl = new URL('https://example.com/ap'); const actor = Actor.create({ - username: 'Testing', - outbox: [], - publicKey: keypair.publicKey - .export({type: 'pkcs1', format: 'pem'}) - .toString(), - privateKey: keypair.privateKey - .export({type: 'pkcs1', format: 'pem'}) - .toString() + username: 'Testing' }); const url = new URL('https://some-server.com/users/username/inbox'); @@ -54,4 +89,104 @@ describe('Actor', function () { assert.equal(actual, expected, 'The signature should have been valid'); }); }); + + describe('#follow', function () { + it('Creates a Follow activity', async function () { + const actor = Actor.create({username: 'TestingFollow'}); + + const actorToFollow = { + id: new URI('https://activitypub.server/actor'), + username: '@user@domain' + }; + + actor.follow(actorToFollow); + + Actor.getActivitiesToSave(actor, function (activities) { + const followActivity = activities.find(activity => activity.type === 'Follow'); + + assert.equal(followActivity?.objectId.href, actorToFollow.id.href); + }); + + Actor.getEventsToDispatch(actor, function (events) { + const followActivityEvent: ActivityEvent = (events.find(event => (event as ActivityEvent).data.activity?.type === 'Follow') as ActivityEvent); + + assert.equal(followActivityEvent.data.activity.objectId.href, actorToFollow.id.href); + }); + }); + }); + + describe('#postToInbox', function () { + it('Handles Follow activities', async function () { + const actor = Actor.create({username: 'TestingPostToInbox'}); + + const newFollower = new URI('https://activitypub.server/actor'); + + const followActivity = new Activity({ + activity: new URI(`https://activitypub.server/activity`), + type: 'Follow', + actor: newFollower, + object: {id: actor.actorId}, + to: actor.actorId + }); + + await actor.postToInbox(followActivity); + + assert(actor.followers.find(follower => follower.id.href === newFollower.href)); + }); + + it('Throws if the Follow activity is anonymous', async function () { + const actor = Actor.create({username: 'TestingPostToInbox'}); + + const newFollower = new URI('https://activitypub.server/actor'); + + const followActivity = new Activity({ + activity: null, + type: 'Follow', + actor: newFollower, + object: {id: actor.actorId}, + to: actor.actorId + }); + + let error: unknown = null; + try { + await actor.postToInbox(followActivity); + } catch (err) { + error = err; + } + + assert.ok(error); + }); + + it('Handles Accept activities', async function () { + const actor = Actor.create({username: 'TestingPostToInbox'}); + + const newFollower = new URI('https://activitypub.server/actor'); + + const activity = new Activity({ + activity: null, + type: 'Accept', + actor: newFollower, + object: { + id: newFollower + }, + to: actor.actorId + }); + + await actor.postToInbox(activity); + + assert(actor.following.find(follower => follower.id.href === newFollower.href)); + }); + }); + + describe('#toJSONLD', function () { + it('Returns valid JSONLD', async function () { + const actor = Actor.create({username: 'TestingJSONLD'}); + + const baseUrl = new URL('https://example.com'); + + const jsonld = actor.getJSONLD(baseUrl); + + assert.ok(jsonld); + }); + }); }); diff --git a/ghost/ghost/src/core/activitypub/actor.entity.ts b/ghost/ghost/src/core/activitypub/actor.entity.ts index 46dcfd130c..35ecb98175 100644 --- a/ghost/ghost/src/core/activitypub/actor.entity.ts +++ b/ghost/ghost/src/core/activitypub/actor.entity.ts @@ -2,16 +2,21 @@ import crypto from 'crypto'; import ObjectID from 'bson-objectid'; import {Entity} from '../../common/entity.base'; import {ActivityPub} from './types'; -import {Activity} from './activity.object'; +import {Activity} from './activity.entity'; import {Article} from './article.object'; import {ActivityEvent} from './activity.event'; import {HTTPSignature} from './http-signature.service'; +import {URI} from './uri.object'; type ActorData = { username: string; + displayName?: string; publicKey: string; privateKey: string; outbox: Activity[]; + inbox: Activity[]; + following: {id: URI, username?: string;}[]; + followers: {id: URI;}[]; }; type CreateActorData = ActorData & { @@ -23,16 +28,83 @@ export class Actor extends Entity { return this.attr.username; } + get displayName() { + if (this.attr.displayName) { + return this.attr.displayName; + } + return this.username; + } + get outbox() { return this.attr.outbox; } + get following() { + return this.attr.following; + } + + get followers() { + return this.attr.followers; + } + + get actorId() { + return new URI(`actor/${this.id.toHexString()}`); + } + async sign(request: Request, baseUrl: URL): Promise { const keyId = new URL(this.getJSONLD(baseUrl).publicKey.id); const key = crypto.createPrivateKey(this.attr.privateKey); return HTTPSignature.sign(request, keyId, key); } + public readonly publicAccount = true; + + async postToInbox(activity: Activity) { + this.attr.inbox.unshift(activity); + if (activity.type === 'Follow') { + if (this.publicAccount) { + await this.acceptFollow(activity); + return; + } + } + if (activity.type === 'Accept') { + // TODO: Check that the Accept is for a real Follow activity + this.attr.following.push(activity.getObject()); + } + } + + async follow(actor: {id: URI, username: string;}) { + const activity = new Activity({ + activity: new URI(`activity/${(new ObjectID).toHexString()}`), + type: 'Follow', + actor: this.actorId, + object: actor, + to: actor.id + }); + this.doActivity(activity); + } + + async acceptFollow(activity: Activity) { + if (!activity.activityId) { + throw new Error('Cannot accept Follow of anonymous activity'); + } + this.attr.followers.push({id: activity.actorId}); + const accept = new Activity({ + activity: new URI(`activity/${(new ObjectID).toHexString()}`), + type: 'Accept', + to: activity.actorId, + actor: this.actorId, + object: {id: activity.activityId} + }); + this.doActivity(accept); + } + + private doActivity(activity: Activity) { + this.attr.outbox.push(activity); + this.activities.push(activity); + this.addEvent(ActivityEvent.create(activity, this)); + } + private activities: Activity[] = []; static getActivitiesToSave(actor: Actor, fn: (activities: Activity[]) => void) { @@ -43,13 +115,13 @@ export class Actor extends Entity { createArticle(article: Article) { const activity = new Activity({ + activity: new URI(`activity/${new ObjectID().toHexString()}`), + to: new URI(`https://www.w3.org/ns/activitystreams#Public`), type: 'Create', - actor: this, - object: article + actor: this.actorId, + object: {id: article.objectId} }); - this.attr.outbox.push(activity); - this.activities.push(activity); - this.addEvent(ActivityEvent.create(activity)); + this.doActivity(activity); } getJSONLD(url: URL): ActivityPub.Actor & ActivityPub.RootObject { @@ -95,7 +167,7 @@ export class Actor extends Entity { ], type: 'Person', id: actor.href, - name: 'Display Name', // Full name + name: this.displayName, // Full name preferredUsername: this.username, // Username summary: 'The bio for the actor', // Bio url: actor.href, // Profile URL @@ -125,13 +197,32 @@ export class Actor extends Entity { }; } - static create(data: CreateActorData) { + static create(data: Partial & {username: string;}) { + let publicKey = data.publicKey; + let privateKey = data.privateKey; + + if (!publicKey || !privateKey) { + const keypair = crypto.generateKeyPairSync('rsa', { + modulusLength: 512 + }); + publicKey = keypair.publicKey + .export({type: 'pkcs1', format: 'pem'}) + .toString(); + privateKey = keypair.privateKey + .export({type: 'pkcs1', format: 'pem'}) + .toString(); + } + return new Actor({ id: data.id instanceof ObjectID ? data.id : undefined, username: data.username, - publicKey: data.publicKey, - privateKey: data.privateKey, - outbox: data.outbox + displayName: data.displayName, + publicKey: publicKey, + privateKey: privateKey, + outbox: data.outbox || [], + inbox: data.inbox || [], + followers: data.followers || [], + following: data.following || [] }); } } diff --git a/ghost/ghost/src/core/activitypub/article.object.ts b/ghost/ghost/src/core/activitypub/article.object.ts index 3ea3045e49..5b6667f2a2 100644 --- a/ghost/ghost/src/core/activitypub/article.object.ts +++ b/ghost/ghost/src/core/activitypub/article.object.ts @@ -1,6 +1,7 @@ import ObjectID from 'bson-objectid'; import {ActivityPub} from './types'; import {Post} from './post.repository'; +import {URI} from './uri.object'; type ArticleData = { id: ObjectID @@ -12,6 +13,10 @@ type ArticleData = { export class Article { constructor(private readonly attr: ArticleData) {} + get objectId() { + return new URI(`article/${this.attr.id.toHexString()}`); + } + getJSONLD(url: URL): ActivityPub.Article & ActivityPub.RootObject { if (!url.href.endsWith('/')) { url.href += '/'; diff --git a/ghost/ghost/src/core/activitypub/inbox.service.test.ts b/ghost/ghost/src/core/activitypub/inbox.service.test.ts new file mode 100644 index 0000000000..972b8f28d3 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/inbox.service.test.ts @@ -0,0 +1,75 @@ +import Sinon from 'sinon'; +import {InboxService} from './inbox.service'; +import assert from 'assert'; +import ObjectID from 'bson-objectid'; +import {Activity} from './activity.entity'; +import {URI} from './uri.object'; +import {Actor} from './actor.entity'; + +describe('InboxService', function () { + describe('#post', function () { + it('Throws if it cannot find the actor', async function () { + const mockActorRepository = { + getOne: Sinon.stub().resolves(null), + save: Sinon.stub().rejects() + }; + const mockActivityRepository = { + getOne: Sinon.stub().resolves(null), + save: Sinon.stub().rejects() + }; + const service = new InboxService( + mockActorRepository, + mockActivityRepository + ); + + const owner = new ObjectID(); + const activity = new Activity({ + type: 'Follow', + activity: null, + object: { + id: new URI('https://whatever.com') + }, + actor: new URI('https://blak.com'), + to: new URI('https://whatever.com') + }); + + await assert.rejects(async () => { + await service.post(owner, activity); + }, /Not Found/); + }); + it('Posts the activity to the actors inbox, saves the actor & the activity', async function () { + const actor = Actor.create({username: 'username'}); + const mockActorRepository = { + getOne: Sinon.stub().resolves(actor), + save: Sinon.stub().resolves() + }; + const mockActivityRepository = { + getOne: Sinon.stub().resolves(null), + save: Sinon.stub().resolves() + }; + const service = new InboxService( + mockActorRepository, + mockActivityRepository + ); + + const postToInboxStub = Sinon.stub(actor, 'postToInbox'); + + const owner = new ObjectID(); + const activity = new Activity({ + type: 'Follow', + activity: null, + object: { + id: new URI('https://whatever.com') + }, + actor: new URI('https://blak.com'), + to: new URI('https://whatever.com') + }); + + await service.post(owner, activity); + + assert(postToInboxStub.calledWith(activity)); + assert(mockActorRepository.save.calledWith(actor)); + assert(mockActivityRepository.save.calledWith(activity)); + }); + }); +}); diff --git a/ghost/ghost/src/core/activitypub/inbox.service.ts b/ghost/ghost/src/core/activitypub/inbox.service.ts new file mode 100644 index 0000000000..c248780cd7 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/inbox.service.ts @@ -0,0 +1,25 @@ +import {Inject} from '@nestjs/common'; +import {Activity} from './activity.entity'; +import {ActorRepository} from './actor.repository'; +import {ActivityRepository} from './activity.repository'; +import ObjectID from 'bson-objectid'; + +export class InboxService { + constructor( + @Inject('ActorRepository') private readonly actors: ActorRepository, + @Inject('ActivityRepository') private readonly activities: ActivityRepository + ) {} + + async post(owner: ObjectID, activity: Activity) { + const actor = await this.actors.getOne(owner); + + if (!actor) { + throw new Error('Not Found'); + } + + await actor.postToInbox(activity); + + await this.actors.save(actor); + await this.activities.save(activity); + } +} diff --git a/ghost/ghost/src/core/activitypub/jsonld.service.test.ts b/ghost/ghost/src/core/activitypub/jsonld.service.test.ts new file mode 100644 index 0000000000..bdc77c43a0 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/jsonld.service.test.ts @@ -0,0 +1,122 @@ +import Sinon from 'sinon'; +import {Actor} from './actor.entity'; +import {JSONLDService} from './jsonld.service'; +import assert from 'assert'; +import ObjectID from 'bson-objectid'; + +describe('JSONLDService', function () { + describe('#getActor', function () { + it('Returns JSONLD representation of Actor', async function () { + const actor = Actor.create({username: 'freddy'}); + const mockActorRepository = { + getOne: Sinon.stub().resolves(actor), + save: Sinon.stub().rejects() + }; + const mockPostRepository = { + getOne: Sinon.stub().resolves(null) + }; + const url = new URL('https://example.com'); + + const service = new JSONLDService( + mockActorRepository, + mockPostRepository, + url + ); + + const result = await service.getActor(actor.id); + + assert(result); + assert.equal(result.type, 'Person'); + }); + }); + describe('#getOutbox', function () { + it('returns JSONLD representation of Outbox', async function () { + const actor = Actor.create({username: 'freddy'}); + const mockActorRepository = { + getOne: Sinon.stub().resolves(actor), + save: Sinon.stub().rejects() + }; + const mockPostRepository = { + getOne: Sinon.stub().resolves(null) + }; + const url = new URL('https://example.com'); + + const service = new JSONLDService( + mockActorRepository, + mockPostRepository, + url + ); + + const result = await service.getOutbox(actor.id); + + assert(result); + assert.equal(result.type, 'OrderedCollection'); + }); + it('returns null if actor not found', async function () { + const actor = Actor.create({username: 'freddy'}); + const mockActorRepository = { + getOne: Sinon.stub().resolves(null), + save: Sinon.stub().rejects() + }; + const mockPostRepository = { + getOne: Sinon.stub().resolves(null) + }; + const url = new URL('https://example.com'); + + const service = new JSONLDService( + mockActorRepository, + mockPostRepository, + url + ); + + const result = await service.getOutbox(actor.id); + + assert.equal(result, null); + }); + }); + describe('#getArticle', function () { + it('Throws if no post found', async function () { + const mockActorRepository = { + getOne: Sinon.stub().resolves(null), + save: Sinon.stub().rejects() + }; + const mockPostRepository = { + getOne: Sinon.stub().resolves(null) + }; + const url = new URL('https://example.com'); + + const service = new JSONLDService( + mockActorRepository, + mockPostRepository, + url + ); + + await assert.rejects(async () => { + await service.getArticle(new ObjectID()); + }); + }); + + it('Throws if post not public', async function () { + const mockActorRepository = { + getOne: Sinon.stub().resolves(null), + save: Sinon.stub().rejects() + }; + const mockPostRepository = { + getOne: Sinon.stub().resolves({ + visibility: 'private' + }) + }; + const url = new URL('https://example.com'); + + const service = new JSONLDService( + mockActorRepository, + mockPostRepository, + url + ); + + await assert.rejects(async () => { + await service.getArticle(new ObjectID()); + }); + }); + }); +}); diff --git a/ghost/ghost/src/core/activitypub/types.ts b/ghost/ghost/src/core/activitypub/types.ts index 7bcf526fd1..8b6046d7c3 100644 --- a/ghost/ghost/src/core/activitypub/types.ts +++ b/ghost/ghost/src/core/activitypub/types.ts @@ -12,7 +12,12 @@ export namespace ActivityPub { type: string | string[]; }; - export type Object = RootObject | AnonymousObject; + export type SubObject = { + id: string; + type: string | string[]; + }; + + export type Object = RootObject | AnonymousObject | SubObject; export type Actor = ActivityPub.Object & { inbox: string; @@ -35,7 +40,10 @@ export namespace ActivityPub { id: string, owner: string, publicKeyPem: string - } + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [x: string]: any }; export type Article = ActivityPub.Object & { @@ -57,12 +65,12 @@ export namespace ActivityPub { width?: number }; - export type ActivityType = 'Create' | 'Update' | 'Delete'; + export type ActivityType = 'Create' | 'Update' | 'Delete' | 'Follow' | 'Accept' | 'Reject' | 'Undo'; export type Activity = ActivityPub.Object & { type: ActivityType; - summary: string; - actor: Link | Actor; + actor: Link | Actor | ActivityPub.Object; object: Link | ActivityPub.Object; + to: Link | Actor | null; } } diff --git a/ghost/ghost/src/core/activitypub/uri.object.ts b/ghost/ghost/src/core/activitypub/uri.object.ts new file mode 100644 index 0000000000..440af95c89 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/uri.object.ts @@ -0,0 +1,11 @@ +export class URI extends URL { + static readonly BASE_URL = new URL('https://example.com'); + + constructor(url: string | URI, base?: string | URI) { + super(url, base || URI.BASE_URL); + } + + getValue(url: URL) { + return this.href.replace(URI.BASE_URL.href, url.href); + } +} diff --git a/ghost/ghost/src/core/activitypub/webfinger.service.ts b/ghost/ghost/src/core/activitypub/webfinger.service.ts index 8908a44a4f..9257923c72 100644 --- a/ghost/ghost/src/core/activitypub/webfinger.service.ts +++ b/ghost/ghost/src/core/activitypub/webfinger.service.ts @@ -64,6 +64,7 @@ export class WebFingerService { throw new Error('Subject does not match - not jumping thru hoops'); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const self = json.links.find((link: any) => link.rel === 'self'); const selfRes = await fetch(self.href, { @@ -74,6 +75,7 @@ export class WebFingerService { const data = await selfRes.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any return data as any; } } diff --git a/ghost/ghost/src/db/in-memory/activity.repository.in-memory.ts b/ghost/ghost/src/db/in-memory/activity.repository.in-memory.ts new file mode 100644 index 0000000000..dcc9929d8d --- /dev/null +++ b/ghost/ghost/src/db/in-memory/activity.repository.in-memory.ts @@ -0,0 +1,18 @@ +import {Activity} from '../../core/activitypub/activity.entity'; +import {ActivityRepository} from '../../core/activitypub/activity.repository'; + +export class ActivityRepositoryInMemory implements ActivityRepository { + private activities: Activity[] = []; + + async getOne(id: URL) { + const found = this.activities.find(entity => entity.activityId?.href === id.href); + if (!found) { + return null; + } + return found; + } + + async save(activity: Activity) { + this.activities.push(activity); + } +} diff --git a/ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts b/ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts index 55daf9d948..5ef17baf21 100644 --- a/ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts +++ b/ghost/ghost/src/db/in-memory/actor.repository.in-memory.ts @@ -21,9 +21,10 @@ export class ActorRepositoryInMemory implements ActorRepository { Actor.create({ id: ObjectID.createFromHexString('deadbeefdeadbeefdeadbeef'), username: 'index', + displayName: settingsCache.get('title'), publicKey: settingsCache.get('ghost_public_key'), privateKey: settingsCache.get('ghost_private_key'), - outbox: [] + following: [] }) ]; this.domainEvents = domainEvents; diff --git a/ghost/ghost/src/http/admin/controllers/activitypub.controller.test.ts b/ghost/ghost/src/http/admin/controllers/activitypub.controller.test.ts new file mode 100644 index 0000000000..05394b2975 --- /dev/null +++ b/ghost/ghost/src/http/admin/controllers/activitypub.controller.test.ts @@ -0,0 +1,36 @@ +import Sinon from 'sinon'; +import {ActivityPubController} from './activitypub.controller'; +import assert from 'assert'; +import {ActivityPubService} from '../../../core/activitypub/activitypub.service'; + +describe('ActivityPubController', function () { + describe('#follow', function () { + it('Calls follow on the ActivityPubService and returns an empty object', async function () { + const mockActivityPubService = { + follow: Sinon.stub().resolves(), + getFollowing: Sinon.stub().resolves([]) + } as unknown as ActivityPubService; + const controller = new ActivityPubController(mockActivityPubService); + + await controller.follow('@egg@ghost.org'); + + assert((mockActivityPubService.follow as Sinon.SinonStub).calledWith('@egg@ghost.org')); + }); + }); + + describe('#getFollowing', function () { + it('Calls getFollowing on the ActivityPubService and returns the result', async function () { + const mockActivityPubService = { + follow: Sinon.stub().resolves(), + getFollowing: Sinon.stub().resolves([]) + } as unknown as ActivityPubService; + const controller = new ActivityPubController(mockActivityPubService); + + const result = await controller.getFollowing(); + + assert((mockActivityPubService.getFollowing as Sinon.SinonStub).called); + const returnValue = await (mockActivityPubService.getFollowing as Sinon.SinonStub).returnValues[0]; + assert.equal(result, returnValue); + }); + }); +}); diff --git a/ghost/ghost/src/http/admin/controllers/activitypub.controller.ts b/ghost/ghost/src/http/admin/controllers/activitypub.controller.ts new file mode 100644 index 0000000000..911a3c70e8 --- /dev/null +++ b/ghost/ghost/src/http/admin/controllers/activitypub.controller.ts @@ -0,0 +1,39 @@ +import { + Controller, + Get, + Param, + Post, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import {Roles} from '../../../common/decorators/permissions.decorator'; +import {LocationHeaderInterceptor} from '../../../nestjs/interceptors/location-header.interceptor'; +import {AdminAPIAuthentication} from '../../../nestjs/guards/admin-api-authentication.guard'; +import {PermissionsGuard} from '../../../nestjs/guards/permissions.guard'; +import {ActivityPubService} from '../../../core/activitypub/activitypub.service'; + +@UseInterceptors(LocationHeaderInterceptor) +@UseGuards(AdminAPIAuthentication, PermissionsGuard) +@Controller('activitypub') +export class ActivityPubController { + constructor(private readonly activitypub: ActivityPubService) {} + + @Roles([ + 'Owner' + ]) + @Post('follow/:username') + async follow(@Param('username') username: string): Promise { + await this.activitypub.follow(username); + return {}; + } + + @Roles([ + 'Owner' + ]) + @Get('following') + async getFollowing(): Promise<{id: string, username?: string;}[]> { + const followers = await this.activitypub.getFollowing(); + + return followers; + } +} diff --git a/ghost/ghost/src/http/frontend/controllers/activitypub.controller.test.ts b/ghost/ghost/src/http/frontend/controllers/activitypub.controller.test.ts new file mode 100644 index 0000000000..ae67afb403 --- /dev/null +++ b/ghost/ghost/src/http/frontend/controllers/activitypub.controller.test.ts @@ -0,0 +1,120 @@ +import request from 'supertest'; +import {Test} from '@nestjs/testing'; +import {WebFingerService} from '../../../core/activitypub/webfinger.service'; +import {ActivityRepositoryInMemory} from '../../../db/in-memory/activity.repository.in-memory'; +import {ActivityPubController} from './activitypub.controller'; +import {JSONLDService} from '../../../core/activitypub/jsonld.service'; +import {InboxService} from '../../../core/activitypub/inbox.service'; +import {ActivityPubService} from '../../../core/activitypub/activitypub.service'; +import {ActorRepositoryInMemory} from '../../../db/in-memory/actor.repository.in-memory'; +import {ActivityService} from '../../../core/activitypub/activity.service'; +import {HTTPSignature} from '../../../core/activitypub/http-signature.service'; +import {ActivityListener} from '../../../listeners/activity.listener'; +import {TheWorld} from '../../../core/activitypub/tell-the-world.service'; +import DomainEvents from '@tryghost/domain-events'; +import {NestApplication} from '@nestjs/core'; +import ObjectID from 'bson-objectid'; + +describe('ActivityPubController', function () { + let app: NestApplication; + before(async function () { + const moduleRef = await Test.createTestingModule({ + controllers: [ActivityPubController], + providers: [ + { + provide: 'ActivityPubBaseURL', + useValue: new URL('https://example.com') + }, + { + provide: 'SettingsCache', + useValue: { + get(_key: string) { + return 'value'; + } + } + }, + { + provide: 'DomainEvents', + useValue: DomainEvents + }, + { + provide: 'ActorRepository', + useClass: ActorRepositoryInMemory + }, + { + provide: 'ActivityService', + useClass: ActivityService + }, + { + provide: 'PostRepository', + useValue: { + async getOne(id: ObjectID) { + return { + id, + title: 'Testing', + slug: 'testing', + html: '

testing

', + visibility: 'public' + }; + } + } + }, + { + provide: 'ActivityRepository', + useClass: ActivityRepositoryInMemory + }, + WebFingerService, + JSONLDService, + HTTPSignature, + ActivityService, + InboxService, + ActivityListener, + ActivityPubService, + TheWorld + ] + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + after(async function () { + await app.close(); + }); + + it('Can handle requests to get the actor', async function () { + await request(app.getHttpServer()) + .get('/activitypub/actor/deadbeefdeadbeefdeadbeef') + .expect(200); + }); + + it('Can handle requests to get the outbox', async function () { + await request(app.getHttpServer()) + .get('/activitypub/outbox/deadbeefdeadbeefdeadbeef') + .expect(200); + }); + + it('Can handle requests to get the following', async function () { + await request(app.getHttpServer()) + .get('/activitypub/following/deadbeefdeadbeefdeadbeef') + .expect(200); + }); + + it('Can handle requests to get an article', async function () { + await request(app.getHttpServer()) + .get('/activitypub/article/deadbeefdeadbeefdeadbeef') + .expect(200); + }); + + describe('/inbox/:id', function () { + it('Errors with invalid requests', async function () { + await request(app.getHttpServer()) + .post('/activitypub/inbox/deadbeefdeadbeefdeadbeef') + .send({ + type: 'Follow', + actor: 'https://site.com/actor', + object: 'https://site.com/object' + }) + .expect(500); + }); + }); +}); diff --git a/ghost/ghost/src/http/frontend/controllers/activitypub.controller.ts b/ghost/ghost/src/http/frontend/controllers/activitypub.controller.ts index f89e3661f2..9ea89aa684 100644 --- a/ghost/ghost/src/http/frontend/controllers/activitypub.controller.ts +++ b/ghost/ghost/src/http/frontend/controllers/activitypub.controller.ts @@ -1,12 +1,18 @@ -import {Controller, Get, Header, Param} from '@nestjs/common'; +import {Controller, Get, Header, Param, Post, RawBody, Headers as NestHeaders, Req, Body} from '@nestjs/common'; import {Roles} from '../../../common/decorators/permissions.decorator'; import ObjectID from 'bson-objectid'; import {JSONLDService} from '../../../core/activitypub/jsonld.service'; +import {HTTPSignature} from '../../../core/activitypub/http-signature.service'; +import {InboxService} from '../../../core/activitypub/inbox.service'; +import {Activity} from '../../../core/activitypub/activity.entity'; +import {ActivityPubService} from '../../../core/activitypub/activitypub.service'; @Controller('activitypub') export class ActivityPubController { constructor( - private readonly service: JSONLDService + private readonly service: JSONLDService, + private readonly inboxService: InboxService, + private readonly activitypub: ActivityPubService ) {} @Header('Cache-Control', 'no-store') @@ -20,6 +26,33 @@ export class ActivityPubController { return this.service.getActor(ObjectID.createFromHexString(id)); } + @Header('Cache-Control', 'no-store') + @Header('Content-Type', 'application/activity+json') + @Roles(['Anon']) + @Post('inbox/:owner') + async handleActivity( + @Param('owner') owner: unknown, + @Body() body: unknown, + @RawBody() rawbody: Buffer, + @NestHeaders() headers: Record, + @Req() req: Request + ) { + if (typeof owner !== 'string') { + throw new Error('Bad Request'); + } + if (typeof body !== 'object' || body === null) { + throw new Error('Bad Request'); + } + if (!('id' in body) || !('type' in body) || !('actor' in body) || !('object' in body)) { + throw new Error('Bad Request'); + } + const verified = await HTTPSignature.validate(req.method, req.url, new Headers(headers), rawbody); + if (!verified) { + throw new Error('Not Authorized'); + } + this.inboxService.post(ObjectID.createFromHexString(owner), Activity.fromJSONLD(body)); + } + @Header('Cache-Control', 'no-store') @Header('Content-Type', 'application/activity+json') @Roles(['Anon']) @@ -31,6 +64,17 @@ export class ActivityPubController { return this.service.getOutbox(ObjectID.createFromHexString(owner)); } + @Header('Cache-Control', 'no-store') + @Header('Content-Type', 'application/activity+json') + @Roles(['Anon']) + @Get('following/:owner') + async getFollowing(@Param('owner') owner: unknown) { + if (typeof owner !== 'string') { + throw new Error('Bad Request'); + } + return this.activitypub.getFollowing(); + } + @Header('Cache-Control', 'no-store') @Header('Content-Type', 'application/activity+json') @Roles(['Anon']) diff --git a/ghost/ghost/src/http/frontend/controllers/webfinger.controller.test.ts b/ghost/ghost/src/http/frontend/controllers/webfinger.controller.test.ts new file mode 100644 index 0000000000..76ee7017d6 --- /dev/null +++ b/ghost/ghost/src/http/frontend/controllers/webfinger.controller.test.ts @@ -0,0 +1,38 @@ +import request from 'supertest'; +import {Test} from '@nestjs/testing'; +import {WebFingerService} from '../../../core/activitypub/webfinger.service'; +import {WebFingerController} from './webfinger.controller'; +import {ActivityRepositoryInMemory} from '../../../db/in-memory/activity.repository.in-memory'; + +describe('WebFingerController', function () { + it('Responds to HTTP requests for .well-known/webfinger correctly', async function () { + const moduleRef = await Test.createTestingModule({ + controllers: [WebFingerController], + providers: [ + WebFingerService, + { + provide: 'ActivityPubBaseURL', + useValue: new URL('https://example.com') + }, + { + provide: 'ActorRepository', + useClass: ActivityRepositoryInMemory + } + ] + }) + .overrideProvider(WebFingerService) + .useValue({ + async getResource() {} + }) + .compile(); + + const app = moduleRef.createNestApplication(); + await app.init(); + + request(app.getHttpServer()) + .get('/.well-known/webfinger?resource=acct:egg@ghost.org') + .expect(200); + + await app.close(); + }); +}); diff --git a/ghost/ghost/src/listeners/activity.listener.test.ts b/ghost/ghost/src/listeners/activity.listener.test.ts new file mode 100644 index 0000000000..4cdb711cab --- /dev/null +++ b/ghost/ghost/src/listeners/activity.listener.test.ts @@ -0,0 +1,50 @@ +import assert from 'assert'; +import {Activity} from '../core/activitypub/activity.entity'; +import {ActivityEvent} from '../core/activitypub/activity.event'; +import {Actor} from '../core/activitypub/actor.entity'; +import {TheWorld} from '../core/activitypub/tell-the-world.service'; +import {URI} from '../core/activitypub/uri.object'; +import {ActivityListener} from './activity.listener'; + +describe('ActivityListener', function () { + describe('#dispatchActivity', function () { + it('uses the service to deliver the activity', function () { + let called = false; + const calledWith: [unknown, unknown][] = []; + class MockTheWorld extends TheWorld { + async deliverActivity(activity: Activity, actor: Actor): Promise { + called = true; + calledWith.push([activity, actor]); + } + } + const listener = new ActivityListener(new MockTheWorld(new URL('https://example.com'))); + + const actor = Actor.create({ + username: 'Testing' + }); + + const toFollow = new URI('https://example.com/user'); + + const activity = new Activity({ + type: 'Follow', + activity: null, + actor: actor.actorId, + object: { + id: toFollow + }, + to: toFollow + }); + + const event = new ActivityEvent({ + activity, + actor + }); + + listener.dispatchActivity(event); + + assert.equal(called, true); + assert.equal(calledWith[0][0], activity); + assert.equal(calledWith[0][1], actor); + }); + }); +}); diff --git a/ghost/ghost/src/listeners/activity.listener.ts b/ghost/ghost/src/listeners/activity.listener.ts new file mode 100644 index 0000000000..7564989afe --- /dev/null +++ b/ghost/ghost/src/listeners/activity.listener.ts @@ -0,0 +1,15 @@ +import {Inject} from '@nestjs/common'; +import {OnEvent} from '../common/decorators/handle-event.decorator'; +import {ActivityEvent} from '../core/activitypub/activity.event'; +import {TheWorld} from '../core/activitypub/tell-the-world.service'; + +export class ActivityListener { + constructor( + @Inject(TheWorld) private readonly service: TheWorld + ) {} + + @OnEvent(ActivityEvent) + async dispatchActivity(event: ActivityEvent) { + await this.service.deliverActivity(event.data.activity, event.data.actor); + } +} diff --git a/ghost/ghost/src/nestjs/filters/global-exception.filter.ts b/ghost/ghost/src/nestjs/filters/global-exception.filter.ts index cbf36aee64..af8ee46ad1 100644 --- a/ghost/ghost/src/nestjs/filters/global-exception.filter.ts +++ b/ghost/ghost/src/nestjs/filters/global-exception.filter.ts @@ -1,4 +1,4 @@ -import {ArgumentsHost, Catch, ExceptionFilter} from '@nestjs/common'; +import {ArgumentsHost, Catch, ExceptionFilter, Inject} from '@nestjs/common'; import {Response} from 'express'; interface GhostError extends Error { @@ -15,10 +15,14 @@ interface GhostError extends Error { @Catch() export class GlobalExceptionFilter implements ExceptionFilter { + constructor(@Inject('logger') private logger: Console) {} + catch(error: GhostError, host: ArgumentsHost) { const context = host.switchToHttp(); const response = context.getResponse(); + this.logger.error(error); + response.status(error.statusCode || 500); response.json({ errors: [ diff --git a/ghost/ghost/src/nestjs/modules/activitypub.module.ts b/ghost/ghost/src/nestjs/modules/activitypub.module.ts index c81a6ee282..86e54d5be0 100644 --- a/ghost/ghost/src/nestjs/modules/activitypub.module.ts +++ b/ghost/ghost/src/nestjs/modules/activitypub.module.ts @@ -1,4 +1,4 @@ -import {Module} from '@nestjs/common'; +import {Global, Module} from '@nestjs/common'; import {ActorRepositoryInMemory} from '../../db/in-memory/actor.repository.in-memory'; import {ActivityPubController} from '../../http/frontend/controllers/activitypub.controller'; import {WebFingerService} from '../../core/activitypub/webfinger.service'; @@ -6,7 +6,14 @@ import {JSONLDService} from '../../core/activitypub/jsonld.service'; import {WebFingerController} from '../../http/frontend/controllers/webfinger.controller'; import {ActivityService} from '../../core/activitypub/activity.service'; import {KnexPostRepository} from '../../db/knex/post.repository.knex'; +import {HTTPSignature} from '../../core/activitypub/http-signature.service'; +import {InboxService} from '../../core/activitypub/inbox.service'; +import {ActivityRepositoryInMemory} from '../../db/in-memory/activity.repository.in-memory'; +import {ActivityListener} from '../../listeners/activity.listener'; +import {TheWorld} from '../../core/activitypub/tell-the-world.service'; +import {ActivityPubService} from '../../core/activitypub/activitypub.service'; +@Global() @Module({ controllers: [ActivityPubController, WebFingerController], exports: [], @@ -23,8 +30,18 @@ import {KnexPostRepository} from '../../db/knex/post.repository.knex'; provide: 'PostRepository', useClass: KnexPostRepository }, + { + provide: 'ActivityRepository', + useClass: ActivityRepositoryInMemory + }, WebFingerService, - JSONLDService + JSONLDService, + HTTPSignature, + ActivityService, + InboxService, + ActivityListener, + ActivityPubService, + TheWorld ] }) export class ActivityPubModule {} diff --git a/ghost/ghost/src/nestjs/modules/admin-api.module.ts b/ghost/ghost/src/nestjs/modules/admin-api.module.ts index ce04e7517f..638b84ea05 100644 --- a/ghost/ghost/src/nestjs/modules/admin-api.module.ts +++ b/ghost/ghost/src/nestjs/modules/admin-api.module.ts @@ -2,15 +2,25 @@ import {Module} from '@nestjs/common'; import {ExampleController} from '../../http/admin/controllers/example.controller'; import {ExampleService} from '../../core/example/example.service'; import {ExampleRepositoryInMemory} from '../../db/in-memory/example.repository.in-memory'; +import {ActivityPubController} from '../../http/admin/controllers/activitypub.controller'; +import {ActivityPubService} from '../../core/activitypub/activitypub.service'; +import {WebFingerService} from '../../core/activitypub/webfinger.service'; +import {ActorRepositoryInMemory} from '../../db/in-memory/actor.repository.in-memory'; @Module({ - controllers: [ExampleController], + controllers: [ExampleController, ActivityPubController], exports: [ExampleService], providers: [ ExampleService, + ActivityPubService, + WebFingerService, { provide: 'ExampleRepository', useClass: ExampleRepositoryInMemory + }, + { + provide: 'ActorRepository', + useClass: ActorRepositoryInMemory } ] })