6fcced41a42c

serve public issue pages: filing with honeypot, 3-second stamp, hashed-ip rate limit and size cap, one-time follow-up links, and the marked thread (v3.0.28)

time
2026-09-26 19:10 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
755ef3f89667
6 files package.json+1 −1public/css/kepr.css+147 −0src/issues/pages.ts+282 −0src/issues/spam.ts+76 −0src/server/app.ts+2 −0tests/issues.test.ts+209 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.27",3+	"version": "3.0.28",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+147 −0
@@ -1201,3 +1201,150 @@12011201 	margin: 4px 0 0;12021202 	font-size: 0.8125rem;12031203 }1204+1205+/* ----- issues ------------------------------------------------------------- */1206+1207+.issues-bar {1208+	display: flex;1209+	flex-wrap: wrap;1210+	align-items: center;1211+	gap: 8px 12px;1212+	font-size: 0.75rem;1213+	padding-bottom: 14px;1214+	border-bottom: 1px solid var(--bg-elevated);1215+}1216+.issues-tab {1217+	color: var(--text-faint);1218+	border: 1px solid transparent;1219+	border-radius: 999px;1220+	padding: 4px 10px;1221+}1222+.issues-tab.active {1223+	color: var(--accent);1224+	border-color: var(--accent-tint-12);1225+}1226+.issues-new {1227+	margin-left: auto;1228+	color: var(--text-muted);1229+}1230+.issue-row {1231+	display: flex;1232+	flex-wrap: wrap;1233+	align-items: baseline;1234+	gap: 2px 10px;1235+	padding: 10px 8px;1236+	border-bottom: 1px solid var(--border);1237+}1238+.issue-row:hover {1239+	text-decoration: none;1240+	background: var(--accent-tint-04);1241+}1242+.issue-title {1243+	flex: 1;1244+	color: var(--text-heading);1245+}1246+.issues-new:hover,1247+.issue-row:hover .issue-title {1248+	color: var(--accent);1249+}1250+.issue-meta,1251+.issue-post-head {1252+	font-size: 0.6875rem;1253+	color: var(--text-muted);1254+}1255+.issue-symbol--open {1256+	color: var(--accent);1257+}1258+.issue-symbol--closed {1259+	color: var(--text-faint);1260+}1261+.issue-post {1262+	margin: 18px 0;1263+	padding: 14px 18px;1264+	border: 1px solid var(--border);1265+	border-radius: 6px;1266+	background: var(--bg-surface);1267+}1268+.issue-post--operator {1269+	border-color: var(--accent-dim);1270+}1271+.issue-post-head {1272+	display: flex;1273+	flex-wrap: wrap;1274+	gap: 4px 12px;1275+	margin: 0 0 8px;1276+}1277+.issue-author {1278+	color: var(--text);1279+}1280+.issue-badge {1281+	color: var(--accent);1282+	text-transform: uppercase;1283+	letter-spacing: 0.08em;1284+}1285+.issue-text {1286+	white-space: pre-wrap;1287+	overflow-wrap: anywhere;1288+}1289+.issue-link {1290+	font-size: 0.75rem;1291+	overflow-wrap: anywhere;1292+	color: var(--accent);1293+}1294+.issues-form {1295+	display: flex;1296+	flex-direction: column;1297+	gap: 6px;1298+	max-width: 640px;1299+	margin-top: 18px;1300+}1301+.issues-label {1302+	font-family: var(--mono);1303+	font-size: 0.6875rem;1304+	margin-top: 6px;1305+	opacity: 0.8;1306+}1307+.issues-input,1308+.issues-textarea {1309+	font-family: var(--mono);1310+	font-size: 0.75rem;1311+	width: 100%;1312+	padding: 6px 8px;1313+	color: var(--text);1314+	background: var(--bg);1315+	border: 1px solid var(--border);1316+	border-radius: 4px;1317+	resize: vertical;1318+}1319+.issues-input:focus,1320+.issues-textarea:focus {1321+	outline: none;1322+	border-color: var(--accent);1323+}1324+.issues-submit {1325+	align-self: flex-start;1326+	margin-top: 10px;1327+	padding: 6px 14px;1328+	font-family: var(--mono);1329+	font-size: 0.75rem;1330+	color: var(--bg);1331+	background: var(--accent);1332+	border: none;1333+	border-radius: 4px;1334+	cursor: pointer;1335+}1336+.issues-submit:hover {1337+	opacity: 0.85;1338+}1339+.issues-error {1340+	font-size: 0.75rem;1341+	color: #c86a5a;1342+}1343+/* The honeypot: off-screen for people, still in the form for bots. */1344+.issues-hp {1345+	position: absolute;1346+	left: -9999px;1347+	height: 0;1348+	overflow: hidden;1349+	opacity: 0;1350+}

view file

src/issues/pages.ts+282 −0
@@ -0,0 +1,282 @@1+import type { Context, Hono } from "hono";2+import { saveByPrefix } from "../mirror/queries.ts";3+import { type Html, html } from "../pages/html.ts";4+import {5+	cacheFor,6+	formatDate,7+	notFound,8+	openPublicRepo,9+	type PageContext,10+	type PublicRepo,11+	page,12+	relativeTime,13+} from "../pages/layout.ts";14+import { visibleReleases } from "../releases/releases.ts";15+import { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts";16+import {17+	addEntry,18+	fileIssue,19+	filingsSince,20+	findIssue,21+	hashToken,22+	ISSUE_TYPES,23+	type Issue,24+	type IssueType,25+	issueCounts,26+	listIssues,27+	threadEntries,28+} from "./store.ts";29+30+type Fields = Record<string, string>;31+type Notice = { error?: string; draft?: string };32+33+const TOKEN = /^[0-9a-f]{64}$/;34+const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];35+const unixNow = () => Math.floor(Date.now() / 1000);36+37+/** §23: the list, the filing form and the thread with its follow-up links. */38+export function registerIssuePages(app: Hono, ctx: PageContext): void {39+	app.get("/:repo/issues", (c) => {40+		const repo = openPublicRepo(ctx, c.req.param("repo"));41+		if (!repo) return notFound(c, ctx);42+		const status = c.req.query("status") === "closed" ? "closed" : "open";43+		cacheFor(c, false);44+		return c.html(listPage(ctx, repo, status));45+	});46+47+	app.get("/:repo/issues/new", (c) => {48+		const repo = openPublicRepo(ctx, c.req.param("repo"));49+		if (!repo) return notFound(c, ctx);50+		const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";51+		return formPage(c, ctx, repo, { type: "bug", version }, "", 200);52+	});53+54+	app.post("/:repo/issues/new", async (c) => {55+		const repo = openPublicRepo(ctx, c.req.param("repo"));56+		if (!repo) return notFound(c, ctx);57+		const fields = await readFields(c, FORM_FIELDS);58+		const now = unixNow();59+		const refuse = (message: string, status: 400 | 403 | 429) =>60+			formPage(c, ctx, repo, fields, message, status);61+		// A bot that fills the hidden field learns nothing from the answer.62+		if (fields.website !== "") return refuse("This issue could not be filed.", 400);63+		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);64+		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);65+		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);66+		const title = fields.title?.trim() ?? "";67+		const body = fields.body?.trim() ?? "";68+		if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);69+		if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);70+		if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {71+			return refuse(72+				`Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,73+				400,74+			);75+		}76+		const hash = ipHash(ctx.kepr, c);77+		if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {78+			return refuse("Too many issues from here this hour. Please try again later.", 429);79+		}80+		const name = repo.settings.name;81+		const filed = fileIssue(ctx.kepr, {82+			repo: name,83+			title,84+			body,85+			type: fields.type as IssueType,86+			version: fields.version?.trim() ?? "",87+			reporterName: fields.name?.trim() ?? "",88+			contactEmail: fields.email?.trim() ?? "",89+			ipHash: hash,90+			filedAt: now,91+		});92+		const link = `https://${ctx.domain}/${name}/issues/${filed.number}?token=${filed.token}`;93+		privatePage(c);94+		return c.html(95+			page(ctx, {96+				title: `issue #${filed.number} filed · ${name}`,97+				description: "",98+				path: c.req.path,99+				body: html`${crumbs(name)}100+<h1 class="file-page-title">Filed as <a href="/${name}/issues/${filed.number}">#${filed.number}</a></h1>101+<p>This is your private link for adding to the issue later. It is shown only this once, so keep it; anyone who has it can post as you.</p>102+<p class="issue-link mono"><a href="${link}">${link}</a></p>`,103+			}),104+		);105+	});106+107+	app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {108+		const repo = openPublicRepo(ctx, c.req.param("repo"));109+		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));110+		if (!repo || !issue) return notFound(c, ctx);111+		const token = c.req.query("token");112+		if (token === undefined) {113+			cacheFor(c, false);114+			return c.html(threadPage(c, ctx, repo, issue, null, {}));115+		}116+		privatePage(c);117+		return c.html(threadPage(c, ctx, repo, issue, token, {}));118+	});119+120+	app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {121+		const repo = openPublicRepo(ctx, c.req.param("repo"));122+		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));123+		if (!repo || !issue) return notFound(c, ctx);124+		const { token = "", body = "", stamp = "" } = await readFields(c, ["token", "body", "stamp"]);125+		privatePage(c);126+		const refuse = (error: string, status: 400 | 403) =>127+			c.html(threadPage(c, ctx, repo, issue, token, { error, draft: body }), status);128+		if (!ownsIssue(issue, token))129+			return refuse("This follow-up link does not match the issue.", 403);130+		const check = checkStamp(ctx.kepr, c, stamp, unixNow());131+		if (check === "invalid") return refuse("This form expired. Please send it again.", 403);132+		if (check === "too-fast") return refuse("That was quick. Please send it again.", 400);133+		const text = body.trim();134+		if (text === "") return refuse("Write something to add.", 400);135+		if (text.length > MAX_CHARS) {136+			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);137+		}138+		const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow());139+		return c.redirect(140+			`/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`,141+			303,142+		);143+	});144+}145+146+async function readFields(c: Context, names: string[]): Promise<Fields> {147+	const form = await c.req.parseBody();148+	const fields: Fields = {};149+	for (const name of names) {150+		const value = form[name];151+		fields[name] = typeof value === "string" ? value : "";152+	}153+	return fields;154+}155+156+function ownsIssue(issue: Issue, token: string): boolean {157+	return TOKEN.test(token) && issue.token_hash === hashToken(token);158+}159+160+// A page carrying a token or a form stamp is never cached, and its URL never leaks as a referrer.161+function privatePage(c: Context): void {162+	c.header("Cache-Control", "no-store");163+	c.header("Referrer-Policy", "no-referrer");164+}165+166+function crumbs(repo: string): Html {167+	return html`<p class="breadcrumb mono"><a href="/${repo}">${repo}</a> / <a href="/${repo}/issues">issues</a></p>`;168+}169+170+function listPage(ctx: PageContext, repo: PublicRepo, status: "open" | "closed"): string {171+	const name = repo.settings.name;172+	const counts = issueCounts(ctx.kepr, name);173+	const issues = listIssues(ctx.kepr, name, status);174+	const tab = (which: "open" | "closed") =>175+		html`<a class="issues-tab${which === status ? " active" : ""}" href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">${which} <span class="muted">${counts[which]}</span></a>`;176+	return page(ctx, {177+		title: `issues · ${name}`,178+		description: `Issues filed against ${name}.`,179+		path: `/${name}/issues`,180+		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>181+<h1 class="file-page-title">issues</h1>182+<p class="issues-bar mono">${tab("open")}${tab("closed")}<a class="issues-new" href="/${name}/issues/new">report an issue →</a></p>183+<section class="repo-section">184+${issues.length > 0 ? issues.map((issue) => html`<a class="issue-row" href="/${name}/issues/${issue.number}"><span class="issue-symbol issue-symbol--${issue.status}">${issue.status === "open" ? "◉" : "◈"}</span><span class="issue-title">${issue.title}</span><span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}</span></a>`) : html`<p class="muted mono save-empty">no ${status} issues</p>`}185+</section>`,186+	});187+}188+189+function formPage(190+	c: Context,191+	ctx: PageContext,192+	repo: PublicRepo,193+	values: Fields,194+	error: string,195+	status: 200 | 400 | 403 | 429,196+): Response {197+	const name = repo.settings.name;198+	privatePage(c);199+	const typeOption = (type: string) =>200+		html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;201+	const body = html`${crumbs(name)}202+<h1 class="file-page-title">report an issue</h1>203+${error && html`<p class="issues-error mono" role="alert">${error}</p>`}204+<form class="issues-form" method="post" action="/${name}/issues/new">205+<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">206+<label class="issues-hp" aria-hidden="true">leave this empty <input name="website" tabindex="-1" autocomplete="off"></label>207+<label class="issues-label" for="issue-title">title</label>208+<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">209+<label class="issues-label" for="issue-body">what happened</label>210+<textarea class="issues-textarea" id="issue-body" name="body" rows="8" required maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>211+<label class="issues-label" for="issue-type">type</label>212+<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>213+<label class="issues-label" for="issue-version">version you're on</label>214+<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">215+<label class="issues-label" for="issue-name">name to show <span class="muted">(optional)</span></label>216+<input class="issues-input" id="issue-name" name="name" maxlength="200" value="${values.name ?? ""}">217+<label class="issues-label" for="issue-email">contact email <span class="muted">(optional, only the operator sees it)</span></label>218+<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">219+<button class="issues-submit" type="submit">file issue</button>220+</form>`;221+	return c.html(222+		page(ctx, {223+			title: `report an issue · ${name}`,224+			description: `File an issue against ${name}.`,225+			path: `/${name}/issues/new`,226+			body,227+		}),228+		status,229+	);230+}231+232+function threadPage(233+	c: Context,234+	ctx: PageContext,235+	repo: PublicRepo,236+	issue: Issue,237+	token: string | null,238+	notice: Notice,239+): string {240+	const name = repo.settings.name;241+	const reporter = issue.reporter_name || "anonymous";242+	const post = (id: string, operator: boolean, at: number, text: string) =>243+		html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}">244+<p class="issue-post-head mono">${operator ? html`<span class="issue-badge">operator</span>` : html`<span class="issue-author">${reporter}</span>`}<span class="history-when">${formatDate(at)}</span></p>245+<div class="issue-text">${text}</div>246+</article>`;247+	const owner = token !== null && ownsIssue(issue, token);248+	return page(ctx, {249+		title: `#${issue.number} ${issue.title} · ${name}`,250+		description: issue.title,251+		path: `/${name}/issues/${issue.number}`,252+		body: html`${crumbs(name)}253+<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>254+<p class="issue-meta mono"><span class="issue-symbol issue-symbol--${issue.status}">${issue.status === "open" ? "◉ open" : "◈ closed"}</span> · ${issue.type}${issue.version && ` · v${issue.version}`} · filed ${relativeTime(issue.filed_at)}${issue.status === "closed" && fixedIn(repo, issue)}</p>255+${post("issue", false, issue.filed_at, issue.body)}256+${threadEntries(ctx.kepr, issue.id).map((entry) => post(`entry-${entry.id}`, entry.author === "operator", entry.created_at, entry.body))}257+${notice.error && html`<p class="issues-error mono" role="alert">${notice.error}</p>`}258+${token !== null && !owner && !notice.error && html`<p class="issues-error mono">This follow-up link does not match the issue.</p>`}259+${260+	owner &&261+	html`<form class="issues-form" method="post" action="/${name}/issues/${issue.number}">262+<p class="muted">This is your private follow-up link; anyone who has it can post as you.</p>263+<input type="hidden" name="token" value="${token}">264+<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">265+<label class="issues-label" for="follow-up">add to this issue</label>266+<textarea class="issues-textarea" id="follow-up" name="body" rows="5" required maxlength="${MAX_CHARS}">${notice.draft ?? ""}</textarea>267+<button class="issues-submit" type="submit">post</button>268+</form>`269+}`,270+	});271+}272+273+function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {274+	if (issue.fixed_in === "") return "";275+	const save = saveByPrefix(repo.mirror, issue.fixed_in);276+	// Only a save with a page of its own is linked; a checkpoint or an unknown ID stays text.277+	const id =278+		save && !save.is_checkpoint279+			? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`280+			: html`<span class="history-id">${issue.fixed_in}</span>`;281+	return html` · fixed in ${id}`;282+}

view file

src/issues/spam.ts+76 −0
@@ -0,0 +1,76 @@1+import type { Database } from "bun:sqlite";2+import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";3+import type { Context } from "hono";4+import { getCookie, setCookie } from "hono/cookie";5+import { setting } from "../server/db.ts";6+7+// §23's spam defenses, all server-side: nothing here trusts a clock or a value from the browser.8+export const MIN_SECONDS_ON_PAGE = 3;9+const STAMP_MAX_AGE = 86400;10+export const FILINGS_PER_HOUR = 5;11+export const MAX_CHARS = 20_000;12+const FORM_COOKIE = "kepr_form";13+const HEX = /^[0-9a-f]{32,64}$/;14+15+/** One random key, made on first use and kept in kepr.db, for the IP hash and the form stamps. */16+function secret(db: Database): string {17+	db.query("INSERT OR IGNORE INTO settings (key, value) VALUES ('issue_secret', ?)").run(18+		randomBytes(32).toString("hex"),19+	);20+	return setting(db, "issue_secret") as string;21+}22+23+function mac(db: Database, message: string): string {24+	return createHmac("sha256", secret(db)).update(message).digest("hex");25+}26+27+/**28+ * The originating address, as Kepr 2 read it: the first `X-Forwarded-For` entry. This trusts the29+ * reverse proxy (Caddy) to set the header itself; Caddy replaces a client's header with the30+ * connecting address unless `trusted_proxies` says otherwise. Exposed directly, a client could31+ * forge it and dodge the rate limit, so without the header the socket address is used.32+ */33+function clientIp(c: Context): string {34+	const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();35+	if (forwarded) return forwarded;36+	// Bun passes its server as the env; Hono's test `request()` has none.37+	const server = c.env as { requestIP?: (request: Request) => { address: string } | null };38+	return server?.requestIP?.(c.req.raw)?.address ?? "";39+}40+41+/** A keyed hash, so the stored value can't be reversed by hashing every IPv4 address. */42+export function ipHash(db: Database, c: Context): string {43+	return mac(db, `ip:${clientIp(c)}`);44+}45+46+/**47+ * The same-site token (§25) and the time-on-page clock in one: a random cookie, SameSite=Strict so a48+ * cross-site post arrives without it, and a form stamp `<unix time>.<HMAC(time, cookie)>`.49+ */50+export function formStamp(db: Database, c: Context, now: number): string {51+	let cookie = getCookie(c, FORM_COOKIE);52+	if (!cookie || !HEX.test(cookie)) {53+		cookie = randomBytes(16).toString("hex");54+		setCookie(c, FORM_COOKIE, cookie, {55+			httpOnly: true,56+			secure: true,57+			sameSite: "Strict",58+			path: "/",59+		});60+	}61+	return `${now}.${mac(db, `form:${now}:${cookie}`)}`;62+}63+64+export type StampCheck = "ok" | "invalid" | "too-fast";65+66+export function checkStamp(db: Database, c: Context, stamp: string, now: number): StampCheck {67+	const cookie = getCookie(c, FORM_COOKIE);68+	const [time, signature] = stamp.split(".");69+	if (!cookie || !time || !signature || !/^\d{1,12}$/.test(time)) return "invalid";70+	const expected = Buffer.from(mac(db, `form:${time}:${cookie}`));71+	const given = Buffer.from(signature);72+	if (given.length !== expected.length || !timingSafeEqual(given, expected)) return "invalid";73+	const age = now - Number(time);74+	if (age > STAMP_MAX_AGE || age < 0) return "invalid";75+	return age < MIN_SECONDS_ON_PAGE ? "too-fast" : "ok";76+}

view file

src/server/app.ts+2 −0
@@ -5,6 +5,7 @@55 import { secureHeaders } from "hono/secure-headers";66 import { openBucket } from "../bucket/reader.ts";77 import { isSafePath } from "../bucket/repo.ts";8+import { registerIssuePages } from "../issues/pages.ts";89 import { registerArchivesPage } from "../pages/archives.ts";910 import { registerFileRoutes } from "../pages/files.ts";1011 import { registerHousekeeping } from "../pages/housekeeping.ts";@@ -59,6 +60,7 @@5960 	registerSavePage(app, ctx);6061 	registerArchivesPage(app, ctx);6162 	registerReleasesPage(app, ctx);63+	registerIssuePages(app, ctx);6264 	registerTarballRoutes(app, ctx, join(config.dataDir, "tarballs"));6365 	registerReleaseRoutes(app, ctx, openBucket(config.bucket));6466 	return app;

view file

tests/issues.test.ts+209 −1
@@ -1,7 +1,11 @@1-import { describe, expect, test } from "bun:test";1+import type { Database } from "bun:sqlite";2+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";23 import { mkdtemp } from "node:fs/promises";34 import { tmpdir } from "node:os";45 import { join } from "node:path";6+import type { Hono } from "hono";7+import { openBucket } from "../src/bucket/reader.ts";8+import { syncAll } from "../src/bucket/sync.ts";59 import {610 	addEntry,711 	fileIssue,@@ -13,7 +17,9 @@1317 	type NewIssue,1418 	threadEntries,1519 } from "../src/issues/store.ts";20+import { createApp } from "../src/server/app.ts";1621 import { openKeprDb } from "../src/server/db.ts";22+import { makeRepos, writeRelease } from "./fixture.ts";1723 1824 const newIssue = (repo: string, title: string, filedAt = 1000): NewIssue => ({1925 	repo,@@ -70,5 +76,207 @@7076 			["reporter", "More detail."],7177 			["operator", "Thanks."],7278 		]);79+	});80+});81+82+describe("issues end to end over HTTP", () => {83+	let app: Hono;84+	let kepr: Database;85+	let save: string;86+	let clock = Date.parse("2026-09-26T12:00:00Z");87+	const tick = (seconds: number) => {88+		clock += seconds * 1000;89+		setSystemTime(new Date(clock));90+	};91+	// A browser session: the form cookie from GET /new, and the form's stamp.92+	type Session = { cookie: string; stamp: string };93+	const openForm = async (path = "/app/issues/new", cookie = ""): Promise<Session> => {94+		const response = await app.request(path, { headers: cookie ? { cookie } : {} });95+		const set = response.headers.get("set-cookie")?.split(";")[0] ?? cookie;96+		const stamp = /name="stamp" value="([^"]+)"/.exec(await response.text())?.[1] ?? "";97+		return { cookie: set, stamp };98+	};99+	const post = (100+		path: string,101+		session: Session,102+		fields: Record<string, string>,103+		ip = "203.0.113.9",104+	) =>105+		app.request(path, {106+			method: "POST",107+			headers: { cookie: session.cookie, "x-forwarded-for": `${ip}, 10.0.0.1` },108+			body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),109+		});110+	const issue = {111+		title: "Crash on <start>",112+		body: "It <b>crashed</b>.",113+		type: "bug",114+		version: "1.2.0",115+	};116+	const page = async (path: string) => (await app.request(path)).text();117+118+	beforeAll(async () => {119+		setSystemTime(new Date(clock));120+		const repos = await makeRepos(["app", "secret"]);121+		save = repos.saves.app as string;122+		await writeRelease(repos.bucketDir, "app", {123+			version: "1.2.0",124+			save,125+			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],126+		});127+		const dataDir = await mkdtemp(join(tmpdir(), "kepr-issues-e2e-"));128+		kepr = openKeprDb(dataDir);129+		const bucket = { kind: "folder" as const, path: repos.bucketDir };130+		await syncAll({131+			bucket: openBucket(bucket),132+			kepr,133+			mirrorDir: join(dataDir, "mirror"),134+			tarballDir: join(dataDir, "tarballs"),135+		});136+		kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");137+		app = createApp(138+			{ domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket },139+			kepr,140+		);141+	});142+	afterAll(() => setSystemTime());143+144+	let token = "";145+146+	test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {147+		const response = await app.request("/app/issues/new");148+		expect(response.status).toBe(200);149+		expect(response.headers.get("cache-control")).toBe("no-store");150+		expect(response.headers.get("set-cookie")).toContain("SameSite=Strict");151+		const text = await response.text();152+		expect(text).toContain('name="version" maxlength="200" value="1.2.0"');153+		expect(text).toContain('name="website"');154+	});155+156+	test("refuses a too-fast, honeypot or cross-site submission", async () => {157+		const session = await openForm();158+		expect((await post("/app/issues/new", session, issue)).status).toBe(400);159+		tick(3);160+		expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400);161+		expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403);162+		const other = await openForm();163+		expect(164+			(await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status,165+		).toBe(403);166+		expect((await page("/app/issues")).includes("Crash")).toBe(false);167+	});168+169+	test("refuses a field over 20,000 characters and keeps what was typed", async () => {170+		const session = await openForm();171+		tick(5);172+		const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) });173+		expect(response.status).toBe(400);174+		expect(await response.text()).toContain("x".repeat(100));175+	});176+177+	test("files an issue and shows the follow-up link once, carrying a token", async () => {178+		const session = await openForm();179+		tick(4);180+		const response = await post("/app/issues/new", session, {181+			...issue,182+			name: "Ada",183+			email: "[email protected]",184+		});185+		expect(response.status).toBe(200);186+		expect(response.headers.get("cache-control")).toBe("no-store");187+		const text = await response.text();188+		token = /\/app\/issues\/1\?token=([0-9a-f]{64})/.exec(text)?.[1] ?? "";189+		expect(token).not.toBe("");190+		expect(text).toContain("https://kepr.example/app/issues/1?token=");191+		expect(await page("/app/issues/1")).not.toContain(token);192+	});193+194+	test("the list and thread show the issue escaped, with its name and never its email", async () => {195+		const list = await page("/app/issues");196+		expect(list).toContain("Crash on &lt;start&gt;");197+		expect(list).toContain("#1 · bug · v1.2.0");198+		const thread = await page("/app/issues/1");199+		expect(thread).toContain("It &lt;b&gt;crashed&lt;/b&gt;.");200+		expect(thread).toContain('<span class="issue-author">Ada</span>');201+		expect(thread).not.toContain("[email protected]");202+		expect(thread).not.toContain('name="token"');203+	});204+205+	test("the token opens the follow-up form, without a referrer or cache; a wrong one does not", async () => {206+		const response = await app.request(`/app/issues/1?token=${token}`);207+		expect(response.headers.get("referrer-policy")).toBe("no-referrer");208+		expect(response.headers.get("cache-control")).toBe("no-store");209+		expect(await response.text()).toContain('name="token"');210+		const wrong = await page(`/app/issues/1?token=${"0".repeat(64)}`);211+		expect(wrong).not.toContain('name="token"');212+		expect(wrong).toContain("does not match");213+	});214+215+	test("a reporter follow-up posts with the token; a wrong token is refused", async () => {216+		const session = await openForm(`/app/issues/1?token=${token}`);217+		tick(3);218+		const wrong = await post("/app/issues/1", session, { token: "f".repeat(64), body: "Sneaky." });219+		expect(wrong.status).toBe(403);220+		const response = await post("/app/issues/1", session, { token, body: "Still <broken>." });221+		expect(response.status).toBe(303);222+		expect(response.headers.get("location")).toMatch(223+			new RegExp(`^/app/issues/1\\?token=${token}#entry-\\d+$`),224+		);225+		const thread = await page("/app/issues/1");226+		expect(thread).toContain("Still &lt;broken&gt;.");227+		expect(thread).not.toContain("Sneaky.");228+		expect(thread.match(/<span class="issue-author">Ada<\/span>/g)?.length).toBe(2);229+	});230+231+	test("five filings an hour per originating address, the sixth is refused", async () => {232+		const session = await openForm();233+		tick(3);234+		const from = (ip: string) => post("/app/issues/new", session, issue, ip);235+		for (let i = 0; i < 5; i++) expect((await from("198.51.100.7")).status).toBe(200);236+		expect((await from("198.51.100.7")).status).toBe(429);237+		expect((await from("198.51.100.8")).status).toBe(200);238+		tick(3601);239+		expect((await from("198.51.100.7")).status).toBe(200);240+		// Keyed hashes only: no address, and not the plain SHA-256 Kepr 2 stored.241+		const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");242+		const stored = kepr243+			.query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")244+			.all()245+			.map((row) => row.ip_hash);246+		expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);247+	});248+249+	test("an operator reply and a close show on the thread, marked (the console is Phase 7's)", async () => {250+		const found = findIssue(kepr, "app", 1);251+		if (!found) throw new Error("issue 1 missing");252+		addEntry(kepr, found.id, "operator", "Fixed, thanks.", Math.floor(clock / 1000));253+		kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [254+			save,255+			found.id,256+		]);257+		const thread = await page("/app/issues/1");258+		expect(thread).toContain('<article class="issue-post issue-post--operator"');259+		expect(thread).toContain('<span class="issue-badge">operator</span>');260+		expect(thread).toContain("Fixed, thanks.");261+		expect(thread).toContain("◈ closed");262+		expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);263+		expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');264+		expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');265+	});266+267+	test("hidden issues and every issue route of a private repo are 404", async () => {268+		kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2");269+		expect((await app.request("/app/issues/2")).status).toBe(404);270+		expect((await app.request("/app/issues/999")).status).toBe(404);271+		kepr.run(272+			"INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)",273+		);274+		for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) {275+			expect((await app.request(path)).status).toBe(404);276+		}277+		const session = await openForm();278+		for (const path of ["/secret/issues/new", "/secret/issues/1"]) {279+			expect((await post(path, session, { ...issue, token, body: "x" })).status).toBe(404);280+		}73281 	});74282 });

view file