Skip to content

Commit

Permalink
Added dedicated endpoint for retrieving activities for a given actor
Browse files Browse the repository at this point in the history
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=<number>&cursor=<string>
```

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.
  • Loading branch information
mike182uk committed Sep 17, 2024
1 parent e09f23e commit bc90ae3
Show file tree
Hide file tree
Showing 2 changed files with 153 additions and 52 deletions.
11 changes: 10 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down
194 changes: 143 additions & 51 deletions src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
Article,
Context as APContext,
Follow,
KvStore,
Like,
Undo,
RequestContext,
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -375,71 +377,102 @@ export async function siteChangedWebhook(
});
}

async function buildInboxItem(
uri: string,
db: KvStore,
apCtx: APContext<ContextData>,
liked: string[] = [],
): Promise<InboxItem | null> {
const item = await db.get<InboxItem>([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<string[]>(['liked'])) || [];
const results = (await ctx.get('db').get<string[]>(['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<StoredThing>([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<string[]>(['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<string[]>(['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,
}),
{
Expand All @@ -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<string[]>(['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<string[]>(['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,
});
}

0 comments on commit bc90ae3

Please sign in to comment.