-
-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added implementation of account service
no refs Added implementation of account service
- Loading branch information
Showing
4 changed files
with
299 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |