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
|
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
|
||||||
follow.
|
follow.
|
||||||
- `GET /api/users/:key/invitees` — anyone. The tree of users they invited.
|
- `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
|
- `PATCH /api/users/:key/role`, `/username` — `user:manage`. Another user's
|
||||||
role, or anyone's name, theirs included.
|
role, or anyone's name, theirs included.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
* page of the public playlists they follow.
|
* page of the public playlists they follow.
|
||||||
* - `GET /api/users/:key/invitees` — public. Answers the tree of users they
|
* - `GET /api/users/:key/invitees` — public. Answers the tree of users they
|
||||||
* invited.
|
* 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
|
* - `PATCH /api/users/:key/role` — permission `user:manage`. Sets another
|
||||||
* user's role.
|
* user's role.
|
||||||
* - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a
|
* - `PATCH /api/users/:key/username` — permission `user:manage`. Renames a
|
||||||
@@ -98,6 +101,7 @@ import {
|
|||||||
getFollowedUsersByUser,
|
getFollowedUsersByUser,
|
||||||
} from "../services/follow-service.ts";
|
} from "../services/follow-service.ts";
|
||||||
import { resolveCategoryFilter } from "../services/category-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.
|
* 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;
|
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(
|
router.patch(
|
||||||
"/:key/role",
|
"/:key/role",
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
* blocked one they may not, and from any other only with an invite, which the
|
* 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
|
* 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
|
* 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
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -40,7 +41,8 @@ import {
|
|||||||
isIdentityClaims,
|
isIdentityClaims,
|
||||||
parseInstance,
|
parseInstance,
|
||||||
} from "../../shared/federation.ts";
|
} 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 { toPublicUser, type User } from "../model/user.ts";
|
||||||
import {
|
import {
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
@@ -447,3 +449,35 @@ export async function receiveEvent(token: string): Promise<void> {
|
|||||||
if (!claims) throw new APIException(400, "Invalid event");
|
if (!claims) throw new APIException(400, "Invalid event");
|
||||||
await applyClaims(identity.user_id, issuer, claims);
|
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 () => {
|
test("federation: a login works once, with the code the home made for it", async () => {
|
||||||
const home = await fakeInstance(HOME);
|
const home = await fakeInstance(HOME);
|
||||||
using _net = network(home);
|
using _net = network(home);
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { API_URL } from "../../config/api.ts";
|
||||||
import { splitHandle } from "../../../shared/validation.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
|
* 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
|
* Returns a link to `path` on `handle`'s home instance, their profile there
|
||||||
* by default, or null for a local user. Instances speak the same protocol as
|
* by default, or null for a local user. The host in a handle is the home's
|
||||||
* this page, https outside development.
|
* 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 {
|
export function homeUrl(handle: string, path?: string): string | null {
|
||||||
const { name, host } = splitHandle(handle);
|
if (!homeHost(handle)) return null;
|
||||||
if (!host) return null;
|
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||||
return `${globalThis.location.protocol}//${host}${path ?? userPath(name)}`;
|
return `${API_URL}/api/users/@${encodeURIComponent(handle)}/home${query}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user