ac8a1e995ab9
add an Umami tracking code to the console's site settings, loaded on public pages and allowed by the CSP (v3.0.223)
- time
- 2026-10-05 03:00 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- umami tracking code
- parent
- c4aae1ea767a
↓ kepr-ac8a1e99.tar.gzatlas at this save
files changed6 files
package.json+1 −1src/admin/site.ts+29 −1src/pages/layout.ts+5 −0src/server/app.ts+17 −4tests/console.test.ts+24 −0tests/server.test.ts+7 −2package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.222",3+ "version": "3.0.223",44 "private": true,55 "type": "module",66 "scripts": {src/admin/site.ts+29 −1
@@ -30,7 +30,16 @@3030 const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;3131 const MAX_IGNORED = 50;3232 const MAX_IGNORED_NAME = 255;33+const MAX_TRACKING = 1000;34+const UMAMI_SRC = /\bsrc="(https:\/\/[^\s"'<>]+)"/;35+const 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;3337 38+/** The tag pages load, rebuilt from the two settings so nothing pasted reaches a page as is. */39+function trackingCode(src: string | null, id: string | null): string {40+ return src && id ? `<script defer src="${src}" data-website-id="${id}"></script>` : "";41+}42+3443 /**3544 * The signature's type, from its first bytes, never its name or declared type. PNG, JPEG and WebP3645 * only: SVG is refused, since it can carry script and links and would need a sandbox of its own.@@ -79,7 +88,17 @@7988 );8089 if (bad !== undefined) return refuse(`"${bad}" is not a file or folder name.`);8190 const previousIgnored = ignoredNames(ctx.kepr);82- if (section !== "profile") values.ignored = ignored.join("\n");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+ }83102 if (values.link && !HTTP_URL.test(values.link)) {84103 return refuse("The link must be an http or https address.");85104 }@@ -167,6 +186,15 @@167186 ${168187 !profile &&169188 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 public191+pages only, never in this console. Blank turns tracking off.</p>192+<textarea class="issues-textarea mono" id="tracking" name="tracking" rows="3"193+maxlength="${MAX_TRACKING}">${194+ fields195+ ? (fields.tracking ?? "")196+ : trackingCode(setting(ctx.kepr, "umami_src"), setting(ctx.kepr, "umami_id"))197+ }</textarea>170198 <label class="issues-label"><input type="checkbox" name="support" value="1"${171199 (fields ? fields.support === "1" : !setting(ctx.kepr, "support_off")) && " checked"172200 }>src/pages/layout.ts+5 −0
@@ -45,6 +45,10 @@4545 export function page(ctx: PageContext, options: PageOptions): string {4646 const site = setting(ctx.kepr, "site_name") ?? "kepr";4747 const branding = setting(ctx.kepr, "branding");48+ // Visitors are counted, never the operator's console or sign-in.49+ const tracked = !/^\/(admin|sign-in|connect)(\/|$)/.test(options.path);50+ const umamiSrc = tracked && setting(ctx.kepr, "umami_src");51+ const umamiId = setting(ctx.kepr, "umami_id");4852 // The front page is the site itself, so its title is the bare site name.4953 const title = options.title ? `${options.title} — ${site}` : site;5054 const url = `https://${ctx.domain}${options.path}`;@@ -68,6 +72,7 @@6872 <link rel="icon" href="/favicon.svg" type="image/svg+xml">6973 <link rel="stylesheet" href="/static/css/kepr.css?v=${version}">7074 <script src="/static/js/kepr.js?v=${version}" defer></script>75+${umamiSrc && umamiId && html`<script defer src="${umamiSrc}" data-website-id="${umamiId}"></script>`}7176 </head>7277 <body>7378 <nav>src/server/app.ts+17 −4
@@ -34,7 +34,7 @@3434 import { registerTarballRoutes } from "../tarball/archive.ts";3535 import { CONFIG_FILE, type Config } from "./config.ts";3636 import { connectKeys, noCode, registerConnect } from "./connect.ts";37-import { renamedTo } from "./db.ts";37+import { renamedTo, setting } from "./db.ts";3838 import { registerLogin } from "./login.ts";3939 4040 const MAX_BODY_BYTES = 64 * 1024;@@ -45,11 +45,16 @@4545 // §18 allows Bunny Fonts: its stylesheet and the font files it points at.4646 4747 /** §25's headers: the CSP, with no inline scripts, and no framing. */48-export function securityHeaders(imgSrc: (string | (() => string))[] = ["'self'"]) {48+export function securityHeaders(49+ imgSrc: (string | (() => string))[] = ["'self'"],50+ scriptSrc: (string | (() => string))[] = ["'self'"],51+) {4952 return secureHeaders({5053 contentSecurityPolicy: {5154 defaultSrc: ["'self'"],52- scriptSrc: ["'self'"],55+ scriptSrc,56+ // Umami's script reports to the origin it loads from.57+ connectSrc: scriptSrc,5358 styleSrc: ["'self'"],5459 fontSrc: ["'self'"],5560 imgSrc,@@ -94,7 +99,15 @@9499 c.header("Content-Security-Policy", policy ? `${policy}; sandbox` : "sandbox");95100 }96101 });97- app.use(securityHeaders(imgSrc));102+ // The operator's Umami script, when set (June, 2026-10-04).103+ const scriptSrc = [104+ "'self'",105+ () => {106+ const src = setting(kepr, "umami_src");107+ return src ? new URL(src).origin : "";108+ },109+ ];110+ app.use(securityHeaders(imgSrc, scriptSrc));98111 // A renamed repo's old pages, which now 404, move to its new name (June, 2026-10-01). Koh's99112 // routes keep their 404, so a machine follows the bucket's rename marker instead.100113 app.use("*", async (c, next) => {tests/console.test.ts+24 −0
@@ -591,6 +591,30 @@591591 expect(await text("/")).toContain('<a href="/support">support</a>');592592 });593593 594+ test("an Umami tracking code loads on public pages, allowed by the CSP", async () => {595+ const id = "94db1cb1-74f4-4a40-ad6c-962362670409";596+ const tracking = `<script defer src="https://stats.example/script.js" data-website-id="${id}"></script>`;597+ expect((await operator.post("/admin/site", { ...settings, tracking })).status).toBe(303);598+ const front = await inst.app.request("/");599+ expect(await front.text()).toContain(tracking);600+ const policy = front.headers.get("Content-Security-Policy") ?? "";601+ expect(policy).toContain("script-src 'self' https://stats.example");602+ expect(policy).toContain("connect-src 'self' https://stats.example");603+ const admin = await (await operator.get("/admin/site")).text();604+ expect(admin).not.toContain('<script defer src="https://stats.example');605+ expect(admin).toContain(`data-website-id="${id}"`);606+ for (const bad of [607+ '<script src="http://stats.example/script.js" data-website-id="x"></script>',608+ '<script src="https://%" data-website-id="94db1cb1-74f4-4a40-ad6c-962362670409"></script>',609+ `<script src="https://stats.example/script.js"></script>`,610+ "<script>alert(1)</script>",611+ ]) {612+ expect((await operator.post("/admin/site", { ...settings, tracking: bad })).status).toBe(400);613+ }614+ expect((await operator.post("/admin/site", settings)).status).toBe(303);615+ expect(await text("/")).not.toContain("stats.example");616+ });617+594618 test("a bad handle, link or overlong field is refused", async () => {595619 for (const bad of [596620 { handle: "a/b" },tests/server.test.ts+7 −2
@@ -1,7 +1,11 @@11 import { Database } from "bun:sqlite";22 import { expect, test } from "bun:test";3+import { mkdtempSync } from "node:fs";4+import { tmpdir } from "node:os";5+import { join } from "node:path";36 import { createApp } from "../src/server/app.ts";47 import type { Config } from "../src/server/config.ts";8+import { openKeprDb } from "../src/server/db.ts";59 import { RESERVED_NAMES } from "../src/server/reserved.ts";610 711 const config: Config = {@@ -40,7 +44,8 @@4044 });4145 4246 test("responses carry the §25 content security policy", async () => {43- const response = await createApp(config, new Database(":memory:")).request("/");47+ const kepr = openKeprDb(mkdtempSync(join(tmpdir(), "kepr-csp-")));48+ const response = await createApp(config, kepr).request("/");4449 const csp = response.headers.get("content-security-policy") ?? "";4550 expect(csp).toContain("script-src 'self'");4651 expect(csp).not.toContain("unsafe-inline");@@ -50,7 +55,7 @@5055 });5156 5257 test("request bodies over 64 KB are refused", async () => {53- const app = createApp(config, new Database(":memory:"));58+ const app = createApp(config, openKeprDb(mkdtempSync(join(tmpdir(), "kepr-body-"))));5459 app.post("/echo", async (c) => c.text(String((await c.req.text()).length)));5560 const post = (bytes: number) => app.request("/echo", { method: "POST", body: "x".repeat(bytes) });5661 expect((await post(64 * 1024)).status).toBe(200);