remote.ts

6.9 KB · raw

1import type { Database } from "bun:sqlite";2import type { Context, Hono } from "hono";3import { type EventRecord, SAVE_ID, type SaveFile, type 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 OBJECT_HASH = /^[0-9a-f]{64}$/;15// The addendum's lifetimes — a save's record can still be rewritten by a strip, so a minute, and an16// object never changes once written but a strip can take it away, so a year rather than immutable.17// `private`, as the pages are: a repo can be turned private, and no shared cache may keep serving18// what it fetched while the repo was public (§20).19const SAVE_CACHE = "private, max-age=60";20const OBJECT_CACHE = "private, max-age=31536000";21const DAY_CACHE = "private, max-age=86400";22const NO_CACHE = "no-cache";2324type SaveRow = {25	id: string;26	parent: string | null;27	message: string;28	created_at: number;29	machine: string;30	is_checkpoint: number;31	size_bytes: number;32	agent: string | null;33	session: string | null;34	restores: string | null;35	replayed_from: string | null;36};3738type EventRow = {39	id: string;40	kind: EventRecord["kind"];41	save_id: string | null;42	path: string | null;43	reason: string | null;44	created_at: number;45	machine: string;46};4748const SAVE_COLUMNS = `id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent,49	session, restores, replayed_from`;50const EVENT_COLUMNS = "id, kind, save_id, path, reason, created_at, machine";5152/** The one answer for a private repo, an unknown one, and a record the mirror does not hold. */53function miss(c: Context): Response {54	return c.body(NOT_FOUND, 404, { "Content-Type": JSON_TYPE, "Cache-Control": "no-store" });55}5657/**58 * §9.2's layout, read-only, for the repos a visitor can see: what `koh steal <host>/<repo>`59 * fetches, so the paths, the bytes and the field names are Koh's, not Kepr's (A2, part 2).60 */61export function registerKohRemote(app: Hono, ctx: PageContext): void {62	const serve = (c: Context, name: string, body: (mirror: Database) => Response): Response => {63		const repo = openPublicRepo(ctx, name);64		return repo ? body(repo.mirror) : miss(c);65	};66	const json = (c: Context, body: string, cache: string): Response =>67		c.body(body, 200, { "Content-Type": JSON_TYPE, "Cache-Control": cache });68	const text = (c: Context, body: string, cache: string): Response =>69		c.body(body, 200, { "Content-Type": TEXT_TYPE, "Cache-Control": cache });7071	app.get("/:repo/koh/format", (c) =>72		serve(c, c.req.param("repo"), () => text(c, FORMAT, DAY_CACHE)),73	);7475	app.get("/:repo/koh/head", (c) =>76		serve(c, c.req.param("repo"), (mirror) => {77			const head = headId(mirror);78			return head ? text(c, head, NO_CACHE) : miss(c);79		}),80	);8182	// Checkpoints are served like any other save: the parent links of the saves around them need83	// them, even though the pages hide them (§21.4).84	app.get("/:repo/koh/saves/:id", (c) =>85		serve(c, c.req.param("repo"), (mirror) => {86			const id = c.req.param("id");87			if (!SAVE_ID.test(id)) return miss(c);88			const row = mirror89				.query<SaveRow, [string]>(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`)90				.get(id);91			if (!row) return miss(c);92			// Field names and their order are Koh's, and every optional is an empty string, as93			// `json2.encode` writes it: `koh steal` decodes this straight into its own record.94			const record: SaveRecord = {95				id: row.id,96				parent: row.parent ?? "",97				message: row.message,98				created_at: row.created_at,99				machine: row.machine,100				is_checkpoint: row.is_checkpoint === 1,101				size_bytes: row.size_bytes,102				agent: row.agent ?? "",103				session: row.session ?? "",104				restores: row.restores ?? "",105				replayed_from: row.replayed_from ?? "",106				// Whatever the strips left: a path they removed is gone from the record too.107				files: filesAt(mirror, row.id).map(108					(file): SaveFile => ({ path: file.path, hash: file.hash, executable: file.executable }),109				),110			};111			return json(c, JSON.stringify(record), SAVE_CACHE);112		}),113	);114115	// The object's stored bytes, zlib and all: `koh steal` inflates them itself and checks the hash.116	// Only objects a visible file refers to: the mirror keeps hidden files' objects for unhiding.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 o124					WHERE hash = ?125					AND EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)`,126				)127				.get(hash);128			return row129				? c.body(row.data, 200, {130						"Content-Type": "application/octet-stream",131						"Cache-Control": OBJECT_CACHE,132					})133				: miss(c);134		}),135	);136137	// Koh lists `<repo>/saves` and `<repo>/objects` as well as its events: a steal walks the chain138	// it is given here, and fetches every object those saves name. Both lists follow the mirror, so139	// neither is cached.140	app.get("/:repo/koh/saves", (c) =>141		serve(c, c.req.param("repo"), (mirror) => {142			const ids = mirror143				.query<{ id: string }, []>("SELECT id FROM saves ORDER BY id")144				.all()145				.map((row) => row.id);146			return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);147		}),148	);149150	app.get("/:repo/koh/objects", (c) =>151		serve(c, c.req.param("repo"), (mirror) => {152			const hashes = mirror153				.query<{ hash: string }, []>(154					`SELECT hash FROM objects o155					WHERE EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)156					ORDER BY hash`,157				)158				.all()159				.map((row) => row.hash);160			return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE);161		}),162	);163164	app.get("/:repo/koh/events", (c) =>165		serve(c, c.req.param("repo"), (mirror) => {166			const ids = mirror167				.query<{ id: string }, []>("SELECT id FROM events ORDER BY id")168				.all()169				.map((row) => row.id);170			return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE);171		}),172	);173174	app.get("/:repo/koh/events/:id", (c) =>175		serve(c, c.req.param("repo"), (mirror) => {176			const id = c.req.param("id");177			if (!SAVE_ID.test(id)) return miss(c);178			const row = mirror179				.query<EventRow, [string]>(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`)180				.get(id);181			if (!row) return miss(c);182			const record: EventRecord = {183				id: row.id,184				kind: row.kind,185				save_id: row.save_id ?? "",186				path: row.path ?? "",187				reason: row.reason ?? "",188				created_at: row.created_at,189				machine: row.machine,190			};191			return json(c, JSON.stringify(record), DAY_CACHE);192		}),193	);194}