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
↓ kepr-fa3e2190.tar.gzatlas at this save
files changed17 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 −7package.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": {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 --------------------------------------------------------- */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();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> {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.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>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 };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, > 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 }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}$/;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 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. */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 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());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.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 <b>crashed</b>.");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 <broken>.");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("> Still <broken>.\n> ```");259+ expect(await page("/app/issues/1?quote=0")).toContain("> It <b>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'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 });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 <on> start");100+ expect(await page()).toContain("Reply on app #1: Crash <on> start");105101 });106102 107103 test("a sync starting to fail raises one; a failure that continues does not", () => {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 });