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 { headId, repoStats, saveByPrefix } 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";18192021222324const FROZEN_KOH2 = { repo: "koh", version: "2.1.16" } as const;252627const 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";3132const 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) =>40 c.body(body, status, { "Content-Type": type });41 const feed = (name: string): Feed | null => {42 const repo = openPublicRepo(ctx, name);43 return repo && { repo, releases: visibleReleases(ctx.kepr, repo.mirror, name) };44 };45 46 const download = (c: Context, repo: string, version: string, file: ReleaseFile) => {47 countDownload(ctx.kepr, repo, version, file.platform);48 const key = `${repo}/releases/${version}/${file.platform}/${file.name}`;49 c.header("Cache-Control", "no-store");50 return c.redirect(bucket.downloadUrl(key, file.name, releaseContentType(file.name)), 302);51 };52 const downloadAt = (c: Context, name: string, version: string, requested: string) => {53 const f = feed(name);54 if (!f) return send(c, 404, REPO_NOT_FOUND);55 56 const platform = requested.startsWith(`${name}-`)57 ? requested.slice(name.length + 1)58 : requested;59 const release = f.releases.find((r) => r.version === version);60 const file = isPlatform(platform) && release?.files.find((x) => x.platform === platform);61 return file ? download(c, name, version, file) : send(c, 404, RELEASE_NOT_FOUND);62 };63 64 const newest = (c: Context, name: string): Release | Response => {65 const f = feed(name);66 if (!f) return send(c, 404, REPO_NOT_FOUND);67 return f.releases[0] ?? send(c, 404, NO_RELEASES);68 };6970 app.get("/api/koh/releases/latest", (c) => {71 const f = feed(FROZEN_KOH2.repo);72 const release = f?.releases.find((r) => r.version === FROZEN_KOH2.version);73 if (!release) return send(c, 404, '{"error":"no_releases","message":"no releases available"}');74 return send(c, 200, legacyReleaseJson(ctx.domain, FROZEN_KOH2.repo, release));75 });76 app.get("/api/koh/releases/:version/:platform", (c) =>77 downloadAt(c, FROZEN_KOH2.repo, c.req.param("version"), c.req.param("platform")),78 );79 for (const [path, cors] of [80 ["/api/:repo/releases/latest", true],81 ["/:repo/releases/latest.json", false],82 ] as const) {83 app.get(path, (c) => {84 85 if (cors) c.header("Access-Control-Allow-Origin", "*");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));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 if (cors) c.header("Access-Control-Allow-Origin", "*");97 const f = feed(c.req.param("repo"));98 if (!f) return send(c, 404, REPO_NOT_FOUND);99 if (f.releases.length === 0) return send(c, 404, NO_RELEASES);100 return send(c, 200, historyJson(f.releases));101 });102 }103 app.get("/:repo/releases/latest", (c) => {104 const repo = openPublicRepo(ctx, c.req.param("repo"));105 if (!repo) return notFound(c, ctx);106 return c.redirect(`/${repo.settings.name}/releases`, 302);107 });108 app.get("/:repo/releases/latest/version.txt", (c) => {109 const release = newest(c, c.req.param("repo"));110 return release instanceof Response ? release : send(c, 200, release.version, TEXT_TYPE);111 });112 app.get("/:repo/releases/:version/checksums.txt", (c) => {113 const name = c.req.param("repo");114 const f = feed(name);115 if (!f) return send(c, 404, REPO_NOT_FOUND);116 const release = f.releases.find((r) => r.version === c.req.param("version"));117 if (!release) return send(c, 404, RELEASE_NOT_FOUND);118 return send(c, 200, checksumsTxt(name, release), TEXT_TYPE);119 });120 121 app.get("/:repo/releases/latest/:platform", (c) => {122 const name = c.req.param("repo");123 const f = feed(name);124 if (!f) return send(c, 404, REPO_NOT_FOUND);125 const platform = c.req.param("platform");126 for (const release of f.releases) {127 const file = release.files.find((x) => x.platform === platform);128 if (file) return download(c, name, release.version, file);129 }130 return send(c, 404, RELEASE_NOT_FOUND);131 });132 app.get("/:repo/releases/:version/:platform", (c) => {133 const version = c.req.param("version");134 if (!isVersion(version)) return send(c, 404, RELEASE_NOT_FOUND);135 return downloadAt(c, c.req.param("repo"), version, c.req.param("platform"));136 });137 138 app.get("/:repo/install.sh", (c) => {139 const name = c.req.param("repo");140 const release = newest(c, name);141 if (release instanceof Response) return release;142 const script = INSTALL_SH.replaceAll("@REPO@", name).replaceAll(143 "@BASE@",144 `https://${ctx.domain}/${name}`,145 );146 c.header("Cache-Control", "public, max-age=60");147 return send(c, 200, script, TEXT_TYPE);148 });149 app.get("/:repo/update-info", (c) => {150 const name = c.req.param("repo");151 const f = feed(name);152 if (!f) return send(c, 404, REPO_NOT_FOUND);153 154 155 const id = headId(f.repo.mirror);156 const save = id ? saveByPrefix(f.repo.mirror, id) : null;157 const saveHead = save && {158 id: save.id,159 version: f.releases.find((r) => r.save === save.id)?.version ?? save.id,160 createdAt: save.created_at,161 };162 return send(c, 200, updateInfoJson(ctx.domain, name, saveHead ?? null, f.releases[0]));163 });164 app.get("/:repo/meta", (c) => {165 const f = feed(c.req.param("repo"));166 if (!f) return send(c, 404, REPO_NOT_FOUND);167 const { name, category } = f.repo.settings;168 const stats = repoStats(f.repo.mirror);169 const meta = {170 repo: name,171 saveCount: stats.saves,172 lineCount: stats.lines,173 lastSaveAt: stats.updated ?? 0,174 latestRelease: f.releases[0]?.version ?? null,175 description: readmeIntro(name, readmeAtHead(f.repo))?.text ?? null,176 language: stats.language || null,177 category: category || null,178 };179 return send(c, 200, metaJson(meta));180 });181}