import { createHash } from "node:crypto"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Context, Hono } from "hono"; import { isSafePath } from "../bucket/repo.ts"; import { applyHidden, openMirror } from "../mirror/store.ts"; import { supportText } from "../pages/docs.ts"; import { html } from "../pages/html.ts"; import { hiddenPaths, ignoredNames, setting } from "../server/db.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; import { allRepos, saveSettings } from "./store.ts"; // The settings Phase 6's pages read, with their labels and length caps. const FIELDS: [key: string, label: string, max: number][] = [ ["site_name", "site name", 100], ["tagline", "tagline", 300], ["branding", "branding text", 100], ["handle", "handle", 39], ["bio", "bio", 2000], ["location", "location", 100], ["link", "link", 500], ["building_since", "building since", 100], ["support_page", "support page (Markdown)", 4000], ]; const TEXTAREAS = new Set(["bio", "support_page"]); const PROFILE_FIELDS = new Set(["handle", "bio", "location", "link", "building_since"]); const HANDLE = /^[A-Za-z0-9._-]+$/; const HTTP_URL = /^https?:\/\/[^\s]+$/i; // Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB. const MAX_SIGNATURE = 60 * 1024; const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/; const MAX_IGNORED = 50; const MAX_IGNORED_NAME = 255; /** * The signature's type, from its first bytes, never its name or declared type. PNG, JPEG and WebP * only: SVG is refused, since it can carry script and links and would need a sandbox of its own. */ function imageType(bytes: Uint8Array): "png" | "jpg" | "webp" | null { const starts = (sig: number[], at = 0) => sig.every((byte, i) => bytes[at + i] === byte); if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "png"; if (starts([0xff, 0xd8, 0xff])) return "jpg"; if (starts([0x52, 0x49, 0x46, 0x46]) && starts([0x57, 0x45, 0x42, 0x50], 8)) return "webp"; return null; } /** §24's Site and profile section, with the signature image stored under `data/assets/` (§19). */ export function registerSite(app: Hono, ctx: AdminContext): void { const assets = join(ctx.dataDir, "assets"); app.get("/admin/site", (c) => c.html(sitePage(c, ctx, ""))); app.post("/admin/site", async (c) => { const fields = await textFields(c); const section = fields.section; if (section && section !== "profile" && section !== "site") return c.text("Unknown section.", 400); const refuse = (error: string) => c.html(sitePage(c, ctx, error, fields), 400); const values: Record = {}; for (const [key, label, max] of FIELDS) { if (section && PROFILE_FIELDS.has(key) !== (section === "profile")) continue; values[key] = (fields[key] ?? "").trim(); if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`); } if (values.handle && !HANDLE.test(values.handle)) { return refuse("A handle is letters, digits, '.', '_' and '-'."); } // Shown by default: the setting records only that it was switched off. if (section !== "profile") values.support_off = fields.support === "1" ? "" : "1"; const ignored = [ ...new Set( (fields.ignored ?? "") .split(/\r?\n/) .map((name) => name.trim()) .filter(Boolean), ), ]; if (ignored.length > MAX_IGNORED) return refuse(`Up to ${MAX_IGNORED} ignored names.`); const bad = ignored.find( (name) => name.length > MAX_IGNORED_NAME || name.includes("/") || !isSafePath(name), ); if (bad !== undefined) return refuse(`"${bad}" is not a file or folder name.`); const previousIgnored = ignoredNames(ctx.kepr); if (section !== "profile") values.ignored = ignored.join("\n"); if (values.link && !HTTP_URL.test(values.link)) { return refuse("The link must be an http or https address."); } const upload = (await c.req.parseBody()).signature; const previous = setting(ctx.kepr, "signature"); if ( section === "site" && ((upload instanceof File && upload.size > 0) || fields.remove_signature === "1") ) return refuse("Edit your signature in profile settings."); if (upload instanceof File && upload.size > MAX_SIGNATURE) { return refuse("The signature must be under 60 KB."); } if (upload instanceof File && upload.size > 0) { const bytes = new Uint8Array(await upload.arrayBuffer()); const type = imageType(bytes); if (!type) return refuse("The signature must be a PNG, JPEG or WebP image."); // Named by content, so the hour-long /static cache never serves an old signature. const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16); values.signature = `signature-${hash}.${type}`; mkdirSync(assets, { recursive: true }); writeFileSync(join(assets, values.signature), bytes); } else if (fields.remove_signature === "1") { values.signature = ""; } saveSettings(ctx.kepr, values); // Only a file this console wrote is removed, whatever the setting holds. if ( previous && OWN_SIGNATURE.test(previous) && values.signature !== undefined && previous !== values.signature ) { rmSync(join(assets, previous), { force: true }); } if (values.ignored !== undefined && values.ignored !== previousIgnored.join("\n")) { for (const { name } of allRepos(ctx.kepr)) { applyHidden(openMirror(ctx.mirrorDir, name), hiddenPaths(ctx.kepr, name)); } } return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303); }); } function sitePage( c: Context, ctx: AdminContext, error: string, fields?: Record, ): string { const profile = (fields?.section ?? c.req.query("section")) === "profile"; // The support page's box starts from the words it shows today. const value = (key: string) => fields ? (fields[key] ?? "") : key === "support_page" ? supportText(ctx.kepr) : (setting(ctx.kepr, key) ?? ""); const input = ([key, label, max]: (typeof FIELDS)[number]) => html` ${ TEXTAREAS.has(key) ? html`` : html`` }`; const body = html`${notice(error)}

${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."}

${profile && setting(ctx.kepr, "handle") && html`

view profile ↗

`}
${formField(c)} ${FIELDS.filter(([key]) => PROFILE_FIELDS.has(key) === profile).map(input)} ${ !profile && html`

One file or folder name per line, such as .agent or AGENTS.md: hidden at any depth in every repo, from pages and tarballs. Applied after any sync under way.

` }
`; return adminPage(c, ctx, profile ? "profile" : "site", body); }