site.ts

5.0 KB · raw

1import { createHash } from "node:crypto";2import { mkdirSync, rmSync, writeFileSync } from "node:fs";3import { join } from "node:path";4import type { Context, Hono } from "hono";5import { html } from "../pages/html.ts";6import { setting } from "../server/db.ts";7import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";8import { saveSettings } from "./store.ts";910// The settings Phase 6's pages read, with their labels and length caps.11const FIELDS: [key: string, label: string, max: number][] = [12	["site_name", "site name", 100],13	["tagline", "tagline", 300],14	["branding", "branding text", 100],15	["handle", "handle", 39],16	["bio", "bio", 2000],17	["location", "location", 100],18	["link", "link", 500],19	["building_since", "building since", 100],20];21const HANDLE = /^[A-Za-z0-9._-]+$/;22const HTTP_URL = /^https?:\/\/[^\s]+$/i;23// Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.24const MAX_SIGNATURE = 60 * 1024;25const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;2627/**28 * The signature's type, from its first bytes, never its name or declared type. PNG, JPEG and WebP29 * only: SVG is refused, since it can carry script and links and would need a sandbox of its own.30 */31function imageType(bytes: Uint8Array): "png" | "jpg" | "webp" | null {32	const starts = (sig: number[], at = 0) => sig.every((byte, i) => bytes[at + i] === byte);33	if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";34	if (starts([0xff, 0xd8, 0xff])) return "jpg";35	if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) return "webp";36	return null;37}3839/** §24's Site and profile section, with the signature image stored under `data/assets/` (§19). */40export function registerSite(app: Hono, ctx: AdminContext): void {41	const assets = join(ctx.dataDir, "assets");42	app.get("/admin/site", (c) => c.html(sitePage(c, ctx, "")));4344	app.post("/admin/site", async (c) => {45		const fields = await textFields(c);46		const refuse = (error: string) => c.html(sitePage(c, ctx, error), 400);47		const values: Record<string, string> = {};48		for (const [key, label, max] of FIELDS) {49			values[key] = (fields[key] ?? "").trim();50			if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`);51		}52		if (values.handle && !HANDLE.test(values.handle)) {53			return refuse("A handle is letters, digits, '.', '_' and '-'.");54		}55		if (values.link && !HTTP_URL.test(values.link)) {56			return refuse("The link must be an http or https address.");57		}58		const upload = (await c.req.parseBody()).signature;59		const previous = setting(ctx.kepr, "signature");60		if (upload instanceof File && upload.size > MAX_SIGNATURE) {61			return refuse("The signature must be under 60 KB.");62		}63		if (upload instanceof File && upload.size > 0) {64			const bytes = new Uint8Array(await upload.arrayBuffer());65			const type = imageType(bytes);66			if (!type) return refuse("The signature must be a PNG, JPEG or WebP image.");67			// Named by content, so the hour-long /static cache never serves an old signature.68			const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);69			values.signature = `signature-${hash}.${type}`;70			mkdirSync(assets, { recursive: true });71			writeFileSync(join(assets, values.signature), bytes);72		} else if (fields.remove_signature === "1") {73			values.signature = "";74		}75		saveSettings(ctx.kepr, values);76		// Only a file this console wrote is removed, whatever the setting holds.77		if (78			previous &&79			OWN_SIGNATURE.test(previous) &&80			values.signature !== undefined &&81			previous !== values.signature82		) {83			rmSync(join(assets, previous), { force: true });84		}85		return c.redirect("/admin/site", 303);86	});87}8889function sitePage(c: Context, ctx: AdminContext, error: string): string {90	const signature = setting(ctx.kepr, "signature");91	const input = ([key, label, max]: (typeof FIELDS)[number]) =>92		html`<label class="issues-label" for="${key}">${label}</label>93${94	key === "bio"95		? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="4"96maxlength="${max}">${setting(ctx.kepr, key) ?? ""}</textarea>`97		: html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}"98value="${setting(ctx.kepr, key) ?? ""}">`99}`;100	const body = html`${notice(error)}101<form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">102${formField(c)}103${FIELDS.map(input)}104<label class="issues-label" for="signature">signature image105<span class="muted">(PNG, JPEG or WebP, under 60 KB)</span></label>106${signature && html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="current signature">`}107<input class="issues-input" id="signature" name="signature" type="file" accept="image/png,image/jpeg,image/webp">108${109	signature &&110	html`<label class="issues-label"><input type="checkbox" name="remove_signature" value="1">111remove the signature</label>`112}113<button class="issues-submit" type="submit">save</button>114</form>`;115	return adminPage(c, ctx, "site and profile", body);116}