From 8bcc0051401eace81aa0bd4ff463521dfb1a95d4 Mon Sep 17 00:00:00 2001 From: khannurien Date: Fri, 9 Oct 2026 21:06:14 +0000 Subject: [PATCH] =?UTF-8?q?v4:=20a=20remote=20account's=20links=20to=20its?= =?UTF-8?q?=20home=20lead=20to=20the=20home's=20pages=20=E2=80=94=20the=20?= =?UTF-8?q?settings'=20"Change=20it=20on=20"=20button=20and=20the=20?= =?UTF-8?q?profile's=20"Account=20on=20"=20link=20went=20to=20the=20?= =?UTF-8?q?host=20in=20the=20handle,=20which=20is=20the=20home's=20issuer,?= =?UTF-8?q?=20its=20API,=20not=20necessarily=20where=20its=20pages=20are:?= =?UTF-8?q?=20in=20development=20musique's=20button=20led=20to=20localhost?= =?UTF-8?q?:8000/settings,=20gerbeur's=20API,=20which=20served=20a=20stale?= =?UTF-8?q?=20build,=20instead=20of=20localhost:3000/settings.=20GET=20/ap?= =?UTF-8?q?i/users/:key/home=3Fpath=3D=20now=20redirects=20to=20a=20page?= =?UTF-8?q?=20of=20a=20remote=20user's=20home,=20their=20profile=20there?= =?UTF-8?q?=20by=20default,=20at=20the=20origin=20of=20the=20home's=20auth?= =?UTF-8?q?orization=5Fendpoint,=20which=20its=20metadata=20gives=20(homeP?= =?UTF-8?q?ageUrl=20in=20federation-service.ts),=20the=20issuer=20standing?= =?UTF-8?q?=20in=20while=20the=20home=20doesn't=20answer;=20a=20local=20us?= =?UTF-8?q?er=20404s,=20and=20a=20path=20that=20isn't=20one=20400s.=20home?= =?UTF-8?q?Url=20links=20to=20it.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- API.md | 2 ++ api/routes/users.ts | 11 ++++++ api/services/federation-service.ts | 38 ++++++++++++++++++-- api/test/services/federation-service.test.ts | 33 +++++++++++++++++ src/utils/auth/home.ts | 15 ++++---- 5 files changed, 90 insertions(+), 9 deletions(-) diff --git a/API.md b/API.md index 4974a1a6..a5de7d31 100644 --- a/API.md +++ b/API.md @@ -130,6 +130,8 @@ there: those endpoints answer it 400. - `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they follow. - `GET /api/users/:key/invitees` — anyone. The tree of users they invited. +- `GET /api/users/:key/home?path=` — anyone. Redirects to a page of a remote + user's home instance, their profile there by default. - `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's role, or anyone's name, theirs included. diff --git a/api/routes/users.ts b/api/routes/users.ts index 516c1a9a..bf3ef1ea 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -30,6 +30,9 @@ * page of the public playlists they follow. * - `GET /api/users/:key/invitees` — public. Answers the tree of users they * invited. + * - `GET /api/users/:key/home?path=` — public. Redirects to a page of the + * instance a user from another instance comes from, their profile there + * unless `path` names another; a local user 404s. * - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another * user's role. * - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a @@ -98,6 +101,7 @@ import { getFollowedUsersByUser, } from "../services/follow-service.ts"; import { resolveCategoryFilter } from "../services/category-service.ts"; +import { homePageUrl } from "../services/federation-service.ts"; /** * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. @@ -261,6 +265,13 @@ router.get("/:key/invitees", (ctx) => { ctx.response.body = responseBody; }); +router.get("/:key/home", async (ctx) => { + const path = ctx.request.url.searchParams.get("path") ?? undefined; + ctx.response.redirect( + await homePageUrl(resolveUserKey(ctx.params.key), path), + ); +}); + router.patch( "/:key/role", authMiddleware, diff --git a/api/services/federation-service.ts b/api/services/federation-service.ts index 75a99a37..3cd8cf2d 100644 --- a/api/services/federation-service.ts +++ b/api/services/federation-service.ts @@ -18,7 +18,8 @@ * blocked one they may not, and from any other only with an invite, which the * register page carries. None may while the deployment turns sign-ins from * other instances off (`FEDERATION.incoming`). Once here, an account works like any other, but its - * password, email, avatar and name are its home's (`assertLocalUser`). + * password, email, avatar and name are its home's (`assertLocalUser`), and + * change on its pages there ({@link homePageUrl}). * * @module */ @@ -40,7 +41,8 @@ import { isIdentityClaims, parseInstance, } from "../../shared/federation.ts"; -import { EMAIL_RE, USERNAME_RE } from "../../shared/validation.ts"; +import { userPath } from "../../shared/paths.ts"; +import { EMAIL_RE, splitHandle, USERNAME_RE } from "../../shared/validation.ts"; import { toPublicUser, type User } from "../model/user.ts"; import { exchangeCode, @@ -447,3 +449,35 @@ export async function receiveEvent(token: string): Promise { if (!claims) throw new APIException(400, "Invalid event"); await applyClaims(identity.user_id, issuer, claims); } + +/** + * Returns the URL of `path` on the home of `user`, an account from another + * instance, for `GET /api/users/:key/home`: their profile there by default, + * or another of its pages, such as `/settings`, where they change their + * password. A handle's host is its home's issuer, the API's origin, which + * needn't be its pages': those are where its `/authorize` page is, and the + * home's metadata says. While it doesn't answer, the issuer stands in. + * + * @throws {APIException} 400 when `path` isn't a path; 404 when `user` is + * local. + */ +export async function homePageUrl(user: User, path?: string): Promise { + const identity = db.prepare( + `SELECT issuer FROM user_identities WHERE user_id = ?;`, + ).get(user.id) as { issuer: string } | undefined; + if (!identity) throw new APIException(404, "This account lives here"); + if (path !== undefined && (!path.startsWith("/") || path.startsWith("//"))) { + throw new APIException(400, "Not a path"); + } + + let pages = identity.issuer; + try { + const { origin, protocol } = new URL( + (await fetchMetadata(identity.issuer)).authorization_endpoint, + ); + if (protocol === "https:" || protocol === "http:") pages = origin; + } catch (err) { + console.error(`[federation] ${identity.issuer} unreachable:`, err); + } + return `${pages}${path ?? userPath(splitHandle(user.username).name)}`; +} diff --git a/api/test/services/federation-service.test.ts b/api/test/services/federation-service.test.ts index dec2f57f..33558e22 100644 --- a/api/test/services/federation-service.test.ts +++ b/api/test/services/federation-service.test.ts @@ -123,6 +123,39 @@ test("federation: a trusted instance's user signs in as name@host, once made", a ); }); +test("federation: a remote user's home link leads to its pages, not its API", async () => { + const home = await fakeInstance(HOME, { + authorization_endpoint: "https://www.home.test/authorize", + }); + using _net = network(home); + await trust(HOME); + await signIn(home, ALICE); + + const profile = await get("/api/users/@alice%40home.test/home"); + assertEquals(profile.status, 302); + assertEquals( + profile.headers.get("location"), + "https://www.home.test/@alice", + ); + const settings = await get( + "/api/users/@alice%40home.test/home?path=%2Fsettings", + ); + assertEquals( + settings.headers.get("location"), + "https://www.home.test/settings", + ); + assertEquals( + (await get("/api/users/@alice%40home.test/home?path=%2F%2Fevil.test")) + .status, + 400, + ); + const local = await createUser(); + assertEquals( + (await get(`/api/users/@${local.username}/home`)).status, + 404, + ); +}); + test("federation: a login works once, with the code the home made for it", async () => { const home = await fakeInstance(HOME); using _net = network(home); diff --git a/src/utils/auth/home.ts b/src/utils/auth/home.ts index 594d63e1..52dd9773 100644 --- a/src/utils/auth/home.ts +++ b/src/utils/auth/home.ts @@ -7,8 +7,8 @@ * @module */ +import { API_URL } from "../../config/api.ts"; import { splitHandle } from "../../../shared/validation.ts"; -import { userPath } from "../../../shared/paths.ts"; /** * Returns the host of `handle`'s home instance, or undefined for a local @@ -19,12 +19,13 @@ export function homeHost(handle: string): string | undefined { } /** - * Returns the URL of `path` on `handle`'s home instance, their profile there - * by default, or null for a local user. Instances speak the same protocol as - * this page, https outside development. + * Returns a link to `path` on `handle`'s home instance, their profile there + * by default, or null for a local user. The host in a handle is the home's + * API, not necessarily its pages, so the link goes through + * `GET /api/users/:key/home`, which redirects to them. */ export function homeUrl(handle: string, path?: string): string | null { - const { name, host } = splitHandle(handle); - if (!host) return null; - return `${globalThis.location.protocol}//${host}${path ?? userPath(name)}`; + if (!homeHost(handle)) return null; + const query = path ? `?path=${encodeURIComponent(path)}` : ""; + return `${API_URL}/api/users/@${encodeURIComponent(handle)}/home${query}`; }