routes.ts

7.1 KB · raw

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