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

view file

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

view file

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 {

view file

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>

view file

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,

view file

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

view file

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 &lt;two&gt;");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");

view file