1440b2ee584c

block a reporter's address from filing and following up, listed and lifted in the console (v3.0.163)

time
2026-09-30 23:22 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
0c5812ee878a
7 files package.json+1 −1src/admin/issues.ts+37 −2src/issues/pages.ts+5 −0src/issues/store.ts+52 −0src/server/db.ts+9 −0tests/issues.test.ts+19 −0tests/triage.test.ts+18 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.162",3+	"version": "3.0.163",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/issues.ts+37 −2
@@ -2,6 +2,8 @@22 import { MAX_CHARS } from "../issues/spam.ts";33 import {44 	addEntry,5+	blockedReporters,6+	blockReporter,57 	type Entry,68 	ISSUE_TYPES,79 	type IssueType,@@ -10,10 +12,12 @@1012 	operatorEntries,1113 	operatorIssue,1214 	operatorIssues,15+	reporterBlocked,1316 	setClosed,1417 	setEntryHidden,1518 	setHidden,1619 	setType,20+	unblockReporter,1721 } from "../issues/store.ts";1822 import { saveByPrefix } from "../mirror/queries.ts";1923 import { openMirror } from "../mirror/store.ts";@@ -57,7 +61,20 @@5761 		const option = (value: string, selected: string) =>5862 			html`<option value="${value}"${value === selected ? html` selected` : ""}>${value}</option>`;5963 		const issues = narrowed(filter);64+		const blocked = blockedReporters(ctx.kepr);6065 		const body = html`<p class="issues-bar mono">${FILTERS.map(tab)}</p>66+${67+	blocked.length > 0 &&68+	html`<details class="issue-meta mono"><summary>blocked reporters ${String(blocked.length)}</summary>69+${blocked.map(70+	(b) => html`<form class="issue-row" method="post" action="/admin/issues">71+${formField(c)}<input type="hidden" name="action" value="unblock">72+<input type="hidden" name="block" value="${String(b.id)}">73+<span>from <a href="/admin/issues/${b.repo}/${String(b.number)}">${b.repo} #${String(b.number)}</a>74+${relativeTime(b.blocked_at)}</span>75+<button class="issues-cancel mono" type="submit">unblock</button></form>`,76+)}</details>`77+}6178 <form class="issues-filter" method="get" action="/admin/issues">6279 <input type="hidden" name="filter" value="${filter}">6380 <select class="issues-input" name="repo" aria-label="repository">@@ -92,6 +109,14 @@92109 		return c.html(adminPage(c, ctx, "issues", body));93110 	});94111 112+	app.post("/admin/issues", async (c) => {113+		const fields = await textFields(c);114+		if (fields.action !== "unblock" || !unblockReporter(ctx.kepr, Number(fields.block))) {115+			return c.notFound();116+		}117+		return c.redirect("/admin/issues", 303);118+	});119+95120 	app.get(ISSUE_PATH, (c) => {96121 		const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));97122 		return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound();@@ -140,6 +165,11 @@140165 			case "unhide":141166 				setHidden(ctx.kepr, issue.id, fields.action === "hide");142167 				break;168+			case "block":169+				if (blockReporter(ctx.kepr, issue.id, unixNow()) === null) {170+					return refuse("This issue has no address to block.");171+				}172+				break;143173 			case "type":144174 				if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");145175 				setType(ctx.kepr, issue.id, fields.type as IssueType);@@ -183,7 +213,11 @@183213 		issue.fixed_in && ` · fixed in ${issue.fixed_in}`184214 	}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>185215 <p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"}186-· contact: ${issue.contact_email || "none given"}</p>216+· contact: ${issue.contact_email || "none given"}${217+		reporterBlocked(ctx.kepr, issue.id)218+			? html` · <span class="issues-error">reporter blocked</span>`219+			: ""220+	}</p>187221 ${post(false, issue.filed_at, issue.body)}188222 ${operatorEntries(ctx.kepr, issue.id).map((entry) =>189223 	post(entry.author === "operator", entry.created_at, entry.body, entry),@@ -212,6 +246,7 @@212246 <select class="issues-input" id="type" name="type">${ISSUE_TYPES.map(typeOption)}</select>213247 <button class="issues-submit" type="submit">set type</button>214248 </form>215-${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}`;249+${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}250+${!reporterBlocked(ctx.kepr, issue.id) && button("block", "block this reporter")}`;216251 	return adminPage(c, ctx, issue.title, body);217252 }

view file

src/issues/pages.ts+5 −0
@@ -23,6 +23,7 @@2323 	ISSUE_TYPES,2424 	type Issue,2525 	type IssueType,26+	isBlocked,2627 	issueCounts,2728 	listIssues,2829 	threadEntries,@@ -34,6 +35,8 @@3435 const TOKEN = /^[0-9a-f]{64}$/;3536 const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];3637 const unixNow = () => Math.floor(Date.now() / 1000);38+// The same words whatever the reason, so a blocked address learns nothing more.39+const BLOCKED = "This address can't post here.";3740 3841 /** §23: the list, the filing form and the thread with its follow-up links. */3942 export function registerIssuePages(app: Hono, ctx: PageContext): void {@@ -76,6 +79,7 @@7679 			);7780 		}7881 		const hash = ipHash(ctx.kepr, c);82+		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);7983 		if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {8084 			return refuse("Too many issues from here this hour. Please try again later.", 429);8185 		}@@ -140,6 +144,7 @@140144 		const check = checkStamp(ctx.kepr, c, stamp, unixNow());141145 		if (check === "invalid") return refuse("This form expired. Please send it again.", 403);142146 		if (check === "too-fast") return refuse("That was quick. Please send it again.", 400);147+		if (isBlocked(ctx.kepr, ipHash(ctx.kepr, c))) return refuse(BLOCKED, 403);143148 		const text = body.trim();144149 		if (text === "") return refuse("Write something to add.", 400);145150 		if (text.length > MAX_CHARS) {

view file

src/issues/store.ts+52 −0
@@ -249,3 +249,55 @@249249 export function setType(db: Database, id: number, type: IssueType): void {250250 	db.query("UPDATE issues SET type = ? WHERE id = ?").run(type, id);251251 }252+253+export type BlockedReporter = { id: number; repo: string; number: number; blocked_at: number };254+255+/**256+ * Blocks the address an issue was filed from (§23); the issue itself is untouched. Returns the257+ * block's id. An issue with no address (imported from Kepr 2) blocks nothing and returns null.258+ */259+export function blockReporter(db: Database, issueId: number, now: number): number | null {260+	return (261+		db262+			.query<{ id: number }, [number, number]>(263+				`INSERT INTO blocked_reporters (ip_hash, repo, number, blocked_at)264+				SELECT ip_hash, repo, number, ?265+				FROM issues266+				WHERE id = ? AND ip_hash != ''267+				ON CONFLICT (ip_hash) DO UPDATE SET ip_hash = excluded.ip_hash268+				RETURNING id`,269+			)270+			.get(now, issueId)?.id ?? null271+	);272+}273+274+/** Lifts a block; false when there was none with that id. */275+export function unblockReporter(db: Database, id: number): boolean {276+	return db.query("DELETE FROM blocked_reporters WHERE id = ?").run(id).changes > 0;277+}278+279+export function isBlocked(db: Database, ipHash: string): boolean {280+	return db.query("SELECT 1 FROM blocked_reporters WHERE ip_hash = ?").get(ipHash) !== null;281+}282+283+/** Whether the address an issue came from is blocked. */284+export function reporterBlocked(db: Database, issueId: number): boolean {285+	return (286+		db287+			.query(288+				`SELECT 1289+				FROM blocked_reporters b290+				JOIN issues i ON i.ip_hash = b.ip_hash291+				WHERE i.id = ?`,292+			)293+			.get(issueId) !== null294+	);295+}296+297+export function blockedReporters(db: Database): BlockedReporter[] {298+	return db299+		.query<BlockedReporter, []>(300+			"SELECT id, repo, number, blocked_at FROM blocked_reporters ORDER BY blocked_at DESC, id DESC",301+		)302+		.all();303+}

view file

src/server/db.ts+9 −0
@@ -69,6 +69,15 @@6969 );7070 CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);7171 -- The thread after the first post: the reporter's follow-ups and the operator's replies.72+-- §23: addresses blocked from filing and following up, by the hash the rate limit keeps, with the73+-- issue they were blocked from.74+CREATE TABLE IF NOT EXISTS blocked_reporters (75+	id         INTEGER PRIMARY KEY,76+	ip_hash    TEXT NOT NULL UNIQUE,77+	repo       TEXT NOT NULL,78+	number     INTEGER NOT NULL,79+	blocked_at INTEGER NOT NULL80+);7281 CREATE TABLE IF NOT EXISTS issue_entries (7382 	id         INTEGER PRIMARY KEY,7483 	issue_id   INTEGER NOT NULL REFERENCES issues (id),

view file

tests/issues.test.ts+19 −0
@@ -8,6 +8,7 @@88 import { syncAll } from "../src/bucket/sync.ts";99 import {1010 	addEntry,11+	blockReporter,1112 	fileIssue,1213 	filingsSince,1314 	findIssue,@@ -16,6 +17,7 @@1617 	listIssues,1718 	type NewIssue,1819 	threadEntries,20+	unblockReporter,1921 } from "../src/issues/store.ts";2022 import { createApp } from "../src/server/app.ts";2123 import { openKeprDb } from "../src/server/db.ts";@@ -310,5 +312,22 @@310312 		for (const path of ["/secret/issues/new", "/secret/issues/1"]) {311313 			expect((await post(path, session, { ...issue, token, body: "x" })).status).toBe(404);312314 		}315+	});316+317+	test("a blocked address can neither file nor follow up, until it is unblocked", async () => {318+		const first = findIssue(kepr, "app", 1);319+		if (!first) throw new Error("issue 1 missing");320+		const block = blockReporter(kepr, first.id, 1) as number;321+		const filing = await openForm();322+		const thread = await openForm(`/app/issues/1?token=${token}`, filing.cookie);323+		tick(4);324+		const refused = await post("/app/issues/new", filing, issue);325+		expect(refused.status).toBe(403);326+		expect(await refused.text()).toContain("This address can&#39;t post here.");327+		const followUp = { token, body: "Blocked words." };328+		expect((await post("/app/issues/1", thread, followUp)).status).toBe(403);329+		expect((await post("/app/issues/new", filing, issue, "198.51.100.8")).status).toBe(200);330+		unblockReporter(kepr, block);331+		expect((await post("/app/issues/1", thread, followUp)).status).toBe(303);313332 	});314333 });

view file

tests/triage.test.ts+18 −0
@@ -133,4 +133,22 @@133133 		await operator.post("/admin/repos/app", { public: "1" });134134 		expect(await text("/app/issues/new")).toContain('name="title"');135135 	});136+137+	test("blocking a reporter lists the block under the tabs until it is lifted", async () => {138+		expect(await list()).not.toContain("blocked reporters");139+		const blocked = await operator.post("/admin/issues/app/2", { action: "block" });140+		expect(blocked.status).toBe(303);141+		const issue = await (await operator.get("/admin/issues/app/2")).text();142+		expect(issue).toContain("reporter blocked");143+		const page = await list();144+		expect(page).toContain("blocked reporters");145+		expect(page).toContain('href="/admin/issues/app/2"');146+		const block = /name="block" value="(\d+)"/.exec(page)?.[1] ?? "";147+		expect(block).not.toBe("");148+		expect((await operator.post("/admin/issues", { action: "unblock", block: "999" })).status).toBe(149+			404,150+		);151+		expect((await operator.post("/admin/issues", { action: "unblock", block })).status).toBe(303);152+		expect(await list()).not.toContain("blocked reporters");153+	});136154 });

view file