Skip to content

Commit

Permalink
Added implementation of account service
Browse files Browse the repository at this point in the history
no refs

Added implementation of account service
  • Loading branch information
mike182uk authored Jan 21, 2025
1 parent b8cd920 commit 664cc8d
Show file tree
Hide file tree
Showing 4 changed files with 299 additions and 0 deletions.
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';

0 comments on commit 664cc8d

Please sign in to comment.