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
↓ kepr-0c07b179.tar.gzatlas at this save
files changed5 files
package.json+1 −1src/admin/site.ts+104 −0src/admin/store.ts+10 −0src/server/app.ts+2 −0tests/console.test.ts+108 −0package.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": {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+}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+}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);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's library</title>");348+ expect(front).toContain("Small <tools>.");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 });