From 276d835a1b418c2385cd82988295ac8d0b4f0afc Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 15 Jan 2025 14:10:51 +0000 Subject: [PATCH] Added API endpoints for retrieving the current users account and follows refs [AP-647](https://linear.app/ghost/issue/AP-648/refactor-profile-tab-to-use-account-and-follows) Added API endpoints for retrieving the current users account and follows so that the UI can be refactored to use these endpoints instead of the ActivityPub endpoints directly. This lets us iteratively move towards utilising the new database schema for retrieving account and follows data --- src/app.ts | 13 +- src/http/api/accounts.ts | 318 +++++++++++++++++++++++++++++++++++++++ src/http/api/index.ts | 3 +- 3 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 src/http/api/accounts.ts diff --git a/src/app.ts b/src/app.ts index 83c46bbd..41209e97 100644 --- a/src/app.ts +++ b/src/app.ts @@ -80,6 +80,8 @@ import { import { getTraceContext } from './helpers/context-header'; import { getRequestData } from './helpers/request-data'; import { + handleGetAccount, + handleGetAccountFollows, handleGetActivities, handleGetActivityThread, handleGetFollowers, @@ -765,7 +767,16 @@ app.get( '/.ghost/activitypub/thread/:activity_id', spanWrapper(handleGetActivityThread), ); - +app.get( + '/.ghost/activitypub/account/:handle', + requireRole(GhostRole.Owner), + spanWrapper(handleGetAccount), +); +app.get( + '/.ghost/activitypub/account/:handle/follows/:type', + requireRole(GhostRole.Owner), + spanWrapper(handleGetAccountFollows), +); /** Federation wire up */ app.use( diff --git a/src/http/api/accounts.ts b/src/http/api/accounts.ts new file mode 100644 index 00000000..48dc03dc --- /dev/null +++ b/src/http/api/accounts.ts @@ -0,0 +1,318 @@ +import type { KvStore } from '@fedify/fedify'; + +import type { AppContext } from '../../app'; +import { fedify } from '../../app'; +import { sanitizeHtml } from '../../helpers/html'; +import { lookupActor } from '../../lookup-helpers'; + +/** + * Maximum number of follows to return + */ +const FOLLOWS_LIMIT = 20; + +/** + * Account data stored in the database - This should correspond to the shape + * of the data when retrieved from the Fediverse + */ +interface DbAccountData { + id: string; + name: string; + summary: string; + preferredUsername: string; + icon: string; + inbox: string; + outbox: string; + following: string; + followers: string; + liked: string; + url: string; +} + +/** + * Account returned by the API - Anywhere an account is returned via the API, + * it should be this shape, or a partial version of it + */ +interface Account { + /** + * Internal ID of the account + */ + id: string; + /** + * Display name of the account + */ + name: string; + /** + * Handle of the account + */ + handle: string; + /** + * Bio of the account + */ + bio: string; + /** + * Public URL of the account + */ + url: string; + /** + * URL pointing to the avatar of the account + */ + avatarUrl: string; + /** + * URL pointing to the banner image of the account + */ + bannerImageUrl: string | null; + /** + * Custom fields of the account + */ + customFields: Record; + /** + * Number of posts created by the account + */ + postsCount: number; + /** + * Number of liked posts by the account + */ + likedCount: number; + /** + * Number of accounts this account follows + */ + followingCount: number; + /** + * Number of accounts following this account + */ + followerCount: number; + /** + * Whether the account of the current user is followed by this account + */ + followsMe: boolean; + /** + * Whether the account of the current user is following this account + */ + followedByMe: boolean; +} + +/** + * Minimal account shape - Used when returning a list of follows + */ +type MinimalAccount = Pick; + +/** + * Compute the handle for an account from the provided host and username + * + * @param host Host of the account + * @param username Username of the account + */ +function getHandle(host?: string, username?: string) { + return `@${username || 'unknown'}@${host || 'unknown'}`; +} + +/** + * Retreive the count of posts created by the account from the database + * + * @param db Database instance + */ +async function getPostsCount(db: KvStore) { + const posts = await db.get(['outbox']); + + return posts?.length || 0; +} + +/** + * Retreive the count of posts liked by the account from the database + * + * @param db Database instance + */ +async function getLikedCount(db: KvStore) { + const liked = await db.get(['liked']); + + return liked?.length || 0; +} + +/** + * Retreive the count of accounts this account follows from the database + * + * @param db Database instance + */ +async function getFollowingCount(db: KvStore) { + const following = await db.get(['following']); + + return following?.length || 0; +} + +/** + * Retreive the count of accounts following this account from the database + * + * @param db Database instance + */ +async function getFollowerCount(db: KvStore) { + const followers = await db.get(['followers']); + + return followers?.length || 0; +} + +/** + * Handle a request for an account + * + * @param ctx App context + */ +export async function handleGetAccount(ctx: AppContext) { + const logger = ctx.get('logger'); + + // Validate input + const handle = ctx.req.param('handle') || ''; + + if (handle === '') { + return new Response(null, { status: 400 }); + } + + // Get account data + const db = ctx.get('db'); + const accountData = await db.get(['handle', handle]); + + if (!accountData) { + return new Response(null, { status: 404 }); + } + + let account: Account; + + try { + account = { + /** + * At the moment we don't have an internal ID for Ghost accounts so + * we use Fediverse ID + */ + id: accountData.id, + name: accountData.name, + handle: getHandle( + new URL(accountData.id).host, + accountData.preferredUsername, + ), + bio: sanitizeHtml(accountData.summary), + url: accountData.id, + avatarUrl: accountData.icon, + /** + * At the moment we don't support banner images for Ghost accounts + */ + bannerImageUrl: null, + /** + * At the moment we don't support custom fields for Ghost accounts + */ + customFields: {}, + postsCount: await getPostsCount(db), + likedCount: await getLikedCount(db), + followingCount: await getFollowingCount(db), + followerCount: await getFollowerCount(db), + /** + * At the moment we only expect to be returning the account for + * the current user, so we can hardcode these values to false as + * the account cannot follow, or be followed by itself + */ + followsMe: false, + followedByMe: false, + }; + } catch (error) { + logger.error('Error getting account: {error}', { error }); + + return new Response(null, { status: 500 }); + } + + // Return response + return new Response(JSON.stringify(account), { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); +} + +/** + * Handle a request for a list of follows + * + * @param ctx App context + */ +export async function handleGetAccountFollows(ctx: AppContext) { + const logger = ctx.get('logger'); + + // Validate input + const handle = ctx.req.param('handle') || ''; + + if (handle === '') { + return new Response(null, { status: 400 }); + } + + const type = ctx.req.param('type'); + + if (!['following', 'followers'].includes(type)) { + return new Response(null, { status: 400 }); + } + + // Get follows and paginate + const queryNext = ctx.req.query('next') || '0'; + const offset = Number.parseInt(queryNext); + + const db = ctx.get('db'); + const follows = (await db.get([type])) || []; + + const next = + follows.length > offset + FOLLOWS_LIMIT + ? (offset + FOLLOWS_LIMIT).toString() + : null; + + const slicedFollows = follows.slice(offset, offset + FOLLOWS_LIMIT); + + // Get required data for each follow account + const apCtx = fedify.createContext(ctx.req.raw as Request, { + db, + globaldb: ctx.get('globaldb'), + logger: ctx.get('logger'), + }); + + const accounts: MinimalAccount[] = []; + + for (const followId of slicedFollows) { + try { + const accountData = await lookupActor(apCtx, followId); + + if (accountData) { + const id = accountData.id; + + if (!id) { + continue; + } + + accounts.push({ + /** + * At the moment we don't have an internal ID for accounts + * so we use Fediverse ID + */ + id: id.href, + name: accountData.name?.toString() || 'unknown', + handle: getHandle( + id.host, + accountData.preferredUsername?.toString(), + ), + avatarUrl: + (await accountData.getIcon())?.url?.href?.toString() || + '', + }); + } + } catch (error) { + logger.error('Error getting account: {error}', { error }); + } + } + + // Return response + return new Response( + JSON.stringify({ + accounts, + total: follows.length, + next, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }, + ); +} diff --git a/src/http/api/index.ts b/src/http/api/index.ts index 13820c08..676e6370 100644 --- a/src/http/api/index.ts +++ b/src/http/api/index.ts @@ -1,6 +1,5 @@ export * from './activities'; -export * from './activities'; -export * from './follows'; +export * from './accounts'; export * from './follows'; export * from './posts'; export * from './profiles';