Skip to content

Commit

Permalink
Added support for updating the site actor
Browse files Browse the repository at this point in the history
closes https://linear.app/ghost/issue/AP-592
closes https://linear.app/ghost/issue/AP-637

We've refactored the update of the site actor so that it can be used in multiple places.
On top of that we've refactored fetching user data from the database, and setting it,
and all places which need this data use the helpers so we can ensure that all data stored
is complete. This fixes the bug AP-637 which was caused by incomplete user data stored
in the database.

With the refactored update of the site actor we're able to include updates when we fetch site
settings, which happens on every boot of Ghost - this ensures that newly onboarded sites
as existing sites will always update their actor to have the latest data on boot.
  • Loading branch information
allouis authored Dec 18, 2024
1 parent 5dc9924 commit 4c62b50
Show file tree
Hide file tree
Showing 7 changed files with 406 additions and 109 deletions.
22 changes: 22 additions & 0 deletions features/step_definitions/stepdefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,28 @@ BeforeAll(async () => {
},
},
);

ghostActivityPub.register(
{
method: 'GET',
endpoint: '/ghost/api/admin/site',
},
{
status: 200,
body: {
settings: {
site: {
title: 'Testing Blog',
icon: 'https://ghost.org/favicon.ico',
description: 'A blog for testing',
},
},
},
headers: {
'Content-Type': 'application/json',
},
},
);
});

AfterAll(async () => {
Expand Down
43 changes: 19 additions & 24 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
} from './dispatchers';
import {
followAction,
getSiteDataHandler,
inboxHandler,
likeAction,
noteAction,
Expand Down Expand Up @@ -571,30 +572,29 @@ if (queue instanceof GCloudPubSubPushMessageQueue) {
app.post('/.ghost/activitypub/mq', spanWrapper(handlePushMessage(queue)));
}

app.use(async (ctx, next) => {
const request = ctx.req;
const host = request.header('host');
if (!host) {
ctx.get('logger').info('No Host header');
return new Response('No Host header', {
status: 401,
});
}

const scopedDb = scopeKvStore(db, ['sites', host]);

ctx.set('db', scopedDb);
ctx.set('globaldb', db);

await next();
});
// This needs to go before the middleware which loads the site
// Because the site doesn't always exist - this is how it's created
app.get(
'/.ghost/activitypub/site',
requireRole(GhostRole.Owner),
async (ctx) => {
const request = ctx.req;
const host = request.header('host');
if (!host) {
ctx.get('logger').info('No Host header');
return new Response('No Host header', {
status: 401,
});
}

const site = await getSite(host, true);

return new Response(JSON.stringify(site), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
},
getSiteDataHandler,
);

app.use(async (ctx, next) => {
Expand All @@ -606,9 +606,6 @@ app.use(async (ctx, next) => {
status: 401,
});
}

const scopedDb = scopeKvStore(db, ['sites', host]);

const site = await getSite(host);

if (!site) {
Expand All @@ -618,8 +615,6 @@ app.use(async (ctx, next) => {
});
}

ctx.set('db', scopedDb);
ctx.set('globaldb', db);
ctx.set('site', site);

await next();
Expand Down
99 changes: 41 additions & 58 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
PUBLIC_COLLECTION,
type RequestContext,
Undo,
Update,
isActor,
} from '@fedify/fedify';
import { Temporal } from '@js-temporal/polyfill';
Expand All @@ -22,13 +21,15 @@ import {
buildActivity,
prepareNoteContent,
} from './helpers/activitypub/activity';
import { getSiteSettings } from './helpers/ghost';
import { toURL } from './helpers/uri';
import type { PersonData } from './helpers/user';
import { getUserData } from './helpers/user';
import { addToList, removeFromList } from './kv-helpers';
import { lookupActor, lookupObject } from './lookup-helpers';

import z from 'zod';
import { getSite } from './db';
import { updateSiteActor } from './helpers/activitypub/actor';
import { getSiteSettings } from './helpers/ghost';

const PostSchema = z.object({
uuid: z.string().uuid(),
Expand Down Expand Up @@ -545,80 +546,62 @@ export async function postPublishedWebhook(
});
}

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

const settings = await getSiteSettings(host);

// Retrieve the persisted actor details and check if anything has changed
const handle = ACTOR_DEFAULT_HANDLE;
const db = ctx.get('db');
const request = ctx.req;
const host = request.header('host');
if (!host) {
ctx.get('logger').info('No Host header');
return new Response('No Host header', {
status: 401,
});
}

const current = await db.get<PersonData>(['handle', handle]);
const handle = ACTOR_DEFAULT_HANDLE;
const apCtx = fedify.createContext(ctx.req.raw as Request, {
db: ctx.get('db'),
globaldb: ctx.get('globaldb'),
logger: ctx.get('logger'),
});

if (
current &&
current.icon === settings.site.icon &&
current.name === settings.site.title &&
current.summary === settings.site.description
) {
ctx.get('logger').info('No site settings changed, nothing to do');
const site = await getSite(host, true);

return new Response(JSON.stringify({}), {
headers: {
'Content-Type': 'application/activity+json',
},
status: 200,
});
}
// This is to ensure that the actor exists - e.g. for a brand new a site
await getUserData(apCtx, handle);

ctx.get('logger').info('Site settings changed, will notify followers');
await updateSiteActor(apCtx, getSiteSettings);

// Update the database if the site settings have changed
const updated = {
...current,
icon: settings.site.icon,
name: settings.site.title,
summary: settings.site.description,
};
return new Response(JSON.stringify(site), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}

await db.set(['handle', handle], updated);
export async function siteChangedWebhook(
ctx: Context<{ Variables: HonoContextVariables }>,
) {
try {
const host = ctx.req.header('host') || '';
const db = ctx.get('db');
const globaldb = ctx.get('globaldb');
const logger = ctx.get('logger');

// Publish activity if the site settings have changed
const apCtx = fedify.createContext(ctx.req.raw as Request, {
db,
globaldb: ctx.get('globaldb'),
logger: ctx.get('logger'),
globaldb,
logger,
});

const actor = await apCtx.getActor(handle);

const update = new Update({
id: apCtx.getObjectUri(Update, { id: uuidv4() }),
actor: actor?.id,
to: PUBLIC_COLLECTION,
object: actor?.id,
cc: apCtx.getFollowersUri('index'),
});

await ctx
.get('globaldb')
.set([update.id!.href], await update.toJsonLd());
await apCtx.sendActivity({ handle }, 'followers', update, {
preferSharedInbox: true,
});
await updateSiteActor(apCtx, getSiteSettings);
} catch (err) {
ctx.get('logger').error('Site changed webhook failed: {error}', {
error: err,
});
}

// Return 200 OK
return new Response(JSON.stringify({}), {
headers: {
'Content-Type': 'application/activity+json',
Expand Down
76 changes: 75 additions & 1 deletion src/helpers/activitypub/actor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import { type Actor, type KvStore, PropertyValue } from '@fedify/fedify';
import {
type Actor,
Image,
type KvStore,
PUBLIC_COLLECTION,
PropertyValue,
type RequestContext,
Update,
} from '@fedify/fedify';
import { v4 as uuidv4 } from 'uuid';
import type { ContextData } from '../../app';
import { ACTOR_DEFAULT_HANDLE } from '../../constants';
import { type UserData, getUserData, setUserData } from '../user';

interface Attachment {
name: string;
Expand Down Expand Up @@ -63,3 +75,65 @@ export async function isFollowing(
export function isHandle(handle: string): boolean {
return /^@([\w-]+)@([\w-]+\.[\w.-]+)$/.test(handle);
}

export async function updateSiteActor(
apCtx: RequestContext<ContextData>,
getSiteSettings: (host: string) => Promise<{
site: { icon: string; title: string; description: string };
}>,
) {
const settings = await getSiteSettings(apCtx.host);
const handle = ACTOR_DEFAULT_HANDLE;

const current = await getUserData(apCtx, handle);

if (
current &&
current.icon.url?.toString() === settings.site.icon &&
current.name === settings.site.title &&
current.summary === settings.site.description
) {
apCtx.data.logger.info(
'No site settings changed, not updating site actor',
);
return false;
}

const updated: UserData = {
...current,
};

try {
updated.icon = new Image({ url: new URL(settings.site.icon) });
} catch (err) {
apCtx.data.logger.error(
'Could not create Image from Icon value ({icon}): {error}',
{ icon: settings.site.icon, error: err },
);
}

updated.name = settings.site.title;
updated.summary = settings.site.description;

await setUserData(apCtx, updated, handle);

apCtx.data.logger.info('Site settings changed, will notify followers');

const actor = await apCtx.getActor(handle);

const update = new Update({
id: apCtx.getObjectUri(Update, { id: uuidv4() }),
actor: actor?.id,
to: PUBLIC_COLLECTION,
object: actor?.id,
cc: apCtx.getFollowersUri('index'),
});

await apCtx.data.globaldb.set([update.id!.href], await update.toJsonLd());

await apCtx.sendActivity({ handle }, 'followers', update, {
preferSharedInbox: true,
});

return true;
}
Loading

0 comments on commit 4c62b50

Please sign in to comment.