a1ea0dff2343
keep the follow hour spent after a stop, clamp the refusal echo, and name pictures by their bytes (v3.1.28)
- time
- 2026-10-08 19:21 UTC · 4 days ago
- machine
- Junes-MacBook-Pro.local
- agent
- deepseek-flash
- session
- finish issue follows
- parent
- 173b22bb129f
↓ kepr-a1ea0dff.tar.gzatlas at this save
files changed7 files
package.json+1 −1src/issues/files.ts+4 −1src/issues/followers.ts+23 −2src/issues/pages.ts+18 −12src/server/db.ts+8 −0tests/follow.test.ts+85 −0tests/issues.test.ts+14 −2package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.1.27",3+ "version": "3.1.28",44 "private": true,55 "type": "module",66 "scripts": {src/issues/files.ts+4 −1
@@ -45,7 +45,10 @@4545 const image = IMAGES.find(([, , magic]) => magic(head));4646 if (image) {4747 if (data.length > IMAGE_BYTES) return `${name} is over 8 MB.`;48- uploads.push({ name: name || `screenshot.${image[1]}`, type: image[0], data });48+ // The bytes decide the type, so the name follows them: a PNG called report.html would49+ // otherwise be offered for download under an extension its bytes are not (§25).50+ const stem = name.replace(/\.\w+$/, "");51+ uploads.push({ name: `${stem || "screenshot"}.${image[1]}`, type: image[0], data });4952 continue;5053 }5154 if (!/\.(txt|log)$/i.test(name)) {src/issues/followers.ts+23 −2
@@ -33,6 +33,9 @@3333 )3434 .get(issue.id, address, ipHash, now);3535 if (!row) return false;36+ // Only a request that mails something counts, and it is logged here rather than in the follower37+ // rows, so stopping the emails cannot free the hour's allowance (§23).38+ recordFollowRequest(kepr, ipHash, now - 3600, now);3639 const token = followToken(kepr, row.id, address);3740 const base = `/${repo}/issues/${issue.number}`;3841 queueEmail(kepr, {@@ -93,14 +96,32 @@9396 return changed === 1;9497 }9598 96-/** Follow requests from one hashed IP since a time; they count toward the reply limit. */99+/**100+ * Follow requests from one hashed IP since a time; they count toward the reply limit (§23). Read101+ * from the request log, not the follower rows, since stopping one's emails deletes its row.102+ */97103 export function followsSince(kepr: Database, ipHash: string, since: number): number {98104 const row = kepr99105 .query<{ n: number }, [string, number]>(100- "SELECT COUNT(*) AS n FROM issue_followers WHERE ip_hash = ? AND created_at > ?",106+ "SELECT COUNT(*) AS n FROM issue_follow_requests WHERE ip_hash = ? AND created_at > ?",101107 )102108 .get(ipHash, since);103109 return row?.n ?? 0;110+}111+112+/** Records one accepted follow request, and drops the ones too old to count. */113+export function recordFollowRequest(114+ kepr: Database,115+ ipHash: string,116+ since: number,117+ now: number,118+): void {119+ kepr120+ .query("DELETE FROM issue_follow_requests WHERE created_at <= ? AND ip_hash = ?")121+ .run(since, ipHash);122+ kepr123+ .query("INSERT INTO issue_follow_requests (ip_hash, created_at) VALUES (?, ?)")124+ .run(ipHash, now);104125 }105126 106127 export function followerCount(kepr: Database, issueId: number): number {src/issues/pages.ts+18 −12
@@ -293,10 +293,10 @@293293 { text: `${author} replied${to}:\n\n${body}${extra}`, href: `${base}#entry-${entry}` },294294 email,295295 );296- const asked =297- mail() &&298- email !== "" &&299- follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now);296+ const asked = mail() && email !== "";297+ if (asked) follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now);298+ // The same answer whether the address already followed, so a reply never says who follows299+ // what; the follow form answers identically, for the same reason (§23).300300 return c.redirect(`${base}${asked ? "?notice=check" : ""}#entry-${entry}`, 303);301301 });302302 @@ -497,6 +497,12 @@497497 });498498 }499499 500+// A refused post shows what was typed, never more than a field may hold: an issue post may be 34 MB501+// (§25), so echoing that back escaped would answer one request with several times its size.502+function kept(value: string | undefined): string {503+ return (value ?? "").slice(0, MAX_CHARS);504+}505+500506 function formPage(501507 c: Context,502508 ctx: PageContext,@@ -524,24 +530,24 @@524530 <label class="issues-hp" aria-hidden="true">leave this empty525531 <input name="website" tabindex="-1" autocomplete="off"></label>526532 <label class="issues-label" for="issue-title">title</label>527-<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">533+<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${kept(values.title)}">528534 <label class="issues-label" for="issue-body">what happened</label>529535 <textarea class="issues-textarea" id="issue-body" name="body" rows="10" required530-maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>536+maxlength="${MAX_CHARS}">${kept(values.body)}</textarea>531537 ${markdownHint}532538 ${attachField("issue-files")}533539 <label class="issues-label" for="issue-type">type</label>534540 <select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>535541 <label class="issues-label" for="issue-version">version you're on</label>536-<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">542+<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${kept(values.version)}">537543 <label class="issues-label" for="issue-name">name to show538544 <span class="muted">(optional)</span></label>539545 <input class="issues-input" id="issue-name" name="name" maxlength="${NAME_CHARS}"540-autocomplete="nickname" value="${values.name ?? ""}">546+autocomplete="nickname" value="${kept(values.name)}">541547 <label class="issues-label" for="issue-email">contact email542548 <span class="muted">(optional, only the operator sees it)</span></label>543549 <input class="issues-input" id="issue-email" name="email" type="email" maxlength="254"544-autocomplete="email" value="${values.email ?? ""}">550+autocomplete="email" value="${kept(values.email)}">545551 ${546552 mail &&547553 html`<label class="issues-label issues-check">@@ -682,20 +688,20 @@682688 ${view.refused === "reply" && error}683689 <textarea class="issues-textarea" id="reply-body" name="body" rows="7" required684690 maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"685->${view.body ?? ""}</textarea>691+>${kept(view.body)}</textarea>686692 ${markdownHint}687693 ${attachField("reply-files")}688694 <div class="issue-composer-who">689695 <label class="issues-label" for="reply-name">name to show690696 <span class="muted">(optional)</span></label>691697 <input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"692-autocomplete="nickname" value="${view.name ?? ""}">698+autocomplete="nickname" value="${kept(view.name)}">693699 ${694700 view.mail &&695701 html`<label class="issues-label" for="reply-email">email me new replies696702 <span class="muted">(optional, never shown)</span></label>697703 <input class="issues-input" id="reply-email" name="email" type="email" maxlength="254"698-autocomplete="email" value="${view.refused === "reply" ? (view.email ?? "") : ""}">`704+autocomplete="email" value="${view.refused === "reply" ? kept(view.email) : ""}">`699705 }700706 </div>701707 <p class="issues-actions"><button class="issues-submit" type="submit">post reply</button></p>src/server/db.ts+8 −0
@@ -161,6 +161,14 @@161161 UNIQUE (issue_id, email)162162 );163163 CREATE INDEX IF NOT EXISTS issue_followers_ip ON issue_followers (ip_hash, created_at);164+-- §23: follow requests as they arrive. A follower's stop link deletes their row above, so counting165+-- only those rows would hand the hour's allowance back every time someone stopped the emails.166+CREATE TABLE IF NOT EXISTS issue_follow_requests (167+ ip_hash TEXT NOT NULL,168+ created_at INTEGER NOT NULL169+);170+CREATE INDEX IF NOT EXISTS issue_follow_requests_ip171+ ON issue_follow_requests (ip_hash, created_at);164172 -- A repo renamed from the console (June, 2026-10-01): its old pages redirect to the new name.165173 CREATE TABLE IF NOT EXISTS renamed (166174 old TEXT PRIMARY KEY,tests/follow.test.ts+85 −0
@@ -179,4 +179,89 @@179179 await send(other, "/app/issues/1", { body: "One more." });180180 expect(queued("[email protected]")).toHaveLength(2);181181 });182+183+ test("a blocked address cannot follow an issue", async () => {184+ const spammer = new Browser(inst.app, "198.51.100.81");185+ await send(spammer, "/app/issues/1", { body: "Buy my thing." });186+ const entry = inst.kepr187+ .query<{ id: number }, []>("SELECT id FROM issue_entries ORDER BY id DESC LIMIT 1")188+ .get()?.id;189+ await operator.get("/admin/issues/app/1");190+ await operator.post("/admin/issues/app/1", { action: "block_entry", entry: String(entry) });191+ const refused = await send(192+ spammer,193+ "/app/issues/1/follow",194+ { email: "[email protected]" },195+ "/app/issues/1",196+ );197+ expect(refused.status).toBe(403);198+ expect(queued("[email protected]")).toHaveLength(0);199+ });200+201+ test("twenty follows an hour per address, and stopping one does not free the hour", async () => {202+ const reader = new Browser(inst.app, "198.51.100.80");203+ const addresses = Array.from({ length: 20 }, (_, n) => `many${n}@example.com`);204+ for (const address of addresses) {205+ const response = await send(206+ reader,207+ "/app/issues/1/follow",208+ { email: address },209+ "/app/issues/1",210+ );211+ expect(response.headers.get("location")).toBe("/app/issues/1?notice=check");212+ }213+ // A confirmation email carries the stop link to the address that asked, so a follower row214+ // would hand the allowance back at will; the request log keeps the hour spent.215+ const stop = link(queued(addresses[0] ?? "")[0]?.body ?? "", "follow").replace(216+ "/follow?",217+ "/unfollow?",218+ );219+ await inst.app.request(stop, { method: "POST" });220+ const over = await send(221+ reader,222+ "/app/issues/1/follow",223+ { email: "[email protected]" },224+ "/app/issues/1",225+ );226+ expect(over.status).toBe(429);227+ });228+229+ test("a reply answers the same whether or not the address already follows", async () => {230+ const reader = new Browser(inst.app, "198.51.100.83");231+ const already = new Browser(inst.app, "198.51.100.84");232+ await send(already, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");233+ await inst.app.request(link(queued("[email protected]")[0]?.body ?? "", "follow"));234+ expect(queued("[email protected]")).toHaveLength(1);235+ // An address that already follows is told the same as one that does not, so a reply never236+ // says who follows what (§23). Only the mail behind it differs.237+ const follows = await send(238+ reader,239+ "/app/issues/1",240+ { body: "Same here.", email: "[email protected]" },241+ "/app/issues/1",242+ );243+ const stranger = await send(244+ reader,245+ "/app/issues/1",246+ { body: "New here.", email: "[email protected]" },247+ "/app/issues/1",248+ );249+ expect(follows.headers.get("location")).toContain("?notice=check#entry-");250+ expect(stranger.headers.get("location")).toContain("?notice=check#entry-");251+ // The already-following address is sent no second confirmation; the answer is the same.252+ const confirmations = (to: string) =>253+ queued(to).filter((row) => row.subject.startsWith("Confirm"));254+ expect(confirmations("[email protected]")).toHaveLength(1);255+ expect(confirmations("[email protected]")).toHaveLength(1);256+ });257+258+ test("a refused post does not echo more than a field may hold", async () => {259+ const long = `${"x".repeat(20_100)}TAIL-MARK`;260+ const reader = new Browser(inst.app, "198.51.100.82");261+ const refused = await send(reader, "/app/issues/1", { body: long });262+ expect(refused.status).toBe(400);263+ const page = await refused.text();264+ expect(page).toContain("x".repeat(20_000));265+ expect(page).not.toContain("TAIL-MARK");266+ });182267 });tests/issues.test.ts+14 −2
@@ -287,11 +287,23 @@287287 expect((await postFiles("/app/issues/1", session, "x", [disguised])).status).toBe(400);288288 const five = Array.from({ length: 5 }, (_, i) => new File([png], `${i}.png`));289289 expect((await postFiles("/app/issues/1", session, "x", five)).status).toBe(400);290- const files = [new File([png], "shot.png"), new File(["one\nline <two>\n"], "crash.log")];290+ const files = [291+ new File([png], "shot.png"),292+ new File([png], "report.html"),293+ new File(["one\nline <two>\n"], "crash.log"),294+ ];291295 expect((await postFiles("/app/issues/1", session, "See attached.", files)).status).toBe(303);292296 const thread = await page("/app/issues/1");293297 expect(thread).toContain("line <two>");294- const shot = /src="(\/app\/issues\/1\/files\/\d+\.png)"/.exec(thread)?.[1] ?? "";298+ const shots = [...thread.matchAll(/src="(\/app\/issues\/1\/files\/\d+\.png)"/g)].map(299+ (m) => m[1] ?? "",300+ );301+ expect(shots).toHaveLength(2);302+ const shot = shots[0] ?? "";303+ // The bytes decide the type, so a picture named report.html is offered as report.png.304+ expect((await app.request(shots[1] ?? "")).headers.get("content-disposition")).toContain(305+ "report.png",306+ );295307 const log = /href="(\/app\/issues\/1\/files\/\d+\.txt)"/.exec(thread)?.[1] ?? "";296308 const image = await app.request(shot);297309 expect(image.headers.get("content-type")).toBe("image/png");