routes.ts

7.6 KB · raw

1import { readFileSync } from "node:fs";2import { join } from "node:path";3import type { Context, Hono } from "hono";4import type { BucketReader } from "../bucket/reader.ts";5import { isPlatform, isVersion, type ReleaseFile } from "../bucket/repo.ts";6import { history, repoStats } from "../mirror/queries.ts";7import { notFound, openPublicRepo, type PageContext, type PublicRepo } from "../pages/layout.ts";8import { readmeAtHead, readmeIntro } from "../pages/repo.ts";9import {10	checksumsTxt,11	historyJson,12	legacyReleaseJson,13	metaJson,14	releaseJson,15	updateInfoJson,16} from "./feeds.ts";17import { countDownload, type Release, releaseContentType, visibleReleases } from "./releases.ts";1819/**20 * §22.4: Koh 2's `koh update` reads `/api/koh/releases/latest`, built into every Koh 2 binary. The21 * route stays on Koh 2's last release forever, never the repo's newest, so a Koh 2 install is never22 * offered Koh 3.23 */24const FROZEN_KOH2 = { repo: "koh", version: "2.1.16" } as const;2526// §22.5: one generic script; each repo gets its own name and address filled in.27const INSTALL_SH = readFileSync(join(import.meta.dir, "install.sh"), "utf8");2829const JSON_TYPE = "application/json; charset=UTF-8";30const TEXT_TYPE = "text/plain; charset=UTF-8";31// Kepr 2's error bodies. A private repo answers exactly as an unknown one (§20).32const REPO_NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}';33const NO_RELEASES = '{"error":"not_found","message":"no releases"}';34const RELEASE_NOT_FOUND = '{"error":"not_found","message":"release not found"}';3536type Feed = { repo: PublicRepo; releases: Release[] };3738export function registerReleaseRoutes(app: Hono, ctx: PageContext, bucket: BucketReader): void {39	const send = (c: Context, status: 200 | 404, body: string, type = JSON_TYPE, cors = false) => {40		if (cors) c.header("Access-Control-Allow-Origin", "*");41		return c.body(body, status, { "Content-Type": type });42	};43	const feed = (name: string): Feed | null => {44		const repo = openPublicRepo(ctx, name);45		return repo && { repo, releases: visibleReleases(ctx.kepr, repo.mirror, name) };46	};47	// Counted before the redirect (§22.2); Kepr never streams a release file itself.48	const download = (c: Context, repo: string, version: string, file: ReleaseFile) => {49		countDownload(ctx.kepr, repo, version, file.platform);50		const key = `${repo}/releases/${version}/${file.platform}/${file.name}`;51		c.header("Cache-Control", "no-store");52		return c.redirect(bucket.downloadUrl(key, file.name, releaseContentType(file.name)), 302);53	};54	const downloadAt = (c: Context, name: string, version: string, requested: string) => {55		const f = feed(name);56		if (!f) return send(c, 404, REPO_NOT_FOUND);57		// Kepr 2's download links end in `<repo>-<platform>`, so `curl -O` saves a telling name.58		const platform = requested.startsWith(`${name}-`)59			? requested.slice(name.length + 1)60			: requested;61		const release = f.releases.find((r) => r.version === version);62		const file = isPlatform(platform) && release?.files.find((x) => x.platform === platform);63		return file ? download(c, name, version, file) : send(c, 404, RELEASE_NOT_FOUND);64	};65	// The newest visible release, or the response that says why there is none.66	const newest = (c: Context, name: string): Release | Response => {67		const f = feed(name);68		if (!f) return send(c, 404, REPO_NOT_FOUND);69		return f.releases[0] ?? send(c, 404, NO_RELEASES);70	};7172	app.get("/api/koh/releases/latest", (c) => {73		const f = feed(FROZEN_KOH2.repo);74		const release = f?.releases.find((r) => r.version === FROZEN_KOH2.version);75		if (!release) return send(c, 404, '{"error":"no_releases","message":"no releases available"}');76		return send(c, 200, legacyReleaseJson(ctx.domain, FROZEN_KOH2.repo, release));77	});78	app.get("/api/koh/releases/:version/:platform", (c) =>79		downloadAt(c, FROZEN_KOH2.repo, c.req.param("version"), c.req.param("platform")),80	);81	for (const [path, cors] of [82		["/api/:repo/releases/latest", true],83		["/:repo/releases/latest.json", false],84	] as const) {85		app.get(path, (c) => {86			const release = newest(c, c.req.param("repo"));87			if (release instanceof Response) return release;88			return send(c, 200, releaseJson(ctx.domain, c.req.param("repo"), release), JSON_TYPE, cors);89		});90	}91	for (const [path, cors] of [92		["/api/:repo/releases/history", true],93		["/:repo/releases/history.json", false],94	] as const) {95		app.get(path, (c) => {96			const f = feed(c.req.param("repo"));97			if (!f) return send(c, 404, REPO_NOT_FOUND);98			if (f.releases.length === 0) return send(c, 404, NO_RELEASES);99			return send(c, 200, historyJson(f.releases), JSON_TYPE, cors);100		});101	}102	app.get("/:repo/releases/latest", (c) => {103		const repo = openPublicRepo(ctx, c.req.param("repo"));104		if (!repo) return notFound(c, ctx);105		return c.redirect(`/${repo.settings.name}/releases`, 302);106	});107	app.get("/:repo/releases/latest/version.txt", (c) => {108		const release = newest(c, c.req.param("repo"));109		return release instanceof Response ? release : send(c, 200, release.version, TEXT_TYPE);110	});111	app.get("/:repo/releases/:version/checksums.txt", (c) => {112		const name = c.req.param("repo");113		const f = feed(name);114		if (!f) return send(c, 404, REPO_NOT_FOUND);115		const release = f.releases.find((r) => r.version === c.req.param("version"));116		if (!release) return send(c, 404, RELEASE_NOT_FOUND);117		return send(c, 200, checksumsTxt(name, release), TEXT_TYPE);118	});119	// §22.1: the newest file for that platform, from the newest release that has one.120	app.get("/:repo/releases/latest/:platform", (c) => {121		const name = c.req.param("repo");122		const f = feed(name);123		if (!f) return send(c, 404, REPO_NOT_FOUND);124		const platform = c.req.param("platform");125		for (const release of f.releases) {126			const file = release.files.find((x) => x.platform === platform);127			if (file) return download(c, name, release.version, file);128		}129		return send(c, 404, RELEASE_NOT_FOUND);130	});131	app.get("/:repo/releases/:version/:platform", (c) => {132		const version = c.req.param("version");133		if (!isVersion(version)) return send(c, 404, RELEASE_NOT_FOUND);134		return downloadAt(c, c.req.param("repo"), version, c.req.param("platform"));135	});136	// Only for a repo with a release, since the script would have nothing to install.137	app.get("/:repo/install.sh", (c) => {138		const name = c.req.param("repo");139		const release = newest(c, name);140		if (release instanceof Response) return release;141		const script = INSTALL_SH.replaceAll("@REPO@", name).replaceAll(142			"@BASE@",143			`https://${ctx.domain}/${name}`,144		);145		c.header("Cache-Control", "public, max-age=60");146		return send(c, 200, script, TEXT_TYPE);147	});148	app.get("/:repo/update-info", (c) => {149		const name = c.req.param("repo");150		const f = feed(name);151		if (!f) return send(c, 404, REPO_NOT_FOUND);152		// Head is the newest save on the line; checkpoints are never shown (§21.4). Its version is the153		// release built from it, else its ID, as in Kepr 2.154		const save = history(f.repo.mirror, 1, 0)[0];155		const saveHead = save && {156			id: save.id,157			version: f.releases.find((r) => r.save === save.id)?.version ?? save.id,158			createdAt: save.created_at,159		};160		return send(c, 200, updateInfoJson(ctx.domain, name, saveHead ?? null, f.releases[0]));161	});162	app.get("/:repo/meta", (c) => {163		const f = feed(c.req.param("repo"));164		if (!f) return send(c, 404, REPO_NOT_FOUND);165		const { name, category } = f.repo.settings;166		const stats = repoStats(f.repo.mirror);167		const meta = {168			repo: name,169			saveCount: stats.saves,170			lineCount: stats.lines,171			lastSaveAt: stats.updated ?? 0,172			latestRelease: f.releases[0]?.version ?? null,173			description: readmeIntro(name, readmeAtHead(f.repo))?.text ?? null,174			language: stats.language || null,175			category: category || null,176		};177		return send(c, 200, metaJson(meta));178	});179}