From 41f724640ec6bd5a7a31220e02e6e8bf6739c587 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 23 Dec 2024 22:05:37 +0900 Subject: [PATCH] Actor aliases --- CHANGES.md | 6 +++ docs/manual/actor.md | 93 +++++++++++++++++++++++++++++++++++ src/federation/callback.ts | 19 +++++++ src/federation/federation.ts | 18 +++++++ src/federation/middleware.ts | 7 +++ src/webfinger/handler.test.ts | 82 +++++++++++++++++++++++++++--- src/webfinger/handler.ts | 72 +++++++++++++++++++-------- 7 files changed, 268 insertions(+), 29 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 1311c09c..2c7e6f8e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,11 +12,17 @@ To be released. `traverseCollection()` function, and `Context.traverseCollection()` method now suppresses errors occurred JSON-LD processing. + - WebFinger responses are now customizable. [[#3]] + + - Added `ActorCallbackSetters.mapAlias()` method. + - Added `ActorAliasMapper` type. + - Added `-t`/`--traverse` option to the `fedify lookup` subcommand. [[#195]] - Added `-S`/`--suppress-errors` option to the `fedify lookup` subcommand. [[#195]] +[#3]: https://github.com/dahlia/fedify/issues/3 [#195]: https://github.com/dahlia/fedify/issues/195 diff --git a/docs/manual/actor.md b/docs/manual/actor.md index f9663a1c..43c66583 100644 --- a/docs/manual/actor.md +++ b/docs/manual/actor.md @@ -498,3 +498,96 @@ property set to . The `icon` property is an `Image` object that represents the actor's icon (i.e., avatar). It is used as the `links` property of the WebFinger response, with the `rel` property set to . + + +Actor aliases +------------- + +*This API is available since Fedify 1.4.0.* + +Sometimes, you may want to give different URLs to the actor URI and its web +profile URL. It can be easily configured by setting the `url` property of +the `Actor` object returned by the actor dispatcher. However, if someone +queries the WebFinger for a profile URL, the WebFinger response will not +contain the corresponding actor URI. + +To solve this problem, you can set the aliases of the actor by +the `~ActorCallbackSetters.mapAlias()` method. It takes a callback function +that takes a `Context` object and a queried URL through WebFinger, and returns +the corresponding actor's internal identifier or username, or `null` if there +is no corresponding actor: + +~~~~ typescript{15-25} twoslash +// @noErrors: 2339 2345 2391 7006 +import { type Federation } from "@fedify/fedify"; +const federation = null as unknown as Federation; +interface User { uuid: string; } +/** + * It's a hypothetical function that finds a user by the UUID. + * @param uuid The UUID of the user. + * @returns The user object. + */ +function findUserByUuid(uuid: string): User; +/** + * It's a hypothetical function that finds a user by the username. + * @param username The username of the user. + * @returns The user object. + */ +function findUserByUsername(username: string): User; +// ---cut-before--- +federation + .setActorDispatcher("/users/{identifier}", async (ctx, identifier) => { + // Since we map a WebFinger username to the corresponding user's UUID below, + // the `identifier` parameter is the user's UUID, not the WebFinger + // username: + const user = await findUserByUuid(identifier); + // Omitted for brevity; see the previous example for details. + }) + .mapHandle(async (ctx, username) => { + // Work with the database to find the user's UUID by the WebFinger username. + const user = await findUserByUsername(username); + if (user == null) return null; // Return null if the actor is not found. + return user.uuid; + }) + .mapAlias((ctx, resource: URL) => { + // Parse the URL and return the corresponding actor's username if + // the URL is the profile URL of the actor: + if (resource.protocol !== "https:") return null; + if (resource.hostname !== "example.com") return null; + const m = /^\/@(\w+)$/.exec(resource.pathname); + if (m == null) return null; + // Note that it is okay even if the returned username is non-existent. + // It's dealt with by the `mapHandle()` above: + return { username: m[1] }; + }); +~~~~ + +By registering the alias mapper, Fedify can respond to WebFinger requests +for the actor's profile URL with the corresponding actor URI. + +> [!TIP] +> You also can return the actor's internal identifier instead of the username +> in the `~ActorCallbackSetters.mapAlias()` method: +> +> ~~~~ typescript twoslash +> // @noErrors: 2339 2345 2391 7006 +> import { type Federation } from "@fedify/fedify"; +> const federation = null as unknown as Federation; +> federation.setActorDispatcher( +> "/users/{identifier}", async (ctx, identifier) => {} +> ) +> // ---cut-before--- +> .mapAlias((ctx, resource: URL) => { +> // Parse the URL and return the corresponding actor's username if +> // the URL is the profile URL of the actor: +> if (resource.protocol !== "https:") return null; +> if (resource.hostname !== "example.com") return null; +> const userId = resource.searchParams.get("userId"); +> if (userId == null) return null; +> return { identifier: userId }; // [!code highlight] +> }); +> ~~~~ + +> [!TIP] +> The callback function of the `~ActorCallbackSetters.mapAlias()` method +> can be an async function. diff --git a/src/federation/callback.ts b/src/federation/callback.ts index d307c11d..4a70d2ea 100644 --- a/src/federation/callback.ts +++ b/src/federation/callback.ts @@ -56,6 +56,25 @@ export type ActorHandleMapper = ( username: string, ) => string | null | Promise; +/** + * A callback that maps a WebFinger query to the corresponding actor's + * internal identifier or username, or `null` if the query is not found. + * @typeParam TContextData The context data to pass to the {@link Context}. + * @param context The request context. + * @param resource The URL that was queried through WebFinger. + * @returns The actor's internal identifier or username, or `null` if the query + * is not found. + * @since 1.4.0 + */ +export type ActorAliasMapper = ( + context: RequestContext, + resource: URL, +) => + | { identifier: string } + | { username: string } + | null + | Promise<{ identifier: string } | { username: string } | null>; + /** * A callback that dispatches an object. * diff --git a/src/federation/federation.ts b/src/federation/federation.ts index 761a5e7b..b28c3a6c 100644 --- a/src/federation/federation.ts +++ b/src/federation/federation.ts @@ -1,6 +1,7 @@ import type { Actor, Recipient } from "../vocab/actor.ts"; import type { Activity, Hashtag, Object } from "../vocab/vocab.ts"; import type { + ActorAliasMapper, ActorDispatcher, ActorHandleMapper, ActorKeyPairsDispatcher, @@ -510,12 +511,29 @@ export interface ActorCallbackSetters { * is assumed to be the same as the WebFinger username, which makes your * actors have the immutable handles. If you want to let your actors change * their fediverse handles, you should set this dispatcher. + * @param mapper A callback that maps a WebFinger username to + * the corresponding actor's identifier. + * @returns The setters object so that settings can be chained. * @since 0.15.0 */ mapHandle( mapper: ActorHandleMapper, ): ActorCallbackSetters; + /** + * Sets the callback function that maps a WebFinger query to the corresponding + * actor's identifier or username. If it's omitted, the WebFinger handler + * only supports the actor URIs and `acct:` URIs. If you want to support + * other queries, you should set this dispatcher. + * @param mapper A callback that maps a WebFinger query to the corresponding + * actor's identifier or username. + * @returns The setters object so that settings can be chained. + * @since 1.4.0 + */ + mapAlias( + mapper: ActorAliasMapper, + ): ActorCallbackSetters; + /** * Specifies the conditions under which requests are authorized. * @param predicate A callback that returns whether a request is authorized. diff --git a/src/federation/middleware.ts b/src/federation/middleware.ts index 5f547332..8a3daec3 100644 --- a/src/federation/middleware.ts +++ b/src/federation/middleware.ts @@ -52,6 +52,7 @@ import { } from "../vocab/vocab.ts"; import { handleWebFinger } from "../webfinger/handler.ts"; import type { + ActorAliasMapper, ActorDispatcher, ActorHandleMapper, ActorKeyPairsDispatcher, @@ -1190,6 +1191,10 @@ export class FederationImpl implements Federation { callbacks.handleMapper = mapper; return setters; }, + mapAlias(mapper: ActorAliasMapper) { + callbacks.aliasMapper = mapper; + return setters; + }, authorize(predicate: AuthorizePredicate) { callbacks.authorizePredicate = predicate; return setters; @@ -2206,6 +2211,7 @@ export class FederationImpl implements Federation { context, actorDispatcher: this.actorCallbacks?.dispatcher, actorHandleMapper: this.actorCallbacks?.handleMapper, + actorAliasMapper: this.actorCallbacks?.aliasMapper, onNotFound, tracer, }); @@ -3639,6 +3645,7 @@ interface ActorCallbacks { dispatcher?: ActorDispatcher; keyPairsDispatcher?: ActorKeyPairsDispatcher; handleMapper?: ActorHandleMapper; + aliasMapper?: ActorAliasMapper; authorizePredicate?: AuthorizePredicate; } diff --git a/src/webfinger/handler.test.ts b/src/webfinger/handler.test.ts index a452c318..e024f2c8 100644 --- a/src/webfinger/handler.test.ts +++ b/src/webfinger/handler.test.ts @@ -1,5 +1,6 @@ import { assertEquals } from "@std/assert"; import type { + ActorAliasMapper, ActorDispatcher, ActorHandleMapper, } from "../federation/callback.ts"; @@ -26,6 +27,7 @@ test("handleWebFinger()", async () => { parseUri(uri) { if (uri == null) return null; if (uri.protocol === "acct:") return null; + if (!uri.pathname.startsWith("/users/")) return null; const paths = uri.pathname.split("/"); const identifier = paths[paths.length - 1]; return { @@ -37,20 +39,24 @@ test("handleWebFinger()", async () => { }; }, }); - const actorDispatcher: ActorDispatcher = (ctx, handle) => { - if (handle !== "someone" && handle !== "someone2") return null; + const actorDispatcher: ActorDispatcher = (ctx, identifier) => { + if (identifier !== "someone" && identifier !== "someone2") return null; return new Person({ - id: ctx.getActorUri(handle), - name: handle === "someone" ? "Someone" : "Someone 2", - preferredUsername: handle === "someone" ? null : handle, + id: ctx.getActorUri(identifier), + name: identifier === "someone" ? "Someone" : "Someone 2", + preferredUsername: identifier === "someone" + ? null + : identifier === "someone2" + ? "bar" + : null, icon: new Image({ url: new URL("https://example.com/icon.jpg"), mediaType: "image/jpeg", }), urls: [ - new URL("https://example.com/@" + handle), + new URL("https://example.com/@" + identifier), new Link({ - href: new URL("https://example.org/@" + handle), + href: new URL("https://example.org/@" + identifier), rel: "alternate", mediaType: "text/html", }), @@ -156,7 +162,7 @@ test("handleWebFinger()", async () => { const expected2 = { subject: "https://example.com/users/someone2", aliases: [ - "acct:someone2@example.com", + "acct:bar@example.com", ], links: [ { @@ -275,4 +281,64 @@ test("handleWebFinger()", async () => { onNotFound, }); assertEquals(response.status, 404); + + const actorAliasMapper: ActorAliasMapper = (_ctx, resource) => { + if (resource.protocol !== "https:") return null; + if (resource.host !== "example.com") return null; + const m = /^\/@(\w+)$/.exec(resource.pathname); + if (m == null) return null; + return { username: m[1] }; + }; + + url.searchParams.set("resource", "https://example.com/@someone"); + request = new Request(url); + response = await handleWebFinger(request, { + context, + actorDispatcher, + actorAliasMapper, + onNotFound, + }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { + ...expected, + aliases: ["https://example.com/users/someone"], + subject: "https://example.com/@someone", + }); + + url.searchParams.set("resource", "https://example.com/@bar"); + request = new Request(url); + response = await handleWebFinger(request, { + context, + actorDispatcher, + actorHandleMapper, + actorAliasMapper, + onNotFound, + }); + assertEquals(response.status, 200); + assertEquals(await response.json(), { + ...expected2, + aliases: ["acct:bar@example.com", "https://example.com/users/someone2"], + subject: "https://example.com/@bar", + }); + + url.searchParams.set("resource", "https://example.com/@no-one"); + request = new Request(url); + response = await handleWebFinger(request, { + context, + actorDispatcher, + actorAliasMapper, + onNotFound, + }); + assertEquals(response.status, 404); + + url.searchParams.set("resource", "https://example.com/@no-one"); + request = new Request(url); + response = await handleWebFinger(request, { + context, + actorDispatcher, + actorHandleMapper, + actorAliasMapper, + onNotFound, + }); + assertEquals(response.status, 404); }); diff --git a/src/webfinger/handler.ts b/src/webfinger/handler.ts index c0a55d0f..f2d7565f 100644 --- a/src/webfinger/handler.ts +++ b/src/webfinger/handler.ts @@ -3,6 +3,7 @@ import type { Span, Tracer } from "@opentelemetry/api"; import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; import { toASCII } from "node:punycode"; import type { + ActorAliasMapper, ActorDispatcher, ActorHandleMapper, } from "../federation/callback.ts"; @@ -28,10 +29,16 @@ export interface WebFingerHandlerParameters { /** * The callback for mapping a WebFinger username to the corresponding actor's - * internal handle, or `null` if the username is not found. + * internal identifier, or `null` if the username is not found. */ actorHandleMapper?: ActorHandleMapper; + /** + * The callback for mapping a WebFinger query to the corresponding actor's + * internal identifier or username, or `null` if the query is not found. + */ + actorAliasMapper?: ActorAliasMapper; + /** * The function to call when the actor is not found. */ @@ -91,6 +98,7 @@ async function handleWebFingerInternal( context, actorDispatcher, actorHandleMapper, + actorAliasMapper, onNotFound, span, }: WebFingerHandlerParameters, @@ -118,32 +126,51 @@ async function handleWebFingerInternal( logger.error("Actor dispatcher is not set."); return await onNotFound(request); } - let identifier: string | null; - const uriParsed = context.parseUri(resourceUrl); - if (uriParsed?.type != "actor") { - const match = /^acct:([^@]+)@([^@]+)$/.exec(resource); - if (match == null || toASCII(match[2].toLowerCase()) != context.url.host) { - return await onNotFound(request); - } - const username = match[1]; + + async function mapUsernameToIdentifier( + username: string, + ): Promise { if (actorHandleMapper == null) { logger.error( "No actor handle mapper is set; use the WebFinger username {username}" + " as the actor's internal identifier.", { username }, ); - identifier = username; - } else { - identifier = await actorHandleMapper(context, username); - if (identifier == null) { - logger.error("Actor {username} not found.", { username }); - return await onNotFound(request); + return username; + } + const identifier = await actorHandleMapper(context, username); + if (identifier == null) { + logger.error("Actor {username} not found.", { username }); + return null; + } + return identifier; + } + + let identifier: string | null = null; + const uriParsed = context.parseUri(resourceUrl); + if (uriParsed?.type != "actor") { + const match = /^acct:([^@]+)@([^@]+)$/.exec(resource); + if (match == null) { + const result = await actorAliasMapper?.(context, resourceUrl); + if (result == null) return await onNotFound(request); + if ("identifier" in result) identifier = result.identifier; + else { + identifier = await mapUsernameToIdentifier( + result.username, + ); } + } else if (toASCII(match[2].toLowerCase()) != context.url.host) { + return await onNotFound(request); + } else { + identifier = await mapUsernameToIdentifier(match[1]); + resourceUrl = new URL(`acct:${match[1]}@${context.url.host}`); } - resourceUrl = new URL(`acct:${username}@${context.url.host}`); } else { identifier = uriParsed.identifier; } + if (identifier == null) { + return await onNotFound(request); + } const actor = await actorDispatcher(context, identifier); if (actor == null) { logger.error("Actor {identifier} not found.", { identifier }); @@ -179,13 +206,16 @@ async function handleWebFingerInternal( if (image.mediaType != null) link.type = image.mediaType; links.push(link); } + const aliases: string[] = []; + if (resourceUrl.protocol != "acct:" && actor.preferredUsername != null) { + aliases.push(`acct:${actor.preferredUsername}@${context.url.host}`); + } + if (resourceUrl.href !== context.getActorUri(identifier).href) { + aliases.push(context.getActorUri(identifier).href); + } const jrd: ResourceDescriptor = { subject: resourceUrl.href, - aliases: resourceUrl.href === context.getActorUri(identifier).href - ? (actor.preferredUsername == null - ? [] - : [`acct:${actor.preferredUsername}@${context.url.host}`]) - : [context.getActorUri(identifier).href], + aliases, links, }; return new Response(JSON.stringify(jrd), {