import { createHash } from "node:crypto"; import type { Hono } from "hono"; import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts"; import { describeFile } from "../describe/describe.ts"; import { openMirror } from "../mirror/store.ts"; import { renderMarkdown, renderParagraph } from "../pages/markdown.ts"; import { readmeAtHead } from "../pages/repo.ts"; import { publicRepo, type RepoText, repoText, setRepoText } from "../server/db.ts"; import { formToken } from "../server/login.ts"; import type { AdminContext } from "./console.ts"; import { textFields } from "./console.ts"; import { findRepo } from "./store.ts"; export const LIMITS: Record = { description: 2000, details: 60_000 }; const MEDIA_ROUTE = "/static/assets/media/:repo/:name{[0-9a-f]{64}\\.(png|jpg|gif|webp|mp4|webm)}"; // What an upload is, from its first bytes rather than its name or declared type. const MEDIA: [ext: string, type: string, is: (bytes: Uint8Array) => boolean][] = [ ["png", "image/png", (b) => ascii(b, 1, 4) === "PNG" && b[0] === 0x89], ["jpg", "image/jpeg", (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff], ["gif", "image/gif", (b) => ascii(b, 0, 4) === "GIF8"], ["webp", "image/webp", (b) => ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP"], ["mp4", "video/mp4", (b) => ascii(b, 4, 8) === "ftyp"], ["webm", "video/webm", (b) => b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3], ]; const mediaKey = (repo: string, name: string) => `${KEPR_PREFIX}media/${repo}/${name}`; function ascii(bytes: Uint8Array, from: number, to: number): string { return String.fromCharCode(...bytes.subarray(from, to)); } /** * Editing a repo's description and details from its own page while signed in: the texts (with * the README's as a starting point), a preview as the page will render it, saving, and pictures * and videos for the details, kept in the bucket under Kepr's own folder, where Koh's data is. */ export function registerRepoText(app: Hono, ctx: AdminContext): void { app.get("/admin/repos/:repo/text", (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); const readme = readmeAtHead(openMirror(ctx.mirrorDir, repo.name)); const own = repoText(ctx.kepr, repo.name); return c.json({ form: formToken(c), description: own.description || (readme && describeFile("README.md", readme)) || "", details: own.details || readme || "", }); }); app.post("/admin/repos/:repo/text", async (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); const { field, value } = await textFields(c); if (field !== "description" && field !== "details") return c.text("Unknown field.", 400); const text = (value ?? "").trim(); if (text.length > LIMITS[field]) { return c.text( `Keep the ${field} under ${LIMITS[field].toLocaleString("en-US")} characters.`, 400, ); } setRepoText(ctx.kepr, repo.name, field, text); return c.body(null, 204); }); app.post("/admin/repos/:repo/preview", async (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); const { field, value } = await textFields(c); const base = { repo: repo.name, dir: "", at: null, own: true }; const text = value ?? ""; return c.html( field === "description" ? renderParagraph(text, base).html.value : renderMarkdown(text, base).value, ); }); // As private as the repo (ยง20). An S3 bucket serves the file itself, Range and all, through a // short-lived link; a folder bucket's bytes are served here, with Range, which video players // need. Named by their hash, so the bytes never change; `private`, so no shared cache keeps // one after the repo turns private. app.get(MEDIA_ROUTE, async (c) => { const [repo, name] = [c.req.param("repo"), c.req.param("name")]; if (!publicRepo(ctx.kepr, repo)) return c.notFound(); const type = MEDIA.find(([ext]) => name.endsWith(`.${ext}`))?.[1] as string; const link = ctx.sync.bucket.viewUrl(mediaKey(repo, name), type); if (link) { c.header("Cache-Control", "private, max-age=60"); return c.redirect(link, 302); } const bytes = await ctx.sync.bucket.get(mediaKey(repo, name)); if (!bytes) return c.notFound(); const headers = { "Content-Type": type, "Cache-Control": "private, max-age=86400" }; c.header("Accept-Ranges", "bytes"); const [, from = "", to = ""] = /^bytes=(\d*)-(\d*)$/.exec(c.req.header("range") ?? "") ?? []; if (from === "" && to === "") return c.body(bytes.slice(), 200, headers); const start = from === "" ? Math.max(0, bytes.length - Number(to)) : Number(from); const end = from === "" || to === "" ? bytes.length - 1 : Math.min(Number(to), bytes.length - 1); if (start > end) return c.body(null, 416, { "Content-Range": `bytes */${bytes.length}` }); const range = `bytes ${start}-${end}/${bytes.length}`; return c.body(bytes.slice(start, end + 1), 206, { ...headers, "Content-Range": range }); }); app.post("/admin/repos/:repo/media", async (c) => { const repo = findRepo(ctx.kepr, c.req.param("repo")); if (!repo) return c.notFound(); const file = (await c.req.parseBody()).file; if (!(file instanceof File)) return c.text("Choose a file to upload.", 400); const bytes = new Uint8Array(await file.arrayBuffer()); const kind = MEDIA.find(([, , is]) => is(bytes)); if (!kind) return c.text("Only PNG, JPEG, GIF, WebP, MP4 and WebM files.", 400); const name = `${createHash("sha256").update(bytes).digest("hex")}.${kind[0]}`; try { await ctx.sync.bucket.put(mediaKey(repo.name, name), bytes, kind[1]); } catch (error) { if (error instanceof BucketWriteError) return c.text(error.message, 503); throw error; } return c.json({ url: `/static/assets/media/${repo.name}/${name}` }); }); }