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
6 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 −2
package.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": {

view file

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 	}>

view file

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>

view file

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) => {

view file

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=&quot;${id}&quot;`);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" },

view file

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);

view file