From 299f7c408eb090fb365c45558c6dd2ec8520111c Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Tue, 23 Apr 2024 15:38:30 +0700 Subject: [PATCH] Added very basic Outbox for Actors ref https://linear.app/tryghost/issue/MOM-28 ref https://linear.app/tryghost/issue/MOM-29 ref https://linear.app/tryghost/issue/MOM-30 Basic wire up of Create Activities, Articles for Posts & Actor's Outbox! I'd definitely like to rethink the whole storage layer and how we split things out - I think separating the Outbox from the Actor would make sense, otherwise the size of thsi is gonna grow, or we're gonna have to deal with sub-pagination. --- .../src/core/activitypub/activity.event.ts | 12 +++++ .../src/core/activitypub/activity.object.ts | 27 ++++++++++ .../src/core/activitypub/actor.entity.ts | 30 +++++++++++- .../src/core/activitypub/article.object.ts | 49 +++++++++++++++++++ .../src/core/activitypub/jsonld.service.ts | 19 +------ .../in-memory/actor.repository.in-memory.ts | 28 +++++++++-- 6 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 ghost/ghost/src/core/activitypub/activity.event.ts create mode 100644 ghost/ghost/src/core/activitypub/activity.object.ts create mode 100644 ghost/ghost/src/core/activitypub/article.object.ts diff --git a/ghost/ghost/src/core/activitypub/activity.event.ts b/ghost/ghost/src/core/activitypub/activity.event.ts new file mode 100644 index 0000000000..b9232dfd46 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activity.event.ts @@ -0,0 +1,12 @@ +import {BaseEvent} from '../../common/event.base'; +import {Activity} from './activity.object'; + +type ActivityEventData = { + activity: Activity +} + +export class ActivityEvent extends BaseEvent { + static create(activity: Activity) { + return new ActivityEvent({activity}); + } +} diff --git a/ghost/ghost/src/core/activitypub/activity.object.ts b/ghost/ghost/src/core/activitypub/activity.object.ts new file mode 100644 index 0000000000..8dbc2b8265 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/activity.object.ts @@ -0,0 +1,27 @@ +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/actor.entity.ts b/ghost/ghost/src/core/activitypub/actor.entity.ts index 6a65ac6b81..e08855ee9e 100644 --- a/ghost/ghost/src/core/activitypub/actor.entity.ts +++ b/ghost/ghost/src/core/activitypub/actor.entity.ts @@ -1,11 +1,15 @@ import ObjectID from 'bson-objectid'; import {Entity} from '../../common/entity.base'; import {ActivityPub} from './types'; +import {Activity} from './activity.object'; +import {Article} from './article.object'; +import {ActivityEvent} from './activity.event'; type ActorData = { username: string; publicKey: string; privateKey: string; + outbox: Activity[]; }; type CreateActorData = ActorData & { @@ -17,6 +21,29 @@ export class Actor extends Entity { return this.attr.username; } + get outbox() { + return this.attr.outbox; + } + + private activities: Activity[] = []; + + static getActivitiesToSave(actor: Actor, fn: (activities: Activity[]) => void) { + const activities = actor.activities; + actor.activities = []; + fn(activities); + } + + createArticle(article: Article) { + const activity = new Activity({ + type: 'Create', + actor: this, + object: article + }); + this.attr.outbox.push(activity); + this.activities.push(activity); + this.addEvent(ActivityEvent.create(activity)); + } + getJSONLD(url: URL): ActivityPub.Actor & ActivityPub.RootObject { if (!url.href.endsWith('/')) { url.href += '/'; @@ -95,7 +122,8 @@ export class Actor extends Entity { id: data.id instanceof ObjectID ? data.id : undefined, username: data.username, publicKey: data.publicKey, - privateKey: data.privateKey + privateKey: data.privateKey, + outbox: data.outbox }); } } diff --git a/ghost/ghost/src/core/activitypub/article.object.ts b/ghost/ghost/src/core/activitypub/article.object.ts new file mode 100644 index 0000000000..eb4bef4668 --- /dev/null +++ b/ghost/ghost/src/core/activitypub/article.object.ts @@ -0,0 +1,49 @@ +import ObjectID from 'bson-objectid'; +import {ActivityPub} from './types'; + +type ArticleData = { + id: ObjectID + name: string + content: string + url: URL +}; + +type Post = { + id: ObjectID; + title: string; + slug: string; + html: string; + lexical: string; + status: 'draft' | 'published' | 'scheduled' | 'sent'; +}; + +export class Article { + constructor(private readonly attr: ArticleData) {} + + getJSONLD(url: URL): ActivityPub.Article & ActivityPub.RootObject { + if (!url.href.endsWith('/')) { + url.href += '/'; + } + + const id = new URL(`article/${this.attr.id.toHexString()}`, url.href); + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Article', + id: id.href, + name: this.attr.name, + content: this.attr.content, + url: this.attr.url.href, + attributedTo: url.href + }; + } + + static fromPost(post: Post) { + return new Article({ + id: post.id, + name: post.title, + content: post.html, + url: new URL(`/posts/${post.slug}`, 'https://example.com') + }); + } +} diff --git a/ghost/ghost/src/core/activitypub/jsonld.service.ts b/ghost/ghost/src/core/activitypub/jsonld.service.ts index a284245ae3..a13e8e4e10 100644 --- a/ghost/ghost/src/core/activitypub/jsonld.service.ts +++ b/ghost/ghost/src/core/activitypub/jsonld.service.ts @@ -24,23 +24,8 @@ export class JSONLDService { id: json.outbox, summary: `Outbox for ${actor.username}`, type: 'OrderedCollection', - totalItems: 1, - orderedItems: [{ - type: 'Create', - actor: json.id, - to: [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - object: { - type: 'Note', - name: 'My First Note', - content: '

Hello, world!

', - attributedTo: json.id, - to: [ - 'https://www.w3.org/ns/activitystreams#Public' - ] - } - }] + totalItems: actor.outbox.length, + orderedItems: actor.outbox.map(activity => activity.getJSONLD(this.url)) }; } } 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 858ba31dec..add740e6d9 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 @@ -3,19 +3,31 @@ import {ActorRepository} from '../../core/activitypub/actor.repository'; import ObjectID from 'bson-objectid'; import {Inject} from '@nestjs/common'; import {SettingsCache} from '../../common/types/settings-cache.type'; +import {Activity} from '../../core/activitypub/activity.object'; + +interface DomainEvents { + dispatch(event: unknown): void +} export class ActorRepositoryInMemory implements ActorRepository { actors: Actor[]; - constructor(@Inject('SettingsCache') settingsCache: SettingsCache) { + private readonly domainEvents: DomainEvents; + + constructor( + @Inject('SettingsCache') settingsCache: SettingsCache, + @Inject('DomainEvents') domainEvents: DomainEvents + ) { this.actors = [ Actor.create({ id: ObjectID.createFromHexString('deadbeefdeadbeefdeadbeef'), username: 'index', publicKey: settingsCache.get('ghost_public_key'), - privateKey: settingsCache.get('ghost_private_key') + privateKey: settingsCache.get('ghost_private_key'), + outbox: [] }) ]; + this.domainEvents = domainEvents; } private getOneByUsername(username: string) { @@ -36,6 +48,16 @@ export class ActorRepositoryInMemory implements ActorRepository { // eslint-disable-next-line @typescript-eslint/no-unused-vars async save(actor: Actor) { - throw new Error('Not Implemented'); + if (!this.actors.includes(actor)) { + this.actors.push(actor); + } + Actor.getActivitiesToSave(actor, (/* activities */) => { + // Persist activities + }); + Actor.getEventsToDispatch(actor, (events) => { + for (const event of events) { + this.domainEvents.dispatch(event); + } + }); } }