import type { Database } from "bun:sqlite"; import type { Context, Hono } from "hono"; import type { EventRecord, SaveFile, SaveRecord } from "../bucket/repo.ts"; import { openPublicRepo, type PageContext } from "../pages/layout.ts"; import { filesAt, headId } from "./queries.ts"; const JSON_TYPE = "application/json; charset=UTF-8"; const TEXT_TYPE = "text/plain; charset=UTF-8"; // §9.2's name for the layout. Koh trims whitespace before it compares, so no trailing newline is // needed to agree with a bucket. const FORMAT = "koh-remote 1"; // A private repo and an unknown one answer alike, to a person and to Koh (§20). const NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}'; const SAVE_ID = /^[0-9a-f]{12}$/; const OBJECT_HASH = /^[0-9a-f]{64}$/; // The addendum's lifetimes — a save's record can still be rewritten by a strip, so a minute, and an // object never changes once written but a strip can take it away, so a year rather than immutable. // `private`, as the pages are: a repo can be turned private, and no shared cache may keep serving // what it fetched while the repo was public (§20). const SAVE_CACHE = "private, max-age=60"; const OBJECT_CACHE = "private, max-age=31536000"; const DAY_CACHE = "private, max-age=86400"; const NO_CACHE = "no-cache"; type SaveRow = { id: string; parent: string | null; message: string; created_at: number; machine: string; is_checkpoint: number; size_bytes: number; agent: string | null; session: string | null; restores: string | null; replayed_from: string | null; }; type EventRow = { id: string; kind: EventRecord["kind"]; save_id: string | null; path: string | null; reason: string | null; created_at: number; machine: string; }; const SAVE_COLUMNS = `id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent, session, restores, replayed_from`; const EVENT_COLUMNS = "id, kind, save_id, path, reason, created_at, machine"; /** The one answer for a private repo, an unknown one, and a record the mirror does not hold. */ function miss(c: Context): Response { return c.body(NOT_FOUND, 404, { "Content-Type": JSON_TYPE, "Cache-Control": "no-store" }); } /** * §9.2's layout, read-only, for the repos a visitor can see: what `koh steal /` * fetches, so the paths, the bytes and the field names are Koh's, not Kepr's (A2, part 2). */ export function registerKohRemote(app: Hono, ctx: PageContext): void { const serve = (c: Context, name: string, body: (mirror: Database) => Response): Response => { const repo = openPublicRepo(ctx, name); return repo ? body(repo.mirror) : miss(c); }; const json = (c: Context, body: string, cache: string): Response => c.body(body, 200, { "Content-Type": JSON_TYPE, "Cache-Control": cache }); const text = (c: Context, body: string, cache: string): Response => c.body(body, 200, { "Content-Type": TEXT_TYPE, "Cache-Control": cache }); app.get("/:repo/koh/format", (c) => serve(c, c.req.param("repo"), () => text(c, FORMAT, DAY_CACHE)), ); app.get("/:repo/koh/head", (c) => serve(c, c.req.param("repo"), (mirror) => { const head = headId(mirror); return head ? text(c, head, NO_CACHE) : miss(c); }), ); // Checkpoints are served like any other save: the parent links of the saves around them need // them, even though the pages hide them (§21.4). app.get("/:repo/koh/saves/:id", (c) => serve(c, c.req.param("repo"), (mirror) => { const id = c.req.param("id"); if (!SAVE_ID.test(id)) return miss(c); const row = mirror .query(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`) .get(id); if (!row) return miss(c); // Field names and their order are Koh's, and every optional is an empty string, as // `json2.encode` writes it: `koh steal` decodes this straight into its own record. const record: SaveRecord = { id: row.id, parent: row.parent ?? "", message: row.message, created_at: row.created_at, machine: row.machine, is_checkpoint: row.is_checkpoint === 1, size_bytes: row.size_bytes, agent: row.agent ?? "", session: row.session ?? "", restores: row.restores ?? "", replayed_from: row.replayed_from ?? "", // Whatever the strips left: a path they removed is gone from the record too. files: filesAt(mirror, row.id).map( (file): SaveFile => ({ path: file.path, hash: file.hash, executable: file.executable }), ), }; return json(c, JSON.stringify(record), SAVE_CACHE); }), ); // The object's stored bytes, zlib and all: `koh steal` inflates them itself and checks the hash. app.get("/:repo/koh/objects/:hash", (c) => serve(c, c.req.param("repo"), (mirror) => { const hash = c.req.param("hash"); if (!OBJECT_HASH.test(hash)) return miss(c); const row = mirror .query<{ data: Uint8Array }, [string]>( "SELECT data FROM objects WHERE hash = ?", ) .get(hash); return row ? c.body(row.data, 200, { "Content-Type": "application/octet-stream", "Cache-Control": OBJECT_CACHE, }) : miss(c); }), ); app.get("/:repo/koh/events", (c) => serve(c, c.req.param("repo"), (mirror) => { const ids = mirror .query<{ id: string }, []>("SELECT id FROM events ORDER BY id") .all() .map((row) => row.id); return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE); }), ); app.get("/:repo/koh/events/:id", (c) => serve(c, c.req.param("repo"), (mirror) => { const id = c.req.param("id"); if (!SAVE_ID.test(id)) return miss(c); const row = mirror .query(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`) .get(id); if (!row) return miss(c); const record: EventRecord = { id: row.id, kind: row.kind, save_id: row.save_id ?? "", path: row.path ?? "", reason: row.reason ?? "", created_at: row.created_at, machine: row.machine, }; return json(c, JSON.stringify(record), DAY_CACHE); }), ); }