Skip to content

Commit

Permalink
Updated /accounts API to use Accounts
Browse files Browse the repository at this point in the history
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!
  • Loading branch information
allouis committed Jan 23, 2025
1 parent e564100 commit 8102ffe
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 66 deletions.
74 changes: 74 additions & 0 deletions src/account/account.service.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,22 @@ export class AccountService {
.orderBy(`${TABLE_ACCOUNTS}.id`, 'desc');
}

async getFollowingCount(account: Account): Promise<number> {
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<number> {
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<Account | null> {
const rows = await this.db(TABLE_ACCOUNTS).select('*').where({ id });

Expand Down
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
133 changes: 68 additions & 65 deletions src/http/api/accounts.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,7 +34,7 @@ interface DbAccountData {
/**
* Follow account shape - Used when returning a list of follows
*/
type FollowAccount = Pick<Account, 'id' | 'name' | 'handle' | 'avatarUrl'>;
type FollowAccount = Pick<AccountDTO, 'id' | 'name' | 'handle' | 'avatarUrl'>;

/**
* Compute the handle for an account from the provided host and username
Expand Down Expand Up @@ -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<DbAccountData>(['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
Expand Down

0 comments on commit 8102ffe

Please sign in to comment.