Skip to content

Commit

Permalink
Implemented Ghost site changed webhook handler
Browse files Browse the repository at this point in the history
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
`<host>/.ghost/activitypub/webhooks/site/changed` endpoint
  • Loading branch information
mike182uk committed Jul 24, 2024
1 parent c952c9e commit b3c404f
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 132 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
createDispatcher,
} 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 });
Expand Down Expand Up @@ -212,6 +212,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 */
Expand Down
104 changes: 1 addition & 103 deletions src/dispatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,119 +2,17 @@ import {
Article,
Accept,
Follow,
Image,
Person,
RequestContext,
lookupObject,
generateCryptoKeyPair,
exportJwk,
importJwk,
Create,
Note,
Activity,
} 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<ContextData>, handle: string) {
const existing = await ctx.data.db.get<PersonData>(['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<ContextData>,
Expand Down
43 changes: 43 additions & 0 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@ import {
Note,
} 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';

type GhostSiteSettings = {
site: {
description: string;
icon: string;
title: string;
}
}

async function postToArticle(ctx: RequestContext<ContextData>, post: any) {
if (!post) {
Expand Down Expand Up @@ -121,6 +131,39 @@ export async function postPublishedWebhook(
});
}

export async function siteChangedWebhook(
ctx: Context<{ Variables: HonoContextVariables }>,
next: Next,
) {
// Retrieve site settings from Ghost
const host = ctx.req.header('host')

const settings = await ky
.get(`https://${host}/ghost/api/admin/site/`)
.json<GhostSiteSettings>();

// Update the database
const handle = 'index';
const db = ctx.get('db');

const current = await db.get<PersonData>(['handle', handle]);

await db.set(['handle', handle], {
...current,
icon: settings.site.icon,
name: settings.site.title,
summary: settings.site.description,
});

// 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,
Expand Down
107 changes: 107 additions & 0 deletions src/user.ts
Original file line number Diff line number Diff line change
@@ -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<ContextData>, handle: string) {
const existing = await ctx.data.db.get<PersonData>(['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;
}
36 changes: 8 additions & 28 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,11 @@ ky-universal@^0.11.0:
abort-controller "^3.0.0"
node-fetch "^3.2.10"

[email protected]:
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"
Expand Down Expand Up @@ -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==
Expand All @@ -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==
Expand Down Expand Up @@ -2102,16 +2091,7 @@ [email protected]:
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==
Expand Down

0 comments on commit b3c404f

Please sign in to comment.