text.ts

5.0 KB · raw

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)}";1718// What an upload is, from its first bytes rather than its name or declared type.19const 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}3132/**33 * Editing a repo's description and details from its own page while signed in: the texts (with34 * the README's as a starting point), a preview as the page will render it, saving, and pictures35 * and videos for the details, kept in `data/assets/media/<repo>/` so backups carry them.36 */37export 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	// Named by their hash, so cached long; `private`, so no shared cache keeps one after the repo80	// turns private (§20). Ranges are answered, which video players need.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}