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 { renderComment } from "../pages/markdown.ts"; import { visibleReleases } from "../releases/releases.ts"; import { notify } from "../server/db.ts"; import { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS, REPLIES_PER_HOUR, } from "./spam.ts"; import { addEntry, type Entry, fileIssue, filingsSince, findIssue, ISSUE_TYPES, type Issue, type IssueType, isBlocked, issueCounts, listIssues, repliesSince, threadEntries, } from "./store.ts"; type Fields = Record; // What a refused reply hands back to the composer, so nothing typed is lost. type Draft = { error?: string; body?: string; name?: string; replyTo?: number | null }; const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"]; const REPLY_FIELDS = ["body", "name", "reply_to", "website", "stamp"]; const NAME_CHARS = 200; 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 anyone can reply to. */ 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 number = 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} #${number}: ${title}`, `/admin/issues/${name}/${number}`, ); return c.redirect(`/${name}/issues/${number}`, 303); }); 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 entries = threadEntries(ctx.kepr, issue.id); // `?reply=` answers a reply; `?quote=` also quotes it, and 0 is the issue itself. const quoting = c.req.query("quote"); const asked = Number(quoting ?? c.req.query("reply")); const target = entries.find((entry) => entry.id === asked); const quoted = quoting === undefined ? null : asked === 0 ? issue.body : target?.body; privatePage(c); return c.html( threadPage(c, ctx, repo, issue, entries, { replyTo: target?.id ?? null, body: quoted ? `${quoted.replace(/^/gm, "> ")}\n\n` : "", }), ); }); 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 fields = await readFields(c, REPLY_FIELDS); const entries = threadEntries(ctx.kepr, issue.id); const replyTo = entries.find((entry) => String(entry.id) === fields.reply_to)?.id ?? null; const body = fields.body?.trim() ?? ""; const name = fields.name?.trim() ?? ""; const now = unixNow(); privatePage(c); const refuse = (error: string, status: 400 | 403 | 429) => c.html( threadPage(c, ctx, repo, issue, entries, { error, body: fields.body, name, replyTo }), status, ); if (fields.website !== "") return refuse("This reply could not be posted.", 400); if (issue.status === "closed") return refuse("This issue is closed to new replies.", 403); 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); if (body === "") return refuse("Write something to reply with.", 400); if (body.length > MAX_CHARS || name.length > NAME_CHARS) { return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400); } const hash = ipHash(ctx.kepr, c); if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403); if (repliesSince(ctx.kepr, hash, now - 3600) >= REPLIES_PER_HOUR) { return refuse("Too many replies from here this hour. Please try again later.", 429); } const entry = addEntry(ctx.kepr, issue.id, { author: "reporter", name, body, at: now, ipHash: hash, replyTo, }); notify( ctx.kepr, "issue_followup", `Reply on ${repo.settings.name} #${issue.number}: ${issue.title}`, `/admin/issues/${repo.settings.name}/${issue.number}`, ); return c.redirect(`/${repo.settings.name}/issues/${issue.number}#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; } // A page carrying 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, closed = false): 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.replies > 0 && ` · ${issue.replies} ${issue.replies === 1 ? "reply" : "replies"}`} ${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``}
${markdownHint}

cancel

`; return c.html( page(ctx, { title: `report an issue · ${name}`, description: `File an issue against ${name}.`, path: `/${name}/issues/new`, body, }), status, ); } const markdownHint = html`

Markdown works: **bold**, \`code\`, \`\`\` around logs, > to quote.

`; function threadPage( c: Context, ctx: PageContext, repo: PublicRepo, issue: Issue, entries: Entry[], draft: Draft, ): string { const name = repo.settings.name; const base = `/${name}/issues/${issue.number}`; const open = issue.status === "open"; const who = (entry: Entry) => entry.author === "operator" ? "operator" : entry.name || "anonymous"; const byId = new Map(entries.map((entry) => [entry.id, entry])); const answering = draft.replyTo ? byId.get(draft.replyTo) : undefined; // The issue itself is post 0: quoting it quotes what was filed. const post = (key: number, author: string, operator: boolean, at: number, body: string) => { const id = key === 0 ? "issue" : `entry-${key}`; const answers = byId.get(byId.get(key)?.reply_to ?? -1); return html``; }; const reporter = issue.reporter_name || "anonymous"; return page(ctx, { title: `#${issue.number} ${issue.title} · ${name}`, description: issue.title, path: base, body: html`${crumbs(name, !open)}

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

${open ? "◉ open" : "◈ closed"} ${issue.type}${issue.version && ` · v${issue.version}`} · filed by ${reporter} ${relativeTime(issue.filed_at)} ${entries.length > 0 && ` · ${entries.length} ${entries.length === 1 ? "reply" : "replies"}`} ${!open && fixedIn(repo, issue)}

${post(0, reporter, false, issue.filed_at, issue.body)} ${entries.map((entry) => post(entry.id, who(entry), entry.author === "operator", entry.created_at, entry.body), )}
${ open ? html`

replying to ${answering ? who(answering) : ""} ×

${draft.error && html``} ${markdownHint}

` : html`${draft.error && html``}

This issue is closed, so it takes no new replies.

` }`, }); } 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}`; }