Skip to content

Commit

Permalink
Actor aliases
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Dec 23, 2024
1 parent 56ee718 commit 41f7246
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 29 deletions.
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
93 changes: 93 additions & 0 deletions docs/manual/actor.md
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,96 @@ property set to <http://webfinger.net/rel/profile-page>.
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 <http://webfinger.net/rel/avatar>.


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<void>;
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<void>;
> 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.
19 changes: 19 additions & 0 deletions src/federation/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ export type ActorHandleMapper<TContextData> = (
username: string,
) => string | null | Promise<string | null>;

/**
* 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<TContextData> = (
context: RequestContext<TContextData>,
resource: URL,
) =>
| { identifier: string }
| { username: string }
| null
| Promise<{ identifier: string } | { username: string } | null>;

/**
* A callback that dispatches an object.
*
Expand Down
18 changes: 18 additions & 0 deletions src/federation/federation.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -510,12 +511,29 @@ export interface ActorCallbackSetters<TContextData> {
* 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<TContextData>,
): ActorCallbackSetters<TContextData>;

/**
* 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<TContextData>,
): ActorCallbackSetters<TContextData>;

/**
* Specifies the conditions under which requests are authorized.
* @param predicate A callback that returns whether a request is authorized.
Expand Down
7 changes: 7 additions & 0 deletions src/federation/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
} from "../vocab/vocab.ts";
import { handleWebFinger } from "../webfinger/handler.ts";
import type {
ActorAliasMapper,
ActorDispatcher,
ActorHandleMapper,
ActorKeyPairsDispatcher,
Expand Down Expand Up @@ -1190,6 +1191,10 @@ export class FederationImpl<TContextData> implements Federation<TContextData> {
callbacks.handleMapper = mapper;
return setters;
},
mapAlias(mapper: ActorAliasMapper<TContextData>) {
callbacks.aliasMapper = mapper;
return setters;
},
authorize(predicate: AuthorizePredicate<TContextData>) {
callbacks.authorizePredicate = predicate;
return setters;
Expand Down Expand Up @@ -2206,6 +2211,7 @@ export class FederationImpl<TContextData> implements Federation<TContextData> {
context,
actorDispatcher: this.actorCallbacks?.dispatcher,
actorHandleMapper: this.actorCallbacks?.handleMapper,
actorAliasMapper: this.actorCallbacks?.aliasMapper,
onNotFound,
tracer,
});
Expand Down Expand Up @@ -3639,6 +3645,7 @@ interface ActorCallbacks<TContextData> {
dispatcher?: ActorDispatcher<TContextData>;
keyPairsDispatcher?: ActorKeyPairsDispatcher<TContextData>;
handleMapper?: ActorHandleMapper<TContextData>;
aliasMapper?: ActorAliasMapper<TContextData>;
authorizePredicate?: AuthorizePredicate<TContextData>;
}

Expand Down
82 changes: 74 additions & 8 deletions src/webfinger/handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assertEquals } from "@std/assert";
import type {
ActorAliasMapper,
ActorDispatcher,
ActorHandleMapper,
} from "../federation/callback.ts";
Expand All @@ -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 {
Expand All @@ -37,20 +39,24 @@ test("handleWebFinger()", async () => {
};
},
});
const actorDispatcher: ActorDispatcher<void> = (ctx, handle) => {
if (handle !== "someone" && handle !== "someone2") return null;
const actorDispatcher: ActorDispatcher<void> = (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",
}),
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -275,4 +281,64 @@ test("handleWebFinger()", async () => {
onNotFound,
});
assertEquals(response.status, 404);

const actorAliasMapper: ActorAliasMapper<void> = (_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:[email protected]", "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);
});
Loading

0 comments on commit 41f7246

Please sign in to comment.