diff --git a/src/account/account-updated.event.ts b/src/account/account-updated.event.ts new file mode 100644 index 000000000..0c8e3572c --- /dev/null +++ b/src/account/account-updated.event.ts @@ -0,0 +1,13 @@ +import type { Account } from './account.entity'; + +export class AccountUpdatedEvent { + static getName(): string { + return 'account.updated.event'; + } + + constructor(private readonly account: Account) {} + + getAccount(): Account { + return this.account; + } +} diff --git a/src/account/account.entity.ts b/src/account/account.entity.ts index f5fb5d334..592196455 100644 --- a/src/account/account.entity.ts +++ b/src/account/account.entity.ts @@ -22,25 +22,44 @@ export type AccountSite = { host: string; }; +export interface ProfileUpdateParams { + name?: string | null; + bio?: string | null; + avatarUrl?: URL | null; + bannerImageUrl?: URL | null; +} + export class Account extends BaseEntity { public readonly uuid: string; public readonly url: URL; public readonly apId: URL; public readonly apFollowers: URL; + + private _name: string | null; + private _bio: string | null; + private _avatarUrl: URL | null; + private _bannerImageUrl: URL | null; + constructor( public readonly id: number | null, uuid: string | null, public readonly username: string, - public readonly name: string | null, - public readonly bio: string | null, - public readonly avatarUrl: URL | null, - public readonly bannerImageUrl: URL | null, + name: string | null, + bio: string | null, + avatarUrl: URL | null, + bannerImageUrl: URL | null, private readonly site: AccountSite | null, apId: URL | null, url: URL | null, apFollowers: URL | null, ) { super(id); + + this._name = name; + this._bio = bio; + this._avatarUrl = avatarUrl; + this._bannerImageUrl = bannerImageUrl; + if (uuid === null) { this.uuid = randomUUID(); } else { @@ -63,6 +82,40 @@ export class Account extends BaseEntity { } } + get name(): string | null { + return this._name; + } + + get bio(): string | null { + return this._bio; + } + + get avatarUrl(): URL | null { + return this._avatarUrl; + } + + get bannerImageUrl(): URL | null { + return this._bannerImageUrl; + } + + updateProfile(params: ProfileUpdateParams): void { + if (params.name !== undefined) { + this._name = params.name; + } + + if (params.bio !== undefined) { + this._bio = params.bio; + } + + if (params.avatarUrl !== undefined) { + this._avatarUrl = params.avatarUrl; + } + + if (params.bannerImageUrl !== undefined) { + this._bannerImageUrl = params.bannerImageUrl; + } + } + get isInternal() { return this.site !== null; } diff --git a/src/account/account.entity.unit.test.ts b/src/account/account.entity.unit.test.ts index ea32eb0d2..a4995cf71 100644 --- a/src/account/account.entity.unit.test.ts +++ b/src/account/account.entity.unit.test.ts @@ -67,4 +67,182 @@ describe('Account', () => { 'http://foobar.com/.ghost/activitypub/followers/foobar', ); }); + + describe('updateProfile', () => { + it('can update name', () => { + const account = new Account( + 1, + 'test-uuid', + 'testuser', + 'Original Name', + 'Original Bio', + new URL('https://example.com/original-avatar.png'), + new URL('https://example.com/original-banner.png'), + null, + new URL('https://example.com/ap_id'), + new URL('https://example.com/url'), + new URL('https://example.com/followers'), + ); + + account.updateProfile({ name: 'Updated Name' }); + + expect(account.name).toBe('Updated Name'); + expect(account.bio).toBe('Original Bio'); + expect(account.avatarUrl?.href).toBe( + 'https://example.com/original-avatar.png', + ); + expect(account.bannerImageUrl?.href).toBe( + 'https://example.com/original-banner.png', + ); + }); + + it('can update bio', () => { + const account = new Account( + 1, + 'test-uuid', + 'testuser', + 'Original Name', + 'Original Bio', + new URL('https://example.com/original-avatar.png'), + new URL('https://example.com/original-banner.png'), + null, + new URL('https://example.com/ap_id'), + new URL('https://example.com/url'), + new URL('https://example.com/followers'), + ); + + account.updateProfile({ bio: 'Updated Bio' }); + + expect(account.name).toBe('Original Name'); + expect(account.bio).toBe('Updated Bio'); + expect(account.avatarUrl?.href).toBe( + 'https://example.com/original-avatar.png', + ); + expect(account.bannerImageUrl?.href).toBe( + 'https://example.com/original-banner.png', + ); + }); + + it('can update avatarUrl', () => { + const account = new Account( + 1, + 'test-uuid', + 'testuser', + 'Original Name', + 'Original Bio', + new URL('https://example.com/original-avatar.png'), + new URL('https://example.com/original-banner.png'), + null, + new URL('https://example.com/ap_id'), + new URL('https://example.com/url'), + new URL('https://example.com/followers'), + ); + + account.updateProfile({ + avatarUrl: new URL('https://example.com/updated-avatar.png'), + }); + + expect(account.name).toBe('Original Name'); + expect(account.bio).toBe('Original Bio'); + expect(account.avatarUrl?.href).toBe( + 'https://example.com/updated-avatar.png', + ); + expect(account.bannerImageUrl?.href).toBe( + 'https://example.com/original-banner.png', + ); + }); + + it('can update bannerImageUrl', () => { + const account = new Account( + 1, + 'test-uuid', + 'testuser', + 'Original Name', + 'Original Bio', + new URL('https://example.com/original-avatar.png'), + new URL('https://example.com/original-banner.png'), + null, + new URL('https://example.com/ap_id'), + new URL('https://example.com/url'), + new URL('https://example.com/followers'), + ); + + account.updateProfile({ + bannerImageUrl: new URL( + 'https://example.com/updated-banner.png', + ), + }); + + expect(account.name).toBe('Original Name'); + expect(account.bio).toBe('Original Bio'); + expect(account.avatarUrl?.href).toBe( + 'https://example.com/original-avatar.png', + ); + expect(account.bannerImageUrl?.href).toBe( + 'https://example.com/updated-banner.png', + ); + }); + + it('can update multiple properties at once', () => { + const account = new Account( + 1, + 'test-uuid', + 'testuser', + 'Original Name', + 'Original Bio', + new URL('https://example.com/original-avatar.png'), + new URL('https://example.com/original-banner.png'), + null, + new URL('https://example.com/ap_id'), + new URL('https://example.com/url'), + new URL('https://example.com/followers'), + ); + + account.updateProfile({ + name: 'Updated Name', + bio: 'Updated Bio', + avatarUrl: new URL('https://example.com/updated-avatar.png'), + bannerImageUrl: new URL( + 'https://example.com/updated-banner.png', + ), + }); + + expect(account.name).toBe('Updated Name'); + expect(account.bio).toBe('Updated Bio'); + expect(account.avatarUrl?.href).toBe( + 'https://example.com/updated-avatar.png', + ); + expect(account.bannerImageUrl?.href).toBe( + 'https://example.com/updated-banner.png', + ); + }); + + it('can set values to null', () => { + const account = new Account( + 1, + 'test-uuid', + 'testuser', + 'Original Name', + 'Original Bio', + new URL('https://example.com/original-avatar.png'), + new URL('https://example.com/original-banner.png'), + null, + new URL('https://example.com/ap_id'), + new URL('https://example.com/url'), + new URL('https://example.com/followers'), + ); + + account.updateProfile({ + bio: null, + avatarUrl: null, + }); + + expect(account.name).toBe('Original Name'); + expect(account.bio).toBeNull(); + expect(account.avatarUrl).toBeNull(); + expect(account.bannerImageUrl?.href).toBe( + 'https://example.com/original-banner.png', + ); + }); + }); }); diff --git a/src/account/account.repository.knex.integration.test.ts b/src/account/account.repository.knex.integration.test.ts index 67b020b03..d4d7b8f1d 100644 --- a/src/account/account.repository.knex.integration.test.ts +++ b/src/account/account.repository.knex.integration.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, it } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import assert from 'node:assert'; import { AsyncEvents } from 'core/events'; @@ -9,6 +9,7 @@ import { KnexAccountRepository } from '../account/account.repository.knex'; import { AccountService } from '../account/account.service'; import { FedifyContextFactory } from '../activitypub/fedify-context.factory'; import { SiteService } from '../site/site.service'; +import { AccountUpdatedEvent } from './account-updated.event'; import { Account } from './account.entity'; describe('KnexAccountRepository', () => { @@ -182,4 +183,59 @@ describe('KnexAccountRepository', () => { assert(result, 'Account should have been found'); assert(result.uuid !== null, 'Account should have a uuid'); }); + + it('emits AccountUpdatedEvent when an account is saved', async () => { + // Setup + const events = new AsyncEvents(); + const accountRepository = new KnexAccountRepository(client, events); + const emitSpy = vi.spyOn(events, 'emitAsync'); + + // Get an account from the DB to update + const account = await client('accounts').select('*').first(); + + if (!account) { + throw new Error('No account found for test'); + } + + // Create an Account entity + const accountEntity = new Account( + account.id, + account.uuid || 'test-uuid', + account.username, + account.name, + account.bio, + account.avatar_url ? new URL(account.avatar_url) : null, + account.banner_image_url ? new URL(account.banner_image_url) : null, + null, // site + account.ap_id ? new URL(account.ap_id) : null, + account.url ? new URL(account.url) : null, + account.ap_followers_url ? new URL(account.ap_followers_url) : null, + ); + + accountEntity.updateProfile({ + name: 'Updated Name', + bio: 'Updated Bio', + }); + + // Act + await accountRepository.save(accountEntity); + + // Assert + expect(emitSpy).toHaveBeenCalledWith( + AccountUpdatedEvent.getName(), + expect.any(AccountUpdatedEvent), + ); + + // Verify that the event contains the account + const event = emitSpy.mock.calls[0][1] as AccountUpdatedEvent; + expect(event.getAccount()).toBe(accountEntity); + + // Verify the database was updated + const updatedAccount = await client('accounts') + .where({ id: account.id }) + .first(); + + expect(updatedAccount.name).toBe('Updated Name'); + expect(updatedAccount.bio).toBe('Updated Bio'); + }); }); diff --git a/src/account/account.repository.knex.ts b/src/account/account.repository.knex.ts index 5fd188f41..fb3d7a206 100644 --- a/src/account/account.repository.knex.ts +++ b/src/account/account.repository.knex.ts @@ -3,6 +3,7 @@ import type { AsyncEvents } from 'core/events'; import type { Knex } from 'knex'; import { parseURL } from '../core/url'; import type { Site } from '../site/site.service'; +import { AccountUpdatedEvent } from './account-updated.event'; import { Account, type AccountSite } from './account.entity'; export class KnexAccountRepository { @@ -11,6 +12,28 @@ export class KnexAccountRepository { private readonly events: AsyncEvents, ) {} + async save(account: Account): Promise { + if (account.isNew) { + throw new Error( + 'Saving of new Accounts has not been implemented yet.', + ); + } + + await this.db('accounts') + .update({ + name: account.name, + bio: account.bio, + avatar_url: account.avatarUrl?.href, + banner_image_url: account.bannerImageUrl?.href, + }) + .where({ id: account.id }); + + await this.events.emitAsync( + AccountUpdatedEvent.getName(), + new AccountUpdatedEvent(account), + ); + } + async getBySite(site: Site): Promise { const users = await this.db('users').where('site_id', site.id); if (users.length === 0) { diff --git a/src/activitypub/fediverse-bridge.ts b/src/activitypub/fediverse-bridge.ts index e57ff3a8e..e5b1ceb15 100644 --- a/src/activitypub/fediverse-bridge.ts +++ b/src/activitypub/fediverse-bridge.ts @@ -2,6 +2,7 @@ import type EventEmitter from 'node:events'; import { Delete, PUBLIC_COLLECTION, Update } from '@fedify/fedify'; import { PostDeletedEvent } from 'post/post-deleted.event'; import { v4 as uuidv4 } from 'uuid'; +import { AccountUpdatedEvent } from '../account/account-updated.event'; import type { Account } from '../account/types'; import type { FedifyContextFactory } from './fedify-context.factory'; @@ -13,6 +14,10 @@ export class FediverseBridge { async init() { this.events.on('account.updated', this.handleAccountUpdate.bind(this)); + this.events.on( + AccountUpdatedEvent.getName(), + this.handleAccountUpdatedEvent.bind(this), + ); this.events.on( PostDeletedEvent.getName(), this.handlePostDeleted.bind(this), @@ -74,4 +79,34 @@ export class FediverseBridge { }, ); } + + private async handleAccountUpdatedEvent(event: AccountUpdatedEvent) { + const account = event.getAccount(); + if (!account.isInternal) { + return; + } + + const ctx = this.fedifyContextFactory.getFedifyContext(); + + const update = new Update({ + id: ctx.getObjectUri(Update, { id: uuidv4() }), + actor: account.apId, + to: PUBLIC_COLLECTION, + object: account.apId, + cc: account.apFollowers, + }); + + await ctx.data.globaldb.set([update.id!.href], await update.toJsonLd()); + + await ctx.sendActivity( + { + handle: account.username, + }, + 'followers', + update, + { + preferSharedInbox: true, + }, + ); + } }