Initial wire up of Posts -> Outbox flow

ref https://linear.app/tryghost/issue/MOM-29

This is very rough, and all still behind a flag. The idea is that any public
post which is published gets added to the Outbox of the site Actor. We also
dispatch an event, which will be used to deliver the Activity to any relevant
inboxes, but that is outside the scope of this commit.
This commit is contained in:
Fabien O'Carroll 2024-04-23 17:00:06 +07:00 committed by Fabien 'egg' O'Carroll
parent e01c9cb546
commit af02ca7044
10 changed files with 132 additions and 10 deletions

View File

@ -408,6 +408,12 @@ async function initNestDependencies() {
}, { }, {
provide: 'SettingsCache', provide: 'SettingsCache',
useValue: require('./shared/settings-cache') useValue: require('./shared/settings-cache')
}, {
provide: 'knex',
useValue: require('./server/data/db').knex
}, {
provide: 'UrlUtils',
useValue: require('./shared/url-utils')
}); });
for (const provider of providers) { for (const provider of providers) {
GhostNestApp.addProvider(provider); GhostNestApp.addProvider(provider);

View File

@ -0,0 +1,36 @@
import ObjectID from 'bson-objectid';
import {ActorRepository} from './actor.repository';
import {Article} from './article.object';
import {PostRepository} from './post.repository';
import {Inject} from '@nestjs/common';
export class ActivityService {
constructor(
@Inject('ActorRepository') private readonly actorRepository: ActorRepository,
@Inject('PostRepository') private readonly postRepository: PostRepository
) {}
async createArticleForPost(postId: ObjectID) {
const actor = await this.actorRepository.getOne('index');
if (!actor) {
throw new Error('Actor not found');
}
const post = await this.postRepository.getOne(postId);
if (!post) {
throw new Error('Post not found');
}
if (post.visibility !== 'public') {
return;
}
const article = Article.fromPost(post);
actor.createArticle(article);
await this.actorRepository.save(actor);
}
}

View File

@ -1,5 +1,6 @@
import ObjectID from 'bson-objectid'; import ObjectID from 'bson-objectid';
import {ActivityPub} from './types'; import {ActivityPub} from './types';
import {Post} from './post.repository';
type ArticleData = { type ArticleData = {
id: ObjectID id: ObjectID
@ -8,15 +9,6 @@ type ArticleData = {
url: URL url: URL
}; };
type Post = {
id: ObjectID;
title: string;
slug: string;
html: string;
lexical: string;
status: 'draft' | 'published' | 'scheduled' | 'sent';
};
export class Article { export class Article {
constructor(private readonly attr: ArticleData) {} constructor(private readonly attr: ArticleData) {}

View File

@ -1,10 +1,13 @@
import {Inject} from '@nestjs/common'; import {Inject} from '@nestjs/common';
import {ActorRepository} from './actor.repository'; import {ActorRepository} from './actor.repository';
import ObjectID from 'bson-objectid'; import ObjectID from 'bson-objectid';
import {PostRepository} from './post.repository';
import {Article} from './article.object';
export class JSONLDService { export class JSONLDService {
constructor( constructor(
@Inject('ActorRepository') private repository: ActorRepository, @Inject('ActorRepository') private repository: ActorRepository,
@Inject('PostRepository') private postRepository: PostRepository,
@Inject('ActivityPubBaseURL') private url: URL @Inject('ActivityPubBaseURL') private url: URL
) {} ) {}
@ -28,4 +31,15 @@ export class JSONLDService {
orderedItems: actor.outbox.map(activity => activity.getJSONLD(this.url)) orderedItems: actor.outbox.map(activity => activity.getJSONLD(this.url))
}; };
} }
async getArticle(id: ObjectID) {
const post = await this.postRepository.getOne(id);
if (!post) {
throw new Error('Not found');
}
if (post.visibility !== 'public') {
throw new Error('Cannot view');
}
return Article.fromPost(post).getJSONLD(this.url);
}
} }

View File

@ -0,0 +1,13 @@
import ObjectID from 'bson-objectid';
export type Post = {
id: ObjectID;
title: string;
slug: string;
html: string;
visibility: string;
};
export interface PostRepository {
getOne(id: ObjectID): Promise<Post | null>
}

View File

@ -3,7 +3,6 @@ import {ActorRepository} from '../../core/activitypub/actor.repository';
import ObjectID from 'bson-objectid'; import ObjectID from 'bson-objectid';
import {Inject} from '@nestjs/common'; import {Inject} from '@nestjs/common';
import {SettingsCache} from '../../common/types/settings-cache.type'; import {SettingsCache} from '../../common/types/settings-cache.type';
import {Activity} from '../../core/activitypub/activity.object';
interface DomainEvents { interface DomainEvents {
dispatch(event: unknown): void dispatch(event: unknown): void

View File

@ -0,0 +1,32 @@
import {Inject} from '@nestjs/common';
import ObjectID from 'bson-objectid';
import {PostRepository} from '../../core/activitypub/post.repository';
type UrlUtils = {
transformReadyToAbsolute(html: string): string
}
export class KnexPostRepository implements PostRepository {
constructor(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Inject('knex') private readonly knex: any,
@Inject('UrlUtils') private readonly urlUtils: UrlUtils
) {}
async getOne(identifier: ObjectID) {
return this.getOneById(identifier);
}
async getOneById(id: ObjectID) {
const row = await this.knex('posts').where('id', id.toHexString()).first();
if (!row) {
return null;
}
return {
id,
title: row.title,
html: this.urlUtils.transformReadyToAbsolute(row.html),
slug: row.slug,
visibility: row.visibility
};
}
};

View File

@ -30,4 +30,15 @@ export class ActivityPubController {
} }
return this.service.getOutbox(ObjectID.createFromHexString(owner)); return this.service.getOutbox(ObjectID.createFromHexString(owner));
} }
@Header('Cache-Control', 'no-store')
@Header('Content-Type', 'application/activity+json')
@Roles(['Anon'])
@Get('article/:id')
async getArticle(@Param('id') id: unknown) {
if (typeof id !== 'string') {
throw new Error('Bad Request');
}
return this.service.getArticle(ObjectID.createFromHexString(id));
}
} }

View File

@ -4,6 +4,8 @@ import {ActivityPubController} from '../../http/frontend/controllers/activitypub
import {WebFingerService} from '../../core/activitypub/webfinger.service'; import {WebFingerService} from '../../core/activitypub/webfinger.service';
import {JSONLDService} from '../../core/activitypub/jsonld.service'; import {JSONLDService} from '../../core/activitypub/jsonld.service';
import {WebFingerController} from '../../http/frontend/controllers/webfinger.controller'; import {WebFingerController} from '../../http/frontend/controllers/webfinger.controller';
import {ActivityService} from '../../core/activitypub/activity.service';
import {KnexPostRepository} from '../../db/knex/post.repository.knex';
@Module({ @Module({
controllers: [ActivityPubController, WebFingerController], controllers: [ActivityPubController, WebFingerController],
@ -13,6 +15,14 @@ import {WebFingerController} from '../../http/frontend/controllers/webfinger.con
provide: 'ActorRepository', provide: 'ActorRepository',
useClass: ActorRepositoryInMemory useClass: ActorRepositoryInMemory
}, },
{
provide: 'ActivityService',
useClass: ActivityService
},
{
provide: 'PostRepository',
useClass: KnexPostRepository
},
WebFingerService, WebFingerService,
JSONLDService JSONLDService
] ]

View File

@ -12,6 +12,8 @@ const {
PostsBulkUnfeaturedEvent, PostsBulkUnfeaturedEvent,
PostsBulkAddTagsEvent PostsBulkAddTagsEvent
} = require('@tryghost/post-events'); } = require('@tryghost/post-events');
const GhostNestApp = require('@tryghost/ghost');
const {default: ObjectID} = require('bson-objectid');
const messages = { const messages = {
invalidVisibilityFilter: 'Invalid visibility filter.', invalidVisibilityFilter: 'Invalid visibility filter.',
@ -207,6 +209,13 @@ class PostsService {
} }
} }
if (this.isSet('ActivityPub')) {
if (model.previous('status') !== model.get('status') && model.get('status') === 'published') {
const activityService = await GhostNestApp.resolve('ActivityService');
await activityService.createArticleForPost(ObjectID.createFromHexString(model.id));
}
}
if (typeof options?.eventHandler === 'function') { if (typeof options?.eventHandler === 'function') {
await options.eventHandler(this.getChanges(model), dto); await options.eventHandler(this.getChanges(model), dto);
} }