From bc90ae3db76f30bfa301b8eef9576c4250c0ddc5 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 17 Sep 2024 15:24:53 +0100 Subject: [PATCH] Added dedicated endpoint for retrieving activities for a given actor refs [AP-377](https://linear.app/tryghost/issue/AP-377/inbox-returning-33mb-of-data) Added a new endpoint for retrieving activities for a given actor: ``` GET /.ghost/activitypub/activities/:handle?limit=&cursor= ``` At the moment this returns the same data as the inbox handler but in the future we are likely to want to augment this with additional data (i.e including the actor's own activities). This endpoint is designed to be used by the activity pub UI in Ghost Admin and not other activity pub compliant servers / clients. The returned data is paginated using cursor based pagination. The `cursor` query parameter is optional and is used to fetch the next page of activities. The `limit` query parameter is also optional and is used to limit the number of activities returned in a single request. --- src/app.ts | 11 ++- src/handlers.ts | 194 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 153 insertions(+), 52 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7519f4ee..d14849ca 100644 --- a/src/app.ts +++ b/src/app.ts @@ -56,7 +56,15 @@ import { handleLike } from './dispatchers'; -import { likeAction, unlikeAction, followAction, inboxHandler, postPublishedWebhook, siteChangedWebhook } from './handlers'; +import { + likeAction, + unlikeAction, + followAction, + inboxHandler, + postPublishedWebhook, + siteChangedWebhook, + getActivities +} from './handlers'; if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN }); @@ -277,6 +285,7 @@ app.get('/ping', (ctx) => { }); app.get('/.ghost/activitypub/inbox/:handle', inboxHandler); +app.get('/.ghost/activitypub/activities/:handle', getActivities); app.post('/.ghost/activitypub/webhooks/post/published', postPublishedWebhook); app.post('/.ghost/activitypub/webhooks/site/changed', siteChangedWebhook); app.post('/.ghost/activitypub/actions/follow/:handle', followAction); diff --git a/src/handlers.ts b/src/handlers.ts index eb429fb3..43fd51c0 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -1,6 +1,8 @@ import { Article, + Context as APContext, Follow, + KvStore, Like, Undo, RequestContext, @@ -9,7 +11,7 @@ import { Note, Update, Actor, - PUBLIC_COLLECTION + PUBLIC_COLLECTION, } from '@fedify/fedify'; import { Context, Next } from 'hono'; import sanitizeHtml from 'sanitize-html'; @@ -24,7 +26,7 @@ import { Temporal } from '@js-temporal/polyfill'; import { createHash } from 'node:crypto'; import { lookupActor } from 'lookup-helpers'; -type StoredThing = { +type InboxItem = { id: string; object: string | { id: string; @@ -375,71 +377,102 @@ export async function siteChangedWebhook( }); } +async function buildInboxItem( + uri: string, + db: KvStore, + apCtx: APContext, + liked: string[] = [], +): Promise { + const item = await db.get([uri]); + + // If the item is not in the db, return null as we can't build it + if (!item) { + return null; + } + + // If the object associated with the item is a string, it's probably a URI, + // so we should look it up in the db. If it's not in the db, we should just + // leave it as is + if (typeof item.object === 'string') { + item.object = await db.get([item.object]) ?? item.object; + } + + // If the object associated with the item is an object with a content property, + // we should sanitize the content to prevent XSS (in case it contains HTML) + if (item.object && typeof item.object !== 'string' && item.object.content) { + item.object.content = sanitizeHtml(item.object.content, { + allowedTags: ['a', 'p', 'img', 'br', 'strong', 'em', 'span'], + allowedAttributes: { + a: ['href'], + img: ['src'], + } + }); + } + + // If the associated object is a Like, we should check if it's in the provided + // liked list and add a liked property to the item if it is + let objectId: string = ''; + + if (typeof item.object === 'string') { + objectId = item.object; + } else if (typeof item.object.id === 'string') { + objectId = item.object.id; + } + + if (objectId) { + const likeId = apCtx.getObjectUri(Like, { + id: createHash('sha256').update(objectId).digest('hex'), + }); + if (liked.includes(likeId.href)) { + if (typeof item.object !== 'string') { + item.object.liked = true; + } + } + } + + // Return the built item + return item; +} + export async function inboxHandler( ctx: Context<{ Variables: HonoContextVariables }>, - next: Next, ) { - const liked = (await ctx.get('db').get(['liked'])) || []; - const results = (await ctx.get('db').get(['inbox'])) || []; - const apCtx = fedify.createContext(ctx.req.raw as Request, { - db: ctx.get('db'), - globaldb: ctx.get('globaldb'), - }); - let items: unknown[] = []; - for (const result of results) { - try { - const db = ctx.get('globaldb'); - const thing = await db.get([result]); - if (!thing) { - continue; - } + const db = ctx.get('db'); + const globaldb = ctx.get('globaldb'); + const apCtx = fedify.createContext(ctx.req.raw as Request, {db, globaldb}); - // If the object is a string, it's probably a URI, so we should - // look it up the db. If it's not in the db, we should just leave - // it as is - if (typeof thing.object === 'string') { - thing.object = await db.get([thing.object]) ?? thing.object; - } + // Fetch the liked items from the database: + // - Data is structured as an array of strings + // - Each string is a URI to an object in the database + // This is used to add a "liked" property to the item if the user has liked it + const liked = (await db.get(['liked'])) || []; - // Sanitize HTML content - if (thing.object && typeof thing.object !== 'string') { - thing.object.content = sanitizeHtml(thing.object.content, { - allowedTags: ['a', 'p', 'img', 'br', 'strong', 'em', 'span'], - allowedAttributes: { - a: ['href'], - img: ['src'], - } - }); - } + // Fetch the inbox from the database: + // - Data is structured as an array of strings + // - Each string is a URI to an object in the database + const inbox = (await db.get(['inbox'])) || []; - let objectId: string = ''; - if (typeof thing.object === 'string') { - objectId = thing.object; - } else if (typeof thing.object.id === 'string') { - objectId = thing.object.id; - } + // Prepare the items for the response + const items: unknown[] = []; - if (objectId) { - const likeId = apCtx.getObjectUri(Like, { - id: createHash('sha256').update(objectId).digest('hex'), - }); - if (liked.includes(likeId.href)) { - if (typeof thing.object !== 'string') { - thing.object.liked = true; - } - } - } + for (const item of inbox) { + try { + const builtInboxItem = await buildInboxItem(item, globaldb, apCtx, liked); - items.push(thing); + if (builtInboxItem) { + items.push(builtInboxItem); + } } catch (err) { console.log(err); } } + + // Return the prepared inbox items return new Response( JSON.stringify({ '@context': 'https://www.w3.org/ns/activitystreams', type: 'OrderedCollection', - totalItems: results.length, + totalItems: inbox.length, items, }), { @@ -450,3 +483,62 @@ export async function inboxHandler( }, ); } + +export async function getActivities( + ctx: Context<{ Variables: HonoContextVariables }>, +) { + const DEFAULT_LIMIT = 10; + + const db = ctx.get('db'); + const globaldb = ctx.get('globaldb'); + const apCtx = fedify.createContext(ctx.req.raw as Request, {db, globaldb}); + + // Parse cursor and limit from query parameters + const cursor = parseInt(ctx.req.query('cursor') || '0', 10); + const limit = parseInt(ctx.req.query('limit') || DEFAULT_LIMIT.toString(), 10); + + // Fetch the liked items from the database: + // - Data is structured as an array of strings + // - Each string is a URI to an object in the database + // This is used to add a "liked" property to the item if the user has liked it + const liked = (await db.get(['liked'])) || []; + + // Fetch the inbox from the database: + // - Data is structured as an array of strings + // - Each string is a URI to an object in the database + const inbox = (await ctx.get('db').get(['inbox'])) || []; + + // Slice the results array based on the cursor and limit + const paginatedInbox = inbox.slice(cursor, cursor + limit); + + // Determine the next cursor + const nextCursor = cursor + paginatedInbox.length < inbox.length + ? (cursor + paginatedInbox.length).toString() + : null; + + // Prepare the items for the response + const items = []; + + for (const item of paginatedInbox) { + try { + const builtInboxItem = await buildInboxItem(item, globaldb, apCtx, liked); + + if (builtInboxItem) { + items.push(builtInboxItem); + } + } catch (err) { + console.log(err); + } + } + + // Return the paginated prepared inbox items and the next cursor + return new Response(JSON.stringify({ + items, + nextCursor, + }), { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); +}