From 8102ffe4fdda39fa467706d1440038815fd94c10 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Thu, 23 Jan 2025 18:44:08 +0400 Subject: [PATCH] Updated /accounts API to use Accounts closes https://linear.app/ghost/issue/AP-666 This also needed to add some methods to getthe follower/following counts. Now our accounts API actually uses the accounts table! --- .../account.service.integration.test.ts | 74 ++++++++++ src/account/account.service.ts | 16 +++ src/app.ts | 2 +- src/http/api/accounts.ts | 133 +++++++++--------- 4 files changed, 159 insertions(+), 66 deletions(-) diff --git a/src/account/account.service.integration.test.ts b/src/account/account.service.integration.test.ts index 17ebcf08..a8b50bb5 100644 --- a/src/account/account.service.integration.test.ts +++ b/src/account/account.service.integration.test.ts @@ -330,6 +330,80 @@ describe('AccountService', () => { ); }); + describe('getFollowingCount', () => { + it( + 'should retrieve the following count for an account', + async () => { + const account = await service.createInternalAccount( + site, + 'account', + ); + const following1 = await service.createInternalAccount( + site, + 'following1', + ); + const following2 = await service.createInternalAccount( + site, + 'following2', + ); + const following3 = await service.createInternalAccount( + site, + 'following3', + ); + + await service.recordAccountFollow(following1, account); + await service.recordAccountFollow(following2, account); + await service.recordAccountFollow(following3, account); + + const rows = await db('follows').select('*'); + console.log(rows); + + // Get a page of followed accounts and assert the requested fields are returned + const followingCount = await service.getFollowingCount(account); + + expect(followingCount).toBe(3); + }, + 1000 * 10, // Increase timeout to 10 seconds as 5 seconds seems to be too short on CI + ); + }); + + describe('getFollowerCount', () => { + it( + 'should retrieve the follower count for an account', + async () => { + const account = await service.createInternalAccount( + site, + 'account', + ); + const follower1 = await service.createInternalAccount( + site, + 'follower1', + ); + const follower2 = await service.createInternalAccount( + site, + 'follower2', + ); + const follower3 = await service.createInternalAccount( + site, + 'follower3', + ); + + await service.recordAccountFollow(account, follower1); + await service.recordAccountFollow(account, follower2); + await service.recordAccountFollow(account, follower3); + + const rows = await db('follows').select('*'); + console.log(rows); + + // Get a page of followed accounts and assert the requested fields are returned + const followerCount = await service.getFollowerCount(account); + + expect(followerCount).toBe(3); + }, + 1000 * 10, // Increase timeout to 10 seconds as 5 seconds seems to be too short on CI + ); + }); + it('should update accounts and emit an account.updated event if they have changed', async () => { const account = await service.createInternalAccount( site, diff --git a/src/account/account.service.ts b/src/account/account.service.ts index 16b7611f..4c52d793 100644 --- a/src/account/account.service.ts +++ b/src/account/account.service.ts @@ -183,6 +183,22 @@ export class AccountService { .orderBy(`${TABLE_ACCOUNTS}.id`, 'desc'); } + async getFollowingCount(account: Account): Promise { + const rows = await this.db(TABLE_FOLLOWS) + .count('id', { as: 'following' }) + .where(`${TABLE_FOLLOWS}.follower_id`, account.id); + + return rows[0].following as number; + } + + async getFollowerCount(account: Account): Promise { + const rows = await this.db(TABLE_FOLLOWS) + .count('id', { as: 'followers' }) + .where(`${TABLE_FOLLOWS}.following_id`, account.id); + + return rows[0].followers as number; + } + async getByInternalId(id: number): Promise { const rows = await this.db(TABLE_ACCOUNTS).select('*').where({ id }); diff --git a/src/app.ts b/src/app.ts index 6eb22364..b2cc5713 100644 --- a/src/app.ts +++ b/src/app.ts @@ -811,7 +811,7 @@ app.get( app.get( '/.ghost/activitypub/account/:handle', requireRole(GhostRole.Owner), - spanWrapper(handleGetAccount), + spanWrapper(handleGetAccount(siteService, accountService)), ); app.get( '/.ghost/activitypub/account/:handle/follows/:type', diff --git a/src/http/api/accounts.ts b/src/http/api/accounts.ts index 200cbd47..a9f3f0df 100644 --- a/src/http/api/accounts.ts +++ b/src/http/api/accounts.ts @@ -1,10 +1,12 @@ import type { KvStore } from '@fedify/fedify'; +import type { AccountService } from '../../account/account.service'; import type { AppContext } from '../../app'; import { fedify } from '../../app'; import { sanitizeHtml } from '../../helpers/html'; import { lookupActor } from '../../lookup-helpers'; -import type { Account } from './types'; +import type { SiteService } from '../../site/site.service'; +import type { Account as AccountDTO } from './types'; /** * Maximum number of follows to return @@ -32,7 +34,7 @@ interface DbAccountData { /** * Follow account shape - Used when returning a list of follows */ -type FollowAccount = Pick; +type FollowAccount = Pick; /** * Compute the handle for an account from the provided host and username @@ -97,75 +99,76 @@ async function getFollowerCount(db: KvStore) { * * @param ctx App context */ -export async function handleGetAccount(ctx: AppContext) { - const logger = ctx.get('logger'); +export const handleGetAccount = ( + siteService: SiteService, + accountService: AccountService, +) => + 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 }); + } - // Validate input - const handle = ctx.req.param('handle') || ''; + const db = ctx.get('db'); + let accountDto: AccountDTO; - if (handle === '') { - return new Response(null, { status: 400 }); - } + const site = await siteService.getSiteByHost(ctx.get('site').host); + if (!site) { + return new Response(null, { status: 404 }); + } - // Get account data - const db = ctx.get('db'); - const accountData = await db.get(['handle', handle]); + const account = await siteService.getDefaultAccountForSite(site); - if (!accountData) { - return new Response(null, { status: 404 }); - } + try { + accountDto = { + /** + * At the moment we don't have an internal ID for Ghost accounts so + * we use Fediverse ID + */ + id: account.ap_id, + name: account.name || '', + handle: getHandle(site.host, account.username), + bio: sanitizeHtml(account.bio || ''), + url: account.url || '', + avatarUrl: account.avatar_url || '', + /** + * At the moment we don't support banner images for Ghost accounts + */ + bannerImageUrl: account.banner_image_url, + /** + * At the moment we don't support custom fields for Ghost accounts + */ + customFields: {}, + postCount: await getPostCount(db), + likedCount: await getLikedCount(db), + followingCount: await accountService.getFollowingCount(account), + followerCount: await accountService.getFollowerCount(account), + /** + * 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 }); - 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.url, - 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: {}, - postCount: await getPostCount(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 new Response(null, { status: 500 }); + } - // Return response - return new Response(JSON.stringify(account), { - headers: { - 'Content-Type': 'application/json', - }, - status: 200, - }); -} + // Return response + return new Response(JSON.stringify(accountDto), { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); + }; /** * Handle a request for a list of follows