text.ts

5.7 KB · raw

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";1314export const 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)}";1617// What an upload is, from its first bytes rather than its name or declared type.18const 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}3233/**34 * Editing a repo's description and details from its own page while signed in: the texts (with35 * the README's as a starting point), a preview as the page will render it, saving, and pictures36 * and videos for the details, kept in the bucket under Kepr's own folder, where Koh's data is.37 */38export 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	// As private as the repo (§20). An S3 bucket serves the file itself, Range and all, through a81	// short-lived link; a folder bucket's bytes are served here, with Range, which video players82	// need. Named by their hash, so the bytes never change; `private`, so no shared cache keeps83	// one after the repo turns private.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}