Files
gerbeur/api/services/federation-peer-service.ts

139 lines
4.8 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, resolveIssuer } from "../remote/gerbeur-instance.ts";
import { isSelfHost } from "../lib/self-url.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, by its API's address or its pages'
* (`resolveIssuer`), 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 origin = parseInstance(instance);
if (!origin) throw new APIException(400, "Not an instance's address");
if (isSelfHost(new URL(origin).host)) {
throw new APIException(400, "That is this instance");
}
// An instance is listed by its issuer, which its pages' address leads to;
// one that doesn't answer may still be blocked, by the address given.
let issuer = origin;
let name: string | null = null;
try {
issuer = await resolveIssuer(origin);
name = (await fetchMetadata(issuer)).gerbeur_site_name;
} catch (err) {
console.error(`[federation] ${origin} doesn't answer:`, err);
if (status === "trusted") {
throw new APIException(502, "That address doesn't answer as a gerbeur");
}
}
if (issuer === PUBLIC_URL) {
throw new APIException(400, "That is this instance");
}
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,
);
}