Skip to content

Commit

Permalink
Implemented Ghost site changed webhook handler (#5)
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 authored Jul 25, 2024
1 parent c952c9e commit c8df524
Show file tree
Hide file tree
Showing 7 changed files with 230 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
10 changes: 9 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Group,
Organization,
Service,
Update,
} from '@fedify/fedify';
import { federation } from '@fedify/fedify/x/hono';
import { Hono, Context } from 'hono';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -135,6 +137,11 @@ fedify.setObjectDispatcher(
`/.ghost/activitypub/create/{id}`,
createDispatcher,
);
fedify.setObjectDispatcher(
Update,
`/.ghost/activitypub/update/{id}`,
updateDispatcher,
);

/** Hono */

Expand Down Expand Up @@ -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 */
Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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';
117 changes: 14 additions & 103 deletions src/dispatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 Expand Up @@ -384,6 +283,18 @@ export async function createDispatcher(
return Create.fromJsonLd(exists);
}

export async function updateDispatcher(
ctx: RequestContext<ContextData>,
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<ContextData>,
data: Record<'id', string>,
Expand Down
87 changes: 87 additions & 0 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GhostSiteSettings> {
const settings = await ky
.get(`https://${host}/ghost/api/admin/site/`)
.json<Partial<GhostSiteSettings>>();

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<ContextData>, post: any) {
if (!post) {
Expand Down Expand Up @@ -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<PersonData>(['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,
Expand Down
Loading

0 comments on commit c8df524

Please sign in to comment.