139 lines
4.8 KiB
TypeScript
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,
|
|
);
|
|
}
|