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