1import type { Context, Hono } from "hono";2import { MAX_CHARS } from "../issues/spam.ts";3import {4 addEntry,5 blockedReporters,6 blockReporter,7 type Entry,8 ISSUE_TYPES,9 type IssueType,10 type OperatorFilter,11 type OperatorIssue,12 operatorEntries,13 operatorIssue,14 operatorIssues,15 reporterBlocked,16 setClosed,17 setEntryHidden,18 setHidden,19 setType,20 unblockReporter,21} from "../issues/store.ts";22import { saveByPrefix } from "../mirror/queries.ts";23import { openMirror } from "../mirror/store.ts";24import { html } from "../pages/html.ts";25import { formatDate, relativeTime } from "../pages/layout.ts";26import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";27import { allRepos } from "./store.ts";2829const FILTERS: [filter: OperatorFilter, label: string][] = [30 ["open", "open"],31 ["awaiting", "awaiting reply"],32 ["closed", "closed"],33 ["hidden", "hidden"],34];35const SAVE_PREFIX = /^[0-9a-f]{6,12}$/;36const ISSUE_PATH = "/admin/issues/:repo/:n{[0-9]{1,9}}";37const unixNow = () => Math.floor(Date.now() / 1000);383940export function registerIssues(app: Hono, ctx: AdminContext): void {41 app.get("/admin/issues", (c) => {42 const filter = FILTERS.find(([f]) => f === c.req.query("filter"))?.[0] ?? "open";43 44 const repos = allRepos(ctx.kepr).map((repo) => repo.name);45 const repo = repos.find((name) => name === c.req.query("repo")) ?? "";46 const type = ISSUE_TYPES.find((t) => t === c.req.query("type")) ?? "";47 const words = (c.req.query("q") ?? "").trim();48 const narrowed = (f: OperatorFilter) =>49 operatorIssues(ctx.kepr, f).filter(50 (issue) =>51 (!repo || issue.repo === repo) &&52 (!type || issue.type === type) &&53 `${issue.title}\n${issue.body}`.toLowerCase().includes(words.toLowerCase()),54 );55 const tab = ([f, label]: (typeof FILTERS)[number]) => {56 const query = new URLSearchParams({ filter: f, repo, type, q: words });57 return html`<a class="issues-tab${f === filter ? " active" : ""}"58 href="/admin/issues?${query.toString()}">59${label} <span class="muted">${narrowed(f).length}</span></a>`;60 };61 const option = (value: string, selected: string) =>62 html`<option value="${value}"${value === selected ? html` selected` : ""}>${value}</option>`;63 const issues = narrowed(filter);64 const blocked = blockedReporters(ctx.kepr);65 const body = html`<p class="issues-bar mono">${FILTERS.map(tab)}</p>66${67 blocked.length > 0 &&68 html`<details class="issue-meta mono"><summary>blocked reporters ${String(blocked.length)}</summary>69${blocked.map(70 (b) => html`<form class="issue-row" method="post" action="/admin/issues">71${formField(c)}<input type="hidden" name="action" value="unblock">72<input type="hidden" name="block" value="${String(b.id)}">73<span>from <a href="/admin/issues/${b.repo}/${String(b.number)}">${b.repo} #${String(b.number)}</a>74${relativeTime(b.blocked_at)}</span>75<button class="issues-cancel mono" type="submit">unblock</button></form>`,76)}</details>`77}78<form class="issues-filter" method="get" action="/admin/issues">79<input type="hidden" name="filter" value="${filter}">80<select class="issues-input" name="repo" aria-label="repository">81<option value="">all repos</option>82${repos.map((name) => option(name, repo))}83</select>84<select class="issues-input" name="type" aria-label="issue type">85<option value="">all types</option>86${ISSUE_TYPES.map((value) => option(value, type))}87</select>88<input class="issues-input" name="q" type="search" aria-label="search titles and text"89placeholder="search titles and text" value="${words}">90<button class="issues-submit" type="submit">filter</button>91</form>92<section class="repo-section">93${94 issues.length > 095 ? issues.map(96 (issue) =>97 html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}">98<span class="issue-title">${issue.title}${99 issue.awaiting && !issue.hidden && issue.status === "open"100 ? html` <span class="issue-badge">awaiting reply</span>`101 : ""102 }</span>103<span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} ·104${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`,105 )106 : html`<p class="muted mono save-empty">no issues here</p>`107}108</section>`;109 return c.html(adminPage(c, ctx, "issues", body));110 });111112 app.post("/admin/issues", async (c) => {113 const fields = await textFields(c);114 if (fields.action !== "unblock" || !unblockReporter(ctx.kepr, Number(fields.block))) {115 return c.notFound();116 }117 return c.redirect("/admin/issues", 303);118 });119120 app.get(ISSUE_PATH, (c) => {121 const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));122 return issue ? c.html(issuePage(c, ctx, issue, "")) : c.notFound();123 });124125 app.post(ISSUE_PATH, async (c) => {126 const issue = operatorIssue(ctx.kepr, c.req.param("repo"), Number(c.req.param("n")));127 if (!issue) return c.notFound();128 const fields = await textFields(c);129 const refuse = (error: string) => c.html(issuePage(c, ctx, issue, error), 400);130 switch (fields.action) {131 case "reply": {132 const body = (fields.body ?? "").trim();133 if (body === "" || body.length > MAX_CHARS) {134 return refuse(`A reply is 1 to ${MAX_CHARS.toLocaleString("en-US")} characters.`);135 }136 addEntry(ctx.kepr, issue.id, "operator", body, unixNow());137 break;138 }139 case "close": {140 const prefix = (fields.fixed_in ?? "").trim();141 if (prefix === "") {142 setClosed(ctx.kepr, issue.id, unixNow());143 break;144 }145 const save = SAVE_PREFIX.test(prefix)146 ? saveByPrefix(openMirror(ctx.mirrorDir, issue.repo), prefix)147 : null;148 if (!save) return refuse(`No save ${prefix} in ${issue.repo}.`);149 setClosed(ctx.kepr, issue.id, unixNow(), save.id);150 break;151 }152 case "reopen":153 setClosed(ctx.kepr, issue.id, null);154 break;155 case "hide_entry":156 case "unhide_entry": {157 const entry = Number(fields.entry);158 const hidden = fields.action === "hide_entry";159 if (!Number.isSafeInteger(entry) || !setEntryHidden(ctx.kepr, issue.id, entry, hidden)) {160 return c.notFound();161 }162 break;163 }164 case "hide":165 case "unhide":166 setHidden(ctx.kepr, issue.id, fields.action === "hide");167 break;168 case "block":169 if (blockReporter(ctx.kepr, issue.id, unixNow()) === null) {170 return refuse("This issue has no address to block.");171 }172 break;173 case "type":174 if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.");175 setType(ctx.kepr, issue.id, fields.type as IssueType);176 break;177 default:178 return refuse("Unknown action.");179 }180 return c.redirect(`/admin/issues/${issue.repo}/${issue.number}`, 303);181 });182}183184function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string {185 const action = `/admin/issues/${issue.repo}/${issue.number}`;186 const button = (name: string, label: string) =>187 html`<form method="post" action="${action}">188${formField(c)}<input type="hidden" name="action" value="${name}">189<button class="issues-submit" type="submit">${label}</button></form>`;190 const post = (operator: boolean, at: number, text: string, entry?: Entry) =>191 html`<article class="issue-post${operator ? " issue-post--operator" : ""}">192<p class="issue-post-head mono">${193 operator194 ? html`<span class="issue-badge">operator</span>`195 : html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`196 }<span class="history-when">${formatDate(at)}</span>${197 entry?.hidden ? html`<span class="issues-error">hidden</span>` : ""198 }</p>199<div class="issue-text">${text}</div>200${201 entry &&202 html`<form method="post" action="${action}">${formField(c)}203<input type="hidden" name="action" value="${entry.hidden ? "unhide_entry" : "hide_entry"}">204<input type="hidden" name="entry" value="${String(entry.id)}">205<button class="issues-cancel mono" type="submit">${entry.hidden ? "unhide" : "hide (spam)"}</button>206</form>`207}208</article>`;209 const typeOption = (type: string) =>210 html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;211 const body = html`${notice(error)}212<p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${213 issue.fixed_in && ` · fixed in ${issue.fixed_in}`214 }${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>215<p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"}216· contact: ${issue.contact_email || "none given"}${217 reporterBlocked(ctx.kepr, issue.id)218 ? html` · <span class="issues-error">reporter blocked</span>`219 : ""220 }</p>221${post(false, issue.filed_at, issue.body)}222${operatorEntries(ctx.kepr, issue.id).map((entry) =>223 post(entry.author === "operator", entry.created_at, entry.body, entry),224)}225<form class="issues-form" method="post" action="${action}">${formField(c)}226<input type="hidden" name="action" value="reply">227<label class="issues-label" for="reply">reply</label>228<textarea class="issues-textarea" id="reply" name="body" rows="5" required229maxlength="${MAX_CHARS}"></textarea>230<button class="issues-submit" type="submit">reply</button>231</form>232${233 issue.status === "open"234 ? html`<form class="issues-form" method="post" action="${action}">${formField(c)}235<input type="hidden" name="action" value="close">236<label class="issues-label" for="fixed-in">fixed in save237<span class="muted">(optional)</span></label>238<input class="issues-input" id="fixed-in" name="fixed_in" maxlength="12">239<button class="issues-submit" type="submit">close</button>240</form>`241 : button("reopen", "reopen")242}243<form class="issues-form" method="post" action="${action}">${formField(c)}244<input type="hidden" name="action" value="type">245<label class="issues-label" for="type">type</label>246<select class="issues-input" id="type" name="type">${ISSUE_TYPES.map(typeOption)}</select>247<button class="issues-submit" type="submit">set type</button>248</form>249${issue.hidden ? button("unhide", "unhide") : button("hide", "hide (spam)")}250${!reporterBlocked(ctx.kepr, issue.id) && button("block", "block this reporter")}`;251 return adminPage(c, ctx, issue.title, body);252}