import type { Context, Hono } from "hono"; import { issueAttachments } from "../issues/files.ts"; import { followerCount, tellFollowers } from "../issues/followers.ts"; import { attachmentsHtml } from "../issues/pages.ts"; import { MAX_CHARS } from "../issues/spam.ts"; import { addEntry, blockedReporters, blockReporter, countIssues, type Entry, ISSUE_TYPES, type IssueType, type OperatorFilter, type OperatorIssue, operatorEntries, operatorIssue, operatorIssues, replyAddresses, reporterBlocked, setClosed, setEntryHidden, setHidden, setManyClosed, setManyHidden, setType, unblockReporter, } from "../issues/store.ts"; import { saveByPrefix } from "../mirror/queries.ts"; import { openMirror } from "../mirror/store.ts"; import { html } from "../pages/html.ts"; import { formatDate, relativeTime } from "../pages/layout.ts"; import { renderComment } from "../pages/markdown.ts"; import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts"; import { allRepos } from "./store.ts"; const FILTERS: [filter: OperatorFilter, label: string][] = [ ["open", "open"], ["awaiting", "awaiting reply"], ["closed", "closed"], ["hidden", "hidden"], ]; const BULK_ACTIONS = ["close", "reopen", "hide", "unhide"] as const; const SAVE_PREFIX = /^[0-9a-f]{6,12}$/; const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}"; const unixNow = () => Math.floor(Date.now() / 1000); /** §24's Issues section: every repo's issues, and the operator's actions of §23. */ export function registerIssues(app: Hono, ctx: AdminContext): void { // §23: followers hear of an issue's replies, closes and reopens, unless it is hidden. const tell = ( issue: Pick, text: string, anchor = "", ) => { if (issue.hidden) return; const href = `/${issue.repo}/issues/${issue.number}${anchor}`; tellFollowers(ctx.kepr, `https://${ctx.domain}`, issue.repo, issue, { text, href }); }; app.get("/admin/issues", (c) => { const filter = FILTERS.find(([f]) => f === c.req.query("filter"))?.[0] ?? "open"; // An unknown repo or type narrows nothing, as the public list treats an unknown type. const repos = allRepos(ctx.kepr).map((repo) => repo.name); const repo = repos.find((name) => name === c.req.query("repo")) ?? ""; const type = ISSUE_TYPES.find((t) => t === c.req.query("type")) ?? ""; const words = (c.req.query("q") ?? "").trim(); const narrowed = (f: OperatorFilter) => operatorIssues(ctx.kepr, f).filter( (issue) => (!repo || issue.repo === repo) && (!type || issue.type === type) && `${issue.title}\n${issue.body}`.toLowerCase().includes(words.toLowerCase()), ); const tab = ([f, label]: (typeof FILTERS)[number]) => { const query = new URLSearchParams({ filter: f, repo, type, q: words }); return html` ${label} ${narrowed(f).length}`; }; const option = (value: string, selected: string) => html``; const issues = narrowed(filter); const blocked = blockedReporters(ctx.kepr); const body = html`

${FILTERS.map(tab)}

${ blocked.length > 0 && html`
blocked reporters ${String(blocked.length)} ${blocked.map( (b) => html`
${formField(c)} from ${b.repo} #${String(b.number)} ${relativeTime(b.blocked_at)}
`, )}
` }
${formField(c)} ${ issues.length > 0 && html`

` } ${ issues.length > 0 ? issues.map( (issue) => html``, ) : html`

no issues here

` }
`; return c.html(adminPage(c, ctx, "issues", body)); }); app.post("/admin/issues", async (c) => { const form = await c.req.parseBody({ all: true }); const action = form.action; if (action === "unblock") { return unblockReporter(ctx.kepr, Number(form.block)) ? c.redirect("/admin/issues", 303) : c.notFound(); } const ids = [form.issue ?? []].flat().map(Number); const known = ids.length > 0 && ids.every(Number.isSafeInteger) && countIssues(ctx.kepr, ids) === new Set(ids).size; if (!known) return c.notFound(); switch (action) { case "close": case "reopen": for (const changed of setManyClosed(ctx.kepr, ids, action === "close" ? unixNow() : null)) { tell(changed, `"${changed.title}" was ${action === "close" ? "closed" : "reopened"}.`); } break; case "hide": case "unhide": setManyHidden(ctx.kepr, ids, action === "hide"); break; default: return c.notFound(); } const filter = FILTERS.find(([f]) => f === form.filter)?.[0] ?? "open"; return c.redirect(`/admin/issues?filter=${filter}`, 303); }); app.get(ISSUE_PATH, (c) => { const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n"))); return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound(); }); app.post(ISSUE_PATH, async (c) => { const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n"))); if (!issue) return c.notFound(); const fields = await textFields(c); const refuse = (error: string) => c.html(issuePage(c, ctx, issue, error), 400); switch (fields.action) { case "reply": { const body = (fields.body ?? "").trim(); if (body === "" || body.length > MAX_CHARS) { return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`); } const entry = addEntry(ctx.kepr, issue.id, { author: "operator", body, at: unixNow() }); tell(issue, `The operator replied:\n\n${body}`, `#entry-${entry}`); break; } case "close": { const prefix = (fields.fixed_in ?? "").trim(); const save = SAVE_PREFIX.test(prefix) ? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix) : null; if (prefix !== "" && !save) return refuse(`No save ${prefix} in ${issue.repo}.`); setClosed(ctx.kepr, issue.id, unixNow(), save?.id ?? ""); tell(issue, `"${issue.title}" was closed${save ? `, fixed in ${save.id}` : ""}.`); break; } case "reopen": setClosed(ctx.kepr, issue.id, null); tell(issue, `"${issue.title}" was reopened.`); break; case "hide_entry": case "unhide_entry": { const entry = Number(fields.entry); const hidden = fields.action === "hide_entry"; if (!Number.isSafeInteger(entry) || !setEntryHidden(ctx.kepr, issue.id, entry, hidden)) { return c.notFound(); } break; } case "hide": case "unhide": setHidden(ctx.kepr, issue.id, fields.action === "hide"); break; case "block": if (blockReporter(ctx.kepr, issue.id, unixNow()) === null) { return refuse("This issue has no address to block."); } break; case "block_entry": { const entry = Number(fields.entry); if ( !Number.isSafeInteger(entry) || blockReporter(ctx.kepr, issue.id, unixNow(), entry) === null ) { return refuse("This reply has no address to block."); } break; } case "type": if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type."); setType(ctx.kepr, issue.id, fields.type as IssueType); break; default: return refuse("Unknown action."); } return c.redirect(`/admin/issues/${issue.repo}/${issue.number}`, 303); }); } function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string { const action = `/admin/issues/${issue.repo}/${issue.number}`; const button = (name: string, label: string) => html`
${formField(c)}
`; const files = issueAttachments(ctx.kepr, issue.id); const addresses = new Map(replyAddresses(ctx.kepr, issue.id).map((row) => [row.id, row.blocked])); const entryAction = (entry: Entry, name: string, label: string) => html`
${formField(c)}
`; const post = (operator: boolean, at: number, text: string, entry?: Entry) => html`

${ operator ? html`operator` : html`${ entry ? entry.name || "anonymous" : issue.reporter_name || "anonymous" }` }${formatDate(at)}${ entry?.hidden ? html`hidden` : "" }

${renderComment(text)}
${attachmentsHtml( `/${issue.repo}/issues/${issue.number}`, files.filter((file) => file.entry_id === (entry?.id ?? null)), )} ${ entry && html`
${ entry.hidden ? entryAction(entry, "unhide_entry", "unhide") : entryAction(entry, "hide_entry", "hide (spam)") } ${ addresses.get(entry.id) === 0 ? entryAction(entry, "block_entry", "block this address") : addresses.get(entry.id) === 1 && html`address blocked` }
` }
`; const typeOption = (type: string) => html``; const body = html`${notice(error)}

${issue.repo} #${issue.number} · ${issue.status}${ issue.fixed_in && ` · fixed in ${issue.fixed_in}` }${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)} · ${followerCount(ctx.kepr, issue.id)} following by email

reporter: ${issue.reporter_name || "anonymous"} · contact: ${issue.contact_email || "none given"}${ reporterBlocked(ctx.kepr, issue.id) ? html` · reporter blocked` : "" }

${post(false, issue.filed_at, issue.body)} ${operatorEntries(ctx.kepr, issue.id).map((entry) => post(entry.author === "operator", entry.created_at, entry.body, entry), )}
${formField(c)}
${ issue.status === "open" ? html`
${formField(c)}
` : button("reopen", "reopen") }
${formField(c)}
${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")} ${!reporterBlocked(ctx.kepr, issue.id) && button("block", "block this reporter")}`; return adminPage(c, ctx, issue.title, body); }