From c8df52486eeba2cd67634abb4699c8dc5aee394f Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Thu, 25 Jul 2024 14:56:07 +0100 Subject: [PATCH] Implemented Ghost site changed webhook handler (#5) refs [MOM-300](https://linear.app/tryghost/issue/MOM-300/update-actor-data-with-ghost-site-settings#comment-4e49803f) Implemented Ghost site changed webhook handler so that when the settings for a Ghost instance are updated, the actor data is also updated in the database (name, description, icon). For this to work, the Ghost instance needs to setup a webhook for the `Site Changed` event and point it to the `/.ghost/activitypub/webhooks/site/changed` endpoint --- package.json | 1 + src/app.ts | 10 +++- src/constants.ts | 4 ++ src/dispatchers.ts | 117 ++++++--------------------------------------- src/handlers.ts | 87 +++++++++++++++++++++++++++++++++ src/user.ts | 107 +++++++++++++++++++++++++++++++++++++++++ yarn.lock | 36 ++++---------- 7 files changed, 230 insertions(+), 132 deletions(-) create mode 100644 src/constants.ts create mode 100644 src/user.ts diff --git a/package.json b/package.json index 09c3f1db..c85016d4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@sentry/node": "8.13.0", "hono": "4.4.6", "knex": "3.1.0", + "ky": "1.4.0", "mysql2": "3.10.1", "uuid": "10.0.0", "x-forwarded-fetch": "0.2.0" diff --git a/src/app.ts b/src/app.ts index 46a5d3a5..dca93a9b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ import { Group, Organization, Service, + Update, } from '@fedify/fedify'; import { federation } from '@fedify/fedify/x/hono'; import { Hono, Context } from 'hono'; @@ -42,9 +43,10 @@ import { followDispatcher, acceptDispatcher, createDispatcher, + updateDispatcher, } from './dispatchers'; -import { inboxHandler, postPublishedWebhook, followAction } from './handlers'; +import { followAction, inboxHandler, postPublishedWebhook, siteChangedWebhook } from './handlers'; if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN }); @@ -135,6 +137,11 @@ fedify.setObjectDispatcher( `/.ghost/activitypub/create/{id}`, createDispatcher, ); +fedify.setObjectDispatcher( + Update, + `/.ghost/activitypub/update/{id}`, + updateDispatcher, +); /** Hono */ @@ -212,6 +219,7 @@ app.use(async (ctx, next) => { app.get('/.ghost/activitypub/inbox/:handle', inboxHandler); app.post('/.ghost/activitypub/webhooks/post/published', postPublishedWebhook); +app.post('/.ghost/activitypub/webhooks/site/changed', siteChangedWebhook); app.post('/.ghost/activitypub/actions/follow/:handle', followAction); /** Federation wire up */ diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..de19c3e5 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,4 @@ +export const ACTOR_DEFAULT_HANDLE = 'index'; +export const ACTOR_DEFAULT_NAME = 'Local Ghost site'; +export const ACTOR_DEFAULT_ICON = 'https://ghost.org/favicon.ico'; +export const ACTOR_DEFAULT_SUMMARY = 'This is a summary'; diff --git a/src/dispatchers.ts b/src/dispatchers.ts index 762a76ea..229299f8 100644 --- a/src/dispatchers.ts +++ b/src/dispatchers.ts @@ -2,119 +2,18 @@ import { Article, Accept, Follow, - Image, Person, RequestContext, lookupObject, - generateCryptoKeyPair, - exportJwk, - importJwk, Create, Note, Activity, + Update, } from '@fedify/fedify'; import { v4 as uuidv4 } from 'uuid'; import { addToList } from './kv-helpers'; import { ContextData } from './app'; - -type PersonData = { - id: string; - name: string; - summary: string; - preferredUsername: string; - icon: string; - inbox: string; - outbox: string; - following: string; - followers: string; -}; - -async function getUserData(ctx: RequestContext, handle: string) { - const existing = await ctx.data.db.get(['handle', handle]); - - if (existing) { - let icon = null; - try { - icon = new Image({ url: new URL(existing.icon) }); - } catch (err) { - console.log('Could not create Image from Icon value', existing.icon); - console.log(err); - } - return { - id: new URL(existing.id), - name: existing.name, - summary: existing.summary, - preferredUsername: existing.preferredUsername, - icon, - inbox: new URL(existing.inbox), - outbox: new URL(existing.outbox), - following: new URL(existing.following), - followers: new URL(existing.followers), - publicKeys: (await ctx.getActorKeyPairs(handle)).map( - (key) => key.cryptographicKey, - ), - }; - } - - const data = { - id: ctx.getActorUri(handle), - name: `Local Ghost site`, - summary: 'This is a summary', - preferredUsername: handle, - icon: new Image({ url: new URL('https://ghost.org/favicon.ico') }), - inbox: ctx.getInboxUri(handle), - outbox: ctx.getOutboxUri(handle), - following: ctx.getFollowingUri(handle), - followers: ctx.getFollowersUri(handle), - publicKeys: (await ctx.getActorKeyPairs(handle)).map( - (key) => key.cryptographicKey, - ), - }; - - const dataToStore: PersonData = { - id: data.id.href, - name: data.name, - summary: data.summary, - preferredUsername: data.preferredUsername, - icon: 'https://ghost.org/favicon.ico', - inbox: data.inbox.href, - outbox: data.outbox.href, - following: data.following.href, - followers: data.followers.href, - }; - - await ctx.data.db.set(['handle', handle], data); - - return data; -} - -async function getUserKeypair(ctx: ContextData, handle: string) { - const existing = await ctx.db.get<{ publicKey: any; privateKey: any }>([ - 'keypair', - handle, - ]); - - if (existing) { - return { - publicKey: await importJwk(existing.publicKey, 'public'), - privateKey: await importJwk(existing.privateKey, 'private'), - }; - } - - const keys = await generateCryptoKeyPair(); - - const data = { - publicKey: keys.publicKey, - privateKey: keys.privateKey, - }; - - await ctx.db.set(['keypair', handle], { - publicKey: await exportJwk(data.publicKey), - privateKey: await exportJwk(data.privateKey), - }); - - return data; -} +import { getUserData, getUserKeypair } from './user'; export async function actorDispatcher( ctx: RequestContext, @@ -384,6 +283,18 @@ export async function createDispatcher( return Create.fromJsonLd(exists); } +export async function updateDispatcher( + ctx: RequestContext, + data: Record<'id', string>, +) { + const id = ctx.getObjectUri(Update, data); + const exists = await ctx.data.globaldb.get([id.href]); + if (!exists) { + return null; + } + return Update.fromJsonLd(exists); +} + export async function noteDispatcher( ctx: RequestContext, data: Record<'id', string>, diff --git a/src/handlers.ts b/src/handlers.ts index 6a89af8a..4da448c1 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -6,12 +6,44 @@ import { isActor, Create, Note, + Update, + PUBLIC_COLLECTION } from '@fedify/fedify'; import { Context, Next } from 'hono'; +import ky from 'ky'; import { v4 as uuidv4 } from 'uuid'; import { addToList } from './kv-helpers'; import { toURL } from './toURL'; import { ContextData, HonoContextVariables, fedify } from './app'; +import type { PersonData } from './user'; +import { + ACTOR_DEFAULT_HANDLE, + ACTOR_DEFAULT_ICON, + ACTOR_DEFAULT_NAME, + ACTOR_DEFAULT_SUMMARY +} from './constants'; + +type GhostSiteSettings = { + site: { + description: string; + icon: string; + title: string; + } +} + +async function getGhostSiteSettings(host: string): Promise { + const settings = await ky + .get(`https://${host}/ghost/api/admin/site/`) + .json>(); + + return { + site: { + description: settings?.site?.description || ACTOR_DEFAULT_SUMMARY, + title: settings?.site?.title || ACTOR_DEFAULT_NAME, + icon: settings?.site?.icon || ACTOR_DEFAULT_ICON + } + }; +} async function postToArticle(ctx: RequestContext, post: any) { if (!post) { @@ -121,6 +153,61 @@ export async function postPublishedWebhook( }); } +export async function siteChangedWebhook( + ctx: Context<{ Variables: HonoContextVariables }>, + next: Next, +) { + try { + // Retrieve site settings from Ghost + const host = ctx.req.header('host') || ''; + + const settings = await getGhostSiteSettings(host); + + // Update the database + const handle = ACTOR_DEFAULT_HANDLE; + const db = ctx.get('db'); + + const current = await db.get(['handle', handle]); + const updated = { + ...current, + icon: settings.site.icon, + name: settings.site.title, + summary: settings.site.description, + } + + await db.set(['handle', handle], updated); + + // Publish activity + const apCtx = fedify.createContext(ctx.req.raw as Request, { + db, + globaldb: ctx.get('globaldb'), + }); + + const actor = await apCtx.getActor(handle); + + const update = new Update({ + id: apCtx.getObjectUri(Update, { id: uuidv4() }), + actor: actor?.id, + to: PUBLIC_COLLECTION, + object: actor + }); + + await ctx.get('globaldb').set([update.id!.href], await update.toJsonLd()); + await addToList(db, ['outbox'], update.id!.href); + await apCtx.sendActivity({ handle }, 'followers', update); + } catch (err) { + console.log(err); + } + + // Return 200 OK + return new Response(JSON.stringify({}), { + headers: { + 'Content-Type': 'application/activity+json', + }, + status: 200, + }); +} + export async function inboxHandler( ctx: Context<{ Variables: HonoContextVariables }>, next: Next, diff --git a/src/user.ts b/src/user.ts new file mode 100644 index 00000000..ce074854 --- /dev/null +++ b/src/user.ts @@ -0,0 +1,107 @@ +import { + Image, + RequestContext, + generateCryptoKeyPair, + exportJwk, + importJwk, +} from '@fedify/fedify'; +import { ContextData } from './app'; + +export type PersonData = { + id: string; + name: string; + summary: string; + preferredUsername: string; + icon: string; + inbox: string; + outbox: string; + following: string; + followers: string; +}; + +export async function getUserData(ctx: RequestContext, handle: string) { + const existing = await ctx.data.db.get(['handle', handle]); + + if (existing) { + let icon = null; + try { + icon = new Image({ url: new URL(existing.icon) }); + } catch (err) { + console.log('Could not create Image from Icon value', existing.icon); + console.log(err); + } + return { + id: new URL(existing.id), + name: existing.name, + summary: existing.summary, + preferredUsername: existing.preferredUsername, + icon, + inbox: new URL(existing.inbox), + outbox: new URL(existing.outbox), + following: new URL(existing.following), + followers: new URL(existing.followers), + publicKeys: (await ctx.getActorKeyPairs(handle)).map( + (key) => key.cryptographicKey, + ), + }; + } + + const data = { + id: ctx.getActorUri(handle), + name: `Local Ghost site`, + summary: 'This is a summary', + preferredUsername: handle, + icon: new Image({ url: new URL('https://ghost.org/favicon.ico') }), + inbox: ctx.getInboxUri(handle), + outbox: ctx.getOutboxUri(handle), + following: ctx.getFollowingUri(handle), + followers: ctx.getFollowersUri(handle), + publicKeys: (await ctx.getActorKeyPairs(handle)).map( + (key) => key.cryptographicKey, + ), + }; + + const dataToStore: PersonData = { + id: data.id.href, + name: data.name, + summary: data.summary, + preferredUsername: data.preferredUsername, + icon: 'https://ghost.org/favicon.ico', + inbox: data.inbox.href, + outbox: data.outbox.href, + following: data.following.href, + followers: data.followers.href, + }; + + await ctx.data.db.set(['handle', handle], data); + + return data; +} + +export async function getUserKeypair(ctx: ContextData, handle: string) { + const existing = await ctx.db.get<{ publicKey: any; privateKey: any }>([ + 'keypair', + handle, + ]); + + if (existing) { + return { + publicKey: await importJwk(existing.publicKey, 'public'), + privateKey: await importJwk(existing.privateKey, 'private'), + }; + } + + const keys = await generateCryptoKeyPair(); + + const data = { + publicKey: keys.publicKey, + privateKey: keys.privateKey, + }; + + await ctx.db.set(['keypair', handle], { + publicKey: await exportJwk(data.publicKey), + privateKey: await exportJwk(data.privateKey), + }); + + return data; +} diff --git a/yarn.lock b/yarn.lock index ca8413ba..823bc287 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1412,6 +1412,11 @@ ky-universal@^0.11.0: abort-controller "^3.0.0" node-fetch "^3.2.10" +ky@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ky/-/ky-1.4.0.tgz#68b4a71eccfb4177199fe6ee2d5041b50bb41931" + integrity sha512-tPhhoGUiEiU/WXR4rt8klIoLdnTtyu+9jVKHd/wauEjYud32jyn63mzKWQweaQrHWxBQtYoVtdcEnYX1LosnFQ== + ky@^0.33.3: version "0.33.3" resolved "https://registry.yarnpkg.com/ky/-/ky-0.33.3.tgz#bf1ad322a3f2c3428c13cfa4b3af95e6c4a2f543" @@ -1907,16 +1912,7 @@ sqlstring@^2.3.2: resolved "https://registry.yarnpkg.com/sqlstring/-/sqlstring-2.3.3.tgz#2ddc21f03bce2c387ed60680e739922c65751d0c" integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -1934,14 +1930,7 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -2102,16 +2091,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==