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";11121314151617const FROZEN_KOH2 = { repo: "koh", version: "2.1.16" } as const;181920const INSTALL_SH = readFileSync(join(import.meta.dir, "install.sh"), "utf8");2122const JSON_TYPE = "application/json; charset=UTF-8";23const TEXT_TYPE = "text/plain; charset=UTF-8";2425const REPO_NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}';26const NO_RELEASES = '{"error":"not_found","message":"no releases"}';27const RELEASE_NOT_FOUND = '{"error":"not_found","message":"release not found"}';2829type Feed = { repo: PublicRepo; releases: Release[] };3031export function registerReleaseRoutes(app: Hono, ctx: PageContext, bucket: Bucket): void {32 const send = (c: Context, status: 200 | 404, body: string, type = JSON_TYPE) =>33 c.body(body, status, { "Content-Type": type });34 const feed = (name: string): Feed | null => {35 const repo = openPublicRepo(ctx, name);36 return repo && { repo, releases: visibleReleases(ctx.kepr, repo.mirror, name) };37 };38 39 const download = (c: Context, repo: string, version: string, file: ReleaseFile) => {40 countDownload(ctx.kepr, repo, version, file.platform);41 const key = `${repo}/releases/${version}/${file.platform}/${file.name}`;42 c.header("Cache-Control", "no-store");43 return c.redirect(bucket.downloadUrl(key, file.name, releaseContentType(file.name)), 302);44 };45 const downloadAt = (c: Context, name: string, version: string, requested: string) => {46 const f = feed(name);47 if (!f) return send(c, 404, REPO_NOT_FOUND);48 49 const platform = requested.startsWith(`${name}-`)50 ? requested.slice(name.length + 1)51 : requested;52 const release = f.releases.find((r) => r.version === version);53 const file = isPlatform(platform) && release?.files.find((x) => x.platform === platform);54 return file ? download(c, name, version, file) : send(c, 404, RELEASE_NOT_FOUND);55 };56 57 const newest = (c: Context, name: string): Release | Response => {58 const f = feed(name);59 if (!f) return send(c, 404, REPO_NOT_FOUND);60 return f.releases[0] ?? send(c, 404, NO_RELEASES);61 };6263 app.get("/api/koh/releases/latest", (c) =>64 send(c, 404, '{"error":"no_releases","message":"no releases available"}'),65 );66 app.get("/api/koh/releases/:version/:platform", (c) =>67 downloadAt(c, FROZEN_KOH2.repo, c.req.param("version"), c.req.param("platform")),68 );69 for (const [path, cors] of [70 ["/api/:repo/releases/latest", true],71 ["/:repo/releases/latest.json", false],72 ] as const) {73 app.get(path, (c) => {74 75 if (cors) c.header("Access-Control-Allow-Origin", "*");76 const release = newest(c, c.req.param("repo"));77 if (release instanceof Response) return release;78 return send(c, 200, releaseJson(ctx.domain, c.req.param("repo"), release));79 });80 }81 for (const [path, cors] of [82 ["/api/:repo/releases/history", true],83 ["/:repo/releases/history.json", false],84 ] as const) {85 app.get(path, (c) => {86 if (cors) c.header("Access-Control-Allow-Origin", "*");87 const f = feed(c.req.param("repo"));88 if (!f) return send(c, 404, REPO_NOT_FOUND);89 if (f.releases.length === 0) return send(c, 404, NO_RELEASES);90 return send(c, 200, historyJson(f.releases));91 });92 }93 app.get("/:repo/releases/latest", (c) => {94 const repo = openPublicRepo(ctx, c.req.param("repo"));95 if (!repo) return notFound(c, ctx);96 return c.redirect(`/${repo.settings.name}/releases`, 302);97 });98 app.get("/:repo/releases/latest/version.txt", (c) => {99 const release = newest(c, c.req.param("repo"));100 return release instanceof Response ? release : send(c, 200, release.version, TEXT_TYPE);101 });102 app.get("/:repo/releases/:version/checksums.txt", (c) => {103 const name = c.req.param("repo");104 const f = feed(name);105 if (!f) return send(c, 404, REPO_NOT_FOUND);106 const release = f.releases.find((r) => r.version === c.req.param("version"));107 if (!release) return send(c, 404, RELEASE_NOT_FOUND);108 return send(c, 200, checksumsTxt(name, release), TEXT_TYPE);109 });110 111 app.get("/:repo/releases/latest/:platform", (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 platform = c.req.param("platform");116 for (const release of f.releases) {117 const file = release.files.find((x) => x.platform === platform);118 if (file) return download(c, name, release.version, file);119 }120 return send(c, 404, RELEASE_NOT_FOUND);121 });122 app.get("/:repo/releases/:version/:platform", (c) => {123 const version = c.req.param("version");124 if (!isVersion(version)) return send(c, 404, RELEASE_NOT_FOUND);125 return downloadAt(c, c.req.param("repo"), version, c.req.param("platform"));126 });127 128 app.get("/:repo/install.sh", (c) => {129 const name = c.req.param("repo");130 const release = newest(c, name);131 if (release instanceof Response) return release;132 const script = INSTALL_SH.replaceAll("@REPO@", name).replaceAll(133 "@BASE@",134 `https://${ctx.domain}/${name}`,135 );136 c.header("Cache-Control", "public, max-age=60");137 return send(c, 200, script, TEXT_TYPE);138 });139 app.get("/:repo/update-info", (c) => {140 const name = c.req.param("repo");141 const f = feed(name);142 if (!f) return send(c, 404, REPO_NOT_FOUND);143 144 145 const id = headId(f.repo.mirror);146 const save = id ? saveByPrefix(f.repo.mirror, id) : null;147 const saveHead = save && {148 id: save.id,149 version: f.releases.find((r) => r.save === save.id)?.version ?? save.id,150 createdAt: save.created_at,151 };152 return send(c, 200, updateInfoJson(ctx.domain, name, saveHead ?? null, f.releases[0]));153 });154 app.get("/:repo/meta", (c) => {155 const f = feed(c.req.param("repo"));156 if (!f) return send(c, 404, REPO_NOT_FOUND);157 const { name, category } = f.repo.settings;158 const stats = repoStats(f.repo.mirror);159 const meta = {160 repo: name,161 saveCount: stats.saves,162 lineCount: stats.lines,163 lastSaveAt: stats.updated ?? 0,164 latestRelease: f.releases[0]?.version ?? null,165 description: repoIntro(f.repo)?.text ?? null,166 language: stats.language || null,167 category: category || null,168 };169 return send(c, 200, metaJson(meta));170 });171}