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 { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts";16import {17 addEntry,18 fileIssue,19 filingsSince,20 findIssue,21 hashToken,22 ISSUE_TYPES,23 type Issue,24 type IssueType,25 issueCounts,26 listIssues,27 threadEntries,28} from "./store.ts";2930type Fields = Record<string, string>;31type Notice = { error?: string; draft?: string };3233const TOKEN = /^[0-9a-f]{64}$/;34const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];35const unixNow = () => Math.floor(Date.now() / 1000);363738export function registerIssuePages(app: Hono, ctx: PageContext): void {39 app.get("/:repo/issues", (c) => {40 const repo = openPublicRepo(ctx, c.req.param("repo"));41 if (!repo) return notFound(c, ctx);42 const status = c.req.query("status") === "closed" ? "closed" : "open";43 cacheFor(c, false);44 return c.html(listPage(ctx, repo, status));45 });4647 app.get("/:repo/issues/new", (c) => {48 const repo = openPublicRepo(ctx, c.req.param("repo"));49 if (!repo) return notFound(c, ctx);50 const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";51 return formPage(c, ctx, repo, { type: "bug", version }, "", 200);52 });5354 app.post("/:repo/issues/new", async (c) => {55 const repo = openPublicRepo(ctx, c.req.param("repo"));56 if (!repo) return notFound(c, ctx);57 const fields = await readFields(c, FORM_FIELDS);58 const now = unixNow();59 const refuse = (message: string, status: 400 | 403 | 429) =>60 formPage(c, ctx, repo, fields, message, status);61 62 if (fields.website !== "") return refuse("This issue could not be filed.", 400);63 const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);64 if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);65 if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);66 const title = fields.title?.trim() ?? "";67 const body = fields.body?.trim() ?? "";68 if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);69 if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);70 if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {71 return refuse(72 `Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,73 400,74 );75 }76 const hash = ipHash(ctx.kepr, c);77 if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {78 return refuse("Too many issues from here this hour. Please try again later.", 429);79 }80 const name = repo.settings.name;81 const filed = fileIssue(ctx.kepr, {82 repo: name,83 title,84 body,85 type: fields.type as IssueType,86 version: fields.version?.trim() ?? "",87 reporterName: fields.name?.trim() ?? "",88 contactEmail: fields.email?.trim() ?? "",89 ipHash: hash,90 filedAt: now,91 });92 const link = `https://${ctx.domain}/${name}/issues/${filed.number}?token=${filed.token}`;93 privatePage(c);94 return c.html(95 page(ctx, {96 title: `issue #${filed.number} filed · ${name}`,97 description: "",98 path: c.req.path,99 body: html`${crumbs(name)}100<h1 class="file-page-title">Filed as101<a href="/${name}/issues/${filed.number}">#${filed.number}</a></h1>102<p>This is your private link for adding to the issue later. It is shown only this once,103so keep it; anyone who has it can post as you.</p>104<p class="issue-link mono"><a href="${link}">${link}</a></p>`,105 }),106 );107 });108109 app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {110 const repo = openPublicRepo(ctx, c.req.param("repo"));111 const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));112 if (!repo || !issue) return notFound(c, ctx);113 const token = c.req.query("token");114 if (token === undefined) {115 cacheFor(c, false);116 return c.html(threadPage(c, ctx, repo, issue, null, {}));117 }118 privatePage(c);119 return c.html(threadPage(c, ctx, repo, issue, token, {}));120 });121122 app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {123 const repo = openPublicRepo(ctx, c.req.param("repo"));124 const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));125 if (!repo || !issue) return notFound(c, ctx);126 const { token = "", body = "", stamp = "" } = await readFields(c, ["token", "body", "stamp"]);127 privatePage(c);128 const refuse = (error: string, status: 400 | 403) =>129 c.html(threadPage(c, ctx, repo, issue, token, { error, draft: body }), status);130 if (!ownsIssue(issue, token))131 return refuse("This follow-up link does not match the issue.", 403);132 const check = checkStamp(ctx.kepr, c, stamp, unixNow());133 if (check === "invalid") return refuse("This form expired. Please send it again.", 403);134 if (check === "too-fast") return refuse("That was quick. Please send it again.", 400);135 const text = body.trim();136 if (text === "") return refuse("Write something to add.", 400);137 if (text.length > MAX_CHARS) {138 return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);139 }140 const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow());141 return c.redirect(142 `/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`,143 303,144 );145 });146}147148async function readFields(c: Context, names: string[]): Promise<Fields> {149 const form = await c.req.parseBody();150 const fields: Fields = {};151 for (const name of names) {152 const value = form[name];153 fields[name] = typeof value === "string" ? value : "";154 }155 return fields;156}157158function ownsIssue(issue: Issue, token: string): boolean {159 return TOKEN.test(token) && issue.token_hash === hashToken(token);160}161162163function privatePage(c: Context): void {164 c.header("Cache-Control", "no-store");165 c.header("Referrer-Policy", "no-referrer");166}167168function crumbs(repo: string): Html {169 return html`<p class="breadcrumb mono">170<a href="/${repo}">${repo}</a> / <a href="/${repo}/issues">issues</a></p>`;171}172173function listPage(ctx: PageContext, repo: PublicRepo, status: "open" | "closed"): string {174 const name = repo.settings.name;175 const counts = issueCounts(ctx.kepr, name);176 const issues = listIssues(ctx.kepr, name, status);177 const tab = (which: "open" | "closed") =>178 html`<a class="issues-tab${which === status ? " active" : ""}"179 href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">180${which} <span class="muted">${counts[which]}</span></a>`;181 return page(ctx, {182 title: `issues · ${name}`,183 description: `Issues filed against ${name}.`,184 path: `/${name}/issues`,185 body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>186<h1 class="file-page-title">issues</h1>187<p class="issues-bar mono">${tab("open")}${tab("closed")}188<a class="issues-new" href="/${name}/issues/new">report an issue →</a></p>189<section class="repo-section">190${191 issues.length > 0192 ? issues.map(193 (issue) =>194 html`<a class="issue-row" href="/${name}/issues/${issue.number}">195<span class="issue-symbol issue-symbol--${issue.status}">196${issue.status === "open" ? "◉" : "◈"}</span>197<span class="issue-title">${issue.title}</span>198${ ""}<span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}</span>199</a>`,200 )201 : html`<p class="muted mono save-empty">no ${status} issues</p>`202}203</section>`,204 });205}206207function formPage(208 c: Context,209 ctx: PageContext,210 repo: PublicRepo,211 values: Fields,212 error: string,213 status: 200 | 400 | 403 | 429,214): Response {215 const name = repo.settings.name;216 privatePage(c);217 const typeOption = (type: string) =>218 html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;219 const body = html`${crumbs(name)}220<h1 class="file-page-title">report an issue</h1>221${error && html`<p class="issues-error mono" role="alert">${error}</p>`}222<form class="issues-form" method="post" action="/${name}/issues/new">223<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">224<label class="issues-hp" aria-hidden="true">leave this empty225<input name="website" tabindex="-1" autocomplete="off"></label>226<label class="issues-label" for="issue-title">title</label>227<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">228<label class="issues-label" for="issue-body">what happened</label>229<textarea class="issues-textarea" id="issue-body" name="body" rows="8" required230maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>231<label class="issues-label" for="issue-type">type</label>232<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>233<label class="issues-label" for="issue-version">version you're on</label>234<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">235<label class="issues-label" for="issue-name">name to show236<span class="muted">(optional)</span></label>237<input class="issues-input" id="issue-name" name="name" maxlength="200" value="${values.name ?? ""}">238<label class="issues-label" for="issue-email">contact email239<span class="muted">(optional, only the operator sees it)</span></label>240<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">241<button class="issues-submit" type="submit">file issue</button>242</form>`;243 return c.html(244 page(ctx, {245 title: `report an issue · ${name}`,246 description: `File an issue against ${name}.`,247 path: `/${name}/issues/new`,248 body,249 }),250 status,251 );252}253254function threadPage(255 c: Context,256 ctx: PageContext,257 repo: PublicRepo,258 issue: Issue,259 token: string | null,260 notice: Notice,261): string {262 const name = repo.settings.name;263 const reporter = issue.reporter_name || "anonymous";264 const post = (id: string, operator: boolean, at: number, text: string) =>265 html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}">266<p class="issue-post-head mono">${267 operator268 ? html`<span class="issue-badge">operator</span>`269 : html`<span class="issue-author">${reporter}</span>`270 }<span class="history-when">${formatDate(at)}</span></p>271<div class="issue-text">${text}</div>272</article>`;273 const owner = token !== null && ownsIssue(issue, token);274 return page(ctx, {275 title: `#${issue.number} ${issue.title} · ${name}`,276 description: issue.title,277 path: `/${name}/issues/${issue.number}`,278 body: html`${crumbs(name)}279<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>280<p class="issue-meta mono">281<span class="issue-symbol issue-symbol--${issue.status}">282${issue.status === "open" ? "◉ open" : "◈ closed"}</span>283${issue.type}${issue.version && ` · v${issue.version}`} · filed ${relativeTime(issue.filed_at)}284${issue.status === "closed" && fixedIn(repo, issue)}</p>285${post("issue", false, issue.filed_at, issue.body)}286${threadEntries(ctx.kepr, issue.id).map((entry) =>287 post(`entry-${entry.id}`, entry.author === "operator", entry.created_at, entry.body),288)}289${notice.error && html`<p class="issues-error mono" role="alert">${notice.error}</p>`}290${291 token !== null &&292 !owner &&293 !notice.error &&294 html`<p class="issues-error mono">This follow-up link does not match the issue.</p>`295}296${297 owner &&298 html`<form class="issues-form" method="post" action="/${name}/issues/${issue.number}">299<p class="muted">This is your private follow-up link; anyone who has it can post as you.</p>300<input type="hidden" name="token" value="${token}">301<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">302<label class="issues-label" for="follow-up">add to this issue</label>303<textarea class="issues-textarea" id="follow-up" name="body" rows="5" required304maxlength="${MAX_CHARS}">${notice.draft ?? ""}</textarea>305<button class="issues-submit" type="submit">post</button>306</form>`307}`,308 });309}310311function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {312 if (issue.fixed_in === "") return "";313 const save = saveByPrefix(repo.mirror, issue.fixed_in);314 315 const id =316 save && !save.is_checkpoint317 ? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`318 : html`<span class="history-id">${issue.fixed_in}</span>`;319 return html` · fixed in ${id}`;320}