import type { Context, Hono } from "hono"; import { saveByPrefix } from "../mirror/queries.ts"; import { type Html, html } from "../pages/html.ts"; import { cacheFor, formatDate, notFound, openPublicRepo, type PageContext, type PublicRepo, page, relativeTime, } from "../pages/layout.ts"; import { visibleReleases } from "../releases/releases.ts"; import { notify } from "../server/db.ts"; import { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts"; import { addEntry, fileIssue, filingsSince, findIssue, hashToken, ISSUE_TYPES, type Issue, type IssueType, isBlocked, issueCounts, listIssues, threadEntries, } from "./store.ts"; type Fields = Record; type Notice = { error?: string; draft?: string }; const TOKEN = /^[0-9a-f]{64}$/; const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"]; const unixNow = () => Math.floor(Date.now() / 1000); // The same words whatever the reason, so a blocked address learns nothing more. const BLOCKED = "This address can't post here."; /** §23: the list, the filing form and the thread with its follow-up links. */ export function registerIssuePages(app: Hono, ctx: PageContext): void { app.get("/:repo/issues", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); if (!repo) return notFound(c, ctx); const status = c.req.query("status") === "closed" ? "closed" : "open"; cacheFor(c, false); return c.html(listPage(ctx, repo, status, c.req.query("q") ?? "", c.req.query("type") ?? "")); }); app.get("/:repo/issues/new", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); if (!repo) return notFound(c, ctx); const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? ""; return formPage(c, ctx, repo, { type: "bug", version }, "", 200); }); app.post("/:repo/issues/new", async (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); if (!repo) return notFound(c, ctx); if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403); const fields = await readFields(c, FORM_FIELDS); const now = unixNow(); const refuse = (message: string, status: 400 | 403 | 429) => formPage(c, ctx, repo, fields, message, status); // A bot that fills the hidden field learns nothing from the answer. if (fields.website !== "") return refuse("This issue could not be filed.", 400); const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now); if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403); if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400); const title = fields.title?.trim() ?? ""; const body = fields.body?.trim() ?? ""; if (title === "" || body === "") return refuse("A title and what happened are needed.", 400); if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400); if (Object.values(fields).some((value) => value.length > MAX_CHARS)) { return refuse( `Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400, ); } const hash = ipHash(ctx.kepr, c); if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403); if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) { return refuse("Too many issues from here this hour. Please try again later.", 429); } const name = repo.settings.name; const filed = fileIssue(ctx.kepr, { repo: name, title, body, type: fields.type as IssueType, version: fields.version?.trim() ?? "", reporterName: fields.name?.trim() ?? "", contactEmail: fields.email?.trim() ?? "", ipHash: hash, filedAt: now, }); notify( ctx.kepr, "issue_filed", `New issue ${name} #${filed.number}: ${title}`, `/admin/issues/${name}/${filed.number}`, ); const link = `https://${ctx.domain}/${name}/issues/${filed.number}?token=${filed.token}`; privatePage(c); return c.html( page(ctx, { title: `issue #${filed.number} filed · ${name}`, description: "", path: c.req.path, body: html`${crumbs(name)}

Filed as #${filed.number}

This is your private link for adding to the issue later. It is shown only this once, so keep it; anyone who has it can post as you.

`, }), ); }); app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n"))); if (!repo || !issue) return notFound(c, ctx); const token = c.req.query("token"); if (token === undefined) { cacheFor(c, false); return c.html(threadPage(c, ctx, repo, issue, null, {})); } privatePage(c); return c.html(threadPage(c, ctx, repo, issue, token, {})); }); app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n"))); if (!repo || !issue) return notFound(c, ctx); const { token = "", body = "", stamp = "" } = await readFields(c, ["token", "body", "stamp"]); privatePage(c); const refuse = (error: string, status: 400 | 403) => c.html(threadPage(c, ctx, repo, issue, token, { error, draft: body }), status); if (!ownsIssue(issue, token)) return refuse("This follow-up link does not match the issue.", 403); const check = checkStamp(ctx.kepr, c, stamp, unixNow()); if (check === "invalid") return refuse("This form expired. Please send it again.", 403); if (check === "too-fast") return refuse("That was quick. Please send it again.", 400); if (isBlocked(ctx.kepr, ipHash(ctx.kepr, c))) return refuse(BLOCKED, 403); const text = body.trim(); if (text === "") return refuse("Write something to add.", 400); if (text.length > MAX_CHARS) { return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400); } const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow()); notify( ctx.kepr, "issue_followup", `Follow-up on ${repo.settings.name} #${issue.number}: ${issue.title}`, `/admin/issues/${repo.settings.name}/${issue.number}`, ); return c.redirect( `/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`, 303, ); }); } async function readFields(c: Context, names: string[]): Promise { const form = await c.req.parseBody(); const fields: Fields = {}; for (const name of names) { const value = form[name]; fields[name] = typeof value === "string" ? value : ""; } return fields; } function ownsIssue(issue: Issue, token: string): boolean { return TOKEN.test(token) && issue.token_hash === hashToken(token); } // A page carrying a token or a form stamp is never cached, and its URL never leaks as a referrer. function privatePage(c: Context): void { c.header("Cache-Control", "no-store"); c.header("Referrer-Policy", "no-referrer"); } function crumbs(repo: string): Html { return html``; } function listPage( ctx: PageContext, repo: PublicRepo, status: "open" | "closed", query: string, type: string, ): string { const name = repo.settings.name; const counts = issueCounts(ctx.kepr, name); const filter = ISSUE_TYPES.includes(type as IssueType) ? type : ""; const issues = listIssues(ctx.kepr, name, status).filter( (issue) => (!filter || issue.type === filter) && issue.title.toLowerCase().includes(query.trim().toLowerCase()), ); const tab = (which: "open" | "closed") => html` ${which} ${counts[which]}`; return page(ctx, { title: `issues · ${name}`, description: `Issues filed against ${name}.`, path: `/${name}/issues`, body: html`

issues

${tab("open")}${tab("closed")} ${!repo.settings.issuesPaused && html`report an issue →`}

${ issues.length > 0 ? issues.map( (issue) => html` ${issue.status === "open" ? "◉" : "◈"} ${issue.title} ${/* this line stays whole: splitting it changes the text tests/issues.test.ts asserts */ ""}#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)} ${issue.reporter_name || "anonymous"} `, ) : html`

${query || filter ? "no matching issues" : `no ${status} issues`}

` }
`, }); } function formPage( c: Context, ctx: PageContext, repo: PublicRepo, values: Fields, error: string, status: 200 | 400 | 403 | 429, ): Response { const name = repo.settings.name; privatePage(c); const typeOption = (type: string) => html``; const body = repo.settings.issuesPaused ? html`${crumbs(name)}

report an issue

${name} is not taking new issues right now.

see the existing issues →

` : html`${crumbs(name)}

report an issue

${error && html``}

cancel

`; return c.html( page(ctx, { title: `report an issue · ${name}`, description: `File an issue against ${name}.`, path: `/${name}/issues/new`, body, }), status, ); } function threadPage( c: Context, ctx: PageContext, repo: PublicRepo, issue: Issue, token: string | null, notice: Notice, ): string { const name = repo.settings.name; const reporter = issue.reporter_name || "anonymous"; const post = (id: string, operator: boolean, at: number, text: string) => html`

${ operator ? html`operator` : html`${reporter}` }${formatDate(at)}

${text}
`; const owner = token !== null && ownsIssue(issue, token); return page(ctx, { title: `#${issue.number} ${issue.title} · ${name}`, description: issue.title, path: `/${name}/issues/${issue.number}`, body: html`${crumbs(name)}

${issue.title} #${issue.number}

${issue.status === "open" ? "◉ open" : "◈ closed"} ${issue.type}${issue.version && ` · v${issue.version}`} · filed ${relativeTime(issue.filed_at)} ${issue.status === "closed" && fixedIn(repo, issue)}

${post("issue", false, issue.filed_at, issue.body)} ${threadEntries(ctx.kepr, issue.id).map((entry) => post(`entry-${entry.id}`, entry.author === "operator", entry.created_at, entry.body), )} ${notice.error && html``} ${ token !== null && !owner && !notice.error && html`

This follow-up link does not match the issue.

` } ${ owner && html`

This is your private follow-up link; anyone who has it can post as you.

` }`, }); } function fixedIn(repo: PublicRepo, issue: Issue): Html | "" { if (issue.fixed_in === "") return ""; const save = saveByPrefix(repo.mirror, issue.fixed_in); // Only a save with a page of its own is linked; a checkpoint or an unknown ID stays text. const id = save && !save.is_checkpoint ? html`${save.id}` : html`${issue.fixed_in}`; return html` · fixed in ${id}`; }