/** * 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. It must answer as a gerbeur instance, whose name is kept. * 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 it doesn't answer as a gerbeur instance. */ export async function setPeer( instance: string, status: PeerStatus, ): Promise { 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; try { name = (await fetchMetadata(issuer)).gerbeur_site_name; } catch (err) { console.error(`[federation] ${issuer} can't be added:`, err); 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 = excluded.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, ); }