site.ts

8.9 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 { isSafePath } from "../bucket/repo.ts";6import { supportText } from "../pages/docs.ts";7import { html } from "../pages/html.ts";8import { ignoredNames, setting } from "../server/db.ts";9import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";10import { allRepos, saveSettings } from "./store.ts";1112// The settings Phase 6's pages read, with their labels and length caps.13const FIELDS: [key: string, label: string, max: number][] = [14	["site_name", "site name", 100],15	["tagline", "tagline", 300],16	["branding", "branding text", 100],17	["handle", "handle", 39],18	["bio", "bio", 2000],19	["location", "location", 100],20	["link", "link", 500],21	["building_since", "building since", 100],22	["support_page", "support page (Markdown)", 4000],23];24const TEXTAREAS = new Set(["bio", "support_page"]);25const PROFILE_FIELDS = new Set(["handle", "bio", "location", "link", "building_since"]);26const HANDLE = /^[A-Za-z0-9._-]+$/;27const HTTP_URL = /^https?:\/\/[^\s]+$/i;28// Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.29const MAX_SIGNATURE = 60 * 1024;30const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;31const MAX_IGNORED = 50;32const MAX_IGNORED_NAME = 255;33const MAX_TRACKING = 1000;34const UMAMI_SRC = /\bsrc="(https:\/\/[^\s"'<>]+)"/;35const UMAMI_ID =36	/\bdata-website-id="([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/i;3738/** The tag pages load, rebuilt from the two settings so nothing pasted reaches a page as is. */39function trackingCode(src: string | null, id: string | null): string {40	return src && id ? `<script defer src="${src}" data-website-id="${id}"></script>` : "";41}4243/**44 * The signature's type, from its first bytes, never its name or declared type. PNG, JPEG and WebP45 * only: SVG is refused, since it can carry script and links and would need a sandbox of its own.46 */47function imageType(bytes: Uint8Array): "png" | "jpg" | "webp" | null {48	const starts = (sig: number[], at = 0) => sig.every((byte, i) => bytes[at + i] === byte);49	if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png";50	if (starts([0xff, 0xd8, 0xff])) return "jpg";51	if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) return "webp";52	return null;53}5455/** §24's Site and profile section, with the signature image stored under `data/assets/` (§19). */56export function registerSite(app: Hono, ctx: AdminContext): void {57	const assets = join(ctx.dataDir, "assets");58	app.get("/admin/site", (c) => c.html(sitePage(c, ctx, "")));5960	app.post("/admin/site", async (c) => {61		const fields = await textFields(c);62		const section = fields.section;63		if (section && section !== "profile" && section !== "site")64			return c.text("Unknown section.", 400);65		const refuse = (error: string) => c.html(sitePage(c, ctx, error, fields), 400);66		const values: Record<string, string> = {};67		for (const [key, label, max] of FIELDS) {68			if (section && PROFILE_FIELDS.has(key) !== (section === "profile")) continue;69			values[key] = (fields[key] ?? "").trim();70			if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`);71		}72		if (values.handle && !HANDLE.test(values.handle)) {73			return refuse("A handle is letters, digits, '.', '_' and '-'.");74		}75		// Shown by default: the setting records only that it was switched off.76		if (section !== "profile") values.support_off = fields.support === "1" ? "" : "1";77		const ignored = [78			...new Set(79				(fields.ignored ?? "")80					.split(/\r?\n/)81					.map((name) => name.trim())82					.filter(Boolean),83			),84		];85		if (ignored.length > MAX_IGNORED) return refuse(`Up to ${MAX_IGNORED} ignored names.`);86		const bad = ignored.find(87			(name) => name.length > MAX_IGNORED_NAME || name.includes("/") || !isSafePath(name),88		);89		if (bad !== undefined) return refuse(`"${bad}" is not a file or folder name.`);90		const previousIgnored = ignoredNames(ctx.kepr);91		if (section !== "profile") {92			values.ignored = ignored.join("\n");93			const tracking = (fields.tracking ?? "").trim();94			const src = tracking.match(UMAMI_SRC)?.[1];95			const id = tracking.match(UMAMI_ID)?.[1];96			if (tracking.length > MAX_TRACKING || (tracking && !(src && URL.canParse(src) && id))) {97				return refuse("Paste Umami's tracking code: an https src and a data-website-id.");98			}99			values.umami_src = src ?? "";100			values.umami_id = id ?? "";101		}102		if (values.link && !HTTP_URL.test(values.link)) {103			return refuse("The link must be an http or https address.");104		}105		const upload = (await c.req.parseBody()).signature;106		const previous = setting(ctx.kepr, "signature");107		if (108			section === "site" &&109			((upload instanceof File && upload.size > 0) || fields.remove_signature === "1")110		)111			return refuse("Edit your signature in profile settings.");112		if (upload instanceof File && upload.size > MAX_SIGNATURE) {113			return refuse("The signature must be under 60 KB.");114		}115		if (upload instanceof File && upload.size > 0) {116			const bytes = new Uint8Array(await upload.arrayBuffer());117			const type = imageType(bytes);118			if (!type) return refuse("The signature must be a PNG, JPEG or WebP image.");119			// Named by content, so the hour-long /static cache never serves an old signature.120			const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);121			values.signature = `signature-${hash}.${type}`;122			mkdirSync(assets, { recursive: true });123			writeFileSync(join(assets, values.signature), bytes);124		} else if (fields.remove_signature === "1") {125			values.signature = "";126		}127		saveSettings(ctx.kepr, values);128		// Only a file this console wrote is removed, whatever the setting holds.129		if (130			previous &&131			OWN_SIGNATURE.test(previous) &&132			values.signature !== undefined &&133			previous !== values.signature134		) {135			rmSync(join(assets, previous), { force: true });136		}137		if (values.ignored !== undefined && values.ignored !== previousIgnored.join("\n")) {138			await ctx.syncer.applyHidden(allRepos(ctx.kepr).map((repo) => repo.name));139		}140		return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303);141	});142}143144function sitePage(145	c: Context,146	ctx: AdminContext,147	error: string,148	fields?: Record<string, string>,149): string {150	const profile = (fields?.section ?? c.req.query("section")) === "profile";151	// The support page's box starts from the words it shows today.152	const value = (key: string) =>153		fields154			? (fields[key] ?? "")155			: key === "support_page"156				? supportText(ctx.kepr)157				: (setting(ctx.kepr, key) ?? "");158	const input = ([key, label, max]: (typeof FIELDS)[number]) =>159		html`<label class="issues-label" for="${key}">${label}</label>160${161	TEXTAREAS.has(key)162		? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="${key === "bio" ? 4 : 12}"163maxlength="${max}">${value(key)}</textarea>`164		: html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}"165value="${value(key)}">`166}`;167	const body = html`${notice(error)}168<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>169${profile && setting(ctx.kepr, "handle") && html`<p><a href="/@${setting(ctx.kepr, "handle")}">view profile ↗</a></p>`}170<form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">171${formField(c)}172<input type="hidden" name="section" value="${profile ? "profile" : "site"}">173${174	!profile &&175	html`<section id="global-hide">176<h2 class="section-label mono">global hide</h2>177<label class="issues-label" for="ignored">file and folder names</label>178<p class="admin-intro">One file or folder name per line, such as .agent or AGENTS.md: hidden at179any depth in every repo, from pages and tarballs. Applied after any sync under way.</p>180<textarea class="issues-textarea mono" id="ignored" name="ignored" rows="4">${181		fields ? (fields.ignored ?? "") : ignoredNames(ctx.kepr).join("\n")182	}</textarea>183</section>`184}185${FIELDS.filter(([key]) => PROFILE_FIELDS.has(key) === profile).map(input)}186${187	!profile &&188	html`189<label class="issues-label" for="tracking">tracking code (Umami)</label>190<p class="admin-intro">Paste the script tag from your Umami website's settings. It loads on public191pages only, never in this console. Blank turns tracking off.</p>192<textarea class="issues-textarea mono" id="tracking" name="tracking" rows="3"193maxlength="${MAX_TRACKING}">${194		fields195			? (fields.tracking ?? "")196			: trackingCode(setting(ctx.kepr, "umami_src"), setting(ctx.kepr, "umami_id"))197	}</textarea>198<label class="issues-label"><input type="checkbox" name="support" value="1"${199		(fields ? fields.support === "1" : !setting(ctx.kepr, "support_off")) && " checked"200	}>201show the support page</label>202`203}204<button class="issues-submit" type="submit">save ${profile ? "profile" : "site"}</button>205</form>`;206	return adminPage(c, ctx, profile ? "profile" : "site", body);207}