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..822825a04 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, + accountService, + ghostProIpAddresses, + ); + }, + ).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..a7687cbd9 100644 --- a/src/http/api/site.controller.ts +++ b/src/http/api/site.controller.ts @@ -1,9 +1,12 @@ +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'; export class SiteController { constructor( private readonly siteService: SiteService, + private readonly accountService: AccountService, private readonly ghostProIpAddresses?: string[], ) {} @@ -25,7 +28,23 @@ export class SiteController { isGhostPro, ); - return new Response(JSON.stringify(site), { + let responseBody: object = site; + + 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..f7d6edb71 100644 --- a/src/http/api/site.controller.unit.test.ts +++ b/src/http/api/site.controller.unit.test.ts @@ -1,11 +1,13 @@ 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'; describe('SiteController', () => { let siteService: SiteService; + let accountService: AccountService; let siteController: SiteController; let mockSite: Site; @@ -21,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( @@ -53,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); @@ -63,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); @@ -76,8 +82,18 @@ describe('SiteController', () => { expect(body).toEqual(mockSite); }); + it('returns site data when no account exists for the site', async () => { + siteController = new SiteController(siteService, 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, [ + siteController = new SiteController(siteService, accountService, [ '10.0.0.1', '10.0.0.2', ]); @@ -94,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', ]); @@ -111,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', ]); @@ -128,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); @@ -141,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); @@ -150,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); @@ -161,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'), 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/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',