From c0d9c8587f951d87d255860ab399dd69a9db70d6 Mon Sep 17 00:00:00 2001 From: John O'Nolan Date: Thu, 21 May 2026 13:57:06 +0700 Subject: [PATCH 1/3] Add alternate WebFinger domain support Allow Ghost ActivityPub sites hosted on subdomains to expose handles on an alternate domain while preserving the existing actor URL and keys. This adds nullable account-level WebFinger host storage, strict live validation, authenticated domain APIs, WebFinger lookup behavior before host-data loading, and handle rendering updates across API views. Validation remains synchronous so a custom handle is only saved after the requested domain resolves to the current actor. --- ...81_add-webfinger-host-to-accounts.down.sql | 9 + ...0081_add-webfinger-host-to-accounts.up.sql | 14 + src/account/account.entity.ts | 28 ++ ...ccount.repository.knex.integration.test.ts | 69 +++++ src/account/account.repository.knex.ts | 84 +++++- .../account.service.integration.test.ts | 29 ++ src/account/account.service.ts | 197 +++++++++++- src/account/account.service.unit.test.ts | 284 ++++++++++++++++++ src/account/types.ts | 6 +- src/account/utils.ts | 57 ++++ src/account/utils.unit.test.ts | 43 +++ src/app.ts | 13 +- src/configuration/registrations.ts | 29 +- src/dispatchers.unit.test.ts | 6 + src/feed/feed.service.ts | 6 + src/helpers/activitypub/activity.ts | 3 +- src/http/api/account.controller.ts | 120 +++++++- src/http/api/account.controller.unit.test.ts | 95 ++++++ src/http/api/feed.controller.ts | 8 +- src/http/api/feed.unit.test.ts | 4 + src/http/api/helpers/post.ts | 7 +- src/http/api/notification.controller.ts | 5 +- src/http/api/site.controller.ts | 24 +- src/http/api/site.controller.unit.test.ts | 18 ++ src/http/api/views/account.follows.view.ts | 10 +- src/http/api/views/account.posts.view.ts | 19 +- .../account.search.view.integration.test.ts | 19 ++ src/http/api/views/account.search.view.ts | 31 +- src/http/api/views/account.view.ts | 13 +- .../api/views/blocks.view.integration.test.ts | 27 ++ src/http/api/views/blocks.view.ts | 18 +- .../views/explore.view.integration.test.ts | 40 +++ src/http/api/views/explore.view.ts | 12 +- .../recommendations.view.integration.test.ts | 38 +++ src/http/api/views/recommendations.view.ts | 12 +- src/http/api/views/reply.chain.view.ts | 14 +- src/http/api/webfinger.controller.ts | 127 +++++++- src/http/api/webfinger.unit.test.ts | 225 +++++++++++++- src/http/host-data-context-loader.ts | 2 + src/http/routing/route-registry.ts | 17 +- src/http/routing/route-registry.unit.test.ts | 23 ++ .../__snapshots__/get-notifications-data.json | 5 + src/notification/notification.service.ts | 2 + src/post/post.entity.ts | 5 +- .../post.repository.knex.integration.test.ts | 2 + src/post/post.repository.knex.ts | 12 +- 46 files changed, 1752 insertions(+), 79 deletions(-) create mode 100644 migrate/migrations/000081_add-webfinger-host-to-accounts.down.sql create mode 100644 migrate/migrations/000081_add-webfinger-host-to-accounts.up.sql diff --git a/migrate/migrations/000081_add-webfinger-host-to-accounts.down.sql b/migrate/migrations/000081_add-webfinger-host-to-accounts.down.sql new file mode 100644 index 000000000..ebe505961 --- /dev/null +++ b/migrate/migrations/000081_add-webfinger-host-to-accounts.down.sql @@ -0,0 +1,9 @@ +DROP INDEX idx_accounts_username_webfinger_host_hash ON accounts; +DROP INDEX idx_accounts_webfinger_host_hash ON accounts; +DROP INDEX idx_accounts_fulltext_search ON accounts; +CREATE FULLTEXT INDEX idx_accounts_fulltext_search + ON accounts(name, username, domain); + +ALTER TABLE accounts + DROP COLUMN webfinger_host_hash, + DROP COLUMN webfinger_host; diff --git a/migrate/migrations/000081_add-webfinger-host-to-accounts.up.sql b/migrate/migrations/000081_add-webfinger-host-to-accounts.up.sql new file mode 100644 index 000000000..b0c7228c5 --- /dev/null +++ b/migrate/migrations/000081_add-webfinger-host-to-accounts.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE accounts + ADD COLUMN webfinger_host VARCHAR(255) NULL, + ADD COLUMN webfinger_host_hash BINARY(32) + GENERATED ALWAYS AS (UNHEX(SHA2(LOWER(webfinger_host), 256))) STORED; + +DROP INDEX idx_accounts_fulltext_search ON accounts; +CREATE FULLTEXT INDEX idx_accounts_fulltext_search + ON accounts(name, username, domain, webfinger_host); + +CREATE UNIQUE INDEX idx_accounts_username_webfinger_host_hash + ON accounts(username, webfinger_host_hash); + +CREATE INDEX idx_accounts_webfinger_host_hash + ON accounts(webfinger_host_hash); diff --git a/src/account/account.entity.ts b/src/account/account.entity.ts index 60dc51ca2..8a8785247 100644 --- a/src/account/account.entity.ts +++ b/src/account/account.entity.ts @@ -32,6 +32,7 @@ export interface Account { readonly apLiked: URL | null; readonly isInternal: boolean; readonly customFields: Record | null; + readonly webfingerHost: string | null; unblock(account: Account): Account; block(account: Account): Account; blockDomain(domain: URL): Account; @@ -43,6 +44,7 @@ export interface Account { * Returns a new Account instance which needs to be saved. */ updateProfile(params: ProfileUpdateParams): Account; + setWebfingerHost(webfingerHost: string | null): Account; addAlias(alias: URL): Account; removeAlias(alias: URL): Account; /** @@ -70,6 +72,7 @@ export interface AccountDraft { apPublicKey: CryptoKey; apPrivateKey: CryptoKey | null; isInternal: boolean; + webfingerHost: string | null; } export type AccountEvent = { @@ -94,6 +97,7 @@ export class AccountEntity implements Account { public readonly apLiked: URL | null, public readonly isInternal: boolean, public readonly customFields: Record | null, + public readonly webfingerHost: string | null, private events: AccountEvent[], ) {} @@ -124,6 +128,7 @@ export class AccountEntity implements Account { data.apLiked, data.isInternal, data.customFields, + data.webfingerHost, events, ); } @@ -147,6 +152,7 @@ export class AccountEntity implements Account { draft.apLiked, draft.isInternal, draft.customFields, + draft.webfingerHost, events, ); } @@ -174,6 +180,7 @@ export class AccountEntity implements Account { : new URL('/.ghost/activitypub/liked/index', from.host); const url = from.url || apId; const apPrivateKey = !from.isInternal ? null : from.apPrivateKey; + const webfingerHost = from.webfingerHost ?? null; return { ...from, @@ -187,6 +194,7 @@ export class AccountEntity implements Account { apFollowing, apLiked, apPrivateKey, + webfingerHost, }; } @@ -258,6 +266,24 @@ export class AccountEntity implements Account { ); } + setWebfingerHost(webfingerHost: string | null): Account { + const account = AccountEntity.create( + { + ...this, + webfingerHost, + }, + this.events, + ); + + if (account.webfingerHost !== this.webfingerHost) { + account.events = account.events.concat( + new AccountUpdatedEvent(account.id), + ); + } + + return account; + } + removeAlias(alias: URL): Account { return AccountEntity.create( this, @@ -355,6 +381,7 @@ type InternalAccountDraftData = { customFields: Record | null; apPublicKey: CryptoKey; apPrivateKey: CryptoKey; + webfingerHost?: string | null; }; /** @@ -377,6 +404,7 @@ type ExternalAccountDraftData = { apFollowing: URL | null; apLiked: URL | null; apPublicKey: CryptoKey; + webfingerHost?: string | null; }; type AccountDraftData = InternalAccountDraftData | ExternalAccountDraftData; diff --git a/src/account/account.repository.knex.integration.test.ts b/src/account/account.repository.knex.integration.test.ts index fdcfb01b8..4e99b8d58 100644 --- a/src/account/account.repository.knex.integration.test.ts +++ b/src/account/account.repository.knex.integration.test.ts @@ -774,6 +774,75 @@ describe('KnexAccountRepository', () => { fromDraftSpy.mockRestore(); }); + it('persists and resolves a custom WebFinger host', async () => { + const site = await fixtureManager.createSite('blog.example.com'); + const draftData = await createInternalAccountDraftData({ + host: new URL(`https://${site.host}`), + username: 'index', + name: 'Test', + bio: null, + url: new URL(`https://${site.host}`), + avatarUrl: null, + bannerImageUrl: null, + customFields: null, + }); + + const account = await accountRepository.create( + AccountEntity.draft(draftData), + ); + const updated = account.setWebfingerHost('example.com'); + + await accountRepository.save(updated); + + const fetched = await accountRepository.getByWebfingerHandle( + 'index', + 'example.com', + ); + + expect(fetched?.id).toBe(account.id); + expect(fetched?.webfingerHost).toBe('example.com'); + await expect( + accountRepository.hasWebfingerHandleConflict( + 'index', + 'example.com', + account.id, + ), + ).resolves.toBe(false); + }); + + it('resolves a custom WebFinger host by stable actor username', async () => { + const site = await fixtureManager.createSite('blog.example.com'); + const draftData = await createInternalAccountDraftData({ + host: new URL(`https://${site.host}`), + username: 'index', + name: 'Test', + bio: null, + url: new URL(`https://${site.host}`), + avatarUrl: null, + bannerImageUrl: null, + customFields: null, + }); + + const account = await accountRepository.create( + AccountEntity.draft(draftData), + ); + const updated = account + .setWebfingerHost('example.com') + .updateProfile({ username: 'alice' }); + + await accountRepository.save(updated); + + const fetched = await accountRepository.getByWebfingerHandle( + 'index', + 'example.com', + ); + + expect(fetched?.id).toBe(account.id); + expect(fetched?.username).toBe('alice'); + expect(fetched?.apId.pathname.endsWith('/index')).toBe(true); + expect(fetched?.webfingerHost).toBe('example.com'); + }); + it('Handles events when creating an account', async () => { const emitSpy = vi.spyOn(events, 'emitAsync'); diff --git a/src/account/account.repository.knex.ts b/src/account/account.repository.knex.ts index 3025b6e3f..bc3dae114 100644 --- a/src/account/account.repository.knex.ts +++ b/src/account/account.repository.knex.ts @@ -39,6 +39,7 @@ interface AccountRow { ap_liked_url: string | null; custom_fields: Record | null; site_id: number | null; + webfinger_host: string | null; } export class KnexAccountRepository { @@ -74,6 +75,7 @@ export class KnexAccountRepository { ? JSON.stringify(draft.customFields) : null, domain: draft.apId.hostname, + webfinger_host: draft.webfingerHost, }); if (draft.isInternal) { @@ -123,6 +125,7 @@ export class KnexAccountRepository { custom_fields: account.customFields ? JSON.stringify(account.customFields) : null, + webfinger_host: account.webfingerHost, }) .where({ id: account.id }); @@ -266,7 +269,7 @@ export class KnexAccountRepository { // We can safely assume that there is an account for the user due to // the foreign key constraint on the users table const accountRow = await this.db('accounts') - .where('id', user.account_id) + .where('accounts.id', user.account_id) .select( 'accounts.id', 'accounts.uuid', @@ -283,7 +286,10 @@ export class KnexAccountRepository { 'accounts.ap_following_url', 'accounts.ap_liked_url', 'accounts.custom_fields', + 'accounts.webfinger_host', + 'users.site_id', ) + .leftJoin('users', 'users.account_id', 'accounts.id') .first(); if (!accountRow) { @@ -313,6 +319,7 @@ export class KnexAccountRepository { 'accounts.ap_following_url', 'accounts.ap_liked_url', 'accounts.custom_fields', + 'accounts.webfinger_host', 'users.site_id', ) .first(); @@ -345,6 +352,7 @@ export class KnexAccountRepository { 'accounts.ap_following_url', 'accounts.ap_liked_url', 'users.site_id', + 'accounts.webfinger_host', ) .first(); @@ -378,6 +386,7 @@ export class KnexAccountRepository { 'accounts.ap_following_url', 'accounts.ap_liked_url', 'accounts.custom_fields', + 'accounts.webfinger_host', 'users.site_id', ) .first(); @@ -442,6 +451,78 @@ export class KnexAccountRepository { return rows.map((row) => new URL(row.ap_id)); } + async getByWebfingerHandle( + username: string, + host: string, + ): Promise { + const accountRow = await this.db('accounts') + .where((qb) => { + qb.whereRaw('LOWER(accounts.username) = LOWER(?)', [ + username, + ]).orWhereRaw( + "LOWER(SUBSTRING_INDEX(accounts.ap_id, '/', -1)) = LOWER(?)", + [username], + ); + }) + .whereRaw( + 'accounts.webfinger_host_hash = UNHEX(SHA2(LOWER(?), 256))', + [host], + ) + .leftJoin('users', 'users.account_id', 'accounts.id') + .select( + 'accounts.id', + 'accounts.uuid', + 'accounts.username', + 'accounts.name', + 'accounts.bio', + 'accounts.url', + 'accounts.avatar_url', + 'accounts.banner_image_url', + 'accounts.ap_id', + 'accounts.ap_followers_url', + 'accounts.ap_inbox_url', + 'accounts.ap_outbox_url', + 'accounts.ap_following_url', + 'accounts.ap_liked_url', + 'accounts.custom_fields', + 'accounts.webfinger_host', + 'users.site_id', + ) + .first(); + + if (!accountRow) { + return null; + } + + return this.mapRowToAccountEntity(accountRow); + } + + async hasWebfingerHandleConflict( + username: string, + host: string, + excludeAccountId: number, + ): Promise { + const row = await this.db('accounts') + .where('accounts.username', username) + .whereNot('accounts.id', excludeAccountId) + .where((qb) => { + qb.whereRaw( + 'accounts.webfinger_host_hash = UNHEX(SHA2(LOWER(?), 256))', + [host], + ).orWhere((fallbackQb) => { + fallbackQb + .whereNull('accounts.webfinger_host') + .whereRaw( + '(accounts.domain_hash = UNHEX(SHA2(LOWER(?), 256)) OR accounts.domain_hash = UNHEX(SHA2(LOWER(?), 256)))', + [host, `www.${host}`], + ); + }); + }) + .first('accounts.id'); + + return !!row; + } + private async mapRowToAccountEntity(row: AccountRow): Promise { if (!row.uuid) { row.uuid = randomUUID(); @@ -467,6 +548,7 @@ export class KnexAccountRepository { apLiked: parseURL(row.ap_liked_url), isInternal: row.site_id !== null, customFields: row.custom_fields, + webfingerHost: row.webfinger_host ?? null, }); } } diff --git a/src/account/account.service.integration.test.ts b/src/account/account.service.integration.test.ts index 5fab22bf5..7b3cb561a 100644 --- a/src/account/account.service.integration.test.ts +++ b/src/account/account.service.integration.test.ts @@ -291,6 +291,35 @@ describe('AccountService', () => { expect(secondAccount).toMatchObject(account); }); + it('rejects creating a default handle that is already claimed as a custom WebFinger host', async () => { + const [otherSiteId] = await db('sites').insert({ + host: 'blog.example.com', + webhook_secret: 'other-secret', + ghost_uuid: null, + }); + const otherSite = { + id: otherSiteId, + host: 'blog.example.com', + webhook_secret: 'other-secret', + ghost_uuid: null, + }; + + const otherAccount = await service.createInternalAccount( + otherSite, + internalAccountData, + ); + + await db('accounts') + .where({ id: otherAccount.id }) + .update({ webfinger_host: 'example.com' }); + + await expect( + service.createInternalAccount(site, internalAccountData), + ).rejects.toThrow( + 'WebFinger handle @index@example.com is already claimed', + ); + }); + it('should ensure the account is created with a domain', async () => { const account = await service.createInternalAccount( site, diff --git a/src/account/account.service.ts b/src/account/account.service.ts index ef6207897..a6008b7e8 100644 --- a/src/account/account.service.ts +++ b/src/account/account.service.ts @@ -19,10 +19,20 @@ import type { InternalAccountData, Site, } from '@/account/types'; -import { mapActorToExternalAccountData } from '@/account/utils'; +import { + mapActorToExternalAccountData, + normalizeWebfingerHost, +} from '@/account/utils'; import type { FedifyContextFactory } from '@/activitypub/fedify-context.factory'; import type { AsyncEvents } from '@/core/events'; -import { error, getValue, isError, ok, type Result } from '@/core/result'; +import { + error, + getError, + getValue, + isError, + ok, + type Result, +} from '@/core/result'; import { parseURL } from '@/core/url'; import { isHandle } from '@/helpers/activitypub/actor'; import { @@ -65,6 +75,18 @@ export type AccountAliasError = | 'self-alias' | 'invalid-actor-uri'; +export type WebfingerHostError = + | { type: 'invalid-domain'; host: string } + | { type: 'conflict'; host: string } + | { type: 'not-reachable'; host: string; status?: number } + | { type: 'invalid-webfinger'; host: string } + | { + type: 'wrong-actor'; + host: string; + expectedActorUrl: string; + actualActorUrl: string; + }; + export const DELIVERY_FAILURE_BACKOFF_SECONDS = 60; export const DELIVERY_FAILURE_BACKOFF_MULTIPLIER = 2; @@ -250,6 +272,157 @@ export class AccountService { return this.accountRepository.getAliases(accountId); } + async validateWebfingerHost( + account: Account, + host: string, + ): Promise> { + const normalizedHost = normalizeWebfingerHost(host); + + if (!normalizedHost) { + return error({ type: 'invalid-domain', host }); + } + + const conflict = + await this.accountRepository.hasWebfingerHandleConflict( + account.username, + normalizedHost, + account.id, + ); + + if (conflict) { + return error({ type: 'conflict', host: normalizedHost }); + } + + const resource = `acct:${account.username}@${normalizedHost}`; + const url = new URL(`https://${normalizedHost}/.well-known/webfinger`); + url.searchParams.set('resource', resource); + + let response: Response; + + try { + response = await fetch(url, { + headers: { + accept: 'application/jrd+json, application/json', + }, + redirect: 'follow', + signal: AbortSignal.timeout(5000), + }); + } catch (_err) { + return error({ type: 'not-reachable', host: normalizedHost }); + } + + if (!response.ok) { + return error({ + type: 'not-reachable', + host: normalizedHost, + status: response.status, + }); + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.toLowerCase().includes('json')) { + return error({ type: 'invalid-webfinger', host: normalizedHost }); + } + + let webfingerData: unknown; + + try { + webfingerData = await response.json(); + } catch (_err) { + return error({ type: 'invalid-webfinger', host: normalizedHost }); + } + + if ( + typeof webfingerData !== 'object' || + webfingerData === null || + !('subject' in webfingerData) || + webfingerData.subject !== resource || + !('links' in webfingerData) || + !Array.isArray(webfingerData.links) + ) { + return error({ type: 'invalid-webfinger', host: normalizedHost }); + } + + const selfLink = webfingerData.links.find((link) => { + if (typeof link !== 'object' || link === null) { + return false; + } + + return ( + 'rel' in link && + link.rel === 'self' && + 'type' in link && + link.type === 'application/activity+json' + ); + }); + + if ( + typeof selfLink !== 'object' || + selfLink === null || + !('href' in selfLink) || + typeof selfLink.href !== 'string' + ) { + return error({ type: 'invalid-webfinger', host: normalizedHost }); + } + + if (selfLink.href !== account.apId.href) { + return error({ + type: 'wrong-actor', + host: normalizedHost, + expectedActorUrl: account.apId.href, + actualActorUrl: selfLink.href, + }); + } + + return ok(true); + } + + async setWebfingerHost( + account: Account, + host: string | null, + ): Promise> { + if (host === null) { + const updated = account.setWebfingerHost(null); + await this.accountRepository.save(updated); + return ok(updated); + } + + const normalizedHost = normalizeWebfingerHost(host); + const fallbackHost = account.apId.host.replace(/^www\./, ''); + + if (!normalizedHost) { + return error({ type: 'invalid-domain', host }); + } + + if (normalizedHost === fallbackHost) { + const updated = account.setWebfingerHost(null); + await this.accountRepository.save(updated); + return ok(updated); + } + + const validationResult = await this.validateWebfingerHost( + account, + normalizedHost, + ); + + if (isError(validationResult)) { + return error(getError(validationResult)); + } + + const updated = account.setWebfingerHost(normalizedHost); + try { + await this.accountRepository.save(updated); + } catch (err) { + if (isDuplicateEntryError(err)) { + return error({ type: 'conflict', host: normalizedHost }); + } + + throw err; + } + + return ok(updated); + } + /** * Create an internal account * @@ -280,6 +453,24 @@ export class AccountService { apPrivateKey: keyPair.privateKey, }); + const existingAccountForApId = await this.accountRepository.getByApId( + draft.apId, + ); + const customHandleAccount = + await this.accountRepository.getByWebfingerHandle( + draft.username, + normalizedHost, + ); + + if ( + customHandleAccount && + customHandleAccount.id !== existingAccountForApId?.id + ) { + throw new Error( + `WebFinger handle @${draft.username}@${normalizedHost} is already claimed`, + ); + } + try { const account = await this.accountRepository.create(draft); const returnVal = await this.getByInternalId(account.id); @@ -372,6 +563,7 @@ export class AccountService { uuid: randomUUID(), ap_private_key: null, domain: new URL(accountData.ap_id).host, + webfinger_host: null, }; try { @@ -683,6 +875,7 @@ export class AccountService { ap_liked_url: row.ap_liked_url, ap_public_key: row.ap_public_key, ap_private_key: row.ap_private_key, + webfinger_host: row.webfinger_host, }; } diff --git a/src/account/account.service.unit.test.ts b/src/account/account.service.unit.test.ts index 7efd314dc..c6efcb74c 100644 --- a/src/account/account.service.unit.test.ts +++ b/src/account/account.service.unit.test.ts @@ -36,6 +36,7 @@ describe('AccountService', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); knex = {} as Knex; asyncEvents = {} as AsyncEvents; @@ -44,6 +45,7 @@ describe('AccountService', () => { getById: vi.fn(), getByApId: vi.fn(), getByInboxUrl: vi.fn(), + hasWebfingerHandleConflict: vi.fn().mockResolvedValue(false), } as unknown as KnexAccountRepository; fedifyContext = {}; fedifyContextFactory = { @@ -60,6 +62,288 @@ describe('AccountService', () => { ); }); + describe('validateWebfingerHost', () => { + const account = { + id: 1, + username: 'index', + apId: new URL('https://blog.example.com/users/index'), + } as AccountEntity; + + it('validates live WebFinger data for the account', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + subject: 'acct:index@example.com', + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://blog.example.com/users/index', + }, + ], + }), + { + status: 200, + headers: { + 'content-type': 'application/jrd+json', + }, + }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await accountService.validateWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual(ok(true)); + expect(fetchMock).toHaveBeenCalledWith( + new URL( + 'https://example.com/.well-known/webfinger?resource=acct%3Aindex%40example.com', + ), + expect.objectContaining({ + redirect: 'follow', + }), + ); + }); + + it('rejects invalid domains', async () => { + const result = await accountService.validateWebfingerHost( + account, + 'https://example.com', + ); + + expect(result).toEqual( + error({ + type: 'invalid-domain', + host: 'https://example.com', + }), + ); + }); + + it('rejects conflicting handles', async () => { + vi.mocked( + knexAccountRepository.hasWebfingerHandleConflict, + ).mockResolvedValue(true); + + const result = await accountService.validateWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual( + error({ type: 'conflict', host: 'example.com' }), + ); + }); + + it('rejects unreachable domains', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no'))); + + const result = await accountService.validateWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual( + error({ type: 'not-reachable', host: 'example.com' }), + ); + }); + + it('rejects non-json WebFinger responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('nope', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ), + ); + + const result = await accountService.validateWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual( + error({ type: 'invalid-webfinger', host: 'example.com' }), + ); + }); + + it('rejects WebFinger responses without a self link', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + subject: 'acct:index@example.com', + links: [], + }), + { + status: 200, + headers: { + 'content-type': 'application/jrd+json', + }, + }, + ), + ), + ); + + const result = await accountService.validateWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual( + error({ type: 'invalid-webfinger', host: 'example.com' }), + ); + }); + + it('rejects WebFinger responses for the wrong actor', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + subject: 'acct:index@example.com', + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://other.example.com/users/index', + }, + ], + }), + { + status: 200, + headers: { + 'content-type': 'application/jrd+json', + }, + }, + ), + ), + ); + + const result = await accountService.validateWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual( + error({ + type: 'wrong-actor', + host: 'example.com', + expectedActorUrl: 'https://blog.example.com/users/index', + actualActorUrl: 'https://other.example.com/users/index', + }), + ); + }); + + it('rejects WebFinger responses for the wrong subject', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + subject: 'acct:index@other.example.com', + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://blog.example.com/users/index', + }, + ], + }), + { + status: 200, + headers: { + 'content-type': 'application/jrd+json', + }, + }, + ), + ), + ); + + const result = await accountService.validateWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual( + error({ type: 'invalid-webfinger', host: 'example.com' }), + ); + }); + }); + + describe('setWebfingerHost', () => { + it('saves a validated custom domain', async () => { + const updatedAccount = {} as AccountEntity; + const account = { + id: 1, + username: 'index', + apId: new URL('https://blog.example.com/users/index'), + setWebfingerHost: vi.fn().mockReturnValue(updatedAccount), + } as unknown as AccountEntity; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + subject: 'acct:index@example.com', + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://blog.example.com/users/index', + }, + ], + }), + { + status: 200, + headers: { + 'content-type': 'application/jrd+json', + }, + }, + ), + ), + ); + + const result = await accountService.setWebfingerHost( + account, + 'example.com', + ); + + expect(result).toEqual(ok(updatedAccount)); + expect(account.setWebfingerHost).toHaveBeenCalledWith( + 'example.com', + ); + expect(knexAccountRepository.save).toHaveBeenCalledWith( + updatedAccount, + ); + }); + + it('clears the custom domain without live validation', async () => { + const updatedAccount = {} as AccountEntity; + const account = { + apId: new URL('https://blog.example.com/users/index'), + setWebfingerHost: vi.fn().mockReturnValue(updatedAccount), + } as unknown as AccountEntity; + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await accountService.setWebfingerHost(account, null); + + expect(result).toEqual(ok(updatedAccount)); + expect(fetchMock).not.toHaveBeenCalled(); + expect(account.setWebfingerHost).toHaveBeenCalledWith(null); + expect(knexAccountRepository.save).toHaveBeenCalledWith( + updatedAccount, + ); + }); + }); + describe('addAlias', () => { it('resolves a handle, verifies the actor, and saves the alias', async () => { const updatedAccount = {} as AccountEntity; diff --git a/src/account/types.ts b/src/account/types.ts index 80c1f9431..16ca97d09 100644 --- a/src/account/types.ts +++ b/src/account/types.ts @@ -37,9 +37,13 @@ export interface Account { ap_liked_url: string; ap_public_key: string; ap_private_key: string | null; + webfinger_host: string | null; } /** * Data used when creating an external account */ -export type ExternalAccountData = Omit; +export type ExternalAccountData = Omit< + Account, + 'id' | 'ap_private_key' | 'webfinger_host' +>; diff --git a/src/account/utils.ts b/src/account/utils.ts index 3d9398cb0..a7f4a92bd 100644 --- a/src/account/utils.ts +++ b/src/account/utils.ts @@ -1,5 +1,8 @@ +import { isIP } from 'node:net'; + import { type Actor, PropertyValue } from '@fedify/fedify'; +import type { Account } from '@/account/account.entity'; import type { ExternalAccountData } from '@/account/types'; interface PublicKey { @@ -78,3 +81,57 @@ export async function mapActorToExternalAccountData( export function getAccountHandle(host?: string, username?: string) { return `@${username || 'unknown'}@${host?.replace(/^www\./, '') || 'unknown'}`; } + +export function getAccountHandleHost( + account: Pick, +) { + return account.webfingerHost || account.apId.host; +} + +export function normalizeWebfingerHost(input: string): string | null { + const host = input + .trim() + .toLowerCase() + .replace(/^www\./, ''); + + if (!host) { + return null; + } + + if ( + host.includes('://') || + host.includes('/') || + host.includes('?') || + host.includes('#') || + host.includes(':') || + host === 'localhost' || + isIP(host) + ) { + return null; + } + + const labels = host.split('.'); + if (labels.length < 2) { + return null; + } + + if ( + labels.some( + (label) => + label.length === 0 || + label.length > 63 || + label.startsWith('-') || + label.endsWith('-') || + !/^[a-z0-9-]+$/.test(label), + ) + ) { + return null; + } + + const tld = labels[labels.length - 1]; + if (!/[a-z]/.test(tld)) { + return null; + } + + return host; +} diff --git a/src/account/utils.unit.test.ts b/src/account/utils.unit.test.ts index 6bdfd6be5..f13d1cdd3 100644 --- a/src/account/utils.unit.test.ts +++ b/src/account/utils.unit.test.ts @@ -4,7 +4,9 @@ import { type Actor, PropertyValue } from '@fedify/fedify'; import { getAccountHandle, + getAccountHandleHost, mapActorToExternalAccountData, + normalizeWebfingerHost, } from '@/account/utils'; describe('mapActorToExternalAccountData', () => { @@ -71,6 +73,47 @@ describe('mapActorToExternalAccountData', () => { }); }); +describe('getAccountHandleHost', () => { + it('returns the configured webfinger host when present', () => { + expect( + getAccountHandleHost({ + apId: new URL('https://blog.example.com/users/index'), + webfingerHost: 'example.com', + }), + ).toBe('example.com'); + }); + + it('falls back to the AP ID host', () => { + expect( + getAccountHandleHost({ + apId: new URL('https://blog.example.com/users/index'), + webfingerHost: null, + }), + ).toBe('blog.example.com'); + }); +}); + +describe('normalizeWebfingerHost', () => { + it('normalizes host casing and leading www', () => { + expect(normalizeWebfingerHost(' WWW.Example.COM ')).toBe('example.com'); + }); + + it.each([ + '', + 'https://example.com', + 'example.com/path', + 'example.com:443', + 'localhost', + '127.0.0.1', + '10.0.0.1', + 'example', + '-example.com', + 'example-.com', + ])('rejects invalid host %s', (host) => { + expect(normalizeWebfingerHost(host)).toBeNull(); + }); +}); + describe('getAccountHandle', () => { it('should return a handle for an account with a username', () => { const handle = getAccountHandle('www.example.com', 'example'); diff --git a/src/app.ts b/src/app.ts index 802fa79c9..cd2f43d5a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -116,7 +116,7 @@ import { ReplyChainController } from '@/http/api/reply-chain.controller'; import { SearchController } from '@/http/api/search.controller'; import type { SiteController } from '@/http/api/site.controller'; import { TopicController } from '@/http/api/topic.controller'; -import { WebFingerController } from '@/http/api/webfinger.controller'; +import type { WebFingerController } from '@/http/api/webfinger.controller'; import type { WebhookController } from '@/http/api/webhook.controller'; import type { HostDataContextLoader } from '@/http/host-data-context-loader'; import { createDeploymentHeadersMiddleware } from '@/http/middleware/deployment-headers'; @@ -863,6 +863,16 @@ app.delete( }), ); +app.get( + '/.well-known/webfinger', + spanWrapper((ctx: HonoContext, next: Next) => { + const webFingerController = container.resolve( + 'webFingerController', + ); + return webFingerController.handleWebFinger(ctx, next); + }), +); + app.use( createHostDataContextMiddleware( container.resolve('hostDataContextLoader'), @@ -952,7 +962,6 @@ app.post( }), ); -routeRegistry.registerController('webFingerController', WebFingerController); routeRegistry.registerController('followController', FollowController); routeRegistry.registerController('likeController', LikeController); routeRegistry.registerController('postController', PostController); diff --git a/src/configuration/registrations.ts b/src/configuration/registrations.ts index 8bd8857e6..c33636c44 100644 --- a/src/configuration/registrations.ts +++ b/src/configuration/registrations.ts @@ -453,17 +453,24 @@ export function registerDependencies( container.register( 'siteController', - asFunction((siteService: SiteService) => { - let ghostProIpAddresses: string[] | undefined; - - if (process.env.GHOST_PRO_IP_ADDRESSES) { - ghostProIpAddresses = process.env.GHOST_PRO_IP_ADDRESSES.split( - ',', - ).map((ip) => ip.trim()); - } - - return new SiteController(siteService, ghostProIpAddresses); - }).singleton(), + asFunction( + (siteService: SiteService, accountService: AccountService) => { + let ghostProIpAddresses: string[] | undefined; + + if (process.env.GHOST_PRO_IP_ADDRESSES) { + ghostProIpAddresses = + process.env.GHOST_PRO_IP_ADDRESSES.split(',').map( + (ip) => ip.trim(), + ); + } + + return new SiteController( + siteService, + ghostProIpAddresses, + accountService, + ); + }, + ).singleton(), ); container.register( diff --git a/src/dispatchers.unit.test.ts b/src/dispatchers.unit.test.ts index bdff8f6bd..319e76023 100644 --- a/src/dispatchers.unit.test.ts +++ b/src/dispatchers.unit.test.ts @@ -510,6 +510,7 @@ describe('dispatchers', () => { apLiked: new URL('https://example.com/user/testuser/liked'), isInternal: true, customFields: null, + webfingerHost: null, } as unknown as Account; let actorCtx: FedifyRequestContext; @@ -703,6 +704,7 @@ describe('dispatchers', () => { apLiked: new URL('https://example.com/user/testuser/liked'), isInternal: true, customFields: null, + webfingerHost: null, } as Account; let keypairCtx: FedifyContext; @@ -873,6 +875,7 @@ describe('dispatchers', () => { apLiked: new URL('https://example.com/user/testuser/liked'), isInternal: true, customFields: null, + webfingerHost: null, } as Account; let followersCtx: FedifyContext; @@ -993,6 +996,7 @@ describe('dispatchers', () => { apLiked: new URL('https://example.com/user/testuser/liked'), isInternal: true, customFields: null, + webfingerHost: null, } as Account; let followingCtx: FedifyRequestContext; @@ -1123,6 +1127,7 @@ describe('dispatchers', () => { apLiked: new URL('https://example.com/user/testuser/liked'), isInternal: true, customFields: null, + webfingerHost: null, } as Account; let followersCounterCtx: FedifyRequestContext; @@ -1236,6 +1241,7 @@ describe('dispatchers', () => { apLiked: new URL('https://example.com/user/testuser/liked'), isInternal: true, customFields: null, + webfingerHost: null, } as Account; let followingCounterCtx: FedifyRequestContext; diff --git a/src/feed/feed.service.ts b/src/feed/feed.service.ts index d0568d731..54aba393a 100644 --- a/src/feed/feed.service.ts +++ b/src/feed/feed.service.ts @@ -56,6 +56,7 @@ interface BaseGetFeedDataResultRow { author_name: string | null; author_username: string; author_url: string | null; + author_webfinger_host: string | null; author_avatar_url: string | null; author_followed_by_user: 0 | 1; } @@ -65,6 +66,7 @@ interface GetFeedDataResultRowReposted extends BaseGetFeedDataResultRow { reposter_name: string | null; reposter_username: string; reposter_url: string | null; + reposter_webfinger_host: string | null; reposter_avatar_url: string | null; reposter_followed_by_user: 0 | 1; } @@ -74,6 +76,7 @@ interface GetFeedDataResultRowWithoutReposted extends BaseGetFeedDataResultRow { reposter_name: null; reposter_username: null; reposter_url: null; + reposter_webfinger_host: null; reposter_avatar_url: null; reposter_followed_by_user: 0; } @@ -160,6 +163,7 @@ export class FeedService { 'author_account.name as author_name', 'author_account.username as author_username', 'author_account.url as author_url', + 'author_account.webfinger_host as author_webfinger_host', 'author_account.avatar_url as author_avatar_url', this.db.raw(` CASE @@ -172,6 +176,7 @@ export class FeedService { 'reposter_account.name as reposter_name', 'reposter_account.username as reposter_username', 'reposter_account.url as reposter_url', + 'reposter_account.webfinger_host as reposter_webfinger_host', 'reposter_account.avatar_url as reposter_avatar_url', this.db.raw(` CASE @@ -303,6 +308,7 @@ export class FeedService { 'author_account.name as author_name', 'author_account.username as author_username', 'author_account.url as author_url', + 'author_account.webfinger_host as author_webfinger_host', 'author_account.avatar_url as author_avatar_url', this.db.raw(` CASE diff --git a/src/helpers/activitypub/activity.ts b/src/helpers/activitypub/activity.ts index 7f48c5d20..074a59291 100644 --- a/src/helpers/activitypub/activity.ts +++ b/src/helpers/activitypub/activity.ts @@ -13,6 +13,7 @@ import { import { Temporal } from '@js-temporal/polyfill'; import type { Account } from '@/account/account.entity'; +import { getAccountHandleHost } from '@/account/utils'; import type { FedifyContext } from '@/app'; import { type Post, PostType } from '@/post/post.entity'; @@ -31,7 +32,7 @@ async function getFedifyObjectForPost( mentions = post.mentions.map( (account) => new Mention({ - name: `@${account.username}@${account.apId.hostname}`, + name: `@${account.username}@${getAccountHandleHost(account)}`, href: account.apId, }), ); diff --git a/src/http/api/account.controller.ts b/src/http/api/account.controller.ts index a2a70b564..ea1e4f309 100644 --- a/src/http/api/account.controller.ts +++ b/src/http/api/account.controller.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import type { Account } from '@/account/account.entity'; import type { KnexAccountRepository } from '@/account/account.repository.knex'; import type { AccountService } from '@/account/account.service'; -import { getAccountHandle } from '@/account/utils'; +import { getAccountHandle, getAccountHandleHost } from '@/account/utils'; import type { FedifyContextFactory } from '@/activitypub/fedify-context.factory'; import type { AppContext } from '@/app'; import { exhaustiveCheck, getError, getValue, isError } from '@/core/result'; @@ -46,6 +46,10 @@ const RemoveAliasSchema = z.object({ actorUri: z.string(), }); +const UpdateDomainSchema = z.object({ + domain: z.string().nullable(), +}); + /** * Controller for account-related operations */ @@ -63,7 +67,10 @@ export class AccountController { const aliases = await this.accountService.getAliases(account.id); return { destination: { - handle: getAccountHandle(account.apId.host, account.username), + handle: getAccountHandle( + getAccountHandleHost(account), + account.username, + ), apId: account.apId.href, }, aliases: aliases.map((alias) => ({ @@ -72,6 +79,17 @@ export class AccountController { }; } + private accountDomainResponse(account: Account) { + return { + domain: account.webfingerHost, + handle: getAccountHandle( + getAccountHandleHost(account), + account.username, + ), + actorUrl: account.apId.href, + }; + } + /** * Handle a request for an account */ @@ -424,6 +442,104 @@ export class AccountController { ); } + @APIRoute('GET', 'domain') + @RequireRoles(GhostRole.Owner, GhostRole.Administrator) + async handleGetAccountDomain(ctx: AppContext) { + const account = await this.accountService.getAccountForSite( + ctx.get('site'), + ); + + if (!account) { + return new Response(null, { status: 404 }); + } + + return new Response( + JSON.stringify(this.accountDomainResponse(account)), + { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }, + ); + } + + @APIRoute('PUT', 'domain') + @RequireRoles(GhostRole.Owner, GhostRole.Administrator) + async handleUpdateAccountDomain(ctx: AppContext) { + const account = await this.accountService.getAccountForSite( + ctx.get('site'), + ); + + if (!account) { + return new Response(null, { status: 404 }); + } + + let data: z.infer; + + try { + data = UpdateDomainSchema.parse((await ctx.req.json()) as unknown); + } catch (_err) { + return new Response(null, { status: 400 }); + } + + const result = await this.accountService.setWebfingerHost( + account, + data.domain, + ); + + if (isError(result)) { + const accountError = getError(result); + + switch (accountError.type) { + case 'invalid-domain': + case 'invalid-webfinger': + return new Response( + JSON.stringify({ code: accountError.type }), + { + headers: { + 'Content-Type': 'application/json', + }, + status: 400, + }, + ); + case 'conflict': + return new Response( + JSON.stringify({ code: accountError.type }), + { + headers: { + 'Content-Type': 'application/json', + }, + status: 409, + }, + ); + case 'not-reachable': + case 'wrong-actor': + return new Response( + JSON.stringify({ code: accountError.type }), + { + headers: { + 'Content-Type': 'application/json', + }, + status: 422, + }, + ); + default: + return exhaustiveCheck(accountError); + } + } + + return new Response( + JSON.stringify(this.accountDomainResponse(getValue(result))), + { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }, + ); + } + @APIRoute('POST', 'aliases') @RequireRoles(GhostRole.Owner, GhostRole.Administrator) async handleAddAccountAlias(ctx: AppContext) { diff --git a/src/http/api/account.controller.unit.test.ts b/src/http/api/account.controller.unit.test.ts index b1de2048c..9840fa4c3 100644 --- a/src/http/api/account.controller.unit.test.ts +++ b/src/http/api/account.controller.unit.test.ts @@ -53,6 +53,7 @@ describe('AccountController aliases', () => { id: 1, username: 'index', apId: new URL('https://example.com/.ghost/activitypub/users/index'), + webfingerHost: null, } as unknown as Account; accountRepository = { getById: vi.fn().mockResolvedValue(account), @@ -62,6 +63,7 @@ describe('AccountController aliases', () => { getAliases: vi.fn().mockResolvedValue([]), addAlias: vi.fn(), removeAlias: vi.fn(), + setWebfingerHost: vi.fn(), } as unknown as AccountService; controller = new AccountController( {} as AccountView, @@ -96,6 +98,16 @@ describe('AccountController aliases', () => { path: '/.ghost/activitypub/:version/aliases', methodName: 'handleRemoveAccountAlias', }), + expect.objectContaining({ + method: 'GET', + path: '/.ghost/activitypub/:version/domain', + methodName: 'handleGetAccountDomain', + }), + expect.objectContaining({ + method: 'PUT', + path: '/.ghost/activitypub/:version/domain', + methodName: 'handleUpdateAccountDomain', + }), ]), ); @@ -103,6 +115,8 @@ describe('AccountController aliases', () => { 'handleGetAccountAliases', 'handleAddAccountAlias', 'handleRemoveAccountAlias', + 'handleGetAccountDomain', + 'handleUpdateAccountDomain', ]) { expect( Reflect.getMetadata( @@ -234,6 +248,87 @@ describe('AccountController aliases', () => { expect(response.status).toBe(404); }); + it('returns account domain state', async () => { + const response = await controller.handleGetAccountDomain( + createContext(), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + domain: null, + handle: '@index@example.com', + actorUrl: 'https://example.com/.ghost/activitypub/users/index', + }); + }); + + it('updates the account domain', async () => { + const updatedAccount = { + ...account, + webfingerHost: 'site.com', + } as Account; + + vi.mocked(accountService.setWebfingerHost).mockResolvedValue( + ok(updatedAccount), + ); + + const response = await controller.handleUpdateAccountDomain( + createContext({ domain: 'site.com' }), + ); + + expect(response.status).toBe(200); + expect(accountService.setWebfingerHost).toHaveBeenCalledWith( + account, + 'site.com', + ); + expect(await response.json()).toEqual({ + domain: 'site.com', + handle: '@index@site.com', + actorUrl: 'https://example.com/.ghost/activitypub/users/index', + }); + }); + + it('clears the account domain', async () => { + vi.mocked(accountService.setWebfingerHost).mockResolvedValue( + ok(account), + ); + + const response = await controller.handleUpdateAccountDomain( + createContext({ domain: null }), + ); + + expect(response.status).toBe(200); + expect(accountService.setWebfingerHost).toHaveBeenCalledWith( + account, + null, + ); + }); + + it('returns conflict when an account domain is already claimed', async () => { + vi.mocked(accountService.setWebfingerHost).mockResolvedValue( + error({ type: 'conflict', host: 'site.com' }), + ); + + const response = await controller.handleUpdateAccountDomain( + createContext({ domain: 'site.com' }), + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ code: 'conflict' }); + }); + + it('returns bad request when an account domain is invalid', async () => { + vi.mocked(accountService.setWebfingerHost).mockResolvedValue( + error({ type: 'invalid-domain', host: 'https://site.com' }), + ); + + const response = await controller.handleUpdateAccountDomain( + createContext({ domain: 'https://site.com' }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: 'invalid-domain' }); + }); + it('adds an alias and returns the updated alias list', async () => { vi.mocked(accountService.addAlias).mockResolvedValue( ok(new URL('https://mastodon.social/users/old')), diff --git a/src/http/api/feed.controller.ts b/src/http/api/feed.controller.ts index 9062a74ab..6db93ae36 100644 --- a/src/http/api/feed.controller.ts +++ b/src/http/api/feed.controller.ts @@ -181,7 +181,9 @@ export class FeedController { author: { id: result.author_id.toString(), handle: getAccountHandle( - parseURL(result.author_url)?.host ?? '', + result.author_webfinger_host ?? + parseURL(result.author_url)?.host ?? + '', result.author_username, ), name: result.author_name ?? '', @@ -196,7 +198,9 @@ export class FeedController { ? { id: result.reposter_id.toString(), handle: getAccountHandle( - parseURL(result.reposter_url)?.host ?? '', + result.reposter_webfinger_host ?? + parseURL(result.reposter_url)?.host ?? + '', result.reposter_username, ), name: result.reposter_name ?? '', diff --git a/src/http/api/feed.unit.test.ts b/src/http/api/feed.unit.test.ts index 4d30b0630..c976bb5e1 100644 --- a/src/http/api/feed.unit.test.ts +++ b/src/http/api/feed.unit.test.ts @@ -61,10 +61,12 @@ describe('Feed API', () => { author_name: 'Foo Bar', author_username: 'foobar', author_url: 'https://example.com/foobar', + author_webfinger_host: null, reposter_id: null, reposter_name: null, reposter_username: null, reposter_url: null, + reposter_webfinger_host: null, reposter_avatar_url: null, post_attachments: [ { @@ -103,10 +105,12 @@ describe('Feed API', () => { author_name: 'Foo Bar', author_username: 'foobar', author_url: 'https://example.com/foobar', + author_webfinger_host: null, reposter_id: 654, reposter_name: 'Baz Qux', reposter_username: 'bazqux', reposter_url: 'https://example.com/bazqux', + reposter_webfinger_host: null, reposter_avatar_url: 'https://example.com/images/bazqux.png', post_attachments: [], diff --git a/src/http/api/helpers/post.ts b/src/http/api/helpers/post.ts index 212cd7c33..c262dd923 100644 --- a/src/http/api/helpers/post.ts +++ b/src/http/api/helpers/post.ts @@ -1,5 +1,5 @@ import type { Account } from '@/account/account.entity'; -import { getAccountHandle } from '@/account/utils'; +import { getAccountHandle, getAccountHandleHost } from '@/account/utils'; import { normalizePlainText } from '@/helpers/html'; import type { AuthorDTO, PostDTO } from '@/http/api/types'; import type { Post } from '@/post/post.entity'; @@ -11,7 +11,10 @@ function accountToAuthorDTO( return { id: account.apId.href, name: account.name || '', - handle: getAccountHandle(new URL(account.apId).host, account.username), + handle: getAccountHandle( + getAccountHandleHost(account), + account.username, + ), avatarUrl: account.avatarUrl?.href || '', url: account.url.href, followedByMe, diff --git a/src/http/api/notification.controller.ts b/src/http/api/notification.controller.ts index 39ccc538d..8d04c9f44 100644 --- a/src/http/api/notification.controller.ts +++ b/src/http/api/notification.controller.ts @@ -68,7 +68,10 @@ export class NotificationController { name: result.actor_name, url: result.actor_url, handle: getAccountHandle( - result.actor_url ? new URL(result.actor_url).host : '', + result.actor_webfinger_host ?? + (result.actor_url + ? new URL(result.actor_url).host + : ''), result.actor_username, ), avatarUrl: result.actor_avatar_url, diff --git a/src/http/api/site.controller.ts b/src/http/api/site.controller.ts index 6494d899d..1a6918b4b 100644 --- a/src/http/api/site.controller.ts +++ b/src/http/api/site.controller.ts @@ -1,3 +1,5 @@ +import type { AccountService } from '@/account/account.service'; +import { getAccountHandle, getAccountHandleHost } from '@/account/utils'; import type { AppContext } from '@/app'; import type { SiteService } from '@/site/site.service'; @@ -5,6 +7,7 @@ export class SiteController { constructor( private readonly siteService: SiteService, private readonly ghostProIpAddresses?: string[], + private readonly accountService?: AccountService, ) {} async handleGetSiteData(ctx: AppContext) { @@ -25,7 +28,26 @@ export class SiteController { isGhostPro, ); - return new Response(JSON.stringify(site), { + let responseBody: object = site; + + if (this.accountService) { + const account = + await this.accountService.getAccountForSite(site); + + if (account) { + responseBody = { + ...site, + domain: getAccountHandleHost(account), + handle: getAccountHandle( + getAccountHandleHost(account), + account.username, + ), + actorUrl: account.apId.href, + }; + } + } + + return new Response(JSON.stringify(responseBody), { status: 200, headers: { 'Content-Type': 'application/json', diff --git a/src/http/api/site.controller.unit.test.ts b/src/http/api/site.controller.unit.test.ts index 6cb7a1f08..4909397ce 100644 --- a/src/http/api/site.controller.unit.test.ts +++ b/src/http/api/site.controller.unit.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AccountService } from '@/account/account.service'; import type { AppContext } from '@/app'; import { SiteController } from '@/http/api/site.controller'; import type { Site, SiteService } from '@/site/site.service'; @@ -76,6 +77,23 @@ describe('SiteController', () => { expect(body).toEqual(mockSite); }); + it('returns site data when no account exists for the site', async () => { + const accountService = { + getAccountForSite: vi.fn().mockResolvedValue(null), + } as unknown as AccountService; + siteController = new SiteController( + siteService, + undefined, + accountService, + ); + + const ctx = getMockAppContext('example.com'); + const response = await siteController.handleGetSiteData(ctx); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(mockSite); + }); + it('sets `ghost_pro` flag to false when none of the x-forwarded-for IP addresses matches any of the Ghost (Pro) IP addresses', async () => { siteController = new SiteController(siteService, [ '10.0.0.1', diff --git a/src/http/api/views/account.follows.view.ts b/src/http/api/views/account.follows.view.ts index 3da59e078..5dc5c0646 100644 --- a/src/http/api/views/account.follows.view.ts +++ b/src/http/api/views/account.follows.view.ts @@ -37,6 +37,7 @@ interface AccountRow { name: string; username: string; avatar_url: string; + webfinger_host: string | null; followed_by_me: number; blocked_by_me: number; } @@ -131,7 +132,10 @@ export class AccountFollowsView { id: result.ap_id, apId: result.ap_id, name: result.name || '', - handle: getAccountHandle(apIdUrl.host, result.username), + handle: getAccountHandle( + result.webfinger_host ?? apIdUrl.host, + result.username, + ), avatarUrl: result.avatar_url || '', isFollowing: !!result.followed_by_me, followedByMe: !!result.followed_by_me, @@ -376,7 +380,7 @@ export class AccountFollowsView { apId: followeeAccount.ap_id, name: followeeAccount.name || '', handle: getAccountHandle( - apIdUrl.host, + followeeAccount.webfinger_host ?? apIdUrl.host, followeeAccount.username, ), avatarUrl: followeeAccount.avatar_url || '', @@ -459,6 +463,7 @@ export class AccountFollowsView { 'accounts.name', 'accounts.username', 'accounts.avatar_url', + 'accounts.webfinger_host', ]) .select( this.db.raw(` @@ -515,6 +520,7 @@ export class AccountFollowsView { 'accounts.name', 'accounts.username', 'accounts.avatar_url', + 'accounts.webfinger_host', ]) .select( this.db.raw(` diff --git a/src/http/api/views/account.posts.view.ts b/src/http/api/views/account.posts.view.ts index 70fc517eb..2c6f1ec7d 100644 --- a/src/http/api/views/account.posts.view.ts +++ b/src/http/api/views/account.posts.view.ts @@ -50,6 +50,7 @@ interface BaseGetProfileDataResultRow { author_name: string | null; author_username: string; author_url: string | null; + author_webfinger_host: string | null; author_avatar_url: string | null; author_followed_by_current_user: 0 | 1; } @@ -59,6 +60,7 @@ interface GetProfileDataResultRowReposted extends BaseGetProfileDataResultRow { reposter_name: string | null; reposter_username: string; reposter_url: string | null; + reposter_webfinger_host: string | null; reposter_avatar_url: string | null; reposter_followed_by_current_user: 0 | 1; } @@ -69,6 +71,7 @@ interface GetProfileDataResultRowWithoutReposted reposter_name: null; reposter_username: null; reposter_url: null; + reposter_webfinger_host: null; reposter_avatar_url: null; reposter_followed_by_current_user: 0; } @@ -169,6 +172,7 @@ export class AccountPostsView { 'author_account.name as author_name', 'author_account.username as author_username', 'author_account.url as author_url', + 'author_account.webfinger_host as author_webfinger_host', 'author_account.avatar_url as author_avatar_url', this.db.raw(` CASE @@ -181,6 +185,7 @@ export class AccountPostsView { 'reposter_account.name as reposter_name', 'reposter_account.username as reposter_username', 'reposter_account.url as reposter_url', + 'reposter_account.webfinger_host as reposter_webfinger_host', 'reposter_account.avatar_url as reposter_avatar_url', this.db.raw(` CASE @@ -593,6 +598,7 @@ export class AccountPostsView { 'author_account.name as author_name', 'author_account.username as author_username', 'author_account.url as author_url', + 'author_account.webfinger_host as author_webfinger_host', 'author_account.avatar_url as author_avatar_url', this.db.raw(` CASE @@ -605,6 +611,7 @@ export class AccountPostsView { 'reposter_account.name as reposter_name', 'reposter_account.username as reposter_username', 'reposter_account.url as reposter_url', + 'reposter_account.webfinger_host as reposter_webfinger_host', 'reposter_account.avatar_url as reposter_avatar_url', this.db.raw('0 as reposter_followed_by_current_user'), ) @@ -695,7 +702,10 @@ export class AccountPostsView { author: { id: result.author_id.toString(), handle: getAccountHandle( - result.author_url ? new URL(result.author_url).host : '', + result.author_webfinger_host ?? + (result.author_url + ? new URL(result.author_url).host + : ''), result.author_username, ), name: result.author_name ?? '', @@ -710,9 +720,10 @@ export class AccountPostsView { ? { id: result.reposter_id.toString(), handle: getAccountHandle( - result.reposter_url - ? new URL(result.reposter_url).host - : '', + result.reposter_webfinger_host ?? + (result.reposter_url + ? new URL(result.reposter_url).host + : ''), result.reposter_username, ), name: result.reposter_name ?? '', diff --git a/src/http/api/views/account.search.view.integration.test.ts b/src/http/api/views/account.search.view.integration.test.ts index d310c578a..56ea1915e 100644 --- a/src/http/api/views/account.search.view.integration.test.ts +++ b/src/http/api/views/account.search.view.integration.test.ts @@ -386,6 +386,25 @@ describe('AccountSearchView', () => { expect(accounts[0].name).toBe('Coding Horror'); }); + it('should return accounts matching by custom WebFinger domain', async () => { + await db('accounts').insert({ + ap_id: 'https://blog.example.com/users/index', + username: 'index', + domain: 'blog.example.com', + webfinger_host: 'customdomain.example', + ap_inbox_url: 'https://blog.example.com/users/index/inbox', + name: 'Plain Site', + }); + + const accounts = await accountSearchView.search( + 'customdomain', + viewerAccount.id, + ); + + expect(accounts).toHaveLength(1); + expect(accounts[0].handle).toBe('@index@customdomain.example'); + }); + it('should return accounts matching by partial domain', async () => { // Fixtures contain account on domain "blog.codinghorror.com" // Searching "codinghorror" should match by domain diff --git a/src/http/api/views/account.search.view.ts b/src/http/api/views/account.search.view.ts index 29d6f1599..487e287dc 100644 --- a/src/http/api/views/account.search.view.ts +++ b/src/http/api/views/account.search.view.ts @@ -50,10 +50,10 @@ export class AccountSearchView { `CASE WHEN accounts.name LIKE ? ESCAPE '\\\\' THEN 0 WHEN accounts.name LIKE ? ESCAPE '\\\\' THEN 1 - WHEN CONCAT('@', accounts.username, '@', accounts.domain) LIKE ? ESCAPE '\\\\' THEN 2 - WHEN CONCAT('@', accounts.username, '@', accounts.domain) LIKE ? ESCAPE '\\\\' THEN 3 - WHEN accounts.domain LIKE ? ESCAPE '\\\\' THEN 4 - WHEN accounts.domain LIKE ? ESCAPE '\\\\' THEN 5 + WHEN CONCAT('@', accounts.username, '@', COALESCE(accounts.webfinger_host, accounts.domain)) LIKE ? ESCAPE '\\\\' THEN 2 + WHEN CONCAT('@', accounts.username, '@', COALESCE(accounts.webfinger_host, accounts.domain)) LIKE ? ESCAPE '\\\\' THEN 3 + WHEN COALESCE(accounts.webfinger_host, accounts.domain) LIKE ? ESCAPE '\\\\' THEN 4 + WHEN COALESCE(accounts.webfinger_host, accounts.domain) LIKE ? ESCAPE '\\\\' THEN 5 ELSE 6 END as search_rank`, [ @@ -70,7 +70,7 @@ export class AccountSearchView { viewerAccountId, (qb) => qb.whereRaw( - 'MATCH(accounts.name, accounts.username, accounts.domain) AGAINST(? IN BOOLEAN MODE)', + 'MATCH(accounts.name, accounts.username, accounts.domain, accounts.webfinger_host) AGAINST(? IN BOOLEAN MODE)', [fulltextQuery], ), SEARCH_RESULT_LIMIT, @@ -86,10 +86,17 @@ export class AccountSearchView { return this.searchByQuery( viewerAccountId, (qb) => - qb.whereRaw( - 'accounts.domain_hash = UNHEX(SHA2(LOWER(?), 256))', - [domain], - ), + qb.where((domainQb) => { + domainQb + .whereRaw( + 'accounts.domain_hash = UNHEX(SHA2(LOWER(?), 256))', + [domain], + ) + .orWhereRaw( + 'accounts.webfinger_host_hash = UNHEX(SHA2(LOWER(?), 256))', + [domain], + ); + }), limit, ); } @@ -105,9 +112,13 @@ export class AccountSearchView { 'accounts.ap_id', 'accounts.name', 'accounts.username', - 'accounts.domain', 'accounts.avatar_url', ) + .select( + this.db.raw( + 'COALESCE(accounts.webfinger_host, accounts.domain) as domain', + ), + ) .where(whereClause) // Compute followedByMe .select( diff --git a/src/http/api/views/account.view.ts b/src/http/api/views/account.view.ts index 18cfd11c5..f4a5ada6b 100644 --- a/src/http/api/views/account.view.ts +++ b/src/http/api/views/account.view.ts @@ -2,7 +2,7 @@ import { type Actor, type Collection, isActor } from '@fedify/fedify'; import type { Knex } from 'knex'; import type { Account } from '@/account/account.entity'; -import { getAccountHandle } from '@/account/utils'; +import { getAccountHandle, getAccountHandleHost } from '@/account/utils'; import type { FedifyContextFactory } from '@/activitypub/fedify-context.factory'; import { getError, getValue, isError } from '@/core/result'; import { getAttachments, getHandle } from '@/helpers/activitypub/actor'; @@ -86,7 +86,10 @@ export class AccountView { apId: accountData.ap_id, name: accountData.name, handle: getAccountHandle( - new URL(accountData.ap_id).host, + getAccountHandleHost({ + apId: new URL(accountData.ap_id), + webfingerHost: accountData.webfinger_host, + }), accountData.username, ), bio: sanitizeHtml(accountData.bio || ''), @@ -184,7 +187,10 @@ export class AccountView { apId: accountData.ap_id, name: accountData.name, handle: getAccountHandle( - new URL(accountData.ap_id).host, + getAccountHandleHost({ + apId: new URL(accountData.ap_id), + webfingerHost: accountData.webfinger_host, + }), accountData.username, ), bio: sanitizeHtml(accountData.bio || ''), @@ -309,6 +315,7 @@ export class AccountView { 'accounts.url', 'accounts.custom_fields', 'accounts.ap_id', + 'accounts.webfinger_host', ]; const countSelects = includeCounts diff --git a/src/http/api/views/blocks.view.integration.test.ts b/src/http/api/views/blocks.view.integration.test.ts index 4b6ba3fdf..f5e138f43 100644 --- a/src/http/api/views/blocks.view.integration.test.ts +++ b/src/http/api/views/blocks.view.integration.test.ts @@ -76,6 +76,33 @@ describe('BlocksView', () => { isFollowing: false, }); }); + + it('should mark blocked accounts with custom WebFinger domains as domain-blocked', async () => { + const [blocker] = await fixtureManager.createInternalAccount(); + const blockedAccount = await fixtureManager.createExternalAccount( + 'https://actor-domain.com/', + ); + + await db('accounts') + .where({ id: blockedAccount.id }) + .update({ webfinger_host: 'custom-blocked.com' }); + + await fixtureManager.createBlock(blocker, blockedAccount); + await fixtureManager.createDomainBlock( + blocker, + new URL('https://custom-blocked.com'), + ); + + const blockedAccounts = await blocksView.getBlockedAccounts( + blocker.id, + ); + + expect(blockedAccounts).toHaveLength(1); + expect(blockedAccounts[0].handle).toBe( + `@${blockedAccount.username}@custom-blocked.com`, + ); + expect(blockedAccounts[0].domainBlockedByMe).toBe(true); + }); }); describe('getBlockedDomains', () => { diff --git a/src/http/api/views/blocks.view.ts b/src/http/api/views/blocks.view.ts index 2abcdd8ce..1e5419db2 100644 --- a/src/http/api/views/blocks.view.ts +++ b/src/http/api/views/blocks.view.ts @@ -7,21 +7,27 @@ export class BlocksView { constructor(private readonly db: Knex) {} async getBlockedAccounts(accountId: number): Promise { + const effectiveDomainHash = this.db.raw( + 'COALESCE(accounts.webfinger_host_hash, accounts.domain_hash)', + ); + const results = await this.db('blocks') .select([ 'accounts.ap_id', 'accounts.name', 'accounts.username', 'accounts.avatar_url', - 'accounts.domain', 'domain_blocks.domain as blocked_domain', ]) - .innerJoin('accounts', 'accounts.id', 'blocks.blocked_id') - .leftJoin( - 'domain_blocks', - 'domain_blocks.domain', - 'accounts.domain', + .select( + this.db.raw( + 'COALESCE(accounts.webfinger_host, accounts.domain) as domain', + ), ) + .innerJoin('accounts', 'accounts.id', 'blocks.blocked_id') + .leftJoin('domain_blocks', function () { + this.on('domain_blocks.domain_hash', effectiveDomainHash); + }) .where('blocks.blocker_id', accountId); return results.map((result) => ({ diff --git a/src/http/api/views/explore.view.integration.test.ts b/src/http/api/views/explore.view.integration.test.ts index af9f87b76..28eb92571 100644 --- a/src/http/api/views/explore.view.integration.test.ts +++ b/src/http/api/views/explore.view.integration.test.ts @@ -180,6 +180,46 @@ describe('ExploreView', () => { expect(next).toBeNull(); }); + it('should filter out accounts with blocked custom WebFinger domains', async () => { + const [viewer] = await fixtureManager.createInternalAccount(); + const [accountOne] = await fixtureManager.createInternalAccount(); + const customDomainAccount = + await fixtureManager.createExternalAccount( + 'https://actor-domain.com/', + ); + + await db('accounts') + .where({ id: customDomainAccount.id }) + .update({ webfinger_host: 'custom-blocked.com' }); + + const topic = await fixtureManager.createTopic( + 'Science', + 'science', + ); + + await fixtureManager.addAccountToTopic(accountOne.id, topic.id); + await fixtureManager.addAccountToTopic( + customDomainAccount.id, + topic.id, + ); + await fixtureManager.createDomainBlock( + viewer, + new URL('https://custom-blocked.com'), + ); + + const { accounts, next } = await exploreView.getAccountsInTopic( + topic.slug, + viewer.id, + ); + + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toBe(accountOne.apId.toString()); + expect(accounts.map((a) => a.id)).not.toContain( + customDomainAccount.apId.toString(), + ); + expect(next).toBeNull(); + }); + it('should set followedByMe field correctly', async () => { const [viewer] = await fixtureManager.createInternalAccount(); const [followedAccount] = diff --git a/src/http/api/views/explore.view.ts b/src/http/api/views/explore.view.ts index 9fba1dbdb..7cff06479 100644 --- a/src/http/api/views/explore.view.ts +++ b/src/http/api/views/explore.view.ts @@ -15,16 +15,24 @@ export class ExploreView { offset = 0, limit = DEFAULT_EXPLORE_LIMIT, ): Promise<{ accounts: ExploreAccountDTO[]; next: string | null }> { + const effectiveDomainHash = this.db.raw( + 'COALESCE(accounts.webfinger_host_hash, accounts.domain_hash)', + ); + const results = await this.db('accounts') .select( 'accounts.ap_id', 'accounts.name', 'accounts.username', - 'accounts.domain', 'accounts.avatar_url', 'accounts.bio', 'accounts.url', ) + .select( + this.db.raw( + 'COALESCE(accounts.webfinger_host, accounts.domain) as domain', + ), + ) .innerJoin( 'account_topics', 'account_topics.account_id', @@ -61,7 +69,7 @@ export class ExploreView { .leftJoin('domain_blocks', function () { this.on( 'domain_blocks.domain_hash', - 'accounts.domain_hash', + effectiveDomainHash, ).andOnVal( 'domain_blocks.blocker_id', '=', diff --git a/src/http/api/views/recommendations.view.integration.test.ts b/src/http/api/views/recommendations.view.integration.test.ts index eca6fc947..10aebf555 100644 --- a/src/http/api/views/recommendations.view.integration.test.ts +++ b/src/http/api/views/recommendations.view.integration.test.ts @@ -343,6 +343,44 @@ describe('RecommendationsView', () => { expect(accounts[0].id).toBe(normalAccount.apId.toString()); }); + it('should exclude accounts with blocked custom WebFinger domains', async () => { + const [viewer] = await fixtureManager.createInternalAccount(); + const [normalAccount] = + await fixtureManager.createInternalAccount(); + const customDomainAccount = + await fixtureManager.createExternalAccount( + 'https://actor-domain.com/', + ); + + await db('accounts') + .where({ id: customDomainAccount.id }) + .update({ webfinger_host: 'custom-blocked.com' }); + + const topic = await fixtureManager.createTopic( + 'Technology', + 'technology', + ); + + await fixtureManager.addAccountToTopic(viewer.id, topic.id); + await fixtureManager.addAccountToTopic(normalAccount.id, topic.id); + await fixtureManager.addAccountToTopic( + customDomainAccount.id, + topic.id, + ); + await fixtureManager.createDomainBlock( + viewer, + new URL('https://custom-blocked.com'), + ); + + const accounts = await recommendationsView.getRecommendations( + viewer.id, + 20, + ); + + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toBe(normalAccount.apId.toString()); + }); + it('should sanitize HTML in bio field', async () => { const [viewer] = await fixtureManager.createInternalAccount(); diff --git a/src/http/api/views/recommendations.view.ts b/src/http/api/views/recommendations.view.ts index aa7863087..48de78b1f 100644 --- a/src/http/api/views/recommendations.view.ts +++ b/src/http/api/views/recommendations.view.ts @@ -74,17 +74,25 @@ export class RecommendationsView { excludeIds: number[], limit: number, ): Promise { + const effectiveDomainHash = this.db.raw( + 'COALESCE(accounts.webfinger_host_hash, accounts.domain_hash)', + ); + const query = this.db('accounts') .select( 'accounts.id', 'accounts.ap_id', 'accounts.name', 'accounts.username', - 'accounts.domain', 'accounts.avatar_url', 'accounts.bio', 'accounts.url', ) + .select( + this.db.raw( + 'COALESCE(accounts.webfinger_host, accounts.domain) as domain', + ), + ) .distinct('accounts.id') // Filter accounts by topics @@ -122,7 +130,7 @@ export class RecommendationsView { .leftJoin('domain_blocks', function () { this.on( 'domain_blocks.domain_hash', - 'accounts.domain_hash', + effectiveDomainHash, ).andOnVal( 'domain_blocks.blocker_id', '=', diff --git a/src/http/api/views/reply.chain.view.ts b/src/http/api/views/reply.chain.view.ts index 3b4fa5ece..d40a31613 100644 --- a/src/http/api/views/reply.chain.view.ts +++ b/src/http/api/views/reply.chain.view.ts @@ -57,6 +57,7 @@ const PostRowSchema = z.object({ author_name: z.string().nullable(), author_username: z.string(), author_url: z.string().nullable(), + author_webfinger_host: z.string().nullable(), author_avatar_url: z.string().nullable(), author_followed_by_user: z.union([z.literal(0), z.literal(1)]), }); @@ -91,9 +92,10 @@ export class ReplyChainView { author: { id: result.author_id.toString(), handle: getAccountHandle( - result.author_url - ? new URL(result.author_url).host - : '', + result.author_webfinger_host ?? + (result.author_url + ? new URL(result.author_url).host + : ''), result.author_username, ), name: result.author_name ?? '', @@ -133,7 +135,10 @@ export class ReplyChainView { author: { id: result.author_id.toString(), handle: getAccountHandle( - result.author_url ? new URL(result.author_url).host : '', + result.author_webfinger_host ?? + (result.author_url + ? new URL(result.author_url).host + : ''), result.author_username, ), name: result.author_name ?? '', @@ -229,6 +234,7 @@ export class ReplyChainView { 'author_account.name as author_name', 'author_account.username as author_username', 'author_account.url as author_url', + 'author_account.webfinger_host as author_webfinger_host', 'author_account.avatar_url as author_avatar_url', this.db.raw(` CASE diff --git a/src/http/api/webfinger.controller.ts b/src/http/api/webfinger.controller.ts index f761106a7..8db2db95e 100644 --- a/src/http/api/webfinger.controller.ts +++ b/src/http/api/webfinger.controller.ts @@ -2,6 +2,7 @@ import type { Context as HonoContext, Next } from 'hono'; import type { Account } from '@/account/account.entity'; import type { KnexAccountRepository } from '@/account/account.repository.knex'; +import { getAccountHandleHost, normalizeWebfingerHost } from '@/account/utils'; import { Route } from '@/http/decorators/route.decorator'; import type { SiteService } from '@/site/site.service'; @@ -27,41 +28,121 @@ export class WebFingerController { // We only support custom handling of `acct:` resources - If the // resource is not an `acct:` resource, fallback to the default // webfinger implementation - if (!resource || !resource.startsWith(ACCOUNT_RESOURCE_PREFIX)) { + if (!resource?.startsWith(ACCOUNT_RESOURCE_PREFIX)) { return next(); } - const [_, resourceHost] = resource + const resourceParts = resource .slice(ACCOUNT_RESOURCE_PREFIX.length) .split('@'); - if (!resourceHost || !HOST_REGEX.test(resourceHost)) { + const [resourceUsername, resourceHost] = resourceParts; + if ( + resourceParts.length !== 2 || + !resourceUsername || + !resourceHost || + !HOST_REGEX.test(resourceHost) + ) { return new Response(null, { status: 400, }); } - const site = - (await this.siteService.getSiteByHost(resourceHost)) || - (await this.siteService.getSiteByHost(`www.${resourceHost}`)); - - if (!site) { + const normalizedResourceHost = normalizeWebfingerHost(resourceHost); + if (!normalizedResourceHost) { return new Response(null, { - status: 404, + status: 400, }); } - let account: Account; + const customDomainAccount = + await this.accountRepository.getByWebfingerHandle( + resourceUsername, + normalizedResourceHost, + ); + + if (customDomainAccount) { + return this.createWebfingerResponse(customDomainAccount); + } + + const site = + (await this.siteService.getSiteByHost(normalizedResourceHost)) || + (await this.siteService.getSiteByHost( + `www.${normalizedResourceHost}`, + )); + + if (site) { + const account = await this.getAccountBySiteOrNull(site); + + if ( + !account || + !this.resourceUsernameMatchesAccount(resourceUsername, account) + ) { + return new Response(null, { + status: 404, + }); + } + + return this.createWebfingerResponse(account); + } + + const requestHost = ctx.req.header('host')?.split(':')[0]; + const normalizedRequestHost = requestHost + ? normalizeWebfingerHost(requestHost) + : null; + + if (normalizedRequestHost === normalizedResourceHost) { + return next(); + } + + if (normalizedRequestHost) { + const requestSite = + (await this.siteService.getSiteByHost(normalizedRequestHost)) || + (await this.siteService.getSiteByHost( + `www.${normalizedRequestHost}`, + )); + + if (requestSite) { + const account = await this.getAccountBySiteOrNull(requestSite); + + if ( + account && + this.resourceUsernameMatchesAccount( + resourceUsername, + account, + ) + ) { + return this.createWebfingerResponse( + account, + normalizedResourceHost, + ); + } + } + } + + return new Response(null, { + status: 404, + }); + } + private async getAccountBySiteOrNull(site: { + id: number; + host: string; + webhook_secret: string; + ghost_uuid: string | null; + }) { try { - account = await this.accountRepository.getBySite(site); + return await this.accountRepository.getBySite(site); } catch (_error) { - return new Response(null, { - status: 404, - }); + return null; } + } + private createWebfingerResponse( + account: Account, + subjectHost = getAccountHandleHost(account).replace(/^www\./, ''), + ) { const webfingerData = { - subject: `acct:${account.username}@${site.host.replace('www.', '')}`, + subject: `acct:${account.username}@${subjectHost}`, aliases: [account.apId.toString()], links: [ { @@ -83,4 +164,20 @@ export class WebFingerController { }, }); } + + private resourceUsernameMatchesAccount( + resourceUsername: string, + account: Account, + ) { + if (account.username === resourceUsername) { + return true; + } + + const actorUsername = account.apId.pathname + .split('/') + .filter(Boolean) + .at(-1); + + return actorUsername === resourceUsername; + } } diff --git a/src/http/api/webfinger.unit.test.ts b/src/http/api/webfinger.unit.test.ts index 1a47b2c89..91fa6da31 100644 --- a/src/http/api/webfinger.unit.test.ts +++ b/src/http/api/webfinger.unit.test.ts @@ -12,12 +12,18 @@ describe('handleWebFinger', () => { let accountRepository: KnexAccountRepository; let webFingerController: WebFingerController; - function getCtx(queries: Record) { + function getCtx(queries: Record, host = 'example.com') { return { req: { query: (key: string) => { return queries[key]; }, + header: (key: string) => { + if (key === 'host') { + return host; + } + return undefined; + }, }, } as unknown as Context; } @@ -28,6 +34,7 @@ describe('handleWebFinger', () => { } as unknown as SiteService; accountRepository = { getBySite: vi.fn(), + getByWebfingerHandle: vi.fn().mockResolvedValue(null), } as unknown as KnexAccountRepository; webFingerController = new WebFingerController( accountRepository, @@ -63,6 +70,146 @@ describe('handleWebFinger', () => { expect(siteService.getSiteByHost).not.toHaveBeenCalled(); }); + it('should return a custom webfinger response for a configured custom domain', async () => { + const ctx = getCtx({ resource: 'acct:alice@example.com' }); + const next = vi.fn(); + + vi.mocked(accountRepository.getByWebfingerHandle).mockResolvedValue({ + username: 'alice', + url: 'https://blog.example.com', + apId: new URL( + 'https://blog.example.com/.ghost/activitypub/users/index', + ), + webfingerHost: 'example.com', + } as unknown as Account); + + const response = await webFingerController.handleWebFinger(ctx, next); + + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + subject: 'acct:alice@example.com', + aliases: [ + 'https://blog.example.com/.ghost/activitypub/users/index', + ], + }); + expect(accountRepository.getByWebfingerHandle).toHaveBeenCalledWith( + 'alice', + 'example.com', + ); + expect(siteService.getSiteByHost).not.toHaveBeenCalled(); + }); + + it('should allow actor-host webfinger to validate an unsaved custom domain', async () => { + const ctx = getCtx( + { resource: 'acct:alice@example.com' }, + 'blog.example.com', + ); + const next = vi.fn(); + + vi.mocked(siteService.getSiteByHost).mockImplementation((host) => { + if (host === 'blog.example.com') { + return Promise.resolve({ + host: 'blog.example.com', + } as Site); + } + + return Promise.resolve(null); + }); + + vi.mocked(accountRepository.getBySite).mockResolvedValue({ + username: 'alice', + url: 'https://blog.example.com', + apId: new URL( + 'https://blog.example.com/.ghost/activitypub/users/index', + ), + webfingerHost: null, + } as unknown as Account); + + const response = await webFingerController.handleWebFinger(ctx, next); + + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + subject: 'acct:alice@example.com', + aliases: [ + 'https://blog.example.com/.ghost/activitypub/users/index', + ], + }); + }); + + it('should allow actor-host webfinger to validate a replacement custom domain', async () => { + const ctx = getCtx( + { resource: 'acct:alice@new.example.com' }, + 'blog.example.com', + ); + const next = vi.fn(); + + vi.mocked(siteService.getSiteByHost).mockImplementation((host) => { + if (host === 'blog.example.com') { + return Promise.resolve({ + host: 'blog.example.com', + } as Site); + } + + return Promise.resolve(null); + }); + + vi.mocked(accountRepository.getBySite).mockResolvedValue({ + username: 'alice', + url: 'https://blog.example.com', + apId: new URL( + 'https://blog.example.com/.ghost/activitypub/users/index', + ), + webfingerHost: 'old.example.com', + } as unknown as Account); + + const response = await webFingerController.handleWebFinger(ctx, next); + + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + subject: 'acct:alice@new.example.com', + aliases: [ + 'https://blog.example.com/.ghost/activitypub/users/index', + ], + }); + }); + + it('should return the configured custom subject from the actor host', async () => { + const ctx = getCtx( + { resource: 'acct:alice@blog.example.com' }, + 'blog.example.com', + ); + const next = vi.fn(); + + vi.mocked(siteService.getSiteByHost).mockImplementation((host) => { + if (host === 'blog.example.com') { + return Promise.resolve({ + host: 'blog.example.com', + } as Site); + } + + return Promise.resolve(null); + }); + + vi.mocked(accountRepository.getBySite).mockResolvedValue({ + username: 'alice', + url: 'https://blog.example.com', + apId: new URL( + 'https://blog.example.com/.ghost/activitypub/users/index', + ), + webfingerHost: 'example.com', + } as unknown as Account); + + const response = await webFingerController.handleWebFinger(ctx, next); + + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + subject: 'acct:alice@example.com', + aliases: [ + 'https://blog.example.com/.ghost/activitypub/users/index', + ], + }); + }); + it('should handle an invalid acct: resource', async () => { const ctx = getCtx({ resource: 'acct:alice@example' }); // missing .com const next = vi.fn(); @@ -74,7 +221,10 @@ describe('handleWebFinger', () => { }); it('should return a 404 if no site is found for the resource', async () => { - const ctx = getCtx({ resource: 'acct:alice@example.com' }); + const ctx = getCtx( + { resource: 'acct:alice@example.com' }, + 'blog.example.com', + ); const next = vi.fn(); vi.mocked(siteService.getSiteByHost).mockResolvedValue(null); @@ -87,6 +237,18 @@ describe('handleWebFinger', () => { ); }); + it('should fall through when the request host has no site for same-host WebFinger', async () => { + const ctx = getCtx({ resource: 'acct:alice@example.com' }); + const next = vi.fn(); + + vi.mocked(siteService.getSiteByHost).mockResolvedValue(null); + + const response = await webFingerController.handleWebFinger(ctx, next); + + expect(response).toBeUndefined(); + expect(next).toHaveBeenCalled(); + }); + it('should return a 404 if no account is found for the site associated with the resource', async () => { const ctx = getCtx({ resource: 'acct:alice@example.com' }); const next = vi.fn(); @@ -131,6 +293,7 @@ describe('handleWebFinger', () => { username: 'alice', url: 'https://www.example.com', apId: new URL('https://www.example.com/users/alice'), + webfingerHost: null, } as unknown as Account); const response = await webFingerController.handleWebFinger(ctx, next); @@ -156,6 +319,62 @@ describe('handleWebFinger', () => { ); }); + it('should resolve the stable actor username after the display username changes', async () => { + const ctx = getCtx({ resource: 'acct:index@example.com' }); + const next = vi.fn(); + + vi.mocked(siteService.getSiteByHost).mockImplementation((host) => { + if (host === 'www.example.com') { + return Promise.resolve({ + host: 'www.example.com', + } as Site); + } + + return Promise.resolve(null); + }); + + vi.mocked(accountRepository.getBySite).mockResolvedValue({ + username: 'alice', + url: 'https://www.example.com', + apId: new URL('https://www.example.com/users/index'), + webfingerHost: null, + } as unknown as Account); + + const response = await webFingerController.handleWebFinger(ctx, next); + + expect(response?.status).toBe(200); + expect(await response?.json()).toMatchObject({ + subject: 'acct:alice@example.com', + aliases: ['https://www.example.com/users/index'], + }); + }); + + it('should return 404 when the resource username does not match the site account', async () => { + const ctx = getCtx({ resource: 'acct:bob@example.com' }); + const next = vi.fn(); + + vi.mocked(siteService.getSiteByHost).mockImplementation((host) => { + if (host === 'www.example.com') { + return Promise.resolve({ + host: 'www.example.com', + } as Site); + } + + return Promise.resolve(null); + }); + + vi.mocked(accountRepository.getBySite).mockResolvedValue({ + username: 'alice', + url: 'https://www.example.com', + apId: new URL('https://www.example.com/users/alice'), + webfingerHost: null, + } as unknown as Account); + + const response = await webFingerController.handleWebFinger(ctx, next); + + expect(response?.status).toBe(404); + }); + it('should handle a multi-level subdomain', async () => { const ctx = getCtx({ resource: 'acct:alice@sub.example.com' }); const next = vi.fn(); @@ -174,6 +393,7 @@ describe('handleWebFinger', () => { username: 'alice', url: 'https://www.sub.example.com', apId: new URL('https://www.sub.example.com/users/alice'), + webfingerHost: null, } as unknown as Account); const response = await webFingerController.handleWebFinger(ctx, next); @@ -199,6 +419,7 @@ describe('handleWebFinger', () => { username: 'alice', url: 'https://www.example.com', apId: new URL('https://www.example.com/users/alice'), + webfingerHost: null, } as unknown as Account); const response = await webFingerController.handleWebFinger(ctx, next); diff --git a/src/http/host-data-context-loader.ts b/src/http/host-data-context-loader.ts index 49e6fa206..765a05690 100644 --- a/src/http/host-data-context-loader.ts +++ b/src/http/host-data-context-loader.ts @@ -45,6 +45,7 @@ export class HostDataContextLoader { 'accounts.ap_following_url as account_ap_following_url', 'accounts.ap_liked_url as account_ap_liked_url', 'accounts.custom_fields as account_custom_fields', + 'accounts.webfinger_host as account_webfinger_host', ) .where('sites.host', host); @@ -85,6 +86,7 @@ export class HostDataContextLoader { ap_following_url: result.account_ap_following_url, ap_liked_url: result.account_ap_liked_url, custom_fields: result.account_custom_fields, + webfinger_host: result.account_webfinger_host, site_id: result.site_id, }); diff --git a/src/http/routing/route-registry.ts b/src/http/routing/route-registry.ts index 27497c814..f06a06d44 100644 --- a/src/http/routing/route-registry.ts +++ b/src/http/routing/route-registry.ts @@ -66,7 +66,7 @@ export class RouteRegistry { app: Hono<{ Variables: HonoContextVariables }>, container: AwilixContainer, ): void { - for (const route of this.routes) { + for (const route of [...this.routes].sort(compareRouteSpecificity)) { const middleware = this.buildMiddleware(route, container); app.on(route.method, [route.path], ...middleware); } @@ -129,3 +129,18 @@ export class RouteRegistry { return middleware; } } + +function compareRouteSpecificity(a: RouteRegistration, b: RouteRegistration) { + const dynamicSegmentDifference = + countDynamicSegments(a.path) - countDynamicSegments(b.path); + + if (dynamicSegmentDifference !== 0) { + return dynamicSegmentDifference; + } + + return b.path.length - a.path.length; +} + +function countDynamicSegments(path: string) { + return path.split('/').filter((segment) => segment.startsWith(':')).length; +} diff --git a/src/http/routing/route-registry.unit.test.ts b/src/http/routing/route-registry.unit.test.ts index 186644cb6..5735ec1d0 100644 --- a/src/http/routing/route-registry.unit.test.ts +++ b/src/http/routing/route-registry.unit.test.ts @@ -80,6 +80,29 @@ describe('RouteRegistry', () => { // Args: method, [path], ...middlewares — so >3 means at least 2 middlewares expect(call.length).toBeGreaterThan(3); }); + + it('mounts static routes before dynamic routes', () => { + routeRegistry.registerRoute({ + method: 'GET', + path: '/account/:handle', + controllerToken: 'TestController', + methodName: 'dynamicMethod', + }); + routeRegistry.registerRoute({ + method: 'GET', + path: '/account/settings', + controllerToken: 'TestController', + methodName: 'staticMethod', + }); + + routeRegistry.mountRoutes( + mockApp as unknown as Hono<{ Variables: HonoContextVariables }>, + mockContainer as unknown as AwilixContainer, + ); + + expect(mockApp.on.mock.calls[0][1]).toEqual(['/account/settings']); + expect(mockApp.on.mock.calls[1][1]).toEqual(['/account/:handle']); + }); }); describe('registerController', () => { diff --git a/src/notification/__snapshots__/get-notifications-data.json b/src/notification/__snapshots__/get-notifications-data.json index d531dcef2..61824f7ee 100644 --- a/src/notification/__snapshots__/get-notifications-data.json +++ b/src/notification/__snapshots__/get-notifications-data.json @@ -6,6 +6,7 @@ "actor_name": null, "actor_url": null, "actor_username": "charlie", + "actor_webfinger_host": null, "in_reply_to_post_ap_id": "https://alice.com/post/some-post", "in_reply_to_post_content": "Velit culpa est amet nisi laboris aliqua cillum consectetur consequat duis excepteur esse non dolor irure.", "in_reply_to_post_title": null, @@ -33,6 +34,7 @@ "actor_name": null, "actor_url": null, "actor_username": "dan", + "actor_webfinger_host": null, "in_reply_to_post_ap_id": null, "in_reply_to_post_content": "", "in_reply_to_post_title": null, @@ -60,6 +62,7 @@ "actor_name": null, "actor_url": null, "actor_username": "bob", + "actor_webfinger_host": null, "in_reply_to_post_ap_id": "https://alice.com/post/some-post", "in_reply_to_post_content": "Velit culpa est amet nisi laboris aliqua cillum consectetur consequat duis excepteur esse non dolor irure.", "in_reply_to_post_title": null, @@ -93,6 +96,7 @@ "actor_name": null, "actor_url": null, "actor_username": "charlie", + "actor_webfinger_host": null, "in_reply_to_post_ap_id": null, "in_reply_to_post_content": "", "in_reply_to_post_title": null, @@ -124,6 +128,7 @@ "actor_name": null, "actor_url": null, "actor_username": "bob", + "actor_webfinger_host": null, "in_reply_to_post_ap_id": null, "in_reply_to_post_content": "", "in_reply_to_post_title": null, diff --git a/src/notification/notification.service.ts b/src/notification/notification.service.ts index 6c5cb8651..e63dc49f2 100644 --- a/src/notification/notification.service.ts +++ b/src/notification/notification.service.ts @@ -36,6 +36,7 @@ interface BaseGetNotificationsDataResultRow { actor_name: string; actor_username: string; actor_url: string; + actor_webfinger_host: string | null; actor_avatar_url: string; actor_followed_by_user: 0 | 1; post_ap_id: string; @@ -104,6 +105,7 @@ export class NotificationService { 'actor_account.name as actor_name', 'actor_account.username as actor_username', 'actor_account.url as actor_url', + 'actor_account.webfinger_host as actor_webfinger_host', 'actor_account.avatar_url as actor_avatar_url', this.db.raw(` CASE diff --git a/src/post/post.entity.ts b/src/post/post.entity.ts index 6a28c2f1a..4ebfe51c6 100644 --- a/src/post/post.entity.ts +++ b/src/post/post.entity.ts @@ -95,7 +95,10 @@ export interface ImageAttachment { altText?: string; } -export type MentionedAccount = Pick; +export type MentionedAccount = Pick< + Account, + 'id' | 'apId' | 'username' | 'webfingerHost' +>; export interface PostData { type: CreatePostType; diff --git a/src/post/post.repository.knex.integration.test.ts b/src/post/post.repository.knex.integration.test.ts index 0febb372a..3706d6fe6 100644 --- a/src/post/post.repository.knex.integration.test.ts +++ b/src/post/post.repository.knex.integration.test.ts @@ -2010,11 +2010,13 @@ describe('KnexPostRepository', () => { id: mentionedAccount1.id, apId: mentionedAccount1.apId, username: mentionedAccount1.username, + webfingerHost: null, }); expect(retrievedPost.mentions[1]).toEqual({ id: mentionedAccount2.id, apId: mentionedAccount2.apId, username: mentionedAccount2.username, + webfingerHost: null, }); }); }); diff --git a/src/post/post.repository.knex.ts b/src/post/post.repository.knex.ts index b1e5bd442..b70c1b409 100644 --- a/src/post/post.repository.knex.ts +++ b/src/post/post.repository.knex.ts @@ -69,6 +69,7 @@ interface PostRow { author_ap_outbox_url: string | null; author_ap_following_url: string | null; author_ap_liked_url: string | null; + author_webfinger_host: string | null; site_id: number | null; site_host: string | null; } @@ -136,6 +137,7 @@ export class KnexPostRepository { 'accounts.ap_outbox_url as author_ap_outbox_url', 'accounts.ap_following_url as author_ap_following_url', 'accounts.ap_liked_url as author_ap_liked_url', + 'accounts.webfinger_host as author_webfinger_host', 'sites.id as site_id', 'sites.host as site_host', ) @@ -152,11 +154,17 @@ export class KnexPostRepository { const mentions = await this.db('mentions') .join('accounts', 'accounts.id', 'mentions.account_id') .where('mentions.post_id', postId) - .select('accounts.id', 'accounts.ap_id', 'accounts.username'); + .select( + 'accounts.id', + 'accounts.ap_id', + 'accounts.username', + 'accounts.webfinger_host', + ); return mentions.map((mention) => ({ id: mention.id, apId: new URL(mention.ap_id), username: mention.username, + webfingerHost: mention.webfinger_host ?? null, })); } @@ -979,6 +987,7 @@ export class KnexPostRepository { apFollowing: parseURL(row.author_ap_following_url), apLiked: parseURL(row.author_ap_liked_url), isInternal: row.site_id !== null, + webfingerHost: row.author_webfinger_host ?? null, }); const attachments = row.attachments @@ -1072,6 +1081,7 @@ export class KnexPostRepository { 'accounts.ap_outbox_url as author_ap_outbox_url', 'accounts.ap_following_url as author_ap_following_url', 'accounts.ap_liked_url as author_ap_liked_url', + 'accounts.webfinger_host as author_webfinger_host', 'sites.id as site_id', 'sites.host as site_host', 'outboxes.outbox_type', From d534a63ac35925e0ad99182b6fe39450de2d9ea5 Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 10 Jun 2026 09:31:06 +0200 Subject: [PATCH 2/3] Remove route specificity sorting from RouteRegistry Mounted routes were sorted so static/less-dynamic paths registered first. No registered routes actually collide at the same segment shape, so the sort never changed matching, and Hono already prioritises static segments over params. Drop the dead complexity and its accompanying unit test. Co-Authored-By: Claude Opus 4.8 --- src/http/routing/route-registry.ts | 17 +-------------- src/http/routing/route-registry.unit.test.ts | 23 -------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/src/http/routing/route-registry.ts b/src/http/routing/route-registry.ts index f06a06d44..27497c814 100644 --- a/src/http/routing/route-registry.ts +++ b/src/http/routing/route-registry.ts @@ -66,7 +66,7 @@ export class RouteRegistry { app: Hono<{ Variables: HonoContextVariables }>, container: AwilixContainer, ): void { - for (const route of [...this.routes].sort(compareRouteSpecificity)) { + for (const route of this.routes) { const middleware = this.buildMiddleware(route, container); app.on(route.method, [route.path], ...middleware); } @@ -129,18 +129,3 @@ export class RouteRegistry { return middleware; } } - -function compareRouteSpecificity(a: RouteRegistration, b: RouteRegistration) { - const dynamicSegmentDifference = - countDynamicSegments(a.path) - countDynamicSegments(b.path); - - if (dynamicSegmentDifference !== 0) { - return dynamicSegmentDifference; - } - - return b.path.length - a.path.length; -} - -function countDynamicSegments(path: string) { - return path.split('/').filter((segment) => segment.startsWith(':')).length; -} diff --git a/src/http/routing/route-registry.unit.test.ts b/src/http/routing/route-registry.unit.test.ts index 5735ec1d0..186644cb6 100644 --- a/src/http/routing/route-registry.unit.test.ts +++ b/src/http/routing/route-registry.unit.test.ts @@ -80,29 +80,6 @@ describe('RouteRegistry', () => { // Args: method, [path], ...middlewares — so >3 means at least 2 middlewares expect(call.length).toBeGreaterThan(3); }); - - it('mounts static routes before dynamic routes', () => { - routeRegistry.registerRoute({ - method: 'GET', - path: '/account/:handle', - controllerToken: 'TestController', - methodName: 'dynamicMethod', - }); - routeRegistry.registerRoute({ - method: 'GET', - path: '/account/settings', - controllerToken: 'TestController', - methodName: 'staticMethod', - }); - - routeRegistry.mountRoutes( - mockApp as unknown as Hono<{ Variables: HonoContextVariables }>, - mockContainer as unknown as AwilixContainer, - ); - - expect(mockApp.on.mock.calls[0][1]).toEqual(['/account/settings']); - expect(mockApp.on.mock.calls[1][1]).toEqual(['/account/:handle']); - }); }); describe('registerController', () => { From 0c78476c92c4b4cbf9f3fefb8fa3aed94b0fb72d Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 10 Jun 2026 09:32:30 +0200 Subject: [PATCH 3/3] Make accountService a required SiteController dependency The DI container always provides accountService, so the optional `?` and the runtime guard were dead defensiveness. Make it required and reorder the constructor to (siteService, accountService, ghostProIpAddresses?) so the optional env-derived argument comes last. Co-Authored-By: Claude Opus 4.8 --- src/configuration/registrations.ts | 2 +- src/http/api/site.controller.ts | 29 +++++++++----------- src/http/api/site.controller.unit.test.ts | 32 +++++++++++------------ 3 files changed, 29 insertions(+), 34 deletions(-) diff --git a/src/configuration/registrations.ts b/src/configuration/registrations.ts index c33636c44..822825a04 100644 --- a/src/configuration/registrations.ts +++ b/src/configuration/registrations.ts @@ -466,8 +466,8 @@ export function registerDependencies( return new SiteController( siteService, - ghostProIpAddresses, accountService, + ghostProIpAddresses, ); }, ).singleton(), diff --git a/src/http/api/site.controller.ts b/src/http/api/site.controller.ts index 1a6918b4b..a7687cbd9 100644 --- a/src/http/api/site.controller.ts +++ b/src/http/api/site.controller.ts @@ -6,8 +6,8 @@ import type { SiteService } from '@/site/site.service'; export class SiteController { constructor( private readonly siteService: SiteService, + private readonly accountService: AccountService, private readonly ghostProIpAddresses?: string[], - private readonly accountService?: AccountService, ) {} async handleGetSiteData(ctx: AppContext) { @@ -30,21 +30,18 @@ export class SiteController { let responseBody: object = site; - if (this.accountService) { - const account = - await this.accountService.getAccountForSite(site); - - if (account) { - responseBody = { - ...site, - domain: getAccountHandleHost(account), - handle: getAccountHandle( - getAccountHandleHost(account), - account.username, - ), - actorUrl: account.apId.href, - }; - } + const account = await this.accountService.getAccountForSite(site); + + if (account) { + responseBody = { + ...site, + domain: getAccountHandleHost(account), + handle: getAccountHandle( + getAccountHandleHost(account), + account.username, + ), + actorUrl: account.apId.href, + }; } return new Response(JSON.stringify(responseBody), { diff --git a/src/http/api/site.controller.unit.test.ts b/src/http/api/site.controller.unit.test.ts index 4909397ce..f7d6edb71 100644 --- a/src/http/api/site.controller.unit.test.ts +++ b/src/http/api/site.controller.unit.test.ts @@ -7,6 +7,7 @@ import type { Site, SiteService } from '@/site/site.service'; describe('SiteController', () => { let siteService: SiteService; + let accountService: AccountService; let siteController: SiteController; let mockSite: Site; @@ -22,6 +23,10 @@ describe('SiteController', () => { initialiseSiteForHost: vi.fn().mockResolvedValue(mockSite), disableSiteForHost: vi.fn().mockResolvedValue(true), } as unknown as SiteService; + + accountService = { + getAccountForSite: vi.fn().mockResolvedValue(null), + } as unknown as AccountService; }); function getMockAppContext( @@ -54,7 +59,7 @@ describe('SiteController', () => { describe('handleGetSiteData', () => { it('returns 401 if no host header is provided', async () => { - siteController = new SiteController(siteService); + siteController = new SiteController(siteService, accountService); const ctx = getMockAppContext(undefined); const response = await siteController.handleGetSiteData(ctx); @@ -64,7 +69,7 @@ describe('SiteController', () => { }); it('returns site data as JSON', async () => { - siteController = new SiteController(siteService); + siteController = new SiteController(siteService, accountService); const ctx = getMockAppContext('example.com'); const response = await siteController.handleGetSiteData(ctx); @@ -78,14 +83,7 @@ describe('SiteController', () => { }); it('returns site data when no account exists for the site', async () => { - const accountService = { - getAccountForSite: vi.fn().mockResolvedValue(null), - } as unknown as AccountService; - siteController = new SiteController( - siteService, - undefined, - accountService, - ); + siteController = new SiteController(siteService, accountService); const ctx = getMockAppContext('example.com'); const response = await siteController.handleGetSiteData(ctx); @@ -95,7 +93,7 @@ describe('SiteController', () => { }); it('sets `ghost_pro` flag to false when none of the x-forwarded-for IP addresses matches any of the Ghost (Pro) IP addresses', async () => { - siteController = new SiteController(siteService, [ + siteController = new SiteController(siteService, accountService, [ '10.0.0.1', '10.0.0.2', ]); @@ -112,7 +110,7 @@ describe('SiteController', () => { }); it('sets `ghost_pro` flag to true when one of the x-forwarded-for IP addresses matches a Ghost (Pro) IP address', async () => { - siteController = new SiteController(siteService, [ + siteController = new SiteController(siteService, accountService, [ '10.0.0.1', '10.0.0.2', ]); @@ -129,7 +127,7 @@ describe('SiteController', () => { }); it('sets `ghost_pro` flag to false when no IP headers are found', async () => { - siteController = new SiteController(siteService, [ + siteController = new SiteController(siteService, accountService, [ '10.0.0.1', '10.0.0.2', ]); @@ -146,7 +144,7 @@ describe('SiteController', () => { describe('handleDisableSite', () => { it('returns 401 if no host header is provided', async () => { - siteController = new SiteController(siteService); + siteController = new SiteController(siteService, accountService); const ctx = getMockAppContext(undefined); const response = await siteController.handleDisableSite(ctx); @@ -159,7 +157,7 @@ describe('SiteController', () => { }); it('returns 200 if the site is disabled', async () => { - siteController = new SiteController(siteService); + siteController = new SiteController(siteService, accountService); const ctx = getMockAppContext('example.com'); const response = await siteController.handleDisableSite(ctx); @@ -168,7 +166,7 @@ describe('SiteController', () => { }); it('returns 404 if the site is not found', async () => { - siteController = new SiteController(siteService); + siteController = new SiteController(siteService, accountService); vi.mocked(siteService.disableSiteForHost).mockResolvedValue(false); @@ -179,7 +177,7 @@ describe('SiteController', () => { }); it('returns 500 if an error occurs', async () => { - siteController = new SiteController(siteService); + siteController = new SiteController(siteService, accountService); vi.mocked(siteService.disableSiteForHost).mockRejectedValue( new Error('test'),