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
↓ kepr-1440b2ee.tar.gzatlas at this save
files changed7 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 −0package.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": {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 }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) {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+}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),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'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 });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 });