site.ts

6.3 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 { supportText } from "../pages/docs.ts";6import { html } from "../pages/html.ts";7import { setting } from "../server/db.ts";8import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";9import { saveSettings } from "./store.ts";1011// The settings Phase 6's pages read, with their labels and length caps.12const FIELDS: [key: string, label: string, max: number][] = [13	["site_name", "site name", 100],14	["tagline", "tagline", 300],15	["branding", "branding text", 100],16	["handle", "handle", 39],17	["bio", "bio", 2000],18	["location", "location", 100],19	["link", "link", 500],20	["building_since", "building since", 100],21	["support_page", "support page (Markdown)", 4000],22];23const TEXTAREAS = new Set(["bio", "support_page"]);24const PROFILE_FIELDS = new Set(["handle", "bio", "location", "link", "building_since"]);25const HANDLE = /^[A-Za-z0-9._-]+$/;26const HTTP_URL = /^https?:\/\/[^\s]+$/i;27// Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.28const MAX_SIGNATURE = 60 * 1024;29const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;3031/**32 * The signature's type, from its first bytes, never its name or declared type. PNG, JPEG and WebP33 * only: SVG is refused, since it can carry script and links and would need a sandbox of its own.34 */35function imageType(bytes: Uint8Array): "png" | "jpg" | "webp" | null {36	const starts = (sig: number[], at = 0) => sig.every((byte, i) => bytes[at + i] === byte);37	if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";38	if (starts([0xff, 0xd8, 0xff])) return "jpg";39	if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) return "webp";40	return null;41}4243/** §24's Site and profile section, with the signature image stored under `data/assets/` (§19). */44export function registerSite(app: Hono, ctx: AdminContext): void {45	const assets = join(ctx.dataDir, "assets");46	app.get("/admin/site", (c) => c.html(sitePage(c, ctx, "")));4748	app.post("/admin/site", async (c) => {49		const fields = await textFields(c);50		const section = fields.section;51		if (section && section !== "profile" && section !== "site")52			return c.text("Unknown section.", 400);53		const refuse = (error: string) => c.html(sitePage(c, ctx, error, fields), 400);54		const values: Record<string, string> = {};55		for (const [key, label, max] of FIELDS) {56			if (section && PROFILE_FIELDS.has(key) !== (section === "profile")) continue;57			values[key] = (fields[key] ?? "").trim();58			if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`);59		}60		if (values.handle && !HANDLE.test(values.handle)) {61			return refuse("A handle is letters, digits, '.', '_' and '-'.");62		}63		// Shown by default: the setting records only that it was switched off.64		if (section !== "profile") values.support_off = fields.support === "1" ? "" : "1";65		if (values.link && !HTTP_URL.test(values.link)) {66			return refuse("The link must be an http or https address.");67		}68		const upload = (await c.req.parseBody()).signature;69		const previous = setting(ctx.kepr, "signature");70		if (71			section === "site" &&72			((upload instanceof File && upload.size > 0) || fields.remove_signature === "1")73		)74			return refuse("Edit your signature in profile settings.");75		if (upload instanceof File && upload.size > MAX_SIGNATURE) {76			return refuse("The signature must be under 60 KB.");77		}78		if (upload instanceof File && upload.size > 0) {79			const bytes = new Uint8Array(await upload.arrayBuffer());80			const type = imageType(bytes);81			if (!type) return refuse("The signature must be a PNG, JPEG or WebP image.");82			// Named by content, so the hour-long /static cache never serves an old signature.83			const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);84			values.signature = `signature-${hash}.${type}`;85			mkdirSync(assets, { recursive: true });86			writeFileSync(join(assets, values.signature), bytes);87		} else if (fields.remove_signature === "1") {88			values.signature = "";89		}90		saveSettings(ctx.kepr, values);91		// Only a file this console wrote is removed, whatever the setting holds.92		if (93			previous &&94			OWN_SIGNATURE.test(previous) &&95			values.signature !== undefined &&96			previous !== values.signature97		) {98			rmSync(join(assets, previous), { force: true });99		}100		return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303);101	});102}103104function sitePage(105	c: Context,106	ctx: AdminContext,107	error: string,108	fields?: Record<string, string>,109): string {110	const profile = (fields?.section ?? c.req.query("section")) === "profile";111	// The support page's box starts from the words it shows today.112	const value = (key: string) =>113		fields114			? (fields[key] ?? "")115			: key === "support_page"116				? supportText(ctx.kepr)117				: (setting(ctx.kepr, key) ?? "");118	const input = ([key, label, max]: (typeof FIELDS)[number]) =>119		html`<label class="issues-label" for="${key}">${label}</label>120${121	TEXTAREAS.has(key)122		? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="${key === "bio" ? 4 : 12}"123maxlength="${max}">${value(key)}</textarea>`124		: html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}"125value="${value(key)}">`126}`;127	const body = html`${notice(error)}128<p class="admin-intro">${profile ? "Your public profile brings your work together. A handle gives it an address beginning with @." : "The name and words visitors see across your site."}</p>129${profile && setting(ctx.kepr, "handle") && html`<p><a href="/@${setting(ctx.kepr, "handle")}">view profile ↗</a></p>`}130<form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">131${formField(c)}132<input type="hidden" name="section" value="${profile ? "profile" : "site"}">133${FIELDS.filter(([key]) => PROFILE_FIELDS.has(key) === profile).map(input)}134${135	!profile &&136	html`137<label class="issues-label"><input type="checkbox" name="support" value="1"${138		(fields ? fields.support === "1" : !setting(ctx.kepr, "support_off")) && " checked"139	}>140show the support page</label>`141}142<button class="issues-submit" type="submit">save ${profile ? "profile" : "site"}</button>143</form>`;144	return adminPage(c, ctx, profile ? "profile" : "site", body);145}