import type { Request, Response } from "express"; import type { PluginContext } from "@termix/plugin-sdk/backend"; import { HANDLE_REGEX, asError } from "./handles.js"; import { matchesAlgoFilter } from "./keys.js"; import type { KeyRow, Store } from "./store.js"; /** * The resolver needs no login: servers fetch it from scripts. Paths are * relative to /plugin-api/termix-identity/. */ export const PUBLIC_PATHS = ["/u/:handle", "/u/:handle/:algo"]; /** The public resolver path for a handle, below the base URL. */ export function resolverPath(handle: string): string { return `/plugin-api/termix-identity/u/${handle}`; } // A disabled or removed key must not keep being served by a cache, and the // feed stays out of search indexes. Set on every response, 404s included. function setResolverHeaders(res: Response) { res.setHeader("Cache-Control", "no-store, max-age=0"); res.setHeader("X-Robots-Tag", "noindex, nofollow"); } function notFound(res: Response, body = "Not found\n") { return res.status(404).type("text/plain").send(body); } export function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } export function renderHtml( handle: string, keys: Array>, resolverUrl: string, ): string { const keyRows = keys.length ? keys .map( (key) => `
${escapeHtml( key.algorithm, )}${escapeHtml(key.publicKey)}
`, ) .join("") : `

No public keys published yet.

`; // A standalone page, not the React app, so the dark theme is inlined. return ` Termix ID @${escapeHtml(handle)}

Termix ID @${escapeHtml(handle)}

Provision a server
curl -fsSL ${escapeHtml(resolverUrl)} >> ~/.ssh/authorized_keys
Published keys
${keyRows}
`; } export function createResolver(ctx: PluginContext, store: Store) { const handleOf = (req: Request) => String(req.params.handle || "").toLowerCase(); /** authorized_keys as text, or a small viewer for browsers. */ async function keys(req: Request, res: Response) { setResolverHeaders(res); const handle = handleOf(req); if (!HANDLE_REGEX.test(handle)) return notFound(res); const algoFilter = req.params.algo ? String(req.params.algo).toUpperCase() : null; try { const identity = await store.identityByHandle(handle); if (!identity) return notFound(res); const published = (await store.enabledKeys(identity.id)).filter((key) => matchesAlgoFilter(key.algorithm, algoFilter), ); if ((req.headers.accept || "").includes("text/html")) { res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.send( renderHtml( handle, published, `${ctx.http.baseUrl(req)}${resolverPath(handle)}`, ), ); } const body = published .map((key) => `${key.publicKey} #termix-id @${handle}`) .join("\n"); res.setHeader("Content-Type", "text/plain; charset=utf-8"); return res.send(body ? `${body}\n` : ""); } catch (error) { ctx.log.error("Termix ID resolve failed", asError(error)); return res.status(500).type("text/plain").send("Internal Server Error\n"); } } /** The CA public key, for TrustedUserCAKeys or an @cert-authority line. */ async function caKey(req: Request, res: Response) { setResolverHeaders(res); const handle = handleOf(req); if (!HANDLE_REGEX.test(handle)) return notFound(res); try { const identity = await store.identityByHandle(handle); if (!identity) return notFound(res); const ca = await store.caForIdentity(identity.id); if (!ca) return notFound(res, "No CA configured\n"); res.setHeader("Content-Type", "text/plain; charset=utf-8"); return res.send(`${ca.publicKey} termix-id-ca@${handle}\n`); } catch (error) { ctx.log.error("Termix ID CA resolve failed", asError(error)); return res.status(500).type("text/plain").send("Internal Server Error\n"); } } return { keys, caKey }; }