import type { Context, Hono } from "hono"; import { saveByPrefix } from "../mirror/queries.ts"; import { type Html, html } from "../pages/html.ts"; import { cacheFor, formatDate, formatSize, 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 { type Attachment, attachFiles, FILE_BYTES_PER_HOUR, FILE_EXTENSIONS, FILES_PER_POST, fileBytesSince, issueAttachments, readUploads, type Upload, visibleFile, } from "./files.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, files } = await readForm(c, FORM_FIELDS); const now = unixNow(); const refuse = (message: string, status: 400 | 403 | 429) => formPage(c, ctx, repo, fields, attachAgain(message, files), 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 uploads = await checkUploads(ctx, files, hash, now); if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status); 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, }); attachFiles(ctx.kepr, filed.id, null, uploads, hash, now); notify( ctx.kepr, "issue_filed", `New issue ${name} #${filed.number}: ${title}`, `/admin/issues/${name}/${filed.number}`, ); return c.redirect(`/${name}/issues/${filed.number}`, 303); }); app.get("/:repo/issues/:n{[0-9]{1,9}}/files/:file{[0-9]{1,9}\\.[a-z]{3,4}}", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); const [id, ext] = c.req.param("file").split("."); const file = repo && visibleFile(ctx.kepr, repo.settings.name, Number(c.req.param("n")), Number(id)); // One address per file: a name ending in another type's extension is as missing as no file. if (!repo || !file || FILE_EXTENSIONS[file.type] !== ext) return notFound(c, ctx); return c.body(file.data, 200, { "Content-Type": file.type === "text/plain" ? "text/plain; charset=utf-8" : file.type, "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`, // Short, so a file hidden as spam soon leaves shared caches too. "Cache-Control": "public, max-age=300", }); }); 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, files } = await readForm(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 = (message: string, status: 400 | 403 | 429) => { const error = attachAgain(message, files); const draft = { error, body: fields.body, name, replyTo }; return c.html(threadPage(c, ctx, repo, issue, entries, draft), 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 uploads = await checkUploads(ctx, files, hash, now); if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status); const entry = addEntry(ctx.kepr, issue.id, { author: "reporter", name, body, at: now, ipHash: hash, replyTo, }); attachFiles(ctx.kepr, issue.id, entry, uploads, hash, now); 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 readForm(c: Context, names: string[]): Promise<{ fields: Fields; files: File[] }> { const form = await c.req.parseBody({ all: true }); const fields: Fields = {}; for (const name of names) { const value = form[name]; fields[name] = typeof value === "string" ? value : ""; } // An empty file input still sends one nameless, empty part. const files = [form.files ?? []] .flat() .filter((file): file is File => file instanceof File && file.size > 0); return { fields, files }; } /** A post's files, checked last so the post's own refusals come first; or why they're refused. */ async function checkUploads( ctx: PageContext, files: File[], hash: string, now: number, ): Promise { const uploads = await readUploads(files); if (typeof uploads === "string") return { error: uploads, status: 400 }; const bytes = uploads.reduce((sum, upload) => sum + upload.data.length, 0); if (bytes > 0 && fileBytesSince(ctx.kepr, hash, now - 3600) + bytes > FILE_BYTES_PER_HOUR) { return { error: "Too many attachments from here this hour. Please try again later.", status: 429, }; } return uploads; } // A browser can't refill a file input, so a refused post says its files need choosing again. function attachAgain(message: string, files: File[]): string { return files.length > 0 ? `${message} Please attach your files again.` : message; } // 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} ${attachField("issue-files")}

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 attachField(id: string): Html { return html`

    PNG, JPEG, GIF or WebP up to 8 MB; .txt or .log up to 2 MB. Pictures can be pasted or dropped into the text box too.

    `; } /** A post's pictures side by side, and each log as its start with the whole file a link away. */ export function attachmentsHtml(base: string, files: Attachment[]): Html { const url = (file: Attachment) => `${base}/files/${file.id}.${FILE_EXTENSIONS[file.type]}`; const images = files.filter((file) => file.type !== "text/plain"); const logs = files.filter((file) => file.type === "text/plain"); return html`${ images.length > 0 && html`
    ${images.map( (file) => html` ${file.name}`, )}
    ` }${logs.map( (file) => html`
    ${file.name} ${formatSize(file.size)}
    ${new TextDecoder().decode(file.head ?? new Uint8Array())}

    open the whole log${ file.size > (file.head?.length ?? 0) && ` · showing its first ${formatSize(file.head?.length ?? 0)}` }

    `, )}`; } 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; const files = issueAttachments(ctx.kepr, issue.id); // 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); const attached = files.filter((file) => (file.entry_id ?? 0) === key); return html`

    ${ operator ? html`operator` : html`${author}` }${ answers && html`replying to ${who(answers)}` }${relativeTime(at)}

    ${renderComment(body)}
    ${attached.length > 0 && attachmentsHtml(base, attached)} ${ open && html`

    reply quote

    ` }
    `; }; 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} ${attachField("reply-files")}

    ` : 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}`; }