fa3e2190d483

replace private follow-up links with public Markdown replies, reply and quote (v3.1.24)

time
2026-10-08 18:42 UTC · 4 days ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
community issue threads
parent
c337f277612e
17 files package.json+1 −1public/css/kepr.css+159 −5public/js/kepr.js+50 −2scripts/budget.ts+5 −5scripts/import-kepr2.ts+6 −1src/admin/issues.ts+2 −2src/admin/notifications.ts+1 −1src/issues/pages.ts+140 −94src/issues/spam.ts+1 −0src/issues/store.ts+73 −40src/pages/markdown.ts+14 −0src/server/db.ts+22 −6tests/console.test.ts+1 −1tests/import.test.ts+5 −7tests/issues.test.ts+110 −66tests/notifications.test.ts+7 −11tests/triage.test.ts+10 −7
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.1.23",3+	"version": "3.1.24",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+159 −5
@@ -1675,11 +1675,6 @@16751675 	white-space: pre-wrap;16761676 	overflow-wrap: anywhere;16771677 }1678-.issue-link {1679-	font-size: 0.75rem;1680-	overflow-wrap: anywhere;1681-	color: var(--accent);1682-}16831678 .issues-form {16841679 	display: flex;16851680 	flex-direction: column;@@ -1768,6 +1763,165 @@17681763 	height: 0;17691764 	overflow: hidden;17701765 	opacity: 0;1766+}1767+/* The search takes four parts of the row and the type one (June, 2026-10-08). */1768+.issues-filter .issues-search {1769+	flex: 4 1 0;1770+}1771+.issues-filter .issues-type {1772+	flex: 1 1 0;1773+	min-width: 7.5rem;1774+}1775+.issues-filter .issues-submit {1776+	margin-top: 0;1777+}1778+@media (max-width: 520px) {1779+	.issues-filter .issues-search {1780+		flex-basis: 100%;1781+	}1782+}1783+/* The thread hangs from a dashed rail, as the timeline does; each post is a dot on it. */1784+.issue-thread {1785+	position: relative;1786+	margin-top: 22px;1787+	padding-left: 22px;1788+}1789+.issue-thread::before {1790+	content: "";1791+	position: absolute;1792+	left: 5px;1793+	top: 24px;1794+	bottom: 24px;1795+	border-left: 1px dashed var(--border);1796+}1797+.issue-thread .issue-post {1798+	position: relative;1799+	scroll-margin-top: 24px;1800+}1801+.issue-thread .issue-post::before {1802+	content: "";1803+	position: absolute;1804+	left: -21px;1805+	top: 18px;1806+	width: 7px;1807+	height: 7px;1808+	border-radius: 50%;1809+	background: var(--bg);1810+	border: 1px solid var(--text-faint);1811+}1812+.issue-thread .issue-post--operator::before {1813+	background: var(--accent);1814+	border-color: var(--accent);1815+}1816+.issue-post:target {1817+	border-color: var(--accent-dim);1818+	box-shadow: 0 0 0 3px var(--accent-tint-12);1819+}1820+.issue-post-head .history-when {1821+	margin-left: auto;1822+	color: var(--text-faint);1823+}1824+.issue-replying {1825+	color: var(--text-faint);1826+}1827+.issue-replying a {1828+	color: var(--text-secondary);1829+}1830+.markdown.issue-text {1831+	white-space: normal;1832+	font-size: 0.95rem;1833+	line-height: 1.65;1834+}1835+.issue-text > :first-child {1836+	margin-top: 0;1837+}1838+.issue-text > :last-child {1839+	margin-bottom: 0;1840+}1841+.issue-text h1,1842+.issue-text h2 {1843+	font-size: 1.15rem;1844+}1845+.issue-text h3,1846+.issue-text h4 {1847+	font-size: 1rem;1848+}1849+/* Long logs scroll inside their block instead of stretching the thread. */1850+.issue-text pre {1851+	max-height: 26rem;1852+	overflow: auto;1853+	font-size: 0.8rem;1854+	background: var(--bg);1855+	border: 1px solid var(--border);1856+}1857+.issue-text blockquote {1858+	color: var(--text-muted);1859+	border-left-color: var(--accent-dim);1860+}1861+.issue-post-actions {1862+	display: flex;1863+	gap: 16px;1864+	margin: 12px 0 0;1865+	font-size: 0.6875rem;1866+}1867+.issue-post-actions a {1868+	color: var(--text-faint);1869+}1870+.issue-post:hover .issue-post-actions a {1871+	color: var(--text-muted);1872+}1873+.issue-post-actions a:hover,1874+.issue-post-actions a:focus-visible {1875+	color: var(--accent);1876+	text-decoration: none;1877+}1878+.issue-composer {1879+	display: flex;1880+	flex-direction: column;1881+	gap: 6px;1882+	margin: 26px 0 0 22px;1883+	padding: 16px 18px;1884+	border: 1px solid var(--border);1885+	border-radius: 6px;1886+	background: var(--bg-surface);1887+	scroll-margin-top: 24px;1888+}1889+.issue-composer:focus-within {1890+	border-color: var(--accent-dim);1891+}1892+.issue-composer-head {1893+	display: flex;1894+	flex-wrap: wrap;1895+	justify-content: space-between;1896+	gap: 4px 12px;1897+	margin: 0 0 4px;1898+	font-size: 0.6875rem;1899+	color: var(--text-muted);1900+}1901+.issue-composer-head label {1902+	color: var(--text-secondary);1903+}1904+.issue-composer-name {1905+	color: var(--accent);1906+}1907+.issue-composer-to .issues-cancel {1908+	margin-left: 6px;1909+	color: var(--text-faint);1910+}1911+.issue-composer .issues-textarea {1912+	min-height: 9rem;1913+	font-size: 0.8125rem;1914+	line-height: 1.55;1915+}1916+.issue-hint {1917+	margin: 0;1918+	font-size: 0.625rem;1919+	color: var(--text-faint);1920+}1921+.issue-closed-note {1922+	margin: 26px 0 0 22px;1923+	font-size: 0.75rem;1924+	color: var(--text-faint);17711925 }17721926 17731927 /* ----- front page --------------------------------------------------------- */

view file

public/js/kepr.js+50 −2
@@ -1,6 +1,6 @@11 // Kepr's browser islands (§18). Every page is complete without this file; it only adds the2-// ticker's pace, the drifting timeline, copy buttons, the inline panels, history's scrolling,3-// line-range highlighting, the Atlas panel, picker, filter and file viewer, the signed-in2+// ticker's pace, the drifting timeline, copy buttons, the inline panels, issue replies, history's3+// scrolling, line-range highlighting, the Atlas panel, picker, filter and file viewer, the signed-in44 // editor, the phone nav menu, and the profile's visibility pause.55 66 // Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength.@@ -319,6 +319,53 @@319319 	});320320 }321321 322+// Issue threads (§23): reply and quote fill the composer in place, and quote with text selected in323+// a post quotes just that. With nothing selected, quote follows its link, which quotes the post's324+// own Markdown. Listening on the document, before the panel's own handler, reaches threads opened325+// in a repo's panel too.326+function issueReplies() {327+	let picked = "";328+	// A click collapses the selection, so it is read as the pointer goes down.329+	document.addEventListener(330+		"pointerdown",331+		(event) => {332+			const post = event.target.closest?.(".issue-post-actions")?.closest(".issue-post");333+			const selection = getSelection();334+			picked = post?.contains(selection.anchorNode) ? selection.toString().trim() : "";335+		},336+		true,337+	);338+	document.addEventListener(339+		"click",340+		(event) => {341+			const link = event.target.closest(".issue-post-actions a, .issue-composer-to .issues-cancel");342+			const form = document.getElementById("reply");343+			if (!link || !form || modified(event)) return;344+			if (link.dataset.action === "quote" && !picked) return;345+			event.preventDefault();346+			event.stopPropagation();347+			const post = link.closest(".issue-post");348+			const to = form.querySelector(".issue-composer-to");349+			form.elements.reply_to.value = post?.dataset.entry ?? "";350+			to.hidden = !post?.dataset.entry;351+			if (post) {352+				const name = to.querySelector(".issue-composer-name");353+				name.textContent = post.dataset.author;354+				name.href = `#${post.id}`;355+			}356+			const body = form.elements.body;357+			if (link.dataset.action === "quote") {358+				const quote = `${picked.replace(/^/gm, "> ")}\n\n`;359+				body.value = body.value.trim() ? `${body.value.trimEnd()}\n\n${quote}` : quote;360+				picked = "";361+			}362+			body.focus();363+			body.scrollIntoView({ block: "center" });364+		},365+		true,366+	);367+}368+322369 // History and the Atlas's save picker hold one page of saves; scrolling to the end of either323370 // brings in the next page's rows. The pager is for pages without JavaScript.324371 function saveLists() {@@ -988,6 +1035,7 @@9881035 	.finally(() => document.documentElement.classList.remove("timeline-pending"));9891036 copyButtons(document);9901037 panels();1038+issueReplies();9911039 saveLists();9921040 atlasPicker();9931041 repoEditor();

view file

scripts/budget.ts+5 −5
@@ -1,6 +1,6 @@11 // §26: fails when any area passes its budget or the TypeScript total passes the2-// 12,900-line ceiling.3-const CEILING = 12_900;2+// 13,400-line ceiling.3+const CEILING = 13_400;44 55 // Prefixes are matched in order, so the specific files come before the directories.66 const TS_AREAS: [string, string[], number][] = [@@ -10,14 +10,14 @@1010 	["pages", ["src/pages/"], 2600],1111 	["releases", ["src/releases/"], 900],1212 	["tarball", ["src/tarball/"], 300],13-	["issues", ["src/issues/"], 1200],13+	["issues", ["src/issues/"], 1700],1414 	["admin", ["src/admin/"], 2800],1515 	["import-kepr2", ["scripts/import-kepr2.ts"], 300],1616 	["server", ["src/server/", "src/main.ts", "scripts/budget.ts"], 2000],1717 ];1818 const ASSET_AREAS: [string, string, number][] = [19-	["public/js", "public/js/**/*.js", 1200],20-	["public/css", "public/css/**/*.css", 3400],19+	["public/js", "public/js/**/*.js", 1400],20+	["public/css", "public/css/**/*.css", 3700],2121 ];2222 2323 async function lineCount(path: string): Promise<number> {

view file

scripts/import-kepr2.ts+6 −1
@@ -112,7 +112,12 @@112112 	ORDER BY i.repo, i.number`) {113113 	const id = insert("issues", i, "ON CONFLICT DO NOTHING");114114 	if (replies.some((r) => !r.author)) errors.push(`${i.repo}#${i.number}: third-party reply`);115-	else for (const r of id ? replies : []) insert("issue_entries", { issue_id: id, ...r }, "");115+	else {116+		for (const r of id ? replies : []) {117+			const name = r.author === "reporter" ? i.reporter_name : "";118+			insert("issue_entries", { issue_id: id, ...r, name }, "");119+		}120+	}116121 }117122 // Kepr 2 counted Nix fetches as installs (kept 90 days).118123 // Its archive downloads have no home in Kepr 3.

view file

src/admin/issues.ts+2 −2
@@ -171,7 +171,7 @@171171 				if (body === "" || body.length > MAX_CHARS) {172172 					return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);173173 				}174-				addEntry(ctx.kepr, issue.id, "operator", body, unixNow());174+				addEntry(ctx.kepr, issue.id, { author: "operator", body, at: unixNow() });175175 				break;176176 			}177177 			case "close": {@@ -230,7 +230,7 @@230230 <p class="issue-post-head mono">${231231 			operator232232 				? html`<span class="issue-badge">operator</span>`233-				: html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`233+				: html`<span class="issue-author">${entry ? entry.name || "anonymous" : issue.reporter_name || "anonymous"}</span>`234234 		}<span class="history-when">${formatDate(at)}</span>${235235 			entry?.hidden ? html`<span class="issues-error">hidden</span>` : ""236236 		}</p>

view file

src/admin/notifications.ts+1 −1
@@ -9,7 +9,7 @@99 1010 export const LABELS: Record<NotificationKind, string> = {1111 	issue_filed: "an issue is filed",12-	issue_followup: "a reporter adds a follow-up",12+	issue_followup: "someone replies to an issue",1313 	sync_failed: "a repo's sync starts failing",1414 	backup_failed: "a backup starts failing",1515 };

view file

src/issues/pages.ts+140 −94
@@ -11,34 +11,45 @@1111 	page,1212 	relativeTime,1313 } from "../pages/layout.ts";14+import { renderComment } from "../pages/markdown.ts";1415 import { visibleReleases } from "../releases/releases.ts";1516 import { notify } from "../server/db.ts";16-import { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts";1717 import {18+	checkStamp,19+	FILINGS_PER_HOUR,20+	formStamp,21+	ipHash,22+	MAX_CHARS,23+	REPLIES_PER_HOUR,24+} from "./spam.ts";25+import {1826 	addEntry,27+	type Entry,1928 	fileIssue,2029 	filingsSince,2130 	findIssue,22-	hashToken,2331 	ISSUE_TYPES,2432 	type Issue,2533 	type IssueType,2634 	isBlocked,2735 	issueCounts,2836 	listIssues,37+	repliesSince,2938 	threadEntries,3039 } from "./store.ts";3140 3241 type Fields = Record<string, string>;33-type Notice = { error?: string; draft?: string };42+// What a refused reply hands back to the composer, so nothing typed is lost.43+type Draft = { error?: string; body?: string; name?: string; replyTo?: number | null };3444 35-const TOKEN = /^[0-9a-f]{64}$/;3645 const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];46+const REPLY_FIELDS = ["body", "name", "reply_to", "website", "stamp"];47+const NAME_CHARS = 200;3748 const unixNow = () => Math.floor(Date.now() / 1000);3849 // The same words whatever the reason, so a blocked address learns nothing more.3950 const BLOCKED = "This address can't post here.";4051 41-/** §23: the list, the filing form and the thread with its follow-up links. */52+/** §23: the list, the filing form, and the thread anyone can reply to. */4253 export function registerIssuePages(app: Hono, ctx: PageContext): void {4354 	app.get("/:repo/issues", (c) => {4455 		const repo = openPublicRepo(ctx, c.req.param("repo"));@@ -84,7 +95,7 @@8495 			return refuse("Too many issues from here this hour. Please try again later.", 429);8596 		}8697 		const name = repo.settings.name;87-		const filed = fileIssue(ctx.kepr, {98+		const number = fileIssue(ctx.kepr, {8899 			repo: name,89100 			title,90101 			body,@@ -98,69 +109,76 @@98109 		notify(99110 			ctx.kepr,100111 			"issue_filed",101-			`New issue ${name} #${filed.number}: ${title}`,102-			`/admin/issues/${name}/${filed.number}`,112+			`New issue ${name} #${number}: ${title}`,113+			`/admin/issues/${name}/${number}`,103114 		);104-		const link = `https://${ctx.domain}/${name}/issues/${filed.number}?token=${filed.token}`;105-		privatePage(c);106-		return c.html(107-			page(ctx, {108-				title: `issue #${filed.number} filed · ${name}`,109-				description: "",110-				path: c.req.path,111-				body: html`${crumbs(name)}112-<h1 class="file-page-title">Filed as113-<a href="/${name}/issues/${filed.number}">#${filed.number}</a></h1>114-<p>This is your private link for adding to the issue later. It is shown only this once,115-so keep it; anyone who has it can post as you.</p>116-<p class="issue-link mono"><a href="${link}">${link}</a></p>`,117-			}),118-		);115+		return c.redirect(`/${name}/issues/${number}`, 303);119116 	});120117 121118 	app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {122119 		const repo = openPublicRepo(ctx, c.req.param("repo"));123120 		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));124121 		if (!repo || !issue) return notFound(c, ctx);125-		const token = c.req.query("token");126-		if (token === undefined) {127-			cacheFor(c, false);128-			return c.html(threadPage(c, ctx, repo, issue, null, {}));129-		}122+		const entries = threadEntries(ctx.kepr, issue.id);123+		// `?reply=<id>` answers a reply; `?quote=<id>` also quotes it, and 0 is the issue itself.124+		const quoting = c.req.query("quote");125+		const asked = Number(quoting ?? c.req.query("reply"));126+		const target = entries.find((entry) => entry.id === asked);127+		const quoted = quoting === undefined ? null : asked === 0 ? issue.body : target?.body;130128 		privatePage(c);131-		return c.html(threadPage(c, ctx, repo, issue, token, {}));129+		return c.html(130+			threadPage(c, ctx, repo, issue, entries, {131+				replyTo: target?.id ?? null,132+				body: quoted ? `${quoted.replace(/^/gm, "> ")}\n\n` : "",133+			}),134+		);132135 	});133136 134137 	app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {135138 		const repo = openPublicRepo(ctx, c.req.param("repo"));136139 		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));137140 		if (!repo || !issue) return notFound(c, ctx);138-		const { token = "", body = "", stamp = "" } = await readFields(c, ["token", "body", "stamp"]);141+		const fields = await readFields(c, REPLY_FIELDS);142+		const entries = threadEntries(ctx.kepr, issue.id);143+		const replyTo = entries.find((entry) => String(entry.id) === fields.reply_to)?.id ?? null;144+		const body = fields.body?.trim() ?? "";145+		const name = fields.name?.trim() ?? "";146+		const now = unixNow();139147 		privatePage(c);140-		const refuse = (error: string, status: 400 | 403) =>141-			c.html(threadPage(c, ctx, repo, issue, token, { error, draft: body }), status);142-		if (!ownsIssue(issue, token))143-			return refuse("This follow-up link does not match the issue.", 403);144-		const check = checkStamp(ctx.kepr, c, stamp, unixNow());145-		if (check === "invalid") return refuse("This form expired. Please send it again.", 403);146-		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);148-		const text = body.trim();149-		if (text === "") return refuse("Write something to add.", 400);150-		if (text.length > MAX_CHARS) {148+		const refuse = (error: string, status: 400 | 403 | 429) =>149+			c.html(150+				threadPage(c, ctx, repo, issue, entries, { error, body: fields.body, name, replyTo }),151+				status,152+			);153+		if (fields.website !== "") return refuse("This reply could not be posted.", 400);154+		if (issue.status === "closed") return refuse("This issue is closed to new replies.", 403);155+		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);156+		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);157+		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);158+		if (body === "") return refuse("Write something to reply with.", 400);159+		if (body.length > MAX_CHARS || name.length > NAME_CHARS) {151160 			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);152161 		}153-		const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow());162+		const hash = ipHash(ctx.kepr, c);163+		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);164+		if (repliesSince(ctx.kepr, hash, now - 3600) >= REPLIES_PER_HOUR) {165+			return refuse("Too many replies from here this hour. Please try again later.", 429);166+		}167+		const entry = addEntry(ctx.kepr, issue.id, {168+			author: "reporter",169+			name,170+			body,171+			at: now,172+			ipHash: hash,173+			replyTo,174+		});154175 		notify(155176 			ctx.kepr,156177 			"issue_followup",157-			`Follow-up on ${repo.settings.name} #${issue.number}: ${issue.title}`,178+			`Reply on ${repo.settings.name} #${issue.number}: ${issue.title}`,158179 			`/admin/issues/${repo.settings.name}/${issue.number}`,159180 		);160-		return c.redirect(161-			`/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`,162-			303,163-		);181+		return c.redirect(`/${repo.settings.name}/issues/${issue.number}#entry-${entry}`, 303);164182 	});165183 }166184 @@ -174,11 +192,7 @@174192 	return fields;175193 }176194 177-function ownsIssue(issue: Issue, token: string): boolean {178-	return TOKEN.test(token) && issue.token_hash === hashToken(token);179-}180-181-// A page carrying a token or a form stamp is never cached, and its URL never leaks as a referrer.195+// A page carrying a form stamp is never cached, and its URL never leaks as a referrer.182196 function privatePage(c: Context): void {183197 	c.header("Cache-Control", "no-store");184198 	c.header("Referrer-Policy", "no-referrer");@@ -219,9 +233,9 @@219233 ${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>220234 <form class="issues-filter" method="get" action="/${name}/issues">221235 <input type="hidden" name="status" value="${status}">222-<input class="issues-input" name="q" type="search" aria-label="search issue titles"236+<input class="issues-input issues-search" name="q" type="search" aria-label="search issue titles"223237 placeholder="search issues" value="${query}">224-<select class="issues-input" name="type" aria-label="issue type">238+<select class="issues-input issues-type" name="type" aria-label="issue type">225239 <option value="">all types</option>226240 ${ISSUE_TYPES.map(227241 	(value) =>@@ -237,7 +251,7 @@237251 <span class="issue-symbol issue-symbol--${issue.status}">238252 ${issue.status === "open" ? "◉" : "◈"}</span>239253 <span class="issue-title">${issue.title}</span>240-${/* this line stays whole: splitting it changes the text tests/issues.test.ts asserts */ ""}<span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}</span>254+${/* this line stays whole: splitting it changes the text tests/issues.test.ts asserts */ ""}<span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}${issue.replies > 0 && ` · ${issue.replies} ${issue.replies === 1 ? "reply" : "replies"}`}</span>241255 <span class="issue-reporter mono">${issue.reporter_name || "anonymous"}</span>242256 </a>`,243257 			)@@ -274,15 +288,17 @@274288 <label class="issues-label" for="issue-title">title</label>275289 <input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">276290 <label class="issues-label" for="issue-body">what happened</label>277-<textarea class="issues-textarea" id="issue-body" name="body" rows="8" required291+<textarea class="issues-textarea" id="issue-body" name="body" rows="10" required278292 maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>293+${markdownHint}279294 <label class="issues-label" for="issue-type">type</label>280295 <select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>281296 <label class="issues-label" for="issue-version">version you're on</label>282297 <input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">283298 <label class="issues-label" for="issue-name">name to show284299 <span class="muted">(optional)</span></label>285-<input class="issues-input" id="issue-name" name="name" maxlength="200" value="${values.name ?? ""}">300+<input class="issues-input" id="issue-name" name="name" maxlength="${NAME_CHARS}"301+autocomplete="nickname" value="${values.name ?? ""}">286302 <label class="issues-label" for="issue-email">contact email287303 <span class="muted">(optional, only the operator sees it)</span></label>288304 <input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">@@ -300,63 +316,93 @@300316 	);301317 }302318 319+const markdownHint = html`<p class="issue-hint mono">Markdown works: **bold**, \`code\`,320+\`\`\` around logs, &gt; to quote.</p>`;321+303322 function threadPage(304323 	c: Context,305324 	ctx: PageContext,306325 	repo: PublicRepo,307326 	issue: Issue,308-	token: string | null,309-	notice: Notice,327+	entries: Entry[],328+	draft: Draft,310329 ): string {311330 	const name = repo.settings.name;312-	const reporter = issue.reporter_name || "anonymous";313-	const post = (id: string, operator: boolean, at: number, text: string) =>314-		html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}">331+	const base = `/${name}/issues/${issue.number}`;332+	const open = issue.status === "open";333+	const who = (entry: Entry) =>334+		entry.author === "operator" ? "operator" : entry.name || "anonymous";335+	const byId = new Map(entries.map((entry) => [entry.id, entry]));336+	const answering = draft.replyTo ? byId.get(draft.replyTo) : undefined;337+	// The issue itself is post 0: quoting it quotes what was filed.338+	const post = (key: number, author: string, operator: boolean, at: number, body: string) => {339+		const id = key === 0 ? "issue" : `entry-${key}`;340+		const answers = byId.get(byId.get(key)?.reply_to ?? -1);341+		return html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}"342+data-entry="${key === 0 ? "" : String(key)}" data-author="${author}">315343 <p class="issue-post-head mono">${316344 			operator317345 				? html`<span class="issue-badge">operator</span>`318-				: html`<span class="issue-author">${reporter}</span>`319-		}<span class="history-when">${formatDate(at)}</span></p>320-<div class="issue-text">${text}</div>346+				: html`<span class="issue-author">${author}</span>`347+		}${348+			answers &&349+			html`<span class="issue-replying">replying to350+<a href="#entry-${answers.id}">${who(answers)}</a></span>`351+		}<a class="history-when" href="#${id}" title="${formatDate(at)}">${relativeTime(at)}</a></p>352+<div class="markdown issue-text">${renderComment(body)}</div>353+${354+	open &&355+	html`<p class="issue-post-actions mono">356+<a href="${base}?reply=${key}#reply" data-action="reply">reply</a>357+<a href="${base}?quote=${key}#reply" data-action="quote">quote</a></p>`358+}321359 </article>`;322-	const owner = token !== null && ownsIssue(issue, token);360+	};361+	const reporter = issue.reporter_name || "anonymous";323362 	return page(ctx, {324363 		title: `#${issue.number} ${issue.title} · ${name}`,325364 		description: issue.title,326-		path: `/${name}/issues/${issue.number}`,327-		body: html`${crumbs(name, issue.status === "closed")}365+		path: base,366+		body: html`${crumbs(name, !open)}328367 <h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>329368 <p class="issue-meta mono">330-<span class="issue-symbol issue-symbol--${issue.status}">331-${issue.status === "open" ? "◉ open" : "◈ closed"}</span>332-${issue.type}${issue.version && ` · v${issue.version}`} · filed ${relativeTime(issue.filed_at)}333-${issue.status === "closed" && fixedIn(repo, issue)}</p>334-${post("issue", false, issue.filed_at, issue.body)}335-${threadEntries(ctx.kepr, issue.id).map((entry) =>336-	post(`entry-${entry.id}`, entry.author === "operator", entry.created_at, entry.body),369+<span class="issue-symbol issue-symbol--${issue.status}">${open ? "◉ open" : "◈ closed"}</span>370+${issue.type}${issue.version && ` · v${issue.version}`}371+· filed by ${reporter} ${relativeTime(issue.filed_at)}372+${entries.length > 0 && ` · ${entries.length} ${entries.length === 1 ? "reply" : "replies"}`}373+${!open && fixedIn(repo, issue)}</p>374+<div class="issue-thread">375+${post(0, reporter, false, issue.filed_at, issue.body)}376+${entries.map((entry) =>377+	post(entry.id, who(entry), entry.author === "operator", entry.created_at, entry.body),337378 )}379+</div>338380 ${339-	token === null &&340-	html`<p class="muted">To add a reply, use the private follow-up link you received when filing this issue.</p>`341-}342-${notice.error && html`<p class="issues-error mono" role="alert">${notice.error}</p>`}343-${344-	token !== null &&345-	!owner &&346-	!notice.error &&347-	html`<p class="issues-error mono">This follow-up link does not match the issue.</p>`348-}349-${350-	owner &&351-	html`<form class="issues-form" method="post" action="/${name}/issues/${issue.number}">352-<p class="muted">This is your private follow-up link; anyone who has it can post as you.</p>353-<input type="hidden" name="token" value="${token}">381+	open382+		? html`<form class="issue-composer" id="reply" method="post" action="${base}">354383 <input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">355-<label class="issues-label" for="follow-up">add to this issue</label>356-<textarea class="issues-textarea" id="follow-up" name="body" rows="5" required357-maxlength="${MAX_CHARS}">${notice.draft ?? ""}</textarea>358-<button class="issues-submit" type="submit">post</button>384+<label class="issues-hp" aria-hidden="true">leave this empty385+<input name="website" tabindex="-1" autocomplete="off"></label>386+<input type="hidden" name="reply_to" value="${answering ? String(answering.id) : ""}">387+<p class="issue-composer-head mono"><label for="reply-body">add a reply</label>388+<span class="issue-composer-to"${answering ? "" : html` hidden`}>replying to389+<a class="issue-composer-name"390+href="#entry-${answering ? String(answering.id) : ""}">${answering ? who(answering) : ""}</a>391+<a class="issues-cancel" href="${base}#reply"392+aria-label="reply to the issue instead">×</a></span></p>393+${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}394+<textarea class="issues-textarea" id="reply-body" name="body" rows="7" required395+maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"396+>${draft.body ?? ""}</textarea>397+${markdownHint}398+<label class="issues-label" for="reply-name">name to show399+<span class="muted">(optional)</span></label>400+<input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"401+autocomplete="nickname" value="${draft.name ?? ""}">402+<p class="issues-actions"><button class="issues-submit" type="submit">post reply</button></p>359403 </form>`404+		: html`${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}405+<p class="issue-closed-note mono">This issue is closed, so it takes no new replies.</p>`360406 }`,361407 	});362408 }

view file

src/issues/spam.ts+1 −0
@@ -9,6 +9,7 @@99 export const MIN_SECONDS_ON_PAGE = 3;1010 const STAMP_MAX_AGE = 86400;1111 export const FILINGS_PER_HOUR = 5;12+export const REPLIES_PER_HOUR = 20;1213 export const MAX_CHARS = 20_000;1314 const FORM_COOKIE = "kepr_form";1415 const HEX = /^[0-9a-f]{32,64}$/;

view file

src/issues/store.ts+73 −40
@@ -1,5 +1,4 @@11 import type { Database } from "bun:sqlite";2-import { createHash, randomBytes } from "node:crypto";32 43 export const ISSUE_TYPES = ["bug", "idea", "question"] as const;54 export type IssueType = (typeof ISSUE_TYPES)[number];@@ -15,17 +14,31 @@1514 	filed_at: number;1615 	status: "open" | "closed";1716 	fixed_in: string;18-	token_hash: string | null;1917 };2018 19+/** A row of the public list: the issue and its visible replies. */20+export type ListedIssue = Issue & { replies: number };21+22+/** One reply. "reporter" is anyone from the public, under the name they chose ('' is anonymous). */2123 export type Entry = {2224 	id: number;2325 	author: "reporter" | "operator";26+	name: string;2427 	body: string;2528 	created_at: number;2629 	hidden: number;30+	reply_to: number | null;2731 };2832 33+export type NewEntry = {34+	author: Entry["author"];35+	body: string;36+	at: number;37+	name?: string;38+	ipHash?: string;39+	replyTo?: number | null;40+};41+2942 export type NewIssue = {3043 	repo: string;3144 	title: string;@@ -40,17 +53,12 @@4053 4154 // Everything the public may see; the contact email and IP hash stay out of every public read.4255 const PUBLIC_COLUMNS =43-	"id, number, title, body, type, version, reporter_name, filed_at, status, fixed_in, token_hash";56+	"id, number, title, body, type, version, reporter_name, filed_at, status, fixed_in";4457 45-export function hashToken(token: string): string {46-	return createHash("sha256").update(token).digest("hex");47-}48-49-/** Files an issue under the repo's next number and returns it with its follow-up token (§23). */50-export function fileIssue(db: Database, issue: NewIssue): { number: number; token: string } {51-	const token = randomBytes(32).toString("hex");58+/** Files an issue under the repo's next number and returns that number (§23). */59+export function fileIssue(db: Database, issue: NewIssue): number {5260 	// Immediate: it reads the next number before writing, and the sync's thread writes kepr.db too.53-	const number = db61+	return db5462 		.transaction(() => {5563 			const row = db5664 				.query<{ next: number }, [string]>(@@ -61,9 +69,8 @@6169 				.get(issue.repo) as { next: number };6270 			db.query(6371 				`INSERT INTO issues (64-repo, number, title, body, type, version, reporter_name, contact_email,65-filed_at, ip_hash, token_hash66-) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,72+repo, number, title, body, type, version, reporter_name, contact_email, filed_at, ip_hash73+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,6774 			).run(6875 				issue.repo,6976 				row.next,@@ -75,12 +82,10 @@7582 				issue.contactEmail,7683 				issue.filedAt,7784 				issue.ipHash,78-				hashToken(token),7985 			);8086 			return row.next;8187 		})8288 		.immediate();83-	return { number, token };8489 }8590 8691 /** Issues filed from one hashed IP since a time, hidden ones included: spam stays counted. */@@ -93,11 +98,23 @@9398 	return row?.n ?? 0;9499 }95100 96-/** A repo's visible issues with one status, newest first. */97-export function listIssues(db: Database, repo: string, status: "open" | "closed"): Issue[] {101+/** Replies posted from one hashed IP since a time, hidden ones included. */102+export function repliesSince(db: Database, ipHash: string, since: number): number {103+	const row = db104+		.query<{ n: number }, [string, number]>(105+			"SELECT COUNT(*) AS n FROM issue_entries WHERE ip_hash = ? AND created_at > ?",106+		)107+		.get(ipHash, since);108+	return row?.n ?? 0;109+}110+111+/** A repo's visible issues with one status, newest first, each counting its visible replies. */112+export function listIssues(db: Database, repo: string, status: "open" | "closed"): ListedIssue[] {98113 	return db99-		.query<Issue, [string, string]>(100-			`SELECT ${PUBLIC_COLUMNS}114+		.query<ListedIssue, [string, string]>(115+			`SELECT ${PUBLIC_COLUMNS}, (116+	SELECT COUNT(*) FROM issue_entries e WHERE e.issue_id = issues.id AND e.hidden = 0117+) AS replies101118 FROM issues102119 WHERE repo = ? AND status = ? AND hidden = 0103120 ORDER BY number DESC`,@@ -129,16 +146,16 @@129146 		.get(repo, number);130147 }131148 132-/** An issue's visible follow-ups and replies, oldest first. */149+/** An issue's visible replies, oldest first. */133150 export function threadEntries(db: Database, issueId: number): Entry[] {134151 	return operatorEntries(db, issueId).filter((entry) => !entry.hidden);135152 }136153 137-/** Every entry in an issue's thread, hidden ones included, for the console. */154+/** Every reply in an issue's thread, hidden ones included, for the console. */138155 export function operatorEntries(db: Database, issueId: number): Entry[] {139156 	return db140157 		.query<Entry, [number]>(141-			`SELECT id, author, body, created_at, hidden158+			`SELECT id, author, name, body, created_at, hidden, reply_to142159 FROM issue_entries143160 WHERE issue_id = ?144161 ORDER BY id`,@@ -160,17 +177,22 @@160177 	);161178 }162179 163-export function addEntry(164-	db: Database,165-	issueId: number,166-	author: Entry["author"],167-	body: string,168-	at: number,169-): number {180+export function addEntry(db: Database, issueId: number, entry: NewEntry): number {170181 	return Number(171182 		db172-			.query("INSERT INTO issue_entries (issue_id, author, body, created_at) VALUES (?, ?, ?, ?)")173-			.run(issueId, author, body, at).lastInsertRowid,183+			.query(184+				`INSERT INTO issue_entries (issue_id, author, name, body, created_at, ip_hash, reply_to)185+VALUES (?, ?, ?, ?, ?, ?, ?)`,186+			)187+			.run(188+				issueId,189+				entry.author,190+				entry.name ?? "",191+				entry.body,192+				entry.at,193+				entry.ipHash ?? "",194+				entry.replyTo ?? null,195+			).lastInsertRowid,174196 	);175197 }176198 @@ -284,21 +306,32 @@284306 export type BlockedReporter = { id: number; repo: string; number: number; blocked_at: number };285307 286308 /**287- * Blocks the address an issue was filed from (§23); the issue itself is untouched. Returns the288- * block's id. An issue with no address (imported from Kepr 2) blocks nothing and returns null.309+ * Blocks the address an issue was filed from, or one of its replies was posted from (§23); the310+ * posts themselves are untouched. Returns the block's id. A post with no address (imported from311+ * Kepr 2, or the operator's) blocks nothing and returns null.289312  */290-export function blockReporter(db: Database, issueId: number, now: number): number | null {313+export function blockReporter(314+	db: Database,315+	issueId: number,316+	now: number,317+	entryId: number | null = null,318+): number | null {291319 	return (292320 		db293-			.query<{ id: number }, [number, number]>(321+			.query<{ id: number }, [number | null, number, number | null, number]>(294322 				`INSERT INTO blocked_reporters (ip_hash, repo, number, blocked_at)295-				SELECT ip_hash, repo, number, ?296-				FROM issues297-				WHERE id = ? AND ip_hash != ''323+				SELECT hash, repo, number, blocked_at FROM (324+					SELECT CASE WHEN ?1 IS NULL THEN i.ip_hash ELSE e.ip_hash END AS hash,325+					i.repo, i.number, ?2 AS blocked_at326+					FROM issues i327+					LEFT JOIN issue_entries e ON e.id = ?3 AND e.issue_id = i.id328+					WHERE i.id = ?4329+				)330+				WHERE hash != ''298331 				ON CONFLICT (ip_hash) DO UPDATE SET ip_hash = excluded.ip_hash299332 				RETURNING id`,300333 			)301-			.get(now, issueId)?.id ?? null334+			.get(entryId, now, entryId, issueId)?.id ?? null302335 	);303336 }304337 

view file

src/pages/markdown.ts+14 −0
@@ -74,9 +74,23 @@7474 		: 'controls preload="metadata"';7575 	return `<video class="${classes}" src="${md.utils.escapeHtml(src)}" ${play} aria-label="${alt}"></video>`;7676 };77+md.renderer.rules.link_open = (tokens, idx, options, env, self) => {78+	if (env?.comment)79+		(tokens[idx] as (typeof tokens)[number]).attrSet("rel", "nofollow ugc noopener");80+	return self.renderToken(tokens, idx, options);81+};7782 7883 export function renderMarkdown(source: string, base: MarkdownBase): Html {7984 	return trusted(md.render(source, base));85+}86+87+/**88+ * An issue or reply (§23): its links as written, marked as the public's own words to search89+ * engines, and a line break wherever the writer pressed return.90+ */91+export function renderComment(source: string): Html {92+	const env = { repo: "", dir: "", at: null, comment: true };93+	return trusted(md.renderer.render(md.parse(source, env), { ...md.options, breaks: true }, env));8094 }8195 8296 /** One of Kepr's own Markdown pages, such as the docs (§21.10), with an anchor on each section. */

view file

src/server/db.ts+22 −6
@@ -50,7 +50,7 @@5050 	count INTEGER NOT NULL DEFAULT 05151 );5252 -- §23. Numbers are per repo and set explicitly, so Kepr 2's carry over on import. The contact53--- email is the operator's alone; token_hash is the follow-up link's SHA-256 (NULL for imports).53+-- email is the operator's alone.5454 CREATE TABLE IF NOT EXISTS issues (5555 	id            INTEGER PRIMARY KEY,5656 	repo          TEXT NOT NULL,@@ -67,13 +67,11 @@6767 	fixed_in      TEXT NOT NULL DEFAULT '',6868 	hidden        INTEGER NOT NULL DEFAULT 0,6969 	ip_hash       TEXT NOT NULL DEFAULT '',70-	token_hash    TEXT,7170 	UNIQUE (repo, number)7271 );7372 CREATE INDEX IF NOT EXISTS issues_ip ON issues (ip_hash, filed_at);74--- The thread after the first post: the reporter's follow-ups and the operator's replies.75--- §23: addresses blocked from filing and following up, by the hash the rate limit keeps, with the76--- issue they were blocked from.73+-- §23: addresses blocked from filing, replying and following, by the hash the rate limit keeps,74+-- with the issue they were blocked from.7775 CREATE TABLE IF NOT EXISTS blocked_reporters (7876 	id         INTEGER PRIMARY KEY,7977 	ip_hash    TEXT NOT NULL UNIQUE,@@ -81,13 +79,18 @@8179 	number     INTEGER NOT NULL,8280 	blocked_at INTEGER NOT NULL8381 );82+-- The thread after the first post. 'reporter' is anyone from the public, the issue's own reporter83+-- or not, under the name they chose; reply_to is the reply this one answers.8484 CREATE TABLE IF NOT EXISTS issue_entries (8585 	id         INTEGER PRIMARY KEY,8686 	issue_id   INTEGER NOT NULL REFERENCES issues (id),8787 	author     TEXT NOT NULL CHECK (author IN ('reporter', 'operator')),88+	name       TEXT NOT NULL DEFAULT '',8889 	body       TEXT NOT NULL,8990 	created_at INTEGER NOT NULL,90-	hidden     INTEGER NOT NULL DEFAULT 091+	hidden     INTEGER NOT NULL DEFAULT 0,92+	ip_hash    TEXT NOT NULL DEFAULT '',93+	reply_to   INTEGER9194 );9295 CREATE INDEX IF NOT EXISTS issue_entries_issue ON issue_entries (issue_id, id);9396 -- §24: admin sessions, revocable; only the SHA-256 of the cookie's token is kept.@@ -151,6 +154,9 @@151154 	["repos", "languages", "TEXT NOT NULL DEFAULT ''"],152155 	["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],153156 	["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],157+	["issue_entries", "name", "TEXT NOT NULL DEFAULT ''"],158+	["issue_entries", "ip_hash", "TEXT NOT NULL DEFAULT ''"],159+	["issue_entries", "reply_to", "INTEGER"],154160 	["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],155161 	["repos", "hidden_handed", "TEXT"],156162 ];@@ -160,6 +166,8 @@160166 	["repos", "install1"],161167 	["repos", "install2"],162168 	["repos", "install3"],169+	// The private follow-up link, replaced by public replies (June, 2026-10-08).170+	["issues", "token_hash"],163171 ];164172 165173 /** `description` and `details` are the operator's Markdown, or '' for the README's. */@@ -199,12 +207,20 @@199207 			.query<{ name: string }, []>(`PRAGMA table_info(${table})`)200208 			.all()201209 			.some((existing) => existing.name === column);210+	const named = has("issue_entries", "name");202211 	for (const [table, column, definition] of ADDED_COLUMNS) {203212 		if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);204213 	}214+	// Follow-ups came only from an issue's reporter, so they keep the name the issue shows.215+	if (!named) {216+		db.run(`UPDATE issue_entries SET name = (217+	SELECT reporter_name FROM issues WHERE issues.id = issue_entries.issue_id218+) WHERE author = 'reporter'`);219+	}205220 	for (const [table, column] of DROPPED_COLUMNS) {206221 		if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);207222 	}223+	db.run("CREATE INDEX IF NOT EXISTS issue_entries_ip ON issue_entries (ip_hash, created_at)");208224 	return db;209225 }210226 

view file

tests/console.test.ts+1 −1
@@ -189,7 +189,7 @@189189 				email: "[email protected]",190190 			}),191191 		});192-		expect(filed.status).toBe(200);192+		expect(filed.status).toBe(303);193193 		fileIssueDirect(inst, "hush");194194 	});195195 	afterAll(() => setSystemTime());

view file

tests/import.test.ts+5 −7
@@ -184,7 +184,7 @@184184 	]);185185 	expect(186186 		q(187-			"SELECT number, title, body, type, version, reporter_name, contact_email, filed_at, status, closed_at, fixed_in, token_hash FROM issues ORDER BY number",187+			"SELECT number, title, body, type, version, reporter_name, contact_email, filed_at, status, closed_at, fixed_in FROM issues ORDER BY number",188188 		),189189 	).toEqual([190190 		{@@ -199,7 +199,6 @@199199 			status: "open",200200 			closed_at: null,201201 			fixed_in: "",202-			token_hash: null,203202 		},204203 		{205204 			number: 9,@@ -213,13 +212,12 @@213212 			status: "closed",214213 			closed_at: 2000,215214 			fixed_in: save,216-			token_hash: null,217215 		},218216 	]);219-	expect(q("SELECT author, body, created_at FROM issue_entries ORDER BY id")).toEqual([220-		{ author: "operator", body: "Looking.", created_at: 1100 },221-		{ author: "reporter", body: "Thanks!", created_at: 1200 },222-		{ author: "operator", body: "Me too.", created_at: 1400 },217+	expect(q("SELECT author, name, body, created_at FROM issue_entries ORDER BY id")).toEqual([218+		{ author: "operator", name: "", body: "Looking.", created_at: 1100 },219+		{ author: "reporter", name: "@rita", body: "Thanks!", created_at: 1200 },220+		{ author: "operator", name: "", body: "Me too.", created_at: 1400 },223221 	]);224222 225223 	// Kepr 3 reads what the import wrote as any Koh 3 release.

view file

tests/issues.test.ts+110 −66
@@ -12,7 +12,7 @@1212 	fileIssue,1313 	filingsSince,1414 	findIssue,15-	hashToken,15+	isBlocked,1616 	issueCounts,1717 	listIssues,1818 	type NewIssue,@@ -39,28 +39,24 @@3939 	const db = openKeprDb(await mkdtemp(join(tmpdir(), "kepr-issues-")));4040 4141 	test("numbers are per repo and continue after an imported number", () => {42-		expect(fileIssue(db, newIssue("app", "one")).number).toBe(1);43-		expect(fileIssue(db, newIssue("app", "two")).number).toBe(2);44-		expect(fileIssue(db, newIssue("lib", "first")).number).toBe(1);42+		expect(fileIssue(db, newIssue("app", "one"))).toBe(1);43+		expect(fileIssue(db, newIssue("app", "two"))).toBe(2);44+		expect(fileIssue(db, newIssue("lib", "first"))).toBe(1);4545 		db.run(4646 			"INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('lib', 41, 'from kepr 2', 'x', 'idea', 5)",4747 		);48-		expect(fileIssue(db, newIssue("lib", "next")).number).toBe(42);48+		expect(fileIssue(db, newIssue("lib", "next"))).toBe(42);4949 	});5050 51-	test("only the token's hash is stored, and public reads leave out contact and IP", () => {52-		const { number, token } = fileIssue(db, newIssue("app", "secret"));53-		expect(token).toMatch(/^[0-9a-f]{64}$/);54-		const issue = findIssue(db, "app", number);55-		expect(issue?.token_hash).toBe(hashToken(token));56-		expect(JSON.stringify(issue)).not.toContain(token);51+	test("public reads leave out contact and IP", () => {52+		const issue = findIssue(db, "app", fileIssue(db, newIssue("app", "secret")));5753 		expect(Object.keys(issue ?? {})).not.toContain("contact_email");5854 		expect(Object.keys(issue ?? {})).not.toContain("ip_hash");5955 	});6056 6157 	test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => {6258 		const before = filingsSince(db, "ip-a", 0);63-		const { number } = fileIssue(db, newIssue("app", "spam"));59+		const number = fileIssue(db, newIssue("app", "spam"));6460 		db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]);6561 		expect(findIssue(db, "app", number)).toBeNull();6662 		expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam");@@ -69,16 +65,34 @@6965 		expect(filingsSince(db, "ip-a", 1000)).toBe(0);7066 	});7167 72-	test("the thread keeps entries in order with their author kind", () => {68+	test("the thread keeps replies in order with their author, name and the reply they answer", () => {7369 		const issue = findIssue(db, "app", 1);7470 		if (!issue) throw new Error("issue 1 missing");75-		addEntry(db, issue.id, "reporter", "More detail.", 2000);76-		addEntry(db, issue.id, "operator", "Thanks.", 3000);77-		expect(threadEntries(db, issue.id).map((e) => [e.author, e.body])).toEqual([78-			["reporter", "More detail."],79-			["operator", "Thanks."],71+		const first = addEntry(db, issue.id, {72+			author: "reporter",73+			name: "Bo",74+			body: "More.",75+			at: 2000,76+		});77+		addEntry(db, issue.id, { author: "operator", body: "Thanks.", at: 3000, replyTo: first });78+		expect(threadEntries(db, issue.id).map((e) => [e.author, e.name, e.body, e.reply_to])).toEqual([79+			["reporter", "Bo", "More.", null],80+			["operator", "", "Thanks.", first],8081 		]);82+		expect(listIssues(db, "app", "open").find((i) => i.number === 1)?.replies).toBe(2);8183 	});84+85+	test("a block from a reply takes that reply's address, and only a reply of that issue", () => {86+		const [one, two] = [findIssue(db, "app", 1), findIssue(db, "app", 2)];87+		if (!one || !two) throw new Error("issues missing");88+		const at = 4000;89+		const reply = addEntry(db, one.id, { author: "reporter", body: "x", at, ipHash: "ip-r" });90+		expect(blockReporter(db, two.id, at, reply)).toBeNull();91+		const block = blockReporter(db, one.id, at, reply) as number;92+		expect(isBlocked(db, "ip-r")).toBe(true);93+		expect(isBlocked(db, "ip-a")).toBe(false);94+		unblockReporter(db, block);95+	});8296 });8397 8498 describe("issues end to end over HTTP", () => {@@ -151,8 +165,6 @@151165 	});152166 	afterAll(() => setSystemTime());153167 154-	let token = "";155-156168 	test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {157169 		const response = await app.request("/app/issues/new");158170 		expect(response.status).toBe(200);@@ -184,7 +196,7 @@184196 		expect(await response.text()).toContain("x".repeat(100));185197 	});186198 187-	test("files an issue and shows the follow-up link once, carrying a token", async () => {199+	test("filing goes straight to the new issue", async () => {188200 		const session = await openForm();189201 		tick(4);190202 		const response = await post("/app/issues/new", session, {@@ -192,13 +204,8 @@192204 			name: "Ada",193205 			email: "[email protected]",194206 		});195-		expect(response.status).toBe(200);196-		expect(response.headers.get("cache-control")).toBe("no-store");197-		const text = await response.text();198-		token = /\/app\/issues\/1\?token=([0-9a-f]{64})/.exec(text)?.[1] ?? "";199-		expect(token).not.toBe("");200-		expect(text).toContain("https://kepr.example/app/issues/1?token=");201-		expect(await page("/app/issues/1")).not.toContain(token);207+		expect(response.status).toBe(303);208+		expect(response.headers.get("location")).toBe("/app/issues/1");202209 	});203210 204211 	test("the list and thread show the issue escaped, with its name and never its email", async () => {@@ -212,55 +219,87 @@212219 		expect(await page("/app/issues?type=question")).not.toContain('href="/app/issues/1"');213220 		expect(await page("/app/issues?q=CRASH&type=bug")).toContain('href="/app/issues/1"');214221 		expect(list).not.toContain("[email protected]");215-		const thread = await page("/app/issues/1");222+		const response = await app.request("/app/issues/1");223+		expect(response.headers.get("referrer-policy")).toBe("no-referrer");224+		expect(response.headers.get("cache-control")).toBe("no-store");225+		const thread = await response.text();216226 		expect(thread).toContain("It &lt;b&gt;crashed&lt;/b&gt;.");217227 		expect(thread).toContain('<span class="issue-author">Ada</span>');218228 		expect(thread).not.toContain("[email protected]");219-		expect(thread).not.toContain('name="token"');220229 		expect(thread).toContain('href="/app/issues">back to issues</a>');221-		expect(thread).toContain("private follow-up link you received when filing");230+		expect(thread).toContain('<form class="issue-composer" id="reply"');222231 	});223232 224-	test("the token opens the follow-up form, without a referrer or cache; a wrong one does not", async () => {225-		const response = await app.request(`/app/issues/1?token=${token}`);226-		expect(response.headers.get("referrer-policy")).toBe("no-referrer");227-		expect(response.headers.get("cache-control")).toBe("no-store");228-		const ownerPage = await response.text();229-		expect(ownerPage).toContain('name="token"');230-		expect(ownerPage).not.toContain("private follow-up link you received when filing");231-		const wrong = await page(`/app/issues/1?token=${"0".repeat(64)}`);232-		expect(wrong).not.toContain('name="token"');233-		expect(wrong).toContain("does not match");234-	});233+	let bobsReply = 0;235234 236-	test("a reporter follow-up posts with the token; a wrong token is refused", async () => {237-		const session = await openForm(`/app/issues/1?token=${token}`);235+	test("anyone can reply, in Markdown with raw HTML escaped and links marked as theirs", async () => {236+		const session = await openForm("/app/issues/1");238237 		tick(3);239-		const wrong = await post("/app/issues/1", session, { token: "f".repeat(64), body: "Sneaky." });240-		expect(wrong.status).toBe(403);241-		const response = await post("/app/issues/1", session, { token, body: "Still <broken>." });238+		const body =239+			"Still <broken>.\n```\nlog line\n```\n**worse** [site](https://example.com) [x](javascript:alert(1))";240+		const response = await post("/app/issues/1", session, { body, name: "Bob" }, "198.51.100.30");242241 		expect(response.status).toBe(303);243-		expect(response.headers.get("location")).toMatch(244-			new RegExp(`^/app/issues/1\\?token=${token}#entry-\\d+$`),245-		);242+		const entry = /^\/app\/issues\/1#entry-(\d+)$/.exec(response.headers.get("location") ?? "");243+		bobsReply = Number(entry?.[1]);244+		expect(bobsReply).toBeGreaterThan(0);246245 		const thread = await page("/app/issues/1");247246 		expect(thread).toContain("Still &lt;broken&gt;.");248-		expect(thread).not.toContain("Sneaky.");249-		expect(thread.match(/<span class="issue-author">Ada<\/span>/g)?.length).toBe(2);247+		expect(thread).toContain("<pre><code>log line");248+		expect(thread).toContain("<strong>worse</strong>");249+		expect(thread).toContain('<a href="https://example.com" rel="nofollow ugc noopener">site</a>');250+		expect(thread).not.toContain('href="javascript:');251+		expect(thread).toContain('<span class="issue-author">Bob</span>');252+		expect(await page("/app/issues")).toContain("· 1 reply");250253 	});251254 255+	test("quote starts a reply with the post quoted, and the reply shows what it answers", async () => {256+		const quoted = await page(`/app/issues/1?quote=${bobsReply}`);257+		expect(quoted).toContain(`name="reply_to" value="${bobsReply}"`);258+		expect(quoted).toContain("&gt; Still &lt;broken&gt;.\n&gt; ```");259+		expect(await page("/app/issues/1?quote=0")).toContain("&gt; It &lt;b&gt;crashed");260+		expect(await page("/app/issues/1?reply=999999")).toContain('name="reply_to" value=""');261+		const session = await openForm("/app/issues/1");262+		tick(3);263+		const reply = { body: "Same here.", reply_to: String(bobsReply) };264+		expect((await post("/app/issues/1", session, reply)).status).toBe(303);265+		const thread = await page("/app/issues/1");266+		expect(thread).toContain(`replying to\n<a href="#entry-${bobsReply}">Bob</a>`);267+		expect(thread).toContain('<span class="issue-author">anonymous</span>');268+	});269+270+	test("a refused reply keeps what was typed", async () => {271+		const session = await openForm("/app/issues/1");272+		tick(3);273+		const response = await post("/app/issues/1", session, { body: "y".repeat(20_001), name: "Cy" });274+		expect(response.status).toBe(400);275+		const text = await response.text();276+		expect(text).toContain("y".repeat(100));277+		expect(text).toContain('value="Cy"');278+		expect((await post("/app/issues/1", session, { body: " " })).status).toBe(400);279+	});280+281+	test("twenty replies an hour per address", async () => {282+		const session = await openForm("/app/issues/1");283+		tick(3);284+		const from = (peer: string) => post("/app/issues/1", session, { body: "more" }, peer);285+		for (let i = 0; i < 20; i++) expect((await from("198.51.100.50")).status).toBe(303);286+		expect((await from("198.51.100.50")).status).toBe(429);287+		expect((await from("198.51.100.51")).status).toBe(303);288+		kepr.run("UPDATE issue_entries SET hidden = 1 WHERE body = 'more'");289+	});290+252291 	test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {253292 		const session = await openForm();254293 		tick(3);255294 		const from = (peer: string, forwardedFor = "") =>256295 			post("/app/issues/new", session, issue, peer, forwardedFor);257296 		for (let i = 0; i < 5; i++) {258-			expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(200);297+			expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(303);259298 		}260299 		expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);261-		expect((await from("198.51.100.8")).status).toBe(200);300+		expect((await from("198.51.100.8")).status).toBe(303);262301 		tick(3601);263-		expect((await from("198.51.100.7")).status).toBe(200);302+		expect((await from("198.51.100.7")).status).toBe(303);264303 		// Keyed hashes only: no address, and not the plain SHA-256 Kepr 2 stored.265304 		const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");266305 		const stored = kepr@@ -277,17 +316,18 @@277316 			post("/app/issues/new", session, issue, peer, forwardedFor);278317 		// The client forges the left entries; the proxy appended the real address last.279318 		for (let i = 0; i < 5; i++) {280-			expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(200);319+			expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(303);281320 		}282321 		expect((await via("::1", "198.51.100.20")).status).toBe(429);283322 		expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);284-		expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(200);323+		expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(303);285324 	});286325 287-	test("an operator reply and a close show on the thread, marked", async () => {326+	test("an operator reply and a close show on the thread, which then takes no replies", async () => {288327 		const found = findIssue(kepr, "app", 1);289328 		if (!found) throw new Error("issue 1 missing");290-		addEntry(kepr, found.id, "operator", "Fixed, thanks.", Math.floor(clock / 1000));329+		const session = await openForm("/app/issues/1");330+		addEntry(kepr, found.id, { author: "operator", body: "Fixed, thanks.", at: clock / 1000 });291331 		kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [292332 			save,293333 			found.id,@@ -299,6 +339,10 @@299339 		expect(thread).toContain("◈ closed");300340 		expect(thread).toContain('href="/app/issues?status=closed">back to issues</a>');301341 		expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);342+		expect(thread).not.toContain('id="reply"');343+		expect(thread).toContain("takes no new replies");344+		tick(3);345+		expect((await post("/app/issues/1", session, { body: "Late." })).status).toBe(403);302346 		expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');303347 		expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');304348 	});@@ -315,24 +359,24 @@315359 		}316360 		const session = await openForm();317361 		for (const path of ["/secret/issues/new", "/secret/issues/1"]) {318-			expect((await post(path, session, { ...issue, token, body: "x" })).status).toBe(404);362+			expect((await post(path, session, { ...issue, body: "x" })).status).toBe(404);319363 		}320364 	});321365 322-	test("a blocked address can neither file nor follow up, until it is unblocked", async () => {366+	test("a blocked address can neither file nor reply, until it is unblocked", async () => {323367 		const first = findIssue(kepr, "app", 1);324368 		if (!first) throw new Error("issue 1 missing");325369 		const block = blockReporter(kepr, first.id, 1) as number;326370 		const filing = await openForm();327-		const thread = await openForm(`/app/issues/1?token=${token}`, filing.cookie);371+		const thread = await openForm("/app/issues/3", filing.cookie);328372 		tick(4);329373 		const refused = await post("/app/issues/new", filing, issue);330374 		expect(refused.status).toBe(403);331375 		expect(await refused.text()).toContain("This address can&#39;t post here.");332-		const followUp = { token, body: "Blocked words." };333-		expect((await post("/app/issues/1", thread, followUp)).status).toBe(403);334-		expect((await post("/app/issues/new", filing, issue, "198.51.100.8")).status).toBe(200);376+		const reply = { body: "Blocked words." };377+		expect((await post("/app/issues/3", thread, reply)).status).toBe(403);378+		expect((await post("/app/issues/new", filing, issue, "198.51.100.8")).status).toBe(303);335379 		unblockReporter(kepr, block);336-		expect((await post("/app/issues/1", thread, followUp)).status).toBe(303);380+		expect((await post("/app/issues/3", thread, reply)).status).toBe(303);337381 	});338382 });

view file

tests/notifications.test.ts+7 −11
@@ -22,7 +22,6 @@2222 describe("operator notifications (§24)", () => {2323 	let inst: Instance;2424 	let operator: Browser;25-	let followUpLink: string;2625 	const unread = () =>2726 		inst.kepr2827 			.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")@@ -41,7 +40,7 @@4140 		const reporter = new Browser(inst.app, "198.51.100.41");4241 		const form = await (await reporter.request("/app/issues/new")).text();4342 		tick(4);44-		const filed = await reporter.request("/app/issues/new", {43+		await reporter.request("/app/issues/new", {4544 			method: "POST",4645 			body: new URLSearchParams({4746 				stamp: /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "",@@ -51,9 +50,6 @@5150 				type: "bug",5251 			}),5352 		});54-		followUpLink = /href="https:\/\/kepr\.example(\/app\/issues\/1\?token=[0-9a-f]+)"/.exec(55-			await filed.text(),56-		)?.[1] as string;5753 	});5854 	afterAll(() => setSystemTime());5955 @@ -88,20 +84,20 @@8884 		expect((await operator.get("/admin/notifications/999999")).status).toBe(404);8985 	});9086 91-	test("a reporter's follow-up raises one", async () => {92-		const reporter = new Browser(inst.app, "198.51.100.41");93-		const thread = await (await reporter.request(followUpLink)).text();87+	test("a public reply raises one", async () => {88+		const reader = new Browser(inst.app, "198.51.100.42");89+		const thread = await (await reader.request("/app/issues/1")).text();9490 		tick(4);95-		const posted = await reporter.request("/app/issues/1", {91+		const posted = await reader.request("/app/issues/1", {9692 			method: "POST",9793 			body: new URLSearchParams({98-				token: new URL(followUpLink, "https://x").searchParams.get("token") ?? "",9994 				stamp: /name="stamp" value="([^"]+)"/.exec(thread)?.[1] ?? "",95+				website: "",10096 				body: "Still broken.",10197 			}),10298 		});10399 		expect(posted.status).toBe(303);104-		expect(await page()).toContain("Follow-up on app #1: Crash &lt;on&gt; start");100+		expect(await page()).toContain("Reply on app #1: Crash &lt;on&gt; start");105101 	});106102 107103 	test("a sync starting to fail raises one; a failure that continues does not", () => {

view file

tests/triage.test.ts+10 −7
@@ -1,5 +1,5 @@11 // Covers §23–§24's triage: tab counts, awaiting reply, repo/type/word filters, hiding one2-// follow-up, and pausing new issues per repo.2+// reply, and pausing new issues per repo.33 44 import { beforeAll, describe, expect, test } from "bun:test";55 import { addEntry, fileIssue, setClosed } from "../src/issues/store.ts";@@ -10,7 +10,6 @@1010 	let inst: Instance;1111 	let operator: Browser;1212 	let followUp: number;13-	let token: string;1413 	const list = async (query = "") => (await operator.get(`/admin/issues${query}`)).text();1514 	const text = async (path: string) => (await inst.app.request(path)).text();1615 	const file = (repo: string, title: string, body: string, type: "bug" | "idea" | "question") =>@@ -44,11 +43,15 @@4443 		}4544 		file("app", "Crash on start", "A segfault in main", "bug");4645 		file("app", "Dark mode", "Please", "idea");47-		token = file("app", "How to install", "Which command?", "question").token;46+		file("app", "How to install", "Which command?", "question");4847 		file("tools", "Tools broken", "Nothing works", "bug");49-		addEntry(inst.kepr, id("app", 2), "operator", "Planned.", 2);50-		addEntry(inst.kepr, id("app", 3), "operator", "Use the script.", 2);51-		followUp = addEntry(inst.kepr, id("app", 3), "reporter", "Buy cheap pills here", 3);48+		addEntry(inst.kepr, id("app", 2), { author: "operator", body: "Planned.", at: 2 });49+		addEntry(inst.kepr, id("app", 3), { author: "operator", body: "Use the script.", at: 2 });50+		followUp = addEntry(inst.kepr, id("app", 3), {51+			author: "reporter",52+			body: "Buy cheap pills here",53+			at: 3,54+		});5255 		setClosed(inst.kepr, id("tools", 1), 4);5356 	});5457 @@ -129,7 +132,7 @@129132 		expect(filed.status).toBe(403);130133 		expect(await text("/app/issues")).not.toContain("report an issue →");131134 		expect(await text("/app/issues")).toContain('href="/app/issues/1"');132-		expect(await text(`/app/issues/3?token=${token}`)).toContain('name="body"');135+		expect(await text("/app/issues/3")).toContain('name="body"');133136 		await operator.post("/admin/repos/app", { public: "1" });134137 		expect(await text("/app/issues/new")).toContain('name="title"');135138 	});

view file