files.ts

5.7 KB · raw

1import type { Context, Hono } from "hono";2import { isSafePath } from "../bucket/repo.ts";3import { type FileEntry, fileAt, headId, objectBytes, saveByPrefix } from "../mirror/queries.ts";4import { highlightLines } from "./highlight.ts";5import { type Html, html } from "./html.ts";6import {7	cacheFor,8	formatSize,9	notFound,10	openPublicRepo,11	type PageContext,12	page,13	pathUrl,14} from "./layout.ts";15import { renderMarkdown } from "./markdown.ts";1617const SHOWN_BYTES = 1024 * 1024;18const IMAGE = /\.(png|jpe?g|gif|webp|avif|ico|svg)$/i;19const MARKDOWN = /\.(md|markdown)$/i;20// Binary types a browser can show or save as they are; anything else textual stays21// text/plain (§21.8).22const PASS_THROUGH = /^(image|audio|video|font)\/|^application\/(pdf|zip|gzip|wasm)$/;2324type Found = {25	repo: string;26	path: string;27	at: string | null;28	file: FileEntry;29	bytes: Uint8Array<ArrayBuffer>;30};3132export function registerFileRoutes(app: Hono, ctx: PageContext): void {33	app.get("/:repo/files/:path{.+}", async (c) => {34		const found = findFile(c, ctx);35		if (!found) return notFound(c, ctx);36		cacheFor(c, found.at !== null);37		const title = found.path.split("/").pop() as string;38		const body = html`${breadcrumb(found)}39<h1 class="file-page-title">${title}</h1>40${await fileBody(found, c.req.query("view") === "source")}`;41		return c.html(42			page(ctx, {43				title: `${title} · ${found.repo}`,44				description: found.path,45				path: c.req.path,46				body,47			}),48		);49	});5051	app.get("/:repo/raw/:path{.+}", (c) => {52		const found = findFile(c, ctx);53		if (!found) return notFound(c, ctx);54		cacheFor(c, found.at !== null);55		const type = contentType(found.path, found.bytes);56		c.header("Content-Type", type);57		return c.body(found.bytes);58	});59}6061/** The file named by the route at `?at=<id>` or HEAD, behind the visibility gate; null means 404. */62function findFile(c: Context, ctx: PageContext): Found | null {63	const name = c.req.param("repo") as string;64	const path = c.req.param("path") as string;65	const repo = openPublicRepo(ctx, name);66	if (!repo || !isSafePath(path)) return null;67	const at = c.req.query("at") ?? null;68	const saveId = at === null ? headId(repo.mirror) : (saveByPrefix(repo.mirror, at)?.id ?? null);69	const file = saveId ? fileAt(repo.mirror, saveId, path) : null;70	const bytes = file && objectBytes(repo.mirror, file.hash);71	return file && bytes ? { repo: name, path, at: at && saveId, file, bytes } : null;72}7374export function isText(bytes: Uint8Array): boolean {75	if (bytes.subarray(0, 8000).includes(0)) return false;76	try {77		// Streaming mode tolerates only a character cut at the 1 MB mark; invalid bytes still throw.78		const cut = bytes.length > SHOWN_BYTES;79		new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, SHOWN_BYTES), {80			stream: cut,81		});82		return true;83	} catch {84		return false;85	}86}8788function contentType(path: string, bytes: Uint8Array): string {89	const type = Bun.file(path).type;90	if (type.startsWith("image/")) return type;91	if (isText(bytes)) return "text/plain; charset=utf-8";92	return PASS_THROUGH.test(type) ? type : "application/octet-stream";93}9495function rawHref(found: Found): string {96	return `/${found.repo}/raw/${pathUrl(found.path)}${found.at ? `?at=${found.at}` : ""}`;97}9899// Each folder opens the repo's Atlas with that folder unfolded (kepr.js reads `open`).100function breadcrumb(found: Found): Html {101	const dirs = found.path.split("/").slice(0, -1);102	const folderHref = (depth: number) => {103		const query = new URLSearchParams(found.at ? { at: found.at } : {});104		query.set("open", dirs.slice(0, depth + 1).join("/"));105		return `/${found.repo}?${query}#atlas`;106	};107	return html`<p class="breadcrumb mono"><a href="/${found.repo}">${found.repo}</a>${dirs.map(108		(dir, depth) => html` / <a href="${folderHref(depth)}">${dir}</a>`,109	)}${found.at && html` @ <a href="/${found.repo}/saves/${found.at}">${found.at}</a>`}</p>`;110}111112async function fileBody(found: Found, source: boolean): Promise<Html> {113	const size = formatSize(found.file.size);114	const raw = rawHref(found);115	if (IMAGE.test(found.path)) {116		return html`<p class="file-meta mono">${117			size118		} · <a href="${raw}">raw</a></p><div class="file-image"><img src="${raw}" alt="${found.path}"></div>`;119	}120	if (!isText(found.bytes)) {121		return html`<p class="file-meta mono">binary file · ${122			size123		} · <a href="${raw}" download>download</a></p>`;124	}125	const truncated = found.bytes.length > SHOWN_BYTES;126	const text = new TextDecoder().decode(found.bytes.subarray(0, SHOWN_BYTES));127	const meta = html`<p class="file-meta mono">${128		size129	}${truncated && " · showing the first 1 MB"} · <a href="${raw}">raw</a></p>`;130	if (MARKDOWN.test(found.path)) {131		const at = found.at ? `at=${found.at}&` : "";132		const toggle = html`<p class="file-toggle mono"><a href="?${133			found.at ? `at=${found.at}` : ""134		}"${!source && html` class="active"`}>rendered</a> · <a href="?${at}view=source"${source && html` class="active"`}>source</a></p>`;135		if (!source) {136			const dir = found.path.split("/").slice(0, -1).join("/");137			const rendered = renderMarkdown(text, { repo: found.repo, dir, at: found.at });138			return html`${meta}${toggle}<div class="markdown file-markdown">${rendered}</div>`;139		}140		return html`${meta}${toggle}${await codeView(text, found.path)}`;141	}142	return html`${meta}${await codeView(text, found.path)}`;143}144145// Each line is its own anchor target, so `#L42` scrolls and highlights through CSS alone; the146// script only adds ranges (`#L42-L50`).147async function codeView(text: string, path: string): Promise<Html> {148	const lines = await highlightLines(text, path);149	return html`<div class="file-view"><pre class="code"><code>${lines.map(150		(line, i) =>151			html`<span class="line" id="L${152				i + 1153			}"><a class="ln" href="#L${i + 1}">${i + 1}</a><span class="lc">${line}</span></span>`,154	)}</code></pre></div>`;155}