Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement account service #271

Merged
merged 1 commit into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions src/account/account.service.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it } from 'vitest';

import {
ACTOR_DEFAULT_ICON,
ACTOR_DEFAULT_NAME,
ACTOR_DEFAULT_SUMMARY,
AP_BASE_PATH,
TABLE_ACCOUNTS,
TABLE_FOLLOWS,
TABLE_SITES,
TABLE_USERS,
} from '../constants';
import { client as db } from '../db';

import { AccountService } from './account.service';
import type { ExternalAccountData, Site } from './types';

describe('AccountService', () => {
let service: AccountService;
let site: Site;

beforeEach(async () => {
// Clean up the database
await db.raw('SET FOREIGN_KEY_CHECKS = 0');
await db(TABLE_FOLLOWS).truncate();
await db(TABLE_ACCOUNTS).truncate();
await db(TABLE_USERS).truncate();
await db(TABLE_SITES).truncate();
await db.raw('SET FOREIGN_KEY_CHECKS = 1');

// Insert a site
const siteData = {
host: 'example.com',
webhook_secret: 'secret',
};
const [id] = await db('sites').insert(siteData);

site = {
id,
...siteData,
};

// Create the service
service = new AccountService(db);
});

it('should create an internal account', async () => {
const username = 'foobarbaz';

const expectedAccount = {
name: ACTOR_DEFAULT_NAME,
username,
bio: ACTOR_DEFAULT_SUMMARY,
avatar_url: ACTOR_DEFAULT_ICON,
url: `https://${site.host}`,
custom_fields: null,
ap_id: `https://${site.host}${AP_BASE_PATH}/users/${username}`,
ap_inbox_url: `https://${site.host}${AP_BASE_PATH}/inbox/${username}`,
ap_outbox_url: `https://${site.host}${AP_BASE_PATH}/outbox/${username}`,
ap_following_url: `https://${site.host}${AP_BASE_PATH}/following/${username}`,
ap_followers_url: `https://${site.host}${AP_BASE_PATH}/followers/${username}`,
ap_liked_url: `https://${site.host}${AP_BASE_PATH}/liked/${username}`,
ap_shared_inbox_url: null,
};

const account = await service.createInternalAccount(site, username);

// Assert the created account was returned
expect(account).toMatchObject(expectedAccount);
expect(account.id).toBeGreaterThan(0);
expect(account.ap_public_key).toBeDefined();
expect(account.ap_public_key).toContain('key_ops');
expect(account.ap_private_key).toBeDefined();
expect(account.ap_private_key).toContain('key_ops');

// Assert the account was inserted into the database
const accounts = await db(TABLE_ACCOUNTS).select('*');

expect(accounts).toHaveLength(1);

const dbAccount = accounts[0];

expect(dbAccount).toMatchObject(expectedAccount);

// Assert the user was inserted into the database
const users = await db(TABLE_USERS).select('*');

expect(users).toHaveLength(1);

const dbUser = users[0];

expect(dbUser.account_id).toBe(account.id);
expect(dbUser.site_id).toBe(site.id);
});

it('should create an external account', async () => {
const accountData: ExternalAccountData = {
username: 'external-account',
name: 'External Account',
bio: 'External Account Bio',
avatar_url: 'https://example.com/avatars/external-account.png',
banner_image_url:
'https://example.com/banners/external-account.png',
url: 'https://example.com/users/external-account',
custom_fields: {},
ap_id: 'https://example.com/activitypub/users/external-account',
ap_inbox_url:
'https://example.com/activitypub/inbox/external-account',
ap_outbox_url:
'https://example.com/activitypub/outbox/external-account',
ap_following_url:
'https://example.com/activitypub/following/external-account',
ap_followers_url:
'https://example.com/activitypub/followers/external-account',
ap_liked_url:
'https://example.com/activitypub/liked/external-account',
ap_shared_inbox_url: null,
ap_public_key: '',
};

const account = await service.createExternalAccount(accountData);

// Assert the created account was returned
expect(account).toMatchObject(accountData);
expect(account.id).toBeGreaterThan(0);

// Assert the account was inserted into the database
const accounts = await db(TABLE_ACCOUNTS).select('*');

expect(accounts).toHaveLength(1);

const dbAccount = accounts[0];

expect(dbAccount).toMatchObject(accountData);
});

it('should record an account being followed', async () => {
const account = await service.createInternalAccount(site, 'account');
const follower = await service.createInternalAccount(site, 'follower');

await service.recordAccountFollow(account, follower);

// Assert the follow was inserted into the database
const follows = await db(TABLE_FOLLOWS).select('*');

expect(follows).toHaveLength(1);

const follow = follows[0];

expect(follow.following_id).toBe(account.id);
expect(follow.follower_id).toBe(follower.id);
});
});
103 changes: 103 additions & 0 deletions src/account/account.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { exportJwk, generateCryptoKeyPair } from '@fedify/fedify';
import type { Knex } from 'knex';

import {
ACTOR_DEFAULT_ICON,
ACTOR_DEFAULT_NAME,
ACTOR_DEFAULT_SUMMARY,
AP_BASE_PATH,
TABLE_ACCOUNTS,
TABLE_FOLLOWS,
TABLE_USERS,
} from '../constants';
import type { Account, ExternalAccountData, Site } from './types';

export class AccountService {
/**
* @param db Database client
*/
constructor(private readonly db: Knex) {}

/**
* Create an internal account
*
* An internal account is an account that is linked to a user
*
* @param site Site that the account belongs to
* @param username Username for the account
*/
async createInternalAccount(
site: Site,
username: string,
): Promise<Account> {
const keyPair = await generateCryptoKeyPair();

const accountData = {
name: ACTOR_DEFAULT_NAME,
username,
bio: ACTOR_DEFAULT_SUMMARY,
avatar_url: ACTOR_DEFAULT_ICON,
banner_image_url: null,
url: `https://${site.host}`,
custom_fields: null,
ap_id: `https://${site.host}${AP_BASE_PATH}/users/${username}`,
ap_inbox_url: `https://${site.host}${AP_BASE_PATH}/inbox/${username}`,
ap_shared_inbox_url: null,
ap_outbox_url: `https://${site.host}${AP_BASE_PATH}/outbox/${username}`,
ap_following_url: `https://${site.host}${AP_BASE_PATH}/following/${username}`,
ap_followers_url: `https://${site.host}${AP_BASE_PATH}/followers/${username}`,
ap_liked_url: `https://${site.host}${AP_BASE_PATH}/liked/${username}`,
ap_public_key: JSON.stringify(await exportJwk(keyPair.publicKey)),
ap_private_key: JSON.stringify(await exportJwk(keyPair.privateKey)),
};

return await this.db.transaction(async (tx) => {
const [accountId] = await tx(TABLE_ACCOUNTS).insert(accountData);

await tx(TABLE_USERS).insert({
account_id: accountId,
site_id: site.id,
});

return {
id: accountId,
...accountData,
};
});
}

/**
* Create an external account
*
* An external account is an account that is not linked to a user
*
* @param accountData Data for the external account
*/
async createExternalAccount(
accountData: ExternalAccountData,
): Promise<Account> {
const [accountId] = await this.db(TABLE_ACCOUNTS).insert(accountData);

return {
id: accountId,
...accountData,
ap_private_key: null,
};
}

/**
* Record an account follow
*
* @param account Account being followed
* @param follower Follower account
*/
async recordAccountFollow(
account: Account,
follower: Account,
): Promise<void> {
await this.db(TABLE_FOLLOWS).insert({
following_id: account.id,
follower_id: follower.id,
});
}
}
36 changes: 36 additions & 0 deletions src/account/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Site
*/
export interface Site {
id: number;
host: string;
webhook_secret: string;
}

/**
* Account
*/
export interface Account {
id: number;
username: string;
name: string | null;
bio: string | null;
avatar_url: string | null;
banner_image_url: string | null;
url: string | null;
custom_fields: Record<string, string> | null;
ap_id: string;
ap_inbox_url: string;
ap_shared_inbox_url: string | null;
ap_outbox_url: string;
ap_following_url: string;
ap_followers_url: string;
ap_liked_url: string;
ap_public_key: string;
ap_private_key: string | null;
}

/**
* Data used when creating an external account
*/
export type ExternalAccountData = Omit<Account, 'id' | 'ap_private_key'>;
7 changes: 7 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export const AP_BASE_PATH = '/.ghost/activitypub';

export const ACTOR_DEFAULT_HANDLE = 'index';
export const ACTOR_DEFAULT_NAME = 'Local Ghost site';
export const ACTOR_DEFAULT_ICON = 'https://ghost.org/favicon.ico';
export const ACTOR_DEFAULT_SUMMARY = 'This is a summary';

export const TABLE_ACCOUNTS = 'accounts';
export const TABLE_FOLLOWS = 'follows';
export const TABLE_SITES = 'sites';
export const TABLE_USERS = 'users';
Loading