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 type { Config } from "../server/config.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 { confirmFollower, follow, followerLinkMatches, followsSince, isEmail, tellFollowers, unfollow, } from "./followers.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; // How a thread is shown: whether email is offered, a notice, and what a refused reply typed. type View = { mail: boolean; notice?: string; /** Which form a refusal belongs to, so its error and what was typed show there. */ refused?: "reply" | "follow"; error?: string; body?: string; name?: string; email?: string; replyTo?: number | null; }; const FORM_FIELDS = [ "title", "body", "type", "version", "name", "email", "follow", "website", "stamp", ]; const REPLY_FIELDS = ["body", "name", "email", "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."; const BAD_EMAIL = "That email address doesn't look right."; // What a thread says after a follow form or link, named by `?notice=`. const NOTICES: Record = { check: "Check your inbox: one email asks you to confirm, and replies follow once you do.", following: "You'll get an email for each reply, and when the issue is closed or reopened.", stopped: "You won't get any more emails about this issue.", expired: "That link didn't work. You can follow again from this page.", }; /** §23: the list, the filing form, and the thread anyone can reply to and follow by email. */ export function registerIssuePages(app: Hono, ctx: PageContext, config: Config): void { const mail = () => config.smtp !== undefined; const site = () => `https://${ctx.domain}`; const open = (c: Context) => { const repo = openPublicRepo(ctx, c.req.param("repo") ?? ""); const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n"))); return repo && issue ? { repo, issue, base: `/${repo.settings.name}/issues/${issue.number}` } : null; }; 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, follow: "1" }, "", 200, mail()); }); 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, mail()); 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, mail()); // 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() ?? ""; const email = fields.email?.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 (email !== "" && !isEmail(email)) return refuse(BAD_EMAIL, 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: email, 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}`, ); const issue = findIssue(ctx.kepr, name, filed.number); const asked = issue && mail() && fields.follow === "1" && email !== "" ? follow(ctx.kepr, site(), name, issue, email, hash, now) : false; return c.redirect(`/${name}/issues/${filed.number}${asked ? "?notice=check" : ""}`, 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 found = open(c); if (!found) return notFound(c, ctx); const { repo, issue } = found; 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, { mail: mail(), notice: NOTICES[c.req.query("notice") ?? ""], replyTo: target?.id ?? null, body: quoted ? `${quoted.replace(/^/gm, "> ")}\n\n` : "", }), ); }); app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => { const found = open(c); if (!found) return notFound(c, ctx); const { repo, issue, base } = found; const { fields, files } = await readForm(c, REPLY_FIELDS); const entries = threadEntries(ctx.kepr, issue.id); const answering = entries.find((entry) => String(entry.id) === fields.reply_to); const body = fields.body?.trim() ?? ""; const name = fields.name?.trim() ?? ""; const email = fields.email?.trim() ?? ""; const now = unixNow(); privatePage(c); const refuse = (message: string, status: 400 | 403 | 429) => { const view = { mail: mail(), refused: "reply" as const, error: attachAgain(message, files), body: fields.body, name, email, replyTo: answering?.id ?? null, }; return c.html(threadPage(c, ctx, repo, issue, entries, view), 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); } if (email !== "" && !isEmail(email)) return refuse(BAD_EMAIL, 400); const hash = ipHash(ctx.kepr, c); if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403); const posted = repliesSince(ctx.kepr, hash, now - 3600) + followsSince(ctx.kepr, hash, now - 3600); if (posted >= 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: answering?.id ?? null, }); 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}`, ); const author = name || "anonymous"; const to = answering ? ` to ${answering.author === "operator" ? "the operator" : answering.name || "anonymous"}` : ""; const extra = uploads.length > 0 ? `\n\n(${uploads.length} attached on the page)` : ""; tellFollowers( ctx.kepr, site(), repo.settings.name, issue, { text: `${author} replied${to}:\n\n${body}${extra}`, href: `${base}#entry-${entry}` }, email, ); const asked = mail() && email !== "" && follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now); return c.redirect(`${base}${asked ? "?notice=check" : ""}#entry-${entry}`, 303); }); app.post("/:repo/issues/:n{[0-9]{1,9}}/follow", async (c) => { const found = open(c); if (!found || !mail()) return notFound(c, ctx); const { repo, issue, base } = found; const { fields } = await readForm(c, ["email", "website", "stamp"]); const email = fields.email?.trim() ?? ""; const now = unixNow(); privatePage(c); const refuse = (error: string, status: 400 | 403 | 429) => { const view = { mail: true, refused: "follow" as const, error, email }; const entries = threadEntries(ctx.kepr, issue.id); return c.html(threadPage(c, ctx, repo, issue, entries, view), status); }; if (fields.website !== "") return refuse("This address could not follow.", 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); if (!isEmail(email)) return refuse(BAD_EMAIL, 400); const hash = ipHash(ctx.kepr, c); if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403); const posted = repliesSince(ctx.kepr, hash, now - 3600) + followsSince(ctx.kepr, hash, now - 3600); if (posted >= REPLIES_PER_HOUR) { return refuse("Too many requests from here this hour. Please try again later.", 429); } follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now); // The same answer whether the address was new, so a page never says who follows what; it // opens at the top, where the notice is. return c.redirect(`${base}?notice=check`, 303); }); // The link in a confirmation email (§23); its token is the proof the inbox is theirs. app.get("/:repo/issues/:n{[0-9]{1,9}}/follow", (c) => { const found = open(c); if (!found) return notFound(c, ctx); const confirmed = confirmFollower( ctx.kepr, found.issue.id, Number(c.req.query("f")), c.req.query("t") ?? "", ); privatePage(c); return c.redirect(`${found.base}?notice=${confirmed ? "following" : "expired"}`, 303); }); // A stop link asks first, so a mail scanner opening it changes nothing. app.get("/:repo/issues/:n{[0-9]{1,9}}/unfollow", (c) => { const found = open(c); if (!found) return notFound(c, ctx); const { repo, issue, base } = found; const id = Number(c.req.query("f")); const token = c.req.query("t") ?? ""; privatePage(c); if (!followerLinkMatches(ctx.kepr, found.issue.id, id, token)) { return c.redirect(`${base}?notice=expired`, 303); } const query = new URLSearchParams({ f: String(id), t: token }); return c.html( page(ctx, { title: `stop emails · #${issue.number} ${issue.title}`, description: "", path: `${base}/unfollow`, body: html`${crumbs(repo.settings.name)}

stop emails about #${issue.number}?

You follow “${issue.title}” by email. Stopping takes effect at once.

keep them
`, }), ); }); // Both the page's button and an inbox's one-click unsubscribe (RFC 8058) post here. app.post("/:repo/issues/:n{[0-9]{1,9}}/unfollow", async (c) => { const found = open(c); if (!found) return notFound(c, ctx); const stopped = unfollow( ctx.kepr, found.issue.id, Number(c.req.query("f")), c.req.query("t") ?? "", ); const form = await c.req.parseBody(); if (form["List-Unsubscribe"] === "One-Click") { return c.text(stopped ? "Unsubscribed." : "Unknown link.", stopped ? 200 : 404); } return c.redirect(`${found.base}?notice=${stopped ? "stopped" : "expired"}`, 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 or a follow link is never cached, nor sent on 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, mail: boolean, ): 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")} ${ mail && html`` }

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[], view: View, ): 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 = view.replyTo ? byId.get(view.replyTo) : undefined; const files = issueAttachments(ctx.kepr, issue.id); const stamp = formStamp(ctx.kepr, c, unixNow()); // 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"; const error = view.error && html``; const honeypot = html` `; 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)}

    ${view.notice && html`

    ${view.notice}

    `}
    ${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`
    ${honeypot}

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

    ${view.refused === "reply" && error} ${markdownHint} ${attachField("reply-files")}
    ${ view.mail && html` ` }

    ` : html`${view.refused === "reply" && error}

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

    ` } ${ view.mail && html`
    ${honeypot} ${view.refused === "follow" && error}

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