From 9b316a0f7999f4486f5a2f26e1fffa24bdf531e5 Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Thu, 12 Dec 2024 11:32:56 +0000 Subject: [PATCH 1/3] Fixed multi tenant handling of inboxes (#232) closes https://linear.app/ghost/issue/AP-551 Fedify previously would not process the same activity twice based on the activity id, but this meant that if it was delivered to two different hosts managed by the same server instance that only the first one would be processed. This has been updated in fedify 1.3.1 to include the origin of the request in the cache key for idempotency checks. --- package.json | 4 ++-- yarn.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 87fdb342..c2ebe01a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "devDependencies": { "@biomejs/biome": "1.9.4", "@cucumber/cucumber": "10.9.0", - "@fedify/cli": "1.3.0", + "@fedify/cli": "1.3.1", "@types/jsonwebtoken": "9.0.7", "@types/node": "20.17.6", "@types/node-jose": "1.1.13", @@ -43,7 +43,7 @@ "wiremock-captain": "3.5.0" }, "dependencies": { - "@fedify/fedify": "1.3.0", + "@fedify/fedify": "1.3.1", "@google-cloud/opentelemetry-cloud-trace-exporter": "2.4.1", "@google-cloud/opentelemetry-cloud-trace-propagator": "0.20.0", "@google-cloud/pubsub": "4.9.0", diff --git a/yarn.lock b/yarn.lock index c0d7bc88..26a7ae90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -496,15 +496,15 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA== -"@fedify/cli@1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@fedify/cli/-/cli-1.3.0.tgz#0097928e38dfd5c0a77bd0ff1554e3134e5c8f27" - integrity sha512-9IsCBTykji7iSBrfWvDeE0A6SCAVZAMorghOYsMUS3ex9wTYdmM7lH3nGHEwkLwep49DNWeLjCj4OYcwvDbVvQ== +"@fedify/cli@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@fedify/cli/-/cli-1.3.1.tgz#56c7708d1c1adb915c38a446e5cc67e1cb974a53" + integrity sha512-fzN56LYYgQerbI0RrRxLHOgh6Mjx0kgPv55x+jkcqOxhg4+15N8ZwqUjmk1rdYI6FrtRuF940h6q0+tt+rjlmQ== -"@fedify/fedify@1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@fedify/fedify/-/fedify-1.3.0.tgz#cf9086bfd40afee84d6771fa3b33488e65cf4a9a" - integrity sha512-U0rUvD0Akl2F8TOwgwbeLoxUINbCsHiHGIWXt5pz3zyJ+1MgehJFPQPpPJR1jfF7iQIDwLik1mRLlXsqGUIQug== +"@fedify/fedify@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@fedify/fedify/-/fedify-1.3.1.tgz#2ff96494224264729d05505e8d0de2edacee9a93" + integrity sha512-1qsCD7kvvij1Ix+PwS98CliDi7+N5ge8z35s+4K4pbsXQkFWNMhMd5w7cAe4cIrMClIyZGTnAYnFLvr+x4BoYQ== dependencies: "@deno/shim-crypto" "~0.3.1" "@deno/shim-deno" "~0.18.0" From 5dc99244726214945e0d0a2a7eae9076005d5a32 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 17 Dec 2024 14:23:40 +0000 Subject: [PATCH 2/3] Ensured activities are delivered to all followers (#233) Ensured activities are delivered to all followers refs [AP-638](https://linear.app/ghost/issue/AP-638/ghost-is-not-delivering-activities-to-all-followers) Added logic to ensure that activities are delivered to all followers and not just the first page of followers --- docker-compose.yml | 4 + features/followers.feature | 15 ++++ features/step_definitions/stepdefs.js | 102 ++++++++++++++++++++++++++ src/constants.ts | 5 -- src/dispatchers.ts | 83 ++++++++++++++------- src/dispatchers.unit.test.ts | 12 +++ 6 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 features/followers.feature diff --git a/docker-compose.yml b/docker-compose.yml index de5d5622..0e5c78de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - MQ_PUBSUB_HOST=pubsub:8085 - MQ_PUBSUB_TOPIC_NAME=activitypub_topic_changeme - MQ_PUBSUB_SUBSCRIPTION_NAME=activitypub_subscription_changeme + - ACTIVITYPUB_COLLECTION_PAGE_SIZE=20 command: yarn build:watch depends_on: migrate: @@ -115,6 +116,7 @@ services: - MQ_PUBSUB_HOST=pubsub-testing:8085 - MQ_PUBSUB_TOPIC_NAME=activitypub_topic_changeme - MQ_PUBSUB_SUBSCRIPTION_NAME=activitypub_subscription_changeme + - ACTIVITYPUB_COLLECTION_PAGE_SIZE=2 command: yarn build:watch depends_on: mysql-testing: @@ -166,6 +168,8 @@ services: - MYSQL_USER=ghost - MYSQL_PASSWORD=password - MYSQL_DATABASE=activitypub + ports: + - "3308:3306" healthcheck: test: "mysql -ughost -ppassword activitypub -e 'select 1'" interval: 1s diff --git a/features/followers.feature b/features/followers.feature new file mode 100644 index 00000000..0cffcc90 --- /dev/null +++ b/features/followers.feature @@ -0,0 +1,15 @@ +Feature: Followers + + Scenario: Activities are sent to all followers + Given we are followed by: + | name | type | + | Alice | Person | + | Bob | Person | + | Charlie | Person | + | Dave | Person | + And the list of followers is paginated across multiple pages + When we create a note "Note" with the content + """ + Hello, world! + """ + Then Activity "Note" is sent to all followers diff --git a/features/step_definitions/stepdefs.js b/features/step_definitions/stepdefs.js index 67e48bc2..1bfee14c 100644 --- a/features/step_definitions/stepdefs.js +++ b/features/step_definitions/stepdefs.js @@ -524,6 +524,51 @@ Given('we follow {string}', async function (name) { } }); +Given('we are followed by:', async function (actors) { + for (const { name, type } of actors.hashes()) { + // Create the actor + this.actors[name] = await createActor(name, { type }); + + // Create the follow activity + const actor = this.actors[name]; + const object = this.actors.Us; + const activity = await createActivity('Follow', object, actor); + + const key = `Follow(Us)_${name}`; + this.activities[key] = activity; + this.objects[key] = object; + + // Send the follow activity to the inbox + this.response = await fetchActivityPub( + 'http://fake-ghost-activitypub/.ghost/activitypub/inbox/index', + { + method: 'POST', + body: JSON.stringify(activity), + }, + ); + + await waitForInboxActivity(activity); + } +}); + +Given('the list of followers is paginated across multiple pages', async () => { + const followersResponse = await fetchActivityPub( + 'http://fake-ghost-activitypub/.ghost/activitypub/followers/index', + ); + const followersResponseJson = await followersResponse.json(); + + const followersFirstPageReponse = await fetchActivityPub( + followersResponseJson.first, + ); + const followersFirstPageReponseJson = + await followersFirstPageReponse.json(); + + assert( + followersFirstPageReponseJson.next, + 'Expected multiple pages of pagination but only got 1', + ); +}); + When('we like the object {string}', async function (name) { const id = this.objects[name].id; this.response = await fetchActivityPub( @@ -868,6 +913,63 @@ Then( }, ); +Then( + 'Activity {string} is sent to all followers', + async function (activityName) { + // Retrieve all followers + const followers = []; + + const followersResponse = await fetchActivityPub( + 'http://fake-ghost-activitypub/.ghost/activitypub/followers/index', + ); + const followersResponseJson = await followersResponse.json(); + + const followersFirstPageResponse = await fetchActivityPub( + followersResponseJson.first, + ); + const followersFirstPageResponseJson = + await followersFirstPageResponse.json(); + + followers.push(...followersFirstPageResponseJson.orderedItems); + + let nextPage = followersFirstPageResponseJson.next; + + while (nextPage) { + const nextPageResponse = await fetchActivityPub(nextPage); + const nextPageResponseJson = await nextPageResponse.json(); + + followers.push(...nextPageResponseJson.orderedItems); + + nextPage = nextPageResponseJson.next; + } + + // Check that the activity was sent to all followers + const activity = this.activities[activityName]; + + for (const follower of followers) { + const inbox = new URL(follower.inbox); + + const found = await waitForRequest( + 'POST', + inbox.pathname, + (call) => { + const json = JSON.parse(call.request.body); + + return ( + json.type === activity.type && + json.object.id === activity.object.id + ); + }, + ); + + assert( + found, + `Activity "${activityName}" was not sent to "${follower.name}"`, + ); + } + }, +); + const webhooks = { 'post.published': { post: { diff --git a/src/constants.ts b/src/constants.ts index bee62924..de19c3e5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,8 +2,3 @@ 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'; - -export const FOLLOWERS_PAGE_SIZE = 20; -export const FOLLOWING_PAGE_SIZE = 20; -export const LIKED_PAGE_SIZE = 20; -export const OUTBOX_PAGE_SIZE = 20; diff --git a/src/dispatchers.ts b/src/dispatchers.ts index dc333369..6eecbea5 100644 --- a/src/dispatchers.ts +++ b/src/dispatchers.ts @@ -23,13 +23,7 @@ import { import * as Sentry from '@sentry/node'; import { v4 as uuidv4 } from 'uuid'; import { type ContextData, fedify } from './app'; -import { - ACTOR_DEFAULT_HANDLE, - FOLLOWERS_PAGE_SIZE, - FOLLOWING_PAGE_SIZE, - LIKED_PAGE_SIZE, - OUTBOX_PAGE_SIZE, -} from './constants'; +import { ACTOR_DEFAULT_HANDLE } from './constants'; import { isFollowing } from './helpers/activitypub/actor'; import { getUserData, getUserKeypair } from './helpers/user'; import { addToList } from './kv-helpers'; @@ -470,6 +464,20 @@ export async function followersDispatcher( ) { ctx.data.logger.info('Followers Dispatcher'); + if (cursor === null) { + ctx.data.logger.info('No cursor provided, returning early'); + + return null; + } + + const pageSize = Number.parseInt( + process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE || '', + ); + + if (Number.isNaN(pageSize)) { + throw new Error(`Page size: ${pageSize} is not valid`); + } + const offset = Number.parseInt(cursor ?? '0'); let nextCursor: string | null = null; @@ -484,11 +492,11 @@ export async function followersDispatcher( if (fullResults) { nextCursor = - fullResults.length > offset + FOLLOWERS_PAGE_SIZE - ? (offset + FOLLOWERS_PAGE_SIZE).toString() + fullResults.length > offset + pageSize + ? (offset + pageSize).toString() : null; - items = fullResults.slice(offset, offset + FOLLOWERS_PAGE_SIZE); + items = fullResults.slice(offset, offset + pageSize); } else { const results = [ // Remove duplicates @@ -496,14 +504,11 @@ export async function followersDispatcher( ]; nextCursor = - results.length > offset + FOLLOWERS_PAGE_SIZE - ? (offset + FOLLOWERS_PAGE_SIZE).toString() + results.length > offset + pageSize + ? (offset + pageSize).toString() : null; - const slicedResults = results.slice( - offset, - offset + FOLLOWERS_PAGE_SIZE, - ); + const slicedResults = results.slice(offset, offset + pageSize); const actors = ( await Promise.all( @@ -511,8 +516,8 @@ export async function followersDispatcher( ) ) // This could potentially mean that the slicedResults is not the size - // of FOLLOWERS_PAGE_SIZE if for some reason the lookupActor returns - // null for some of the results. TODO: Find a better way to handle this + // of pageSize if for some reason the lookupActor returns null for + // some of the results. TODO: Find a better way to handle this .filter((item): item is Actor => isActor(item)); const toStore = await Promise.all( @@ -554,17 +559,25 @@ export async function followingDispatcher( ) { ctx.data.logger.info('Following Dispatcher'); + const pageSize = Number.parseInt( + process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE || '', + ); + + if (Number.isNaN(pageSize)) { + throw new Error(`Page size: ${pageSize} is not valid`); + } + const offset = Number.parseInt(cursor ?? '0'); let nextCursor: string | null = null; const results = (await ctx.data.db.get(['following'])) || []; nextCursor = - results.length > offset + FOLLOWING_PAGE_SIZE - ? (offset + FOLLOWING_PAGE_SIZE).toString() + results.length > offset + pageSize + ? (offset + pageSize).toString() : null; - const slicedResults = results.slice(offset, offset + FOLLOWING_PAGE_SIZE); + const slicedResults = results.slice(offset, offset + pageSize); ctx.data.logger.info('Following results', { results: slicedResults }); @@ -611,6 +624,14 @@ export async function outboxDispatcher( ) { ctx.data.logger.info('Outbox Dispatcher'); + const pageSize = Number.parseInt( + process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE || '', + ); + + if (Number.isNaN(pageSize)) { + throw new Error(`Page size: ${pageSize} is not valid`); + } + const offset = Number.parseInt(cursor ?? '0'); let nextCursor: string | null = null; @@ -619,11 +640,11 @@ export async function outboxDispatcher( ).reverse(); nextCursor = - results.length > offset + OUTBOX_PAGE_SIZE - ? (offset + OUTBOX_PAGE_SIZE).toString() + results.length > offset + pageSize + ? (offset + pageSize).toString() : null; - const slicedResults = results.slice(offset, offset + OUTBOX_PAGE_SIZE); + const slicedResults = results.slice(offset, offset + pageSize); ctx.data.logger.info('Outbox results', { results: slicedResults }); @@ -679,17 +700,25 @@ export async function likedDispatcher( logger, }); + const pageSize = Number.parseInt( + process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE || '', + ); + + if (Number.isNaN(pageSize)) { + throw new Error(`Page size: ${pageSize} is not valid`); + } + const offset = Number.parseInt(cursor ?? '0'); let nextCursor: string | null = null; const results = ((await db.get(['liked'])) || []).reverse(); nextCursor = - results.length > offset + LIKED_PAGE_SIZE - ? (offset + LIKED_PAGE_SIZE).toString() + results.length > offset + pageSize + ? (offset + pageSize).toString() : null; - const slicedResults = results.slice(offset, offset + LIKED_PAGE_SIZE); + const slicedResults = results.slice(offset, offset + pageSize); ctx.data.logger.info('Liked results', { results: slicedResults }); diff --git a/src/dispatchers.unit.test.ts b/src/dispatchers.unit.test.ts index 5ad165b6..62f1c161 100644 --- a/src/dispatchers.unit.test.ts +++ b/src/dispatchers.unit.test.ts @@ -81,6 +81,10 @@ describe('dispatchers', () => { ctx.data.globaldb.get.mockImplementation((key: string[]) => { return Promise.resolve(following[key[0]]); }); + + if (!process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE) { + process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE = '2'; + } }); it('returns items from the following collection in the correct order', async () => { @@ -181,6 +185,10 @@ describe('dispatchers', () => { ctx.data.globaldb.get.mockImplementation((key: string[]) => { return Promise.resolve(likeActivities[key[0]]); }); + + if (!process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE) { + process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE = '2'; + } }); it('returns items from the liked collection in the correct order', async () => { @@ -358,6 +366,10 @@ describe('dispatchers', () => { ctx.data.globaldb.get.mockImplementation((key: string[]) => { return Promise.resolve(outboxActivities[key[0]]); }); + + if (!process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE) { + process.env.ACTIVITYPUB_COLLECTION_PAGE_SIZE = '2'; + } }); it('returns items from the outbox collection in the correct order', async () => { From 4c62b50485b28c19a68982b195dc2e3acbcabd1f Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Wed, 18 Dec 2024 03:28:29 +0000 Subject: [PATCH 3/3] Added support for updating the site actor 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. --- features/step_definitions/stepdefs.js | 22 +++ src/app.ts | 43 +++-- src/handlers.ts | 99 +++++------ src/helpers/activitypub/actor.ts | 76 ++++++++- src/helpers/activitypub/actor.unit.test.ts | 189 ++++++++++++++++++++- src/helpers/user.ts | 82 ++++++--- src/helpers/user.unit.test.ts | 4 +- 7 files changed, 406 insertions(+), 109 deletions(-) diff --git a/features/step_definitions/stepdefs.js b/features/step_definitions/stepdefs.js index 1bfee14c..80f56d89 100644 --- a/features/step_definitions/stepdefs.js +++ b/features/step_definitions/stepdefs.js @@ -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 () => { diff --git a/src/app.ts b/src/app.ts index e8f6a50c..0f5f02f6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -78,6 +78,7 @@ import { } from './dispatchers'; import { followAction, + getSiteDataHandler, inboxHandler, likeAction, noteAction, @@ -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) => { @@ -606,9 +606,6 @@ app.use(async (ctx, next) => { status: 401, }); } - - const scopedDb = scopeKvStore(db, ['sites', host]); - const site = await getSite(host); if (!site) { @@ -618,8 +615,6 @@ app.use(async (ctx, next) => { }); } - ctx.set('db', scopedDb); - ctx.set('globaldb', db); ctx.set('site', site); await next(); diff --git a/src/handlers.ts b/src/handlers.ts index 6db452ec..2533fba9 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -10,7 +10,6 @@ import { PUBLIC_COLLECTION, type RequestContext, Undo, - Update, isActor, } from '@fedify/fedify'; import { Temporal } from '@js-temporal/polyfill'; @@ -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(), @@ -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(['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', diff --git a/src/helpers/activitypub/actor.ts b/src/helpers/activitypub/actor.ts index cb73eaed..f103a27d 100644 --- a/src/helpers/activitypub/actor.ts +++ b/src/helpers/activitypub/actor.ts @@ -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; @@ -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, + 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; +} diff --git a/src/helpers/activitypub/actor.unit.test.ts b/src/helpers/activitypub/actor.unit.test.ts index 4c8a52f6..d1f111f2 100644 --- a/src/helpers/activitypub/actor.unit.test.ts +++ b/src/helpers/activitypub/actor.unit.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; -import { type Actor, type KvStore, PropertyValue } from '@fedify/fedify'; - +import { + type Actor, + type KvStore, + PropertyValue, + type RequestContext, +} from '@fedify/fedify'; + +import type { Logger } from '@logtape/logtape'; +import type { ContextData } from '../../app'; import { getAttachments, getFollowerCount, @@ -9,6 +16,7 @@ import { getHandle, isFollowing, isHandle, + updateSiteActor, } from './actor'; describe('getAttachments', () => { @@ -226,3 +234,180 @@ describe('isHandle', () => { expect(isHandle('@foo@@example.com')).toBe(false); }); }); + +describe('updateSiteActor', () => { + function mockApContext(db: KvStore, globaldb: KvStore) { + return { + data: { + db, + globaldb, + logger: console as unknown as Logger, + }, + getActor: vi.fn().mockResolvedValue({}), + getInboxUri: vi + .fn() + .mockReturnValue(new URL('https://example.com/inbox')), + getOutboxUri: vi + .fn() + .mockReturnValue(new URL('https://example.com/outbox')), + getLikedUri: vi + .fn() + .mockReturnValue(new URL('https://example.com/liked')), + getFollowingUri: vi + .fn() + .mockReturnValue(new URL('https://example.com/following')), + getActorUri: vi + .fn() + .mockReturnValue(new URL('https://example.com/user/1')), + getActorKeyPairs: vi.fn().mockReturnValue([ + { + cryptographicKey: 'abc123', + }, + ]), + getObjectUri: vi + .fn() + .mockReturnValue(new URL('https://example.com')), + getFollowersUri: vi + .fn() + .mockReturnValue(new URL('https://example.com/followers')), + sendActivity: vi.fn(), + host: 'example.com', + } as unknown as RequestContext; + } + + it('should return false if the site settings have not changed', async () => { + const db = { + get: vi.fn().mockResolvedValue({ + id: 'https://example.com/user/1', + name: 'Site Title', + summary: 'Site Description', + preferredUsername: 'index', + icon: 'https://example.com/icon.png', + inbox: 'https://example.com/inbox', + outbox: 'https://example.com/outbox', + following: 'https://example.com/following', + followers: 'https://example.com/followers', + liked: 'https://example.com/liked', + url: 'https://example.com/', + }), + set: vi.fn(), + delete: vi.fn(), + }; + + const globaldb = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + delete: vi.fn(), + }; + + const getSiteSettings = vi.fn().mockResolvedValue({ + site: { + description: 'Site Description', + title: 'Site Title', + icon: 'https://example.com/icon.png', + }, + }); + + const apCtx = mockApContext(db, globaldb); + + const result = await updateSiteActor(apCtx, getSiteSettings); + + expect(result).toBe(false); + }); + + it('should update the site actor if one does not exist', async () => { + const db = { + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn(), + delete: vi.fn(), + }; + + const globaldb = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + delete: vi.fn(), + }; + + const getSiteSettings = vi.fn().mockResolvedValue({ + site: { + description: 'New Site Description', + title: 'New Site Title', + icon: 'https://example.com/icon.png', + }, + }); + + const apCtx = mockApContext(db, globaldb); + + const result = await updateSiteActor(apCtx, getSiteSettings); + + expect(result).toBe(true); + + expect(db.set.mock.lastCall?.[1]).toStrictEqual({ + id: 'https://example.com/user/1', + name: 'New Site Title', + summary: 'New Site Description', + preferredUsername: 'index', + icon: 'https://example.com/icon.png', + inbox: 'https://example.com/inbox', + outbox: 'https://example.com/outbox', + following: 'https://example.com/following', + followers: 'https://example.com/followers', + liked: 'https://example.com/liked', + url: 'https://example.com/', + }); + }); + + it('should update the site actor if the site settings have changed', async () => { + const db = { + get: vi.fn().mockResolvedValue({ + id: 'https://example.com/user/1', + name: 'Site Title', + summary: 'Site Description', + preferredUsername: 'index', + icon: 'https://example.com/icon.png', + inbox: 'https://example.com/inbox', + outbox: 'https://example.com/outbox', + following: 'https://example.com/following', + followers: 'https://example.com/followers', + liked: 'https://example.com/liked', + url: 'https://example.com/', + }), + set: vi.fn(), + delete: vi.fn(), + }; + + const globaldb = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn(), + delete: vi.fn(), + }; + + const getSiteSettings = vi.fn().mockResolvedValue({ + site: { + description: 'New Site Description', + title: 'New Site Title', + icon: 'https://example.com/icon.png', + }, + }); + + const apCtx = mockApContext(db, globaldb); + + const result = await updateSiteActor(apCtx, getSiteSettings); + + expect(result).toBe(true); + + expect(db.set.mock.calls[0][1]).toStrictEqual({ + id: 'https://example.com/user/1', + name: 'New Site Title', + summary: 'New Site Description', + preferredUsername: 'index', + icon: 'https://example.com/icon.png', + inbox: 'https://example.com/inbox', + outbox: 'https://example.com/outbox', + following: 'https://example.com/following', + followers: 'https://example.com/followers', + liked: 'https://example.com/liked', + url: 'https://example.com/', + }); + }); +}); diff --git a/src/helpers/user.ts b/src/helpers/user.ts index 96db2e7e..c108918f 100644 --- a/src/helpers/user.ts +++ b/src/helpers/user.ts @@ -1,5 +1,6 @@ import { type Context, + type CryptographicKey, Image, exportJwk, generateCryptoKeyPair, @@ -26,7 +27,25 @@ export type PersonData = { url: string; }; -export async function getUserData(ctx: Context, handle: string) { +export type UserData = { + id: URL; + name: string; + summary: string; + preferredUsername: string; + icon: Image; + inbox: URL; + outbox: URL; + following: URL; + followers: URL; + liked: URL; + url: URL; + publicKeys: CryptographicKey[]; +}; + +export async function getUserData( + ctx: Context, + handle: string, +): Promise { const existing = await ctx.data.db.get(['handle', handle]); if (existing) { @@ -38,6 +57,7 @@ export async function getUserData(ctx: Context, handle: string) { 'Could not create Image from Icon value ({icon}): {error}', { icon: existing.icon, error: err }, ); + icon = new Image({ url: new URL(ACTOR_DEFAULT_ICON) }); } let url = null; @@ -48,25 +68,33 @@ export async function getUserData(ctx: Context, handle: string) { 'Could not create URL from value ({url}): {error}', { url: existing.url, error: err }, ); + url = new URL(`https://${ctx.host}`); + } + try { + 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), + liked: existing.liked + ? new URL(existing.liked) + : ctx.getLikedUri(handle), + publicKeys: (await ctx.getActorKeyPairs(handle)).map( + (key) => key.cryptographicKey, + ), + url, + }; + } catch (err) { + ctx.data.logger.error( + 'Could not create UserData from store value (id: {id}): {error}', + { id: existing.id, error: 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), - liked: existing.liked - ? new URL(existing.liked) - : ctx.getLikedUri(handle), - publicKeys: (await ctx.getActorKeyPairs(handle)).map( - (key) => key.cryptographicKey, - ), - url, - }; } const data = { @@ -86,12 +114,24 @@ export async function getUserData(ctx: Context, handle: string) { url: new URL(`https://${ctx.host}`), }; + await setUserData(ctx, data, handle); + + return data; +} + +// TODO: Consider using handle from `data` +export async function setUserData( + ctx: Context, + data: UserData, + handle: string, +) { + const iconUrl = data.icon.url?.toString() || ''; const dataToStore: PersonData = { id: data.id.href, name: data.name, summary: data.summary, preferredUsername: data.preferredUsername, - icon: ACTOR_DEFAULT_ICON, + icon: iconUrl, inbox: data.inbox.href, outbox: data.outbox.href, following: data.following.href, @@ -101,8 +141,6 @@ export async function getUserData(ctx: Context, handle: string) { }; await ctx.data.db.set(['handle', handle], dataToStore); - - return data; } export async function getUserKeypair( diff --git a/src/helpers/user.unit.test.ts b/src/helpers/user.unit.test.ts index ff6303d7..9d104e12 100644 --- a/src/helpers/user.unit.test.ts +++ b/src/helpers/user.unit.test.ts @@ -174,7 +174,7 @@ describe('getUserData', () => { name: 'foo', summary: 'bar', preferredUsername: HANDLE, - icon: null, + icon: new Image({ url: new URL(ACTOR_DEFAULT_ICON) }), inbox: new URL(INBOX_URI), outbox: new URL(OUTBOX_URI), liked: new URL(LIKED_URI), @@ -220,7 +220,7 @@ describe('getUserData', () => { following: new URL(FOLLOWING_URI), followers: new URL(FOLLOWERS_URI), publicKeys: ['abc123'], - url: null, + url: new URL(`https://${ctx.host}`), }; expect(ctx.data.db.set).toBeCalledTimes(0);