0c07b179ceeb

add the admin site and profile section: site name, tagline, branding, handle, bio, location, link and building since, and a signature upload checked by magic bytes as png, jpeg or webp only, stored content-named under data/assets with svg refused (v3.0.38)

time
2026-09-26 20:24 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
f412b53f8e44
5 files package.json+1 −1src/admin/site.ts+104 −0src/admin/store.ts+10 −0src/server/app.ts+2 −0tests/console.test.ts+108 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.37",3+	"version": "3.0.38",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

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

view file

src/admin/store.ts+10 −0
@@ -39,3 +39,13 @@3939 		name,4040 	);4141 }42+43+/** Writes site settings in one transaction; an empty value removes the key, so defaults apply. */44+export function saveSettings(db: Database, values: Record<string, string>): void {45+	db.transaction(() => {46+		for (const [key, value] of Object.entries(values)) {47+			if (value === "") db.query("DELETE FROM settings WHERE key = ?").run(key);48+			else db.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, value);49+		}50+	})();51+}

view file

src/server/app.ts+2 −0
@@ -7,6 +7,7 @@77 import { registerIssues } from "../admin/issues.ts";88 import { registerReleases } from "../admin/releases.ts";99 import { registerRepos } from "../admin/repos.ts";10+import { registerSite } from "../admin/site.ts";1011 import { openBucket } from "../bucket/reader.ts";1112 import { isSafePath } from "../bucket/repo.ts";1213 import { registerIssuePages } from "../issues/pages.ts";@@ -78,6 +79,7 @@7879 	registerRepos(app, admin);7980 	registerIssues(app, admin);8081 	registerReleases(app, admin);82+	registerSite(app, admin);8183 	registerHousekeeping(app, ctx);8284 	registerFrontPages(app, ctx);8385 	registerDocsPages(app, ctx);

view file

tests/console.test.ts+108 −0
@@ -1,4 +1,5 @@11 import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2+import { join } from "node:path";23 import { fileIssue } from "../src/issues/store.ts";34 import { Browser, type Instance, instance } from "./browser.ts";45 import { makeRepos, writeRelease } from "./fixture.ts";@@ -301,5 +302,112 @@301302 		expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");302303 		expect((await edit("9.9.9", { notes: "" })).status).toBe(404);303304 		expect((await operator.post("/admin/releases/nowhere/1.0.0", { notes: "" })).status).toBe(404);305+	});306+});307+308+describe("site and profile settings, and the signature upload", () => {309+	let inst: Instance;310+	let operator: Browser;311+	const PNG = Uint8Array.from(312+		atob(313+			"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",314+		),315+		(ch) => ch.charCodeAt(0),316+	);317+	const settings = {318+		site_name: "ada's library",319+		tagline: "Small <tools>.",320+		branding: "by ada",321+		handle: "ada",322+		bio: "Writes code.",323+		location: "Lisbon",324+		link: "https://ada.example",325+		building_since: "june 2026",326+	};327+	const text = async (path: string) => (await inst.app.request(path)).text();328+	const upload = (bytes: Uint8Array | string, name: string, type: string, extra = {}) => {329+		const form = new FormData();330+		for (const [key, value] of Object.entries({ ...settings, ...extra })) form.set(key, value);331+		form.set("signature", new File([bytes], name, { type }));332+		return operator.post("/admin/site", form);333+	};334+	const signature = () =>335+		inst.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get();336+337+	beforeAll(async () => {338+		const repos = await makeRepos(["app"]);339+		inst = await instance(repos.bucketDir);340+		operator = new Browser(inst.app);341+		await operator.signIn();342+	});343+344+	test("settings are saved and every public page reads them", async () => {345+		expect((await operator.post("/admin/site", settings)).status).toBe(303);346+		const front = await text("/");347+		expect(front).toContain("<title>ada&#39;s library</title>");348+		expect(front).toContain("Small &lt;tools&gt;.");349+		expect(front).toContain('<span class="wordmark-by">by ada</span>');350+		const profile = await text("/@ada");351+		for (const value of [352+			"Writes code.",353+			"Lisbon",354+			'href="https://ada.example"',355+			"building since june 2026",356+		]) {357+			expect(profile).toContain(value);358+		}359+		expect(await (await operator.get("/admin/site")).text()).toContain('value="Lisbon"');360+		// Emptying a field removes the setting, so its default comes back.361+		expect((await operator.post("/admin/site", { ...settings, site_name: "" })).status).toBe(303);362+		expect(await text("/")).toContain("<title>kepr</title>");363+	});364+365+	test("a bad handle, link or overlong field is refused", async () => {366+		for (const bad of [367+			{ handle: "a/b" },368+			{ link: "javascript:alert(1)" },369+			{ bio: "x".repeat(2001) },370+		]) {371+			expect((await operator.post("/admin/site", { ...settings, ...bad })).status).toBe(400);372+		}373+		expect((await inst.app.request("/@ada")).status).toBe(200);374+	});375+376+	test("a PNG signature is stored in data/assets and shown on the profile", async () => {377+		expect((await upload(PNG, "sig.png", "image/png")).status).toBe(303);378+		const name = (signature() as { value: string }).value;379+		expect(name).toMatch(/^signature-[0-9a-f]{16}\.png$/);380+		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(true);381+		expect(await text("/@ada")).toContain(382+			`<img class="profile-signature" src="/static/assets/${name}"`,383+		);384+		const served = await inst.app.request(`/static/assets/${name}`);385+		expect(served.headers.get("content-type")).toBe("image/png");386+		expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);387+	});388+389+	test("SVG, or anything whose bytes are not PNG, JPEG or WebP, is refused whatever it claims", async () => {390+		const before = signature();391+		const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>';392+		expect((await upload(svg, "sig.svg", "image/svg+xml")).status).toBe(400);393+		expect((await upload(svg, "sig.png", "image/png")).status).toBe(400);394+		expect((await upload("GIF89a", "sig.gif", "image/gif")).status).toBe(400);395+		expect(signature()).toEqual(before);396+		expect((await upload(new Uint8Array(70 * 1024), "big.png", "image/png")).status).toBe(413);397+	});398+399+	test("a new signature replaces the old file; removing it brings the handle back", async () => {400+		const old = (signature() as { value: string }).value;401+		const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 1]);402+		expect((await upload(webp, "sig.webp", "image/webp")).status).toBe(303);403+		const name = (signature() as { value: string }).value;404+		expect(name).toMatch(/\.webp$/);405+		expect(await Bun.file(join(inst.dataDir, "assets", old)).exists()).toBe(false);406+		expect(407+			(await operator.post("/admin/site", { ...settings, remove_signature: "1" })).status,408+		).toBe(303);409+		expect(signature()).toBeNull();410+		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(false);411+		expect(await text("/@ada")).toContain('<h1 class="profile-username">ada</h1>');304412 	});305413 });

view file