v4: a remote account's links to its home lead to the home's pages — the settings' "Change it on <host>" button and the profile's "Account on <host>" link went to the host in the handle, which is the home's issuer, its API, not necessarily where its pages are: in development musique's button led to localhost:8000/settings, gerbeur's API, which served a stale build, instead of localhost:3000/settings. GET /api/users/:key/home?path= now redirects to a page of a remote user's home, their profile there by default, at the origin of the home's authorization_endpoint, which its metadata gives (homePageUrl in federation-service.ts), the issuer standing in while the home doesn't answer; a local user 404s, and a path that isn't one 400s. homeUrl links to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2
API.md
2
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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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)}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user