1import type { Context, Hono } from "hono";2import { saveByPrefix } from "../mirror/queries.ts";3import { type Html, html } from "../pages/html.ts";4import {5 cacheFor,6 formatDate,7 notFound,8 openPublicRepo,9 type PageContext,10 type PublicRepo,11 page,12 relativeTime,13} from "../pages/layout.ts";14import { visibleReleases } from "../releases/releases.ts";15import { notify } from "../server/db.ts";16import { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts";17import {18 addEntry,19 fileIssue,20 filingsSince,21 findIssue,22 hashToken,23 ISSUE_TYPES,24 type Issue,25 type IssueType,26 isBlocked,27 issueCounts,28 listIssues,29 threadEntries,30} from "./store.ts";3132type Fields = Record<string, string>;33type Notice = { error?: string; draft?: string };3435const TOKEN = /^[0-9a-f]{64}$/;36const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];37const unixNow = () => Math.floor(Date.now() / 1000);3839const BLOCKED = "This address can't post here.";404142export function registerIssuePages(app: Hono, ctx: PageContext): void {43 app.get("/:repo/issues", (c) => {44 const repo = openPublicRepo(ctx, c.req.param("repo"));45 if (!repo) return notFound(c, ctx);46 const status = c.req.query("status") === "closed" ? "closed" : "open";47 cacheFor(c, false);48 return c.html(listPage(ctx, repo, status, c.req.query("q") ?? "", c.req.query("type") ?? ""));49 });5051 app.get("/:repo/issues/new", (c) => {52 const repo = openPublicRepo(ctx, c.req.param("repo"));53 if (!repo) return notFound(c, ctx);54 const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";55 return formPage(c, ctx, repo, { type: "bug", version }, "", 200);56 });5758 app.post("/:repo/issues/new", async (c) => {59 const repo = openPublicRepo(ctx, c.req.param("repo"));60 if (!repo) return notFound(c, ctx);61 if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403);62 const fields = await readFields(c, FORM_FIELDS);63 const now = unixNow();64 const refuse = (message: string, status: 400 | 403 | 429) =>65 formPage(c, ctx, repo, fields, message, status);66 67 if (fields.website !== "") return refuse("This issue could not be filed.", 400);68 const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);69 if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);70 if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);71 const title = fields.title?.trim() ?? "";72 const body = fields.body?.trim() ?? "";73 if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);74 if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);75 if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {76 return refuse(77 `Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,78 400,79 );80 }81 const hash = ipHash(ctx.kepr, c);82 if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);83 if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {84 return refuse("Too many issues from here this hour. Please try again later.", 429);85 }86 const name = repo.settings.name;87 const filed = fileIssue(ctx.kepr, {88 repo: name,89 title,90 body,91 type: fields.type as IssueType,92 version: fields.version?.trim() ?? "",93 reporterName: fields.name?.trim() ?? "",94 contactEmail: fields.email?.trim() ?? "",95 ipHash: hash,96 filedAt: now,97 });98 notify(99 ctx.kepr,100 "issue_filed",101 `New issue ${name} #${filed.number}: ${title}`,102 `/admin/issues/${name}/${filed.number}`,103 );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,115so 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 );119 });120121 app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {122 const repo = openPublicRepo(ctx, c.req.param("repo"));123 const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));124 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 }130 privatePage(c);131 return c.html(threadPage(c, ctx, repo, issue, token, {}));132 });133134 app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {135 const repo = openPublicRepo(ctx, c.req.param("repo"));136 const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));137 if (!repo || !issue) return notFound(c, ctx);138 const { token = "", body = "", stamp = "" } = await readFields(c, ["token", "body", "stamp"]);139 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) {151 return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);152 }153 const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow());154 notify(155 ctx.kepr,156 "issue_followup",157 `Follow-up on ${repo.settings.name} #${issue.number}: ${issue.title}`,158 `/admin/issues/${repo.settings.name}/${issue.number}`,159 );160 return c.redirect(161 `/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`,162 303,163 );164 });165}166167async function readFields(c: Context, names: string[]): Promise<Fields> {168 const form = await c.req.parseBody();169 const fields: Fields = {};170 for (const name of names) {171 const value = form[name];172 fields[name] = typeof value === "string" ? value : "";173 }174 return fields;175}176177function ownsIssue(issue: Issue, token: string): boolean {178 return TOKEN.test(token) && issue.token_hash === hashToken(token);179}180181182function privatePage(c: Context): void {183 c.header("Cache-Control", "no-store");184 c.header("Referrer-Policy", "no-referrer");185}186187function crumbs(repo: string, closed = false): Html {188 return html`<p class="breadcrumb mono">189<a href="/${repo}">${repo}</a> /190<a href="/${repo}/issues${closed ? "?status=closed" : ""}">back to issues</a></p>`;191}192193function listPage(194 ctx: PageContext,195 repo: PublicRepo,196 status: "open" | "closed",197 query: string,198 type: string,199): string {200 const name = repo.settings.name;201 const counts = issueCounts(ctx.kepr, name);202 const filter = ISSUE_TYPES.includes(type as IssueType) ? type : "";203 const issues = listIssues(ctx.kepr, name, status).filter(204 (issue) =>205 (!filter || issue.type === filter) &&206 issue.title.toLowerCase().includes(query.trim().toLowerCase()),207 );208 const tab = (which: "open" | "closed") =>209 html`<a class="issues-tab${which === status ? " active" : ""}"210 href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">211${which} <span class="muted">${counts[which]}</span></a>`;212 return page(ctx, {213 title: `issues · ${name}`,214 description: `Issues filed against ${name}.`,215 path: `/${name}/issues`,216 body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>217<h1 class="file-page-title">issues</h1>218<p class="issues-bar mono">${tab("open")}${tab("closed")}219${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>220<form class="issues-filter" method="get" action="/${name}/issues">221<input type="hidden" name="status" value="${status}">222<input class="issues-input" name="q" type="search" aria-label="search issue titles"223placeholder="search issues" value="${query}">224<select class="issues-input" name="type" aria-label="issue type">225<option value="">all types</option>226${ISSUE_TYPES.map(227 (value) =>228 html`<option value="${value}"${filter === value ? html` selected` : ""}>${value}</option>`,229)}230</select><button class="issues-submit" type="submit">filter</button></form>231<section class="repo-section">232${233 issues.length > 0234 ? issues.map(235 (issue) =>236 html`<a class="issue-row" href="/${name}/issues/${issue.number}">237<span class="issue-symbol issue-symbol--${issue.status}">238${issue.status === "open" ? "◉" : "◈"}</span>239<span class="issue-title">${issue.title}</span>240${ ""}<span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}</span>241<span class="issue-reporter mono">${issue.reporter_name || "anonymous"}</span>242</a>`,243 )244 : html`<p class="muted mono save-empty">${query || filter ? "no matching issues" : `no ${status} issues`}</p>`245}246</section>`,247 });248}249250function formPage(251 c: Context,252 ctx: PageContext,253 repo: PublicRepo,254 values: Fields,255 error: string,256 status: 200 | 400 | 403 | 429,257): Response {258 const name = repo.settings.name;259 privatePage(c);260 const typeOption = (type: string) =>261 html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;262 const body = repo.settings.issuesPaused263 ? html`${crumbs(name)}264<h1 class="file-page-title">report an issue</h1>265<p>${name} is not taking new issues right now.</p>266<p><a href="/${name}/issues">see the existing issues →</a></p>`267 : html`${crumbs(name)}268<h1 class="file-page-title">report an issue</h1>269${error && html`<p class="issues-error mono" role="alert">${error}</p>`}270<form class="issues-form" method="post" action="/${name}/issues/new">271<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">272<label class="issues-hp" aria-hidden="true">leave this empty273<input name="website" tabindex="-1" autocomplete="off"></label>274<label class="issues-label" for="issue-title">title</label>275<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">276<label class="issues-label" for="issue-body">what happened</label>277<textarea class="issues-textarea" id="issue-body" name="body" rows="8" required278maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>279<label class="issues-label" for="issue-type">type</label>280<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>281<label class="issues-label" for="issue-version">version you're on</label>282<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">283<label class="issues-label" for="issue-name">name to show284<span class="muted">(optional)</span></label>285<input class="issues-input" id="issue-name" name="name" maxlength="200" value="${values.name ?? ""}">286<label class="issues-label" for="issue-email">contact email287<span class="muted">(optional, only the operator sees it)</span></label>288<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">289<p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>290<a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>291</form>`;292 return c.html(293 page(ctx, {294 title: `report an issue · ${name}`,295 description: `File an issue against ${name}.`,296 path: `/${name}/issues/new`,297 body,298 }),299 status,300 );301}302303function threadPage(304 c: Context,305 ctx: PageContext,306 repo: PublicRepo,307 issue: Issue,308 token: string | null,309 notice: Notice,310): string {311 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}">315<p class="issue-post-head mono">${316 operator317 ? 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>321</article>`;322 const owner = token !== null && ownsIssue(issue, token);323 return page(ctx, {324 title: `#${issue.number} ${issue.title} · ${name}`,325 description: issue.title,326 path: `/${name}/issues/${issue.number}`,327 body: html`${crumbs(name, issue.status === "closed")}328<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>329<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),337)}338${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}">354<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" required357maxlength="${MAX_CHARS}">${notice.draft ?? ""}</textarea>358<button class="issues-submit" type="submit">post</button>359</form>`360}`,361 });362}363364function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {365 if (issue.fixed_in === "") return "";366 const save = saveByPrefix(repo.mirror, issue.fixed_in);367 368 const id =369 save && !save.is_checkpoint370 ? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`371 : html`<span class="history-id">${issue.fixed_in}</span>`;372 return html` · fixed in ${id}`;373}