diff --git a/src/app.ts b/src/app.ts index 13507238..d3431161 100644 --- a/src/app.ts +++ b/src/app.ts @@ -99,11 +99,11 @@ import { createGetProfileFollowersHandler, createGetProfileFollowingHandler, createGetProfileHandler, + createGetThreadHandler, createPostPublishedWebhookHandler, createSearchHandler, handleCreateNote, handleGetActivities, - handleGetActivityThread, handleGetProfilePosts, handleWebhookSiteChanged, } from './http/api'; @@ -916,8 +916,8 @@ app.get( spanWrapper(handleGetProfilePosts), ); app.get( - '/.ghost/activitypub/thread/:object_id', - spanWrapper(handleGetActivityThread), + '/.ghost/activitypub/thread/:post_ap_id', + spanWrapper(createGetThreadHandler(postRepository, accountService)), ); app.get( '/.ghost/activitypub/account/:handle', diff --git a/src/db.ts b/src/db.ts index 5c017943..352fa2f6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -141,32 +141,6 @@ export async function getActivityMetaWithoutJoin( return map; } -export async function getActivityChildren(activity: ActivityJsonLd) { - const objectId = activity.object.id; - - const results = await client - .select('value') - .from('key_value') - .where(function () { - // If inReplyTo is a string - this.where( - client.raw( - `JSON_EXTRACT(value, "$.object.inReplyTo") = "${objectId}"`, - ), - ); - - // If inReplyTo is an object - this.orWhere( - client.raw( - `JSON_EXTRACT(value, "$.object.inReplyTo.id") = "${objectId}"`, - ), - ); - }) - .andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`)); - - return results.map((result) => result.value); -} - export async function getActivityChildrenCount(activity: ActivityJsonLd) { const objectId = activity.object.id; @@ -210,51 +184,3 @@ export async function getRepostCount(activity: ActivityJsonLd) { return result[0].count; } - -export async function getActivityParents(activity: ActivityJsonLd) { - const parents: ActivityJsonLd[] = []; - - const getParent = async (objectId: string) => { - const result = await client - .select('value') - .from('key_value') - .where( - client.raw( - `JSON_EXTRACT(value, "$.object.id") = "${objectId}"`, - ), - ) - .andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`)); - - if (result.length === 1) { - const parent = result[0]; - - parents.unshift(parent.value); - - // inReplyTo can be a string or an object - const inReplyToId = - parent.value.object.inReplyTo?.id ?? - parent.value.object.inReplyTo; - - if (inReplyToId) { - await getParent(inReplyToId); - } - } - }; - - await getParent( - // inReplyTo can be a string or an object - activity.object.inReplyTo?.id ?? activity.object.inReplyTo, - ); - - return parents; -} - -export async function getActivityForObject(objectId: string) { - const result = await client - .select('value') - .from('key_value') - .where(client.raw(`JSON_EXTRACT(value, "$.object.id") = "${objectId}"`)) - .andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`)); - - return result[0].value; -} diff --git a/src/http/api/activities.ts b/src/http/api/activities.ts index d960af76..45439aa6 100644 --- a/src/http/api/activities.ts +++ b/src/http/api/activities.ts @@ -1,12 +1,6 @@ import { type AppContext, fedify } from '../../app'; -import { - getActivityChildren, - getActivityForObject, - getActivityMeta, - getActivityParents, -} from '../../db'; +import { getActivityMeta } from '../../db'; import { buildActivity } from '../../helpers/activitypub/activity'; -import { isUri } from '../../helpers/uri'; import { spanWrapper } from '../../instrumentation'; const GET_ACTIVITIES_DEFAULT_LIMIT = 10; @@ -259,93 +253,3 @@ export async function handleGetActivities(ctx: AppContext) { }, ); } - -interface ActivityJsonLd { - [key: string]: any; -} - -/** - * Handle a request for an activity thread - * - * @param ctx App context instance - */ -export async function handleGetActivityThread(ctx: AppContext) { - const db = ctx.get('db'); - const globaldb = ctx.get('globaldb'); - const logger = ctx.get('logger'); - const apCtx = fedify.createContext(ctx.req.raw as Request, { - db, - globaldb, - logger, - }); - - // Parse "object_id" from request parameters - // /thread/:object_id - const paramObjectId = ctx.req.param('object_id'); - const objectId = paramObjectId ? decodeURIComponent(paramObjectId) : ''; - - // If the provided objectId is invalid, return early - if (isUri(objectId) === false) { - return new Response(null, { status: 400 }); - } - - const activityJsonLd = await getActivityForObject(objectId); - - // If the activity can not be found, return early - if (activityJsonLd === undefined) { - return new Response(null, { status: 404 }); - } - - const items: ActivityJsonLd[] = [activityJsonLd]; - - // If the object is a string, fetch the object from the database. We need to - // do this because we need the inReplyTo property of the object to find the - // parent(s) and children of the activity - if (typeof activityJsonLd.object === 'string') { - const object = await globaldb.get([ - activityJsonLd.object, - ]); - - if (object) { - activityJsonLd.object = object; - } - } - - // Find children (replies) and append to the thread - const children = await getActivityChildren(activityJsonLd); - items.push(...children); - - // Find parent(s) and prepend to the thread - const parents = await getActivityParents(activityJsonLd); - items.unshift(...parents); - - // Build the activities so that they have all the data expected by the client - const likedRefs = (await db.get(['liked'])) || []; - const repostedRefs = (await db.get(['reposted'])) || []; - - const builtActivities = await Promise.all( - items.map((item) => - buildActivity( - item.id, - globaldb, - apCtx, - likedRefs, - repostedRefs, - true, - ), - ), - ); - - // Return the response - return new Response( - JSON.stringify({ - items: builtActivities, - }), - { - headers: { - 'Content-Type': 'application/json', - }, - status: 200, - }, - ); -} diff --git a/src/http/api/index.ts b/src/http/api/index.ts index cee382a7..88625473 100644 --- a/src/http/api/index.ts +++ b/src/http/api/index.ts @@ -4,4 +4,5 @@ export * from './feed'; export * from './note'; export * from './profile'; export * from './search'; +export * from './thread'; export * from './webhook'; diff --git a/src/http/api/thread.ts b/src/http/api/thread.ts new file mode 100644 index 00000000..467c4979 --- /dev/null +++ b/src/http/api/thread.ts @@ -0,0 +1,51 @@ +import type { AccountService } from 'account/account.service'; +import type { AppContext } from '../../app'; +import type { KnexPostRepository } from '../../post/post.repository.knex'; +import { postToDTO } from './helpers/post'; + +/** + * Create a handler for a request for a thread + * + * @param postRepository Post repository instance + */ +export function createGetThreadHandler( + postRepository: KnexPostRepository, + accountService: AccountService, +) { + /** + * Handle a request for a thread + * + * @param ctx App context instance + */ + return async function handleGetThread(ctx: AppContext) { + const paramPostApId = ctx.req.param('post_ap_id'); + const postApId = paramPostApId ? decodeURIComponent(paramPostApId) : ''; + + if (!postApId) { + return new Response(null, { status: 400 }); + } + + const account = await accountService.getDefaultAccountForSite( + ctx.get('site'), + ); + + const posts = ( + await postRepository.getThreadByApId(postApId, account.id) + ).map(({ post, likedByAccount, repostedByAccount }) => { + return postToDTO(post, { + likedByMe: likedByAccount, + repostedByMe: repostedByAccount, + repostedBy: null, + }); + }); + + return new Response( + JSON.stringify({ + posts, + }), + { + status: 200, + }, + ); + }; +} diff --git a/src/post/post.repository.knex.ts b/src/post/post.repository.knex.ts index e76f0c62..4d1ad4b2 100644 --- a/src/post/post.repository.knex.ts +++ b/src/post/post.repository.knex.ts @@ -10,6 +10,12 @@ import { PostDerepostedEvent } from './post-dereposted.event'; import { PostRepostedEvent } from './post-reposted.event'; import { Post } from './post.entity'; +interface ThreadPost { + post: Post; + likedByAccount: boolean; + repostedByAccount: boolean; +} + export class KnexPostRepository { constructor( private readonly db: Knex, @@ -108,6 +114,227 @@ export class KnexPostRepository { return post; } + /** + * Get a thread of posts by AP ID + * + * A thread should include all ancestors (the entire chain of parent posts) + * and all immediate children (direct replies) of the given post + * + * For example, if we have the following posts: + * + * ```text + * POST 1 + * POST 1.1 (child of POST 1) + * POST 1.2 (child of POST 1) + * POST 1.2.1 (child of POST 1.2) + * POST 1.2.2 (child of POST 1.2) + * POST 1.2.2.1 (child of POST 1.2.2) + * POST 1.2.3 (child of POST 1.2) + * POST 2 + * POST 2.1 (child of POST 2) + * POST 2.1.1 (child of POST 2.1) + * POST 3 + * ``` + * + * If we request a thread for POST 1 we should get back: + * + * ```text + * POST 1 (requested post) + * POST 1.1 (immediate child) + * POST 1.2 (immediate child) + * ``` + * + * If we request a thread for post 1.2.2 we should get back: + * + * ```text + * POST 1 (root ancestor) + * POST 1.2 (immediate parent) + * POST 1.2.2 (requested post) + * POST 1.2.2.1 (immediate child) + * ``` + * + * If we request a thread for post 2.1.1 we should get back: + * + * ```text + * POST 2 (root ancestor) + * POST 2.1 (immediate parent) + * POST 2.1.1 (requested post) + * ``` + * + * @param apId AP ID of the post to get the thread for + * @param accountId ID of the account to resolve post metadata for (i.e is + * the post liked by the account, is the post reposted by the account, etc) + */ + async getThreadByApId( + apId: string, + accountId: number, + ): Promise { + // Get the post for the given AP ID + const post = await this.db('posts') + .select('id', 'in_reply_to') + .where('ap_id', apId) + .first(); + + if (!post) { + return []; + } + + const postIdsForThread = []; + + // Recursively find the parent posts of the given post + // and add them to the thread in reverse order so that we can + // eventually return the thread in the correct order + let nextParentId = post.in_reply_to; + + while (nextParentId) { + const parent = await this.db('posts') + .select('in_reply_to') + .where('id', nextParentId) + .first(); + + if (parent) { + postIdsForThread.unshift(nextParentId); + } + + nextParentId = parent.in_reply_to; + } + + // Add the given post to the thread + postIdsForThread.push(post.id); + + // Find all the posts that are immediate children of the given post + for (const row of await this.db('posts') + .select('id') + .where('in_reply_to', post.id)) { + postIdsForThread.push(row.id); + } + + // Get all the posts that are in the thread + const thread = await this.db('posts') + .select( + // Post fields + 'posts.id', + 'posts.uuid', + 'posts.type', + 'posts.audience', + 'posts.title', + 'posts.excerpt', + 'posts.content', + 'posts.url', + 'posts.image_url', + 'posts.published_at', + 'posts.like_count', + 'posts.repost_count', + 'posts.reply_count', + 'posts.reading_time_minutes', + 'posts.attachments', + 'posts.author_id', + 'posts.ap_id', + 'posts.in_reply_to', + 'posts.thread_root', + // Author account fields + 'accounts.username', + 'accounts.uuid as author_uuid', + 'accounts.name', + 'accounts.bio', + 'accounts.avatar_url', + 'accounts.banner_image_url', + 'accounts.ap_id as author_ap_id', + 'accounts.url as author_url', + // Account metadata fields + this.db.raw(` + CASE + WHEN likes.account_id IS NOT NULL THEN 1 + ELSE 0 + END AS liked_by_account + `), + this.db.raw(` + CASE + WHEN reposts.account_id IS NOT NULL THEN 1 + ELSE 0 + END AS reposted_by_account + `), + ) + .join('accounts', 'accounts.id', 'posts.author_id') + .leftJoin('likes', function () { + this.on('likes.post_id', 'posts.id').andOnVal( + 'likes.account_id', + '=', + accountId, + ); + }) + .leftJoin('reposts', function () { + this.on('reposts.post_id', 'posts.id').andOnVal( + 'reposts.account_id', + '=', + accountId, + ); + }) + .whereIn('posts.id', postIdsForThread) + .orderBy('posts.published_at', 'asc'); + + const posts = []; + + for (const row of thread) { + if (!row.author_uuid) { + row.author_uuid = randomUUID(); + await this.db('accounts') + .update({ uuid: row.author_uuid }) + .where({ id: row.author_id }); + } + + const author = new Account( + row.author_id, + row.author_uuid, + row.username, + row.name, + row.bio, + parseURL(row.avatar_url), + parseURL(row.banner_image_url), + null, + parseURL(row.author_ap_id), + parseURL(row.author_url), + ); + + const attachments = row.attachments + ? row.attachments.map((attachment: any) => ({ + ...attachment, + url: new URL(attachment.url), + })) + : []; + + const post = new Post( + row.id, + row.uuid, + author, + row.type, + row.audience, + row.title, + row.excerpt, + row.content, + new URL(row.url), + parseURL(row.image_url), + new Date(row.published_at), + row.like_count, + row.repost_count, + row.reply_count, + row.in_reply_to, + row.thread_root, + row.reading_time_minutes, + attachments, + new URL(row.ap_id), + ); + + posts.push({ + post, + likedByAccount: row.liked_by_account === 1, + repostedByAccount: row.reposted_by_account === 1, + }); + } + + return posts; + } + /** * Save a post to the database *