Receiving, a home's keys that can't be fetched answer 502, which the sender retries, instead of 401, which it dropped; a bad update answers 400 rather than 502; a blocked instance's events answer 403; events are limited to a burst of 120 per instance, and a token naming an unknown key refetches the keys at most once a minute. An avatar must be on its issuer's origin, and its URL is versioned by its file's write time instead of updatedAt, so a profile edit no longer makes clients download it again. An instance that doesn't answer can be blocked, keeping its name, though not trusted. 303 backend tests and 703 frontend tests pass, the federation tests 8 times in a row, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
/**
|
|
* Keeps the instances an admin trusts or blocks for federated sign-in
|
|
* (`federation_peers`). Users of a trusted instance sign in here directly,
|
|
* those of a blocked one never do, and an instance on neither list is invite
|
|
* only. `federation-service.ts` reads it at each sign-in, `/manage` edits it
|
|
* through `routes/federation.ts`, and the login page offers the trusted ones.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { db } from "../db/connection.ts";
|
|
import { PUBLIC_URL } from "../config.ts";
|
|
import {
|
|
type FederationPeer,
|
|
instanceHost,
|
|
isPeerStatus,
|
|
parseInstance,
|
|
type PeerStatus,
|
|
} from "../../shared/federation.ts";
|
|
import { fetchMetadata } from "../remote/gerbeur-instance.ts";
|
|
import { closeUserSockets } from "./ws-service.ts";
|
|
|
|
interface PeerRow {
|
|
issuer: string;
|
|
status: string;
|
|
name: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
/** Converts a row to a {@link FederationPeer}. */
|
|
function rowToPeer(row: PeerRow): FederationPeer {
|
|
return {
|
|
issuer: row.issuer,
|
|
host: instanceHost(row.issuer),
|
|
name: row.name,
|
|
status: isPeerStatus(row.status) ? row.status : "blocked",
|
|
createdAt: new Date(row.created_at),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns what an admin decided about the instance at `issuer`, or null when
|
|
* it is on neither list.
|
|
*/
|
|
export function getPeerStatus(issuer: string): PeerStatus | null {
|
|
const row = db.prepare(
|
|
`SELECT status FROM federation_peers WHERE issuer = ?;`,
|
|
).get(issuer) as { status: string } | undefined;
|
|
return row && isPeerStatus(row.status) ? row.status : null;
|
|
}
|
|
|
|
/**
|
|
* Returns the instances on the lists, by name: every one for `/manage`, or
|
|
* only the trusted ones, which the login page offers.
|
|
*/
|
|
export function listPeers(trustedOnly: boolean): FederationPeer[] {
|
|
const rows = db.prepare(
|
|
`SELECT issuer, status, name, created_at FROM federation_peers
|
|
${trustedOnly ? "WHERE status = 'trusted'" : ""}
|
|
ORDER BY lower(coalesce(name, issuer));`,
|
|
).all() as unknown as PeerRow[];
|
|
return rows.map(rowToPeer);
|
|
}
|
|
|
|
/**
|
|
* Puts the instance `instance` names on the list of `status`, for `/manage`,
|
|
* and returns it. A trusted one must answer as a gerbeur instance, whose name
|
|
* is kept; a blocked one is blocked whether it answers or not, keeping the
|
|
* name it had. Blocking one ends the sessions of its users here at once:
|
|
* their tokens stop counting by `getSessionState`, and this closes their
|
|
* sockets.
|
|
*
|
|
* @throws {APIException} 400 when `instance` names no instance, or this one,
|
|
* 502 when an instance to trust doesn't answer as a gerbeur instance.
|
|
*/
|
|
export async function setPeer(
|
|
instance: string,
|
|
status: PeerStatus,
|
|
): Promise<FederationPeer> {
|
|
const issuer = parseInstance(instance);
|
|
if (!issuer) throw new APIException(400, "Not an instance's address");
|
|
if (issuer === PUBLIC_URL) {
|
|
throw new APIException(400, "That is this instance");
|
|
}
|
|
let name: string | null = null;
|
|
try {
|
|
name = (await fetchMetadata(issuer)).gerbeur_site_name;
|
|
} catch (err) {
|
|
console.error(`[federation] ${issuer} doesn't answer:`, err);
|
|
if (status === "trusted") {
|
|
throw new APIException(502, "That address doesn't answer as a gerbeur");
|
|
}
|
|
}
|
|
db.prepare(
|
|
`INSERT INTO federation_peers (issuer, status, name, created_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT (issuer) DO UPDATE SET status = excluded.status,
|
|
name = coalesce(excluded.name, name);`,
|
|
).run(issuer, status, name, new Date().toISOString());
|
|
|
|
if (status === "blocked") {
|
|
const users = db.prepare(
|
|
`SELECT user_id FROM user_identities WHERE issuer = ?;`,
|
|
).all(issuer) as { user_id: string }[];
|
|
for (const { user_id } of users) {
|
|
closeUserSockets(user_id, "Instance blocked");
|
|
}
|
|
}
|
|
|
|
const row = db.prepare(
|
|
`SELECT issuer, status, name, created_at FROM federation_peers WHERE issuer = ?;`,
|
|
).get(issuer) as unknown as PeerRow;
|
|
return rowToPeer(row);
|
|
}
|
|
|
|
/**
|
|
* Takes the instance whose host is `host` off the lists, for `/manage`: its
|
|
* users are invite only again, and those already here may sign in again if it
|
|
* was blocked.
|
|
*
|
|
* @throws {APIException} 404 when no listed instance has that host.
|
|
*/
|
|
export function removePeer(host: string): void {
|
|
const peer = listPeers(false).find((p) => p.host === host.toLowerCase());
|
|
if (!peer) throw new APIException(404, "Instance not found");
|
|
db.prepare(`DELETE FROM federation_peers WHERE issuer = ?;`).run(
|
|
peer.issuer,
|
|
);
|
|
}
|