1import { createHash } from "node:crypto";2import { mkdirSync } from "node:fs";3import { join } from "node:path";4import type { Hono } from "hono";5import { describeFile } from "../describe/describe.ts";6import { openMirror } from "../mirror/store.ts";7import { renderMarkdown, renderParagraph } from "../pages/markdown.ts";8import { readmeAtHead } from "../pages/repo.ts";9import { publicRepo, type RepoText, repoText, setRepoText } from "../server/db.ts";10import { formToken } from "../server/login.ts";11import type { AdminContext } from "./console.ts";12import { textFields } from "./console.ts";13import { findRepo } from "./store.ts";1415const LIMITS: Record<RepoText, number> = { description: 2000, details: 60_000 };16const MEDIA_ROUTE = "/static/assets/media/:repo/:name{[0-9a-f]{64}\\.(png|jpg|gif|webp|mp4|webm)}";171819const MEDIA: [string, (bytes: Uint8Array) => boolean][] = [20 ["png", (b) => ascii(b, 1, 4) === "PNG" && b[0] === 0x89],21 ["jpg", (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff],22 ["gif", (b) => ascii(b, 0, 4) === "GIF8"],23 ["webp", (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP"],24 ["mp4", (b) => ascii(b, 4, 8) === "ftyp"],25 ["webm", (b) => b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3],26];2728function ascii(bytes: Uint8Array, from: number, to: number): string {29 return String.fromCharCode(...bytes.subarray(from, to));30}31323334353637export function registerRepoText(app: Hono, ctx: AdminContext): void {38 app.get("/admin/repos/:repo/text", (c) => {39 const repo = findRepo(ctx.kepr, c.req.param("repo"));40 if (!repo) return c.notFound();41 const readme = readmeAtHead(openMirror(ctx.mirrorDir, repo.name));42 const own = repoText(ctx.kepr, repo.name);43 return c.json({44 form: formToken(c),45 description: own.description || (readme && describeFile("README.md", readme)) || "",46 details: own.details || readme || "",47 });48 });4950 app.post("/admin/repos/:repo/text", async (c) => {51 const repo = findRepo(ctx.kepr, c.req.param("repo"));52 if (!repo) return c.notFound();53 const { field, value } = await textFields(c);54 if (field !== "description" && field !== "details") return c.text("Unknown field.", 400);55 const text = (value ?? "").trim();56 if (text.length > LIMITS[field]) {57 return c.text(58 `Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`,59 400,60 );61 }62 setRepoText(ctx.kepr, repo.name, field, text);63 return c.body(null, 204);64 });6566 app.post("/admin/repos/:repo/preview", async (c) => {67 const repo = findRepo(ctx.kepr, c.req.param("repo"));68 if (!repo) return c.notFound();69 const { field, value } = await textFields(c);70 const base = { repo: repo.name, dir: "", at: null, own: true };71 const text = value ?? "";72 return c.html(73 field === "description"74 ? renderParagraph(text, base).html.value75 : renderMarkdown(text, base).value,76 );77 });7879 80 81 app.get(MEDIA_ROUTE, async (c) => {82 const repo = c.req.param("repo");83 const file = Bun.file(join(ctx.dataDir, "assets", "media", repo, c.req.param("name")));84 if (!publicRepo(ctx.kepr, repo) || !(await file.exists())) return c.notFound();85 const headers = { "Content-Type": file.type, "Cache-Control": "private, max-age=86400" };86 c.header("Accept-Ranges", "bytes");87 const [, from = "", to = ""] = /^bytes=(\d*)-(\d*)$/.exec(c.req.header("range") ?? "") ?? [];88 if (from === "" && to === "") return c.body(await file.bytes(), 200, headers);89 const start = from === "" ? Math.max(0, file.size - Number(to)) : Number(from);90 const end = from === "" || to === "" ? file.size - 1 : Math.min(Number(to), file.size - 1);91 if (start > end) return c.body(null, 416, { "Content-Range": `bytes */${file.size}` });92 const part = await file.slice(start, end + 1).bytes();93 return c.body(part, 206, { ...headers, "Content-Range": `bytes ${start}-${end}/${file.size}` });94 });9596 app.post("/admin/repos/:repo/media", async (c) => {97 const repo = findRepo(ctx.kepr, c.req.param("repo"));98 if (!repo) return c.notFound();99 const file = (await c.req.parseBody()).file;100 if (!(file instanceof File)) return c.text("Choose a file to upload.", 400);101 const bytes = new Uint8Array(await file.arrayBuffer());102 const kind = MEDIA.find(([, is]) => is(bytes))?.[0];103 if (!kind) return c.text("Only PNG, JPEG, GIF, WebP, MP4 and WebM files.", 400);104 const name = `${createHash("sha256").update(bytes).digest("hex")}.${kind}`;105 const dir = join(ctx.dataDir, "assets", "media", repo.name);106 mkdirSync(dir, { recursive: true });107 await Bun.write(join(dir, name), bytes);108 return c.json({ url: `/static/assets/media/${repo.name}/${name}` });109 });110}