diff --git a/CHANGES.md b/CHANGES.md index 1cee497c4..d22db87be 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,8 +18,16 @@ To be released. collections instead of being treated as unknown routes. [[#849], [#851] by ChanHaeng Lee] + - Fixed split-origin WebFinger responses for `acct:` aliases on the web + origin host. When a local actor is queried through the server-origin + `acct:` alias, Fedify now returns the canonical handle-host `acct:` URI as + the JRD `subject` and keeps the queried `acct:` URI in `aliases`. + [[#920], [#921]] + [#849]: https://github.com/fedify-dev/fedify/issues/849 [#851]: https://github.com/fedify-dev/fedify/pull/851 +[#920]: https://github.com/fedify-dev/fedify/issues/920 +[#921]: https://github.com/fedify-dev/fedify/pull/921 ### @fedify/cli diff --git a/packages/fedify/src/federation/webfinger.test.ts b/packages/fedify/src/federation/webfinger.test.ts index 6bd9167cf..8a8d03a34 100644 --- a/packages/fedify/src/federation/webfinger.test.ts +++ b/packages/fedify/src/federation/webfinger.test.ts @@ -269,6 +269,8 @@ test("handleWebFinger()", async (t) => { ? "someone" : username === "bar" ? "someone2" + : username === "qux" + ? "someone2" : null; }; @@ -402,22 +404,88 @@ test("handleWebFinger()", async (t) => { }); await t.step("handleHost", async () => { - const u = new URL(url); - u.searchParams.set("resource", "acct:someone@example.com"); + let u = new URL("https://ap.example.com/.well-known/webfinger"); + u.searchParams.set("resource", "acct:someone@ap.example.com"); let context = createContext(u); let request = context.request; let response = await handleWebFinger(request, { context, - host: "handle.example.com", + host: "example.com", actorDispatcher, onNotFound, }); assertEquals(response.status, 200); assertEquals(await response.json(), { - ...expected, - aliases: [...expected.aliases, "acct:someone@handle.example.com"], + subject: "acct:someone@example.com", + aliases: [ + "https://ap.example.com/users/someone", + "acct:someone@ap.example.com", + ], + links: [ + { + href: "https://ap.example.com/users/someone", + rel: "self", + type: "application/activity+json", + }, + { + href: "https://ap.example.com/@someone", + rel: "http://webfinger.net/rel/profile-page", + }, + { + href: "https://ap.example.com/@someone", + rel: "alternate", + type: "text/html", + }, + { + href: "https://ap.example.com/icon.jpg", + rel: "http://webfinger.net/rel/avatar", + type: "image/jpeg", + }, + ], + }); + + u.searchParams.set("resource", "acct:qux@ap.example.com"); + context = createContext(u); + request = context.request; + response = await handleWebFinger(request, { + context, + host: "example.com", + actorDispatcher, + actorHandleMapper, + onNotFound, + }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { + subject: "acct:bar@example.com", + aliases: [ + "https://ap.example.com/users/someone2", + "acct:qux@ap.example.com", + "acct:bar@ap.example.com", + ], + links: [ + { + href: "https://ap.example.com/users/someone2", + rel: "self", + type: "application/activity+json", + }, + { + href: "https://ap.example.com/@someone2", + rel: "http://webfinger.net/rel/profile-page", + }, + { + href: "https://ap.example.com/@someone2", + rel: "alternate", + type: "text/html", + }, + { + href: "https://ap.example.com/icon.jpg", + rel: "http://webfinger.net/rel/avatar", + type: "image/jpeg", + }, + ], }); + u = new URL(url); u.searchParams.set("resource", "acct:someone@handle.example.com"); context = createContext(u); request = context.request; diff --git a/packages/fedify/src/federation/webfinger.ts b/packages/fedify/src/federation/webfinger.ts index 14255cac6..8c870e23a 100644 --- a/packages/fedify/src/federation/webfinger.ts +++ b/packages/fedify/src/federation/webfinger.ts @@ -1,4 +1,4 @@ -import { Link as LinkObject } from "@fedify/vocab"; +import { type LanguageString, Link as LinkObject } from "@fedify/vocab"; import type { Link, ResourceDescriptor } from "@fedify/webfinger"; import { getLogger } from "@logtape/logtape"; import type { Span, Tracer } from "@opentelemetry/api"; @@ -14,6 +14,53 @@ import type { RequestContext } from "./context.ts"; const logger = getLogger(["fedify", "webfinger", "server"]); +interface WebFingerSubjectAndAliasesOptions { + resourceUrl: URL; + actorUri: URL; + preferredUsername: string | LanguageString | null | undefined; + acctUsername: string | null; + host: string | undefined; + contextHost: string; +} + +function getWebFingerSubjectAndAliases( + { + resourceUrl, + actorUri, + preferredUsername, + acctUsername, + host, + contextHost, + }: WebFingerSubjectAndAliasesOptions, +): Pick { + const aliases: string[] = []; + let subject = resourceUrl.href; + if (resourceUrl.protocol != "acct:" && preferredUsername != null) { + aliases.push(`acct:${preferredUsername}@${host ?? contextHost}`); + if (host != null && host !== contextHost) { + aliases.push(`acct:${preferredUsername}@${contextHost}`); + } + } + if (resourceUrl.href !== actorUri.href) { + aliases.push(actorUri.href); + } + if ( + resourceUrl.protocol === "acct:" && host != null && + host !== contextHost && + !resourceUrl.href.endsWith(`@${host}`) + ) { + subject = `acct:${preferredUsername ?? acctUsername}@${host}`; + aliases.push(resourceUrl.href); + if ( + preferredUsername != null && preferredUsername !== "" && + preferredUsername !== acctUsername + ) { + aliases.push(`acct:${preferredUsername}@${contextHost}`); + } + } + return { subject, aliases }; +} + /** * Parameters for {@link handleWebFinger}. */ @@ -164,6 +211,7 @@ async function handleWebFingerInternal( } let identifier: string | null = null; + let acctUsername: string | null = null; const uriParsed = context.parseUri(resourceUrl); if (uriParsed?.type != "actor") { const match = /^acct:([^@]+)@([^@]+)$/.exec(resource); @@ -185,8 +233,9 @@ async function handleWebFingerInternal( if (normalizedHost != context.url.host && normalizedHost != host) { return await onNotFound(request); } else { - identifier = await mapUsernameToIdentifier(match[1]); - resourceUrl = new URL(`acct:${match[1]}@${normalizedHost}`); + acctUsername = match[1]; + identifier = await mapUsernameToIdentifier(acctUsername); + resourceUrl = new URL(`acct:${acctUsername}@${normalizedHost}`); } } } else { @@ -200,10 +249,11 @@ async function handleWebFingerInternal( logger.error("Actor {identifier} not found.", { identifier }); return await onNotFound(request); } + const actorUri = context.getActorUri(identifier); const links: Link[] = [ { rel: "self", - href: context.getActorUri(identifier).href, + href: actorUri.href, type: "application/activity+json", }, ]; @@ -239,26 +289,16 @@ async function handleWebFingerInternal( } } - const aliases: string[] = []; - if (resourceUrl.protocol != "acct:" && actor.preferredUsername != null) { - aliases.push(`acct:${actor.preferredUsername}@${host ?? context.url.host}`); - if (host != null && host !== context.url.host) { - aliases.push(`acct:${actor.preferredUsername}@${context.url.host}`); - } - } - if (resourceUrl.href !== context.getActorUri(identifier).href) { - aliases.push(context.getActorUri(identifier).href); - } - if ( - resourceUrl.protocol === "acct:" && host != null && - host !== context.url.host && - !resourceUrl.href.endsWith(`@${host}`) - ) { - const username = resourceUrl.href.replace(/^acct:/, "").replace(/@.*$/, ""); - aliases.push(`acct:${username}@${host}`); - } + const { subject, aliases } = getWebFingerSubjectAndAliases({ + resourceUrl, + actorUri, + preferredUsername: actor.preferredUsername, + acctUsername, + host, + contextHost: context.url.host, + }); const jrd: ResourceDescriptor = { - subject: resourceUrl.href, + subject, aliases, links, };