remote.ts

6.7 KB · raw

1import type { Database } from "bun:sqlite";2import type { Context, Hono } from "hono";3import type { EventRecord, SaveFile, SaveRecord } from "../bucket/repo.ts";4import { openPublicRepo, type PageContext } from "../pages/layout.ts";5import { filesAt, headId } from "./queries.ts";67const JSON_TYPE = "application/json; charset=UTF-8";8const TEXT_TYPE = "text/plain; charset=UTF-8";9// §9.2's name for the layout. Koh trims whitespace before it compares, so no trailing newline is10// needed to agree with a bucket.11const FORMAT = "koh-remote 1";12// A private repo and an unknown one answer alike, to a person and to Koh (§20).13const NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}';14const SAVE_ID = /^[0-9a-f]{12}$/;15const OBJECT_HASH = /^[0-9a-f]{64}$/;16// The addendum's lifetimes — a save's record can still be rewritten by a strip, so a minute, and an17// object never changes once written but a strip can take it away, so a year rather than immutable.18// `private`, as the pages are: a repo can be turned private, and no shared cache may keep serving19// what it fetched while the repo was public (§20).20const SAVE_CACHE = "private, max-age=60";21const OBJECT_CACHE = "private, max-age=31536000";22const DAY_CACHE = "private, max-age=86400";23const NO_CACHE = "no-cache";2425type SaveRow = {26	id: string;27	parent: string | null;28	message: string;29	created_at: number;30	machine: string;31	is_checkpoint: number;32	size_bytes: number;33	agent: string | null;34	session: string | null;35	restores: string | null;36	replayed_from: string | null;37};3839type EventRow = {40	id: string;41	kind: EventRecord["kind"];42	save_id: string | null;43	path: string | null;44	reason: string | null;45	created_at: number;46	machine: string;47};4849const SAVE_COLUMNS = `id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent,50	session, restores, replayed_from`;51const EVENT_COLUMNS = "id, kind, save_id, path, reason, created_at, machine";5253/** The one answer for a private repo, an unknown one, and a record the mirror does not hold. */54function miss(c: Context): Response {55	return c.body(NOT_FOUND, 404, { "Content-Type": JSON_TYPE, "Cache-Control": "no-store" });56}5758/**59 * §9.2's layout, read-only, for the repos a visitor can see: what `koh steal <host>/<repo>`60 * fetches, so the paths, the bytes and the field names are Koh's, not Kepr's (A2, part 2).61 */62export function registerKohRemote(app: Hono, ctx: PageContext): void {63	const serve = (c: Context, name: string, body: (mirror: Database) => Response): Response => {64		const repo = openPublicRepo(ctx, name);65		return repo ? body(repo.mirror) : miss(c);66	};67	const json = (c: Context, body: string, cache: string): Response =>68		c.body(body, 200, { "Content-Type": JSON_TYPE, "Cache-Control": cache });69	const text = (c: Context, body: string, cache: string): Response =>70		c.body(body, 200, { "Content-Type": TEXT_TYPE, "Cache-Control": cache });7172	app.get("/:repo/koh/format", (c) =>73		serve(c, c.req.param("repo"), () => text(c, FORMAT, DAY_CACHE)),74	);7576	app.get("/:repo/koh/head", (c) =>77		serve(c, c.req.param("repo"), (mirror) => {78			const head = headId(mirror);79			return head ? text(c, head, NO_CACHE) : miss(c);80		}),81	);8283	// Checkpoints are served like any other save: the parent links of the saves around them need84	// them, even though the pages hide them (§21.4).85	app.get("/:repo/koh/saves/:id", (c) =>86		serve(c, c.req.param("repo"), (mirror) => {87			const id = c.req.param("id");88			if (!SAVE_ID.test(id)) return miss(c);89			const row = mirror90				.query<SaveRow, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`)91				.get(id);92			if (!row) return miss(c);93			// Field names and their order are Koh's, and every optional is an empty string, as94			// `json2.encode` writes it: `koh steal` decodes this straight into its own record.95			const record: SaveRecord = {96				id: row.id,97				parent: row.parent ?? "",98				message: row.message,99				created_at: row.created_at,100				machine: row.machine,101				is_checkpoint: row.is_checkpoint === 1,102				size_bytes: row.size_bytes,103				agent: row.agent ?? "",104				session: row.session ?? "",105				restores: row.restores ?? "",106				replayed_from: row.replayed_from ?? "",107				// Whatever the strips left: a path they removed is gone from the record too.108				files: filesAt(mirror, row.id).map(109					(file): SaveFile => ({ path: file.path, hash: file.hash, executable: file.executable }),110				),111			};112			return json(c, JSON.stringify(record), SAVE_CACHE);113		}),114	);115116	// The object's stored bytes, zlib and all: `koh steal` inflates them itself and checks the hash.117	app.get("/:repo/koh/objects/:hash", (c) =>118		serve(c, c.req.param("repo"), (mirror) => {119			const hash = c.req.param("hash");120			if (!OBJECT_HASH.test(hash)) return miss(c);121			const row = mirror122				.query<{ data: Uint8Array<ArrayBuffer> }, [string]>(123					"SELECT data FROM objects WHERE hash = ?",124				)125				.get(hash);126			return row127				? c.body(row.data, 200, {128						"Content-Type": "application/octet-stream",129						"Cache-Control": OBJECT_CACHE,130					})131				: miss(c);132		}),133	);134135	// Koh lists `<repo>/saves` and `<repo>/objects` as well as its events: a clone walks the chain136	// it is given here, and fetches every object those saves name. Both lists follow the mirror, so137	// neither is cached.138	app.get("/:repo/koh/saves", (c) =>139		serve(c, c.req.param("repo"), (mirror) => {140			const ids = mirror141				.query<{ id: string }, []>("SELECT id FROM saves ORDER BY id")142				.all()143				.map((row) => row.id);144			return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);145		}),146	);147148	app.get("/:repo/koh/objects", (c) =>149		serve(c, c.req.param("repo"), (mirror) => {150			const hashes = mirror151				.query<{ hash: string }, []>("SELECT hash FROM objects ORDER BY hash")152				.all()153				.map((row) => row.hash);154			return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);155		}),156	);157158	app.get("/:repo/koh/events", (c) =>159		serve(c, c.req.param("repo"), (mirror) => {160			const ids = mirror161				.query<{ id: string }, []>("SELECT id FROM events ORDER BY id")162				.all()163				.map((row) => row.id);164			return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);165		}),166	);167168	app.get("/:repo/koh/events/:id", (c) =>169		serve(c, c.req.param("repo"), (mirror) => {170			const id = c.req.param("id");171			if (!SAVE_ID.test(id)) return miss(c);172			const row = mirror173				.query<EventRow, [string]>(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`)174				.get(id);175			if (!row) return miss(c);176			const record: EventRecord = {177				id: row.id,178				kind: row.kind,179				save_id: row.save_id ?? "",180				path: row.path ?? "",181				reason: row.reason ?? "",182				created_at: row.created_at,183				machine: row.machine,184			};185			return json(c, JSON.stringify(record), DAY_CACHE);186		}),187	);188}