From e24a35641eaa78496c8d3d0847fcb42fb95ef672 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Sun, 4 Aug 2024 18:16:40 +0700 Subject: [PATCH] Refactoring handlers --- src/app.ts | 61 +++++++++++- src/handlers.ts | 99 ------------------- .../post.published.handler.ts | 99 +++++++++++++++++++ 3 files changed, 158 insertions(+), 101 deletions(-) create mode 100644 src/http/webhook-handlers/post.published.handler.ts diff --git a/src/app.ts b/src/app.ts index 0298c6f7..5ef117ce 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,8 @@ import { Update, Announce, Context, + Actor, + RequestContext, } from '@fedify/fedify'; import { federation } from '@fedify/fedify/x/hono'; import { Hono, Context as HonoContext } from 'hono'; @@ -49,7 +51,8 @@ import { handleAnnounce, } from './dispatchers'; -import { followAction, inboxHandler, postPublishedWebhook, siteChangedWebhook } from './handlers'; +import { followAction, inboxHandler, siteChangedWebhook } from './handlers'; +import { PostPublishedHandler } from 'http/webhook-handlers/post.published.handler'; if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN }); @@ -249,8 +252,62 @@ app.get('/ping', (ctx) => { }); }); +type MethodType = 'post' | 'get'; + +interface HandlerConstructor { + new( + context: RequestContext, + globaldb: KvStore, + localdb: KvStore, + actor: Actor, + ): Handler; + method: MethodType; + url: string; +} + +interface Handler { + parse(body: unknown): Promise; + execute(data: T): Promise; +} + +async function mount( + app: Hono<{Variables: HonoContextVariables}>, + handler: HandlerConstructor +) { + app[handler.method](handler.url, async function (ctx, next) { + try { + const context = fedify.createContext(ctx.req.raw, { + globaldb: ctx.get('globaldb'), + db: ctx.get('db'), + }); + const actor = await context.getActor('index'); + if (!actor) { + throw new Error('Could not find actor'); + } + const handler = new PostPublishedHandler( + context, + ctx.get('globaldb'), + ctx.get('db'), + actor, + ); + const json = await ctx.req.json(); + const data = await handler.parse(json); + return await handler.execute(data); + } catch (error: any) { + return new Response(JSON.stringify({ + error: error, + }), { + headers: { + 'Content-Type': 'application/json' + }, + status: 500 + }); + } + }); +} + app.get('/.ghost/activitypub/inbox/:handle', inboxHandler); -app.post('/.ghost/activitypub/webhooks/post/published', postPublishedWebhook); +mount(app, PostPublishedHandler); app.post('/.ghost/activitypub/webhooks/site/changed', siteChangedWebhook); app.post('/.ghost/activitypub/actions/follow/:handle', followAction); diff --git a/src/handlers.ts b/src/handlers.ts index 7189de0b..0b2057fe 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -26,47 +26,6 @@ type StoredThing = { } } -import z from 'zod'; - -const PostSchema = z.object({ - uuid: z.string().uuid(), - title: z.string(), - html: z.string(), - excerpt: z.string(), - feature_image: z.string().url().nullable(), - published_at: z.string().datetime(), - url: z.string().url() -}); - -type Post = z.infer - -async function postToArticle(ctx: RequestContext, post: Post) { - if (!post) { - return { - article: null, - preview: null, - }; - } - const preview = new Note({ - id: ctx.getObjectUri(Note, { id: post.uuid }), - content: post.excerpt, - }); - const article = new Article({ - id: ctx.getObjectUri(Article, { id: post.uuid }), - name: post.title, - content: post.html, - image: toURL(post.feature_image), - published: Temporal.Instant.from(post.published_at), - preview: preview, - url: toURL(post.url), - }); - - return { - article, - preview, - }; -} - export async function followAction( ctx: Context<{ Variables: HonoContextVariables }>, ) { @@ -101,64 +60,6 @@ export async function followAction( }); } -const PostPublishedWebhookSchema = z.object({ - post: z.object({ - current: PostSchema - }) -}); - -export async function postPublishedWebhook( - ctx: Context<{ Variables: HonoContextVariables }>, - next: Next, -) { - // TODO: Validate webhook with secret - const data = PostPublishedWebhookSchema.parse( - await ctx.req.json() as unknown - ); - const apCtx = fedify.createContext(ctx.req.raw as Request, { - db: ctx.get('db'), - globaldb: ctx.get('globaldb'), - }); - const { article, preview } = await postToArticle( - apCtx, - data.post.current, - ); - if (article) { - const actor = await apCtx.getActor(ACTOR_DEFAULT_HANDLE); - const create = new Create({ - actor, - object: article, - id: apCtx.getObjectUri(Create, { id: uuidv4() }), - to: PUBLIC_COLLECTION, - cc: apCtx.getFollowersUri('index'), - }); - try { - await article.toJsonLd(); - await ctx - .get('globaldb') - .set([preview.id!.href], await preview.toJsonLd()); - await ctx - .get('globaldb') - .set([create.id!.href], await create.toJsonLd()); - await ctx - .get('globaldb') - .set([article.id!.href], await article.toJsonLd()); - await addToList(ctx.get('db'), ['outbox'], create.id!.href); - await apCtx.sendActivity({ handle: ACTOR_DEFAULT_HANDLE }, 'followers', create, { - preferSharedInbox: true - }); - } catch (err) { - console.log(err); - } - } - return new Response(JSON.stringify({}), { - headers: { - 'Content-Type': 'application/activity+json', - }, - status: 200, - }); -} - export async function siteChangedWebhook( ctx: Context<{ Variables: HonoContextVariables }>, next: Next, diff --git a/src/http/webhook-handlers/post.published.handler.ts b/src/http/webhook-handlers/post.published.handler.ts new file mode 100644 index 00000000..58ca0f4f --- /dev/null +++ b/src/http/webhook-handlers/post.published.handler.ts @@ -0,0 +1,99 @@ +import { Actor, Article, RequestContext, Create, KvStore, Note, PUBLIC_COLLECTION } from '@fedify/fedify'; +import z from 'zod'; +import { Temporal } from '@js-temporal/polyfill'; +import { toURL } from '../../toURL'; +import { v4 as uuidv4 } from 'uuid'; +import { addToList } from 'kv-helpers'; + +export class PostPublishedHandler { + static method: 'post' + static url: '/.ghost/activitypub/webhooks/post/published' + + constructor( + private readonly context: RequestContext, + private readonly globaldb: KvStore, + private readonly localdb: KvStore, + private readonly actor: Actor, + ) {} + + async parse(body: unknown): Promise { + return PostPublishedWebhook.parse(body); + } + + async execute(body: PostPublishedWebhook): Promise { + const [article, preview] = this.postToArticle(body.post.current); + + const create = new Create({ + actor: this.actor, + object: article, + id: this.context.getObjectUri(Create, { id: uuidv4() }), + to: PUBLIC_COLLECTION, + cc: this.context.getFollowersUri('index'), + }); + + await this.globaldb.set([preview.id!.href], await preview.toJsonLd()); + await this.globaldb.set([create.id!.href], await create.toJsonLd()); + await this.globaldb.set([article.id!.href], await article.toJsonLd()); + + await addToList(this.localdb, ['outbox'], create.id!.href); + + await this.context.sendActivity( + { + handle: 'index' + }, + 'followers', + create, + { + preferSharedInbox: true + } + ); + + return new Response('OK', { + headers: { + 'Content-Type': 'text/plain', + }, + status: 200, + }); + } + + postToArticle(post: Post): [Article, Note] { + const preview = new Note({ + id: this.context.getObjectUri(Note, { id: post.uuid }), + content: post.excerpt, + }); + const article = new Article({ + id: this.context.getObjectUri(Article, { id: post.uuid }), + name: post.title, + content: post.html, + image: toURL(post.feature_image), + published: Temporal.Instant.from(post.published_at), + preview: preview, + url: toURL(post.url), + }); + + return [ + article, + preview, + ]; + } +} + +const Post = z.object({ + uuid: z.string().uuid(), + title: z.string(), + html: z.string(), + excerpt: z.string(), + feature_image: z.string().url().nullable(), + published_at: z.string().datetime(), + url: z.string().url() +}); + +const PostPublishedWebhook = z.object({ + post: z.object({ + current: Post + }) +}); + +export type Post = z.infer + +export type PostPublishedWebhook = z.infer