1import { createHash } from "node:crypto";2import type { Hono } from "hono";3import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";4import { describeFile } from "../describe/describe.ts";5import { openMirror } from "../mirror/store.ts";6import { renderMarkdown, renderParagraph } from "../pages/markdown.ts";7import { readmeAtHead } from "../pages/repo.ts";8import { publicRepo, type RepoText, repoText, setRepoText } from "../server/db.ts";9import { formToken } from "../server/login.ts";10import type { AdminContext } from "./console.ts";11import { textFields } from "./console.ts";12import { findRepo } from "./store.ts";1314const LIMITS: Record<RepoText, number> = { description: 2000, details: 60_000 };15const MEDIA_ROUTE = "/static/assets/media/:repo/:name{[0-9a-f]{64}\\.(png|jpg|gif|webp|mp4|webm)}";161718const MEDIA: [ext: string, type: string, is: (bytes: Uint8Array) => boolean][] = [19 ["png", "image/png", (b) => ascii(b, 1, 4) === "PNG" && b[0] === 0x89],20 ["jpg", "image/jpeg", (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff],21 ["gif", "image/gif", (b) => ascii(b, 0, 4) === "GIF8"],22 ["webp", "image/webp", (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP"],23 ["mp4", "video/mp4", (b) => ascii(b, 4, 8) === "ftyp"],24 ["webm", "video/webm", (b) => b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3],25];2627const mediaKey = (repo: string, name: string) => `${KEPR_PREFIX}media/${repo}/${name}`;2829function ascii(bytes: Uint8Array, from: number, to: number): string {30 return String.fromCharCode(...bytes.subarray(from, to));31}32333435363738export function registerRepoText(app: Hono, ctx: AdminContext): void {39 app.get("/admin/repos/:repo/text", (c) => {40 const repo = findRepo(ctx.kepr, c.req.param("repo"));41 if (!repo) return c.notFound();42 const readme = readmeAtHead(openMirror(ctx.mirrorDir, repo.name));43 const own = repoText(ctx.kepr, repo.name);44 return c.json({45 form: formToken(c),46 description: own.description || (readme && describeFile("README.md", readme)) || "",47 details: own.details || readme || "",48 });49 });5051 app.post("/admin/repos/:repo/text", async (c) => {52 const repo = findRepo(ctx.kepr, c.req.param("repo"));53 if (!repo) return c.notFound();54 const { field, value } = await textFields(c);55 if (field !== "description" && field !== "details") return c.text("Unknown field.", 400);56 const text = (value ?? "").trim();57 if (text.length > LIMITS[field]) {58 return c.text(59 `Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`,60 400,61 );62 }63 setRepoText(ctx.kepr, repo.name, field, text);64 return c.body(null, 204);65 });6667 app.post("/admin/repos/:repo/preview", async (c) => {68 const repo = findRepo(ctx.kepr, c.req.param("repo"));69 if (!repo) return c.notFound();70 const { field, value } = await textFields(c);71 const base = { repo: repo.name, dir: "", at: null, own: true };72 const text = value ?? "";73 return c.html(74 field === "description"75 ? renderParagraph(text, base).html.value76 : renderMarkdown(text, base).value,77 );78 });7980 81 82 83 84 app.get(MEDIA_ROUTE, async (c) => {85 const [repo, name] = [c.req.param("repo"), c.req.param("name")];86 if (!publicRepo(ctx.kepr, repo)) return c.notFound();87 const type = MEDIA.find(([ext]) => name.endsWith(`.${ext}`))?.[1] as string;88 const link = ctx.sync.bucket.viewUrl(mediaKey(repo, name), type);89 if (link) {90 c.header("Cache-Control", "private, max-age=60");91 return c.redirect(link, 302);92 }93 const bytes = await ctx.sync.bucket.get(mediaKey(repo, name));94 if (!bytes) return c.notFound();95 const headers = { "Content-Type": type, "Cache-Control": "private, max-age=86400" };96 c.header("Accept-Ranges", "bytes");97 const [, from = "", to = ""] = /^bytes=(\d*)-(\d*)$/.exec(c.req.header("range") ?? "") ?? [];98 if (from === "" && to === "") return c.body(bytes.slice(), 200, headers);99 const start = from === "" ? Math.max(0, bytes.length - Number(to)) : Number(from);100 const end =101 from === "" || to === "" ? bytes.length - 1 : Math.min(Number(to), bytes.length - 1);102 if (start > end) return c.body(null, 416, { "Content-Range": `bytes */${bytes.length}` });103 const range = `bytes ${start}-${end}/${bytes.length}`;104 return c.body(bytes.slice(start, end + 1), 206, { ...headers, "Content-Range": range });105 });106107 app.post("/admin/repos/:repo/media", async (c) => {108 const repo = findRepo(ctx.kepr, c.req.param("repo"));109 if (!repo) return c.notFound();110 const file = (await c.req.parseBody()).file;111 if (!(file instanceof File)) return c.text("Choose a file to upload.", 400);112 const bytes = new Uint8Array(await file.arrayBuffer());113 const kind = MEDIA.find(([, , is]) => is(bytes));114 if (!kind) return c.text("Only PNG, JPEG, GIF, WebP, MP4 and WebM files.", 400);115 const name = `${createHash("sha256").update(bytes).digest("hex")}.${kind[0]}`;116 try {117 await ctx.sync.bucket.put(mediaKey(repo.name, name), bytes, kind[1]);118 } catch (error) {119 if (error instanceof BucketWriteError) return c.text(error.message, 503);120 throw error;121 }122 return c.json({ url: `/static/assets/media/${repo.name}/${name}` });123 });124}