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