1import type { Context, Hono } from "hono";2import { saveByPrefix } from "../mirror/queries.ts";3import { type Html, html } from "../pages/html.ts";4import {5 cacheFor,6 formatDate,7 formatSize,8 notFound,9 openPublicRepo,10 type PageContext,11 type PublicRepo,12 page,13 relativeTime,14} from "../pages/layout.ts";15import { renderComment } from "../pages/markdown.ts";16import { visibleReleases } from "../releases/releases.ts";17import type { Config } from "../server/config.ts";18import { notify } from "../server/db.ts";19import {20 type Attachment,21 attachFiles,22 FILE_BYTES_PER_HOUR,23 FILE_EXTENSIONS,24 FILES_PER_POST,25 fileBytesSince,26 issueAttachments,27 readUploads,28 type Upload,29 visibleFile,30} from "./files.ts";31import {32 confirmFollower,33 follow,34 followerLinkMatches,35 followsSince,36 isEmail,37 tellFollowers,38 unfollow,39} from "./followers.ts";40import {41 checkStamp,42 FILINGS_PER_HOUR,43 formStamp,44 ipHash,45 MAX_CHARS,46 REPLIES_PER_HOUR,47} from "./spam.ts";48import {49 addEntry,50 type Entry,51 fileIssue,52 filingsSince,53 findIssue,54 ISSUE_TYPES,55 type Issue,56 type IssueType,57 isBlocked,58 issueCounts,59 listIssues,60 repliesSince,61 threadEntries,62} from "./store.ts";6364type Fields = Record<string, string>;6566type View = {67 mail: boolean;68 notice?: string;69 70 refused?: "reply" | "follow";71 error?: string;72 body?: string;73 name?: string;74 email?: string;75 replyTo?: number | null;76};7778const FORM_FIELDS = [79 "title",80 "body",81 "type",82 "version",83 "name",84 "email",85 "follow",86 "website",87 "stamp",88];89const REPLY_FIELDS = ["body", "name", "email", "reply_to", "website", "stamp"];90const NAME_CHARS = 200;91const unixNow = () => Math.floor(Date.now() / 1000);9293const BLOCKED = "This address can't post here.";94const BAD_EMAIL = "That email address doesn't look right.";9596const NOTICES: Record<string, string> = {97 check: "Check your inbox: one email asks you to confirm, and replies follow once you do.",98 following: "You'll get an email for each reply, and when the issue is closed or reopened.",99 stopped: "You won't get any more emails about this issue.",100 expired: "That link didn't work. You can follow again from this page.",101};102103104export function registerIssuePages(app: Hono, ctx: PageContext, config: Config): void {105 const mail = () => config.smtp !== undefined;106 const site = () => `https://${ctx.domain}`;107 const open = (c: Context) => {108 const repo = openPublicRepo(ctx, c.req.param("repo") ?? "");109 const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));110 return repo && issue111 ? { repo, issue, base: `/${repo.settings.name}/issues/${issue.number}` }112 : null;113 };114115 app.get("/:repo/issues", (c) => {116 const repo = openPublicRepo(ctx, c.req.param("repo"));117 if (!repo) return notFound(c, ctx);118 const status = c.req.query("status") === "closed" ? "closed" : "open";119 cacheFor(c, false);120 return c.html(listPage(ctx, repo, status, c.req.query("q") ?? "", c.req.query("type") ?? ""));121 });122123 app.get("/:repo/issues/new", (c) => {124 const repo = openPublicRepo(ctx, c.req.param("repo"));125 if (!repo) return notFound(c, ctx);126 const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";127 return formPage(c, ctx, repo, { type: "bug", version, follow: "1" }, "", 200, mail());128 });129130 app.post("/:repo/issues/new", async (c) => {131 const repo = openPublicRepo(ctx, c.req.param("repo"));132 if (!repo) return notFound(c, ctx);133 if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403, mail());134 const { fields, files } = await readForm(c, FORM_FIELDS);135 const now = unixNow();136 const refuse = (message: string, status: 400 | 403 | 429) =>137 formPage(c, ctx, repo, fields, attachAgain(message, files), status, mail());138 139 if (fields.website !== "") return refuse("This issue could not be filed.", 400);140 const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);141 if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);142 if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);143 const title = fields.title?.trim() ?? "";144 const body = fields.body?.trim() ?? "";145 const email = fields.email?.trim() ?? "";146 if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);147 if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);148 if (email !== "" && !isEmail(email)) return refuse(BAD_EMAIL, 400);149 if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {150 return refuse(151 `Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,152 400,153 );154 }155 const hash = ipHash(ctx.kepr, c);156 if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);157 if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {158 return refuse("Too many issues from here this hour. Please try again later.", 429);159 }160 const uploads = await checkUploads(ctx, files, hash, now);161 if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status);162 const name = repo.settings.name;163 const filed = fileIssue(ctx.kepr, {164 repo: name,165 title,166 body,167 type: fields.type as IssueType,168 version: fields.version?.trim() ?? "",169 reporterName: fields.name?.trim() ?? "",170 contactEmail: email,171 ipHash: hash,172 filedAt: now,173 });174 attachFiles(ctx.kepr, filed.id, null, uploads, hash, now);175 notify(176 ctx.kepr,177 "issue_filed",178 `New issue ${name} #${filed.number}: ${title}`,179 `/admin/issues/${name}/${filed.number}`,180 );181 const issue = findIssue(ctx.kepr, name, filed.number);182 const asked =183 issue && mail() && fields.follow === "1" && email !== ""184 ? follow(ctx.kepr, site(), name, issue, email, hash, now)185 : false;186 return c.redirect(`/${name}/issues/${filed.number}${asked ? "?notice=check" : ""}`, 303);187 });188189 app.get("/:repo/issues/:n{[0-9]{1,9}}/files/:file{[0-9]{1,9}\\.[a-z]{3,4}}", (c) => {190 const repo = openPublicRepo(ctx, c.req.param("repo"));191 const [id, ext] = c.req.param("file").split(".");192 const file =193 repo && visibleFile(ctx.kepr, repo.settings.name, Number(c.req.param("n")), Number(id));194 195 if (!repo || !file || FILE_EXTENSIONS[file.type] !== ext) return notFound(c, ctx);196 return c.body(file.data, 200, {197 "Content-Type": file.type === "text/plain" ? "text/plain; charset=utf-8" : file.type,198 "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`,199 200 "Cache-Control": "public, max-age=300",201 });202 });203204 app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {205 const found = open(c);206 if (!found) return notFound(c, ctx);207 const { repo, issue } = found;208 const entries = threadEntries(ctx.kepr, issue.id);209 210 const quoting = c.req.query("quote");211 const asked = Number(quoting ?? c.req.query("reply"));212 const target = entries.find((entry) => entry.id === asked);213 const quoted = quoting === undefined ? null : asked === 0 ? issue.body : target?.body;214 privatePage(c);215 return c.html(216 threadPage(c, ctx, repo, issue, entries, {217 mail: mail(),218 notice: NOTICES[c.req.query("notice") ?? ""],219 replyTo: target?.id ?? null,220 body: quoted ? `${quoted.replace(/^/gm, "> ")}\n\n` : "",221 }),222 );223 });224225 app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {226 const found = open(c);227 if (!found) return notFound(c, ctx);228 const { repo, issue, base } = found;229 const { fields, files } = await readForm(c, REPLY_FIELDS);230 const entries = threadEntries(ctx.kepr, issue.id);231 const answering = entries.find((entry) => String(entry.id) === fields.reply_to);232 const body = fields.body?.trim() ?? "";233 const name = fields.name?.trim() ?? "";234 const email = fields.email?.trim() ?? "";235 const now = unixNow();236 privatePage(c);237 const refuse = (message: string, status: 400 | 403 | 429) => {238 const view = {239 mail: mail(),240 refused: "reply" as const,241 error: attachAgain(message, files),242 body: fields.body,243 name,244 email,245 replyTo: answering?.id ?? null,246 };247 return c.html(threadPage(c, ctx, repo, issue, entries, view), status);248 };249 if (fields.website !== "") return refuse("This reply could not be posted.", 400);250 if (issue.status === "closed") return refuse("This issue is closed to new replies.", 403);251 const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);252 if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);253 if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);254 if (body === "") return refuse("Write something to reply with.", 400);255 if (body.length > MAX_CHARS || name.length > NAME_CHARS) {256 return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);257 }258 if (email !== "" && !isEmail(email)) return refuse(BAD_EMAIL, 400);259 const hash = ipHash(ctx.kepr, c);260 if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);261 const posted =262 repliesSince(ctx.kepr, hash, now - 3600) + followsSince(ctx.kepr, hash, now - 3600);263 if (posted >= REPLIES_PER_HOUR) {264 return refuse("Too many replies from here this hour. Please try again later.", 429);265 }266 const uploads = await checkUploads(ctx, files, hash, now);267 if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status);268 const entry = addEntry(ctx.kepr, issue.id, {269 author: "reporter",270 name,271 body,272 at: now,273 ipHash: hash,274 replyTo: answering?.id ?? null,275 });276 attachFiles(ctx.kepr, issue.id, entry, uploads, hash, now);277 notify(278 ctx.kepr,279 "issue_followup",280 `Reply on ${repo.settings.name} #${issue.number}: ${issue.title}`,281 `/admin/issues/${repo.settings.name}/${issue.number}`,282 );283 const author = name || "anonymous";284 const to = answering285 ? ` to ${answering.author === "operator" ? "the operator" : answering.name || "anonymous"}`286 : "";287 const extra = uploads.length > 0 ? `\n\n(${uploads.length} attached on the page)` : "";288 tellFollowers(289 ctx.kepr,290 site(),291 repo.settings.name,292 issue,293 { text: `${author} replied${to}:\n\n${body}${extra}`, href: `${base}#entry-${entry}` },294 email,295 );296 const asked = mail() && email !== "";297 if (asked) follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now);298 299 300 return c.redirect(`${base}${asked ? "?notice=check" : ""}#entry-${entry}`, 303);301 });302303 app.post("/:repo/issues/:n{[0-9]{1,9}}/follow", async (c) => {304 const found = open(c);305 if (!found || !mail()) return notFound(c, ctx);306 const { repo, issue, base } = found;307 const { fields } = await readForm(c, ["email", "website", "stamp"]);308 const email = fields.email?.trim() ?? "";309 const now = unixNow();310 privatePage(c);311 const refuse = (error: string, status: 400 | 403 | 429) => {312 const view = { mail: true, refused: "follow" as const, error, email };313 const entries = threadEntries(ctx.kepr, issue.id);314 return c.html(threadPage(c, ctx, repo, issue, entries, view), status);315 };316 if (fields.website !== "") return refuse("This address could not follow.", 400);317 const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);318 if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);319 if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);320 if (!isEmail(email)) return refuse(BAD_EMAIL, 400);321 const hash = ipHash(ctx.kepr, c);322 if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);323 const posted =324 repliesSince(ctx.kepr, hash, now - 3600) + followsSince(ctx.kepr, hash, now - 3600);325 if (posted >= REPLIES_PER_HOUR) {326 return refuse("Too many requests from here this hour. Please try again later.", 429);327 }328 follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now);329 330 331 return c.redirect(`${base}?notice=check`, 303);332 });333334 335 app.get("/:repo/issues/:n{[0-9]{1,9}}/follow", (c) => {336 const found = open(c);337 if (!found) return notFound(c, ctx);338 const confirmed = confirmFollower(339 ctx.kepr,340 found.issue.id,341 Number(c.req.query("f")),342 c.req.query("t") ?? "",343 );344 privatePage(c);345 return c.redirect(`${found.base}?notice=${confirmed ? "following" : "expired"}`, 303);346 });347348 349 app.get("/:repo/issues/:n{[0-9]{1,9}}/unfollow", (c) => {350 const found = open(c);351 if (!found) return notFound(c, ctx);352 const { repo, issue, base } = found;353 const id = Number(c.req.query("f"));354 const token = c.req.query("t") ?? "";355 privatePage(c);356 if (!followerLinkMatches(ctx.kepr, found.issue.id, id, token)) {357 return c.redirect(`${base}?notice=expired`, 303);358 }359 const query = new URLSearchParams({ f: String(id), t: token });360 return c.html(361 page(ctx, {362 title: `stop emails · #${issue.number} ${issue.title}`,363 description: "",364 path: `${base}/unfollow`,365 body: html`${crumbs(repo.settings.name)}366<h1 class="file-page-title">stop emails about #${issue.number}?</h1>367<p>You follow “${issue.title}” by email. Stopping takes effect at once.</p>368<form class="issues-actions" method="post" action="${base}/unfollow?${query.toString()}">369<button class="issues-submit" type="submit">stop emails</button>370<a class="issues-cancel mono" href="${base}">keep them</a></form>`,371 }),372 );373 });374375 376 app.post("/:repo/issues/:n{[0-9]{1,9}}/unfollow", async (c) => {377 const found = open(c);378 if (!found) return notFound(c, ctx);379 const stopped = unfollow(380 ctx.kepr,381 found.issue.id,382 Number(c.req.query("f")),383 c.req.query("t") ?? "",384 );385 const form = await c.req.parseBody();386 if (form["List-Unsubscribe"] === "One-Click") {387 return c.text(stopped ? "Unsubscribed." : "Unknown link.", stopped ? 200 : 404);388 }389 return c.redirect(`${found.base}?notice=${stopped ? "stopped" : "expired"}`, 303);390 });391}392393async function readForm(c: Context, names: string[]): Promise<{ fields: Fields; files: File[] }> {394 const form = await c.req.parseBody({ all: true });395 const fields: Fields = {};396 for (const name of names) {397 const value = form[name];398 fields[name] = typeof value === "string" ? value : "";399 }400 401 const files = [form.files ?? []]402 .flat()403 .filter((file): file is File => file instanceof File && file.size > 0);404 return { fields, files };405}406407408async function checkUploads(409 ctx: PageContext,410 files: File[],411 hash: string,412 now: number,413): Promise<Upload[] | { error: string; status: 400 | 429 }> {414 const uploads = await readUploads(files);415 if (typeof uploads === "string") return { error: uploads, status: 400 };416 const bytes = uploads.reduce((sum, upload) => sum + upload.data.length, 0);417 if (bytes > 0 && fileBytesSince(ctx.kepr, hash, now - 3600) + bytes > FILE_BYTES_PER_HOUR) {418 return {419 error: "Too many attachments from here this hour. Please try again later.",420 status: 429,421 };422 }423 return uploads;424}425426427function attachAgain(message: string, files: File[]): string {428 return files.length > 0 ? `${message} Please attach your files again.` : message;429}430431432function privatePage(c: Context): void {433 c.header("Cache-Control", "no-store");434 c.header("Referrer-Policy", "no-referrer");435}436437function crumbs(repo: string, closed = false): Html {438 return html`<p class="breadcrumb mono">439<a href="/${repo}">${repo}</a> /440<a href="/${repo}/issues${closed ? "?status=closed" : ""}">back to issues</a></p>`;441}442443function listPage(444 ctx: PageContext,445 repo: PublicRepo,446 status: "open" | "closed",447 query: string,448 type: string,449): string {450 const name = repo.settings.name;451 const counts = issueCounts(ctx.kepr, name);452 const filter = ISSUE_TYPES.includes(type as IssueType) ? type : "";453 const issues = listIssues(ctx.kepr, name, status).filter(454 (issue) =>455 (!filter || issue.type === filter) &&456 issue.title.toLowerCase().includes(query.trim().toLowerCase()),457 );458 const tab = (which: "open" | "closed") =>459 html`<a class="issues-tab${which === status ? " active" : ""}"460 href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">461${which} <span class="muted">${counts[which]}</span></a>`;462 return page(ctx, {463 title: `issues · ${name}`,464 description: `Issues filed against ${name}.`,465 path: `/${name}/issues`,466 body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>467<h1 class="file-page-title">issues</h1>468<p class="issues-bar mono">${tab("open")}${tab("closed")}469${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>470<form class="issues-filter" method="get" action="/${name}/issues">471<input type="hidden" name="status" value="${status}">472<input class="issues-input issues-search" name="q" type="search" aria-label="search issue titles"473placeholder="search issues" value="${query}">474<select class="issues-input issues-type" name="type" aria-label="issue type">475<option value="">all types</option>476${ISSUE_TYPES.map(477 (value) =>478 html`<option value="${value}"${filter === value ? html` selected` : ""}>${value}</option>`,479)}480</select><button class="issues-submit" type="submit">filter</button></form>481<section class="repo-section">482${483 issues.length > 0484 ? issues.map(485 (issue) =>486 html`<a class="issue-row" href="/${name}/issues/${issue.number}">487<span class="issue-symbol issue-symbol--${issue.status}">488${issue.status === "open" ? "◉" : "◈"}</span>489<span class="issue-title">${issue.title}</span>490${ ""}<span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}${issue.replies > 0 && ` · ${issue.replies} ${issue.replies === 1 ? "reply" : "replies"}`}</span>491<span class="issue-reporter mono">${issue.reporter_name || "anonymous"}</span>492</a>`,493 )494 : html`<p class="muted mono save-empty">${query || filter ? "no matching issues" : `no ${status} issues`}</p>`495}496</section>`,497 });498}499500501502function kept(value: string | undefined): string {503 return (value ?? "").slice(0, MAX_CHARS);504}505506function formPage(507 c: Context,508 ctx: PageContext,509 repo: PublicRepo,510 values: Fields,511 error: string,512 status: 200 | 400 | 403 | 429,513 mail: boolean,514): Response {515 const name = repo.settings.name;516 privatePage(c);517 const typeOption = (type: string) =>518 html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;519 const body = repo.settings.issuesPaused520 ? html`${crumbs(name)}521<h1 class="file-page-title">report an issue</h1>522<p>${name} is not taking new issues right now.</p>523<p><a href="/${name}/issues">see the existing issues →</a></p>`524 : html`${crumbs(name)}525<h1 class="file-page-title">report an issue</h1>526${error && html`<p class="issues-error mono" role="alert">${error}</p>`}527<form class="issues-form" method="post" action="/${name}/issues/new"528enctype="multipart/form-data">529<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">530<label class="issues-hp" aria-hidden="true">leave this empty531<input name="website" tabindex="-1" autocomplete="off"></label>532<label class="issues-label" for="issue-title">title</label>533<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${kept(values.title)}">534<label class="issues-label" for="issue-body">what happened</label>535<textarea class="issues-textarea" id="issue-body" name="body" rows="10" required536maxlength="${MAX_CHARS}">${kept(values.body)}</textarea>537${markdownHint}538${attachField("issue-files")}539<label class="issues-label" for="issue-type">type</label>540<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>541<label class="issues-label" for="issue-version">version you're on</label>542<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${kept(values.version)}">543<label class="issues-label" for="issue-name">name to show544<span class="muted">(optional)</span></label>545<input class="issues-input" id="issue-name" name="name" maxlength="${NAME_CHARS}"546autocomplete="nickname" value="${kept(values.name)}">547<label class="issues-label" for="issue-email">contact email548<span class="muted">(optional, only the operator sees it)</span></label>549<input class="issues-input" id="issue-email" name="email" type="email" maxlength="254"550autocomplete="email" value="${kept(values.email)}">551${552 mail &&553 html`<label class="issues-label issues-check">554<input type="checkbox" name="follow" value="1"${values.follow === "1" ? html` checked` : ""}>555email me each reply, and when it's closed</label>`556}557<p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>558<a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>559</form>`;560 return c.html(561 page(ctx, {562 title: `report an issue · ${name}`,563 description: `File an issue against ${name}.`,564 path: `/${name}/issues/new`,565 body,566 }),567 status,568 );569}570571const markdownHint = html`<p class="issue-hint mono">Markdown works: **bold**, \`code\`,572\`\`\` around logs, > to quote.</p>`;573574function attachField(id: string): Html {575 return html`<label class="issues-label" for="${id}">screenshots or logs576<span class="muted">(optional, up to ${FILES_PER_POST})</span></label>577<input class="issues-file" id="${id}" name="files" type="file" multiple578accept="image/png,image/jpeg,image/gif,image/webp,.txt,.log">579<ul class="issue-attached mono" aria-live="polite"></ul>580<p class="issue-hint mono">PNG, JPEG, GIF or WebP up to 8 MB; .txt or .log up to 2 MB.581Pictures can be pasted or dropped into the text box too.</p>`;582}583584585export function attachmentsHtml(base: string, files: Attachment[]): Html {586 const url = (file: Attachment) => `${base}/files/${file.id}.${FILE_EXTENSIONS[file.type]}`;587 const images = files.filter((file) => file.type !== "text/plain");588 const logs = files.filter((file) => file.type === "text/plain");589 return html`${590 images.length > 0 &&591 html`<div class="issue-shots">${images.map(592 (file) => html`<a class="issue-shot" href="${url(file)}" target="_blank" rel="noopener">593<img src="${url(file)}" alt="${file.name}" loading="lazy"></a>`,594 )}</div>`595 }${logs.map(596 (file) => html`<details class="issue-log">597<summary class="issue-log-name mono">${file.name}598<span class="muted">${formatSize(file.size)}</span></summary>599<pre><code>${new TextDecoder().decode(file.head ?? new Uint8Array())}</code></pre>600<p class="issue-log-foot mono">601<a class="issue-log-whole" href="${url(file)}">open the whole log</a>${602 file.size > (file.head?.length ?? 0) &&603 ` · showing its first ${formatSize(file.head?.length ?? 0)}`604 }</p>605</details>`,606 )}`;607}608609function threadPage(610 c: Context,611 ctx: PageContext,612 repo: PublicRepo,613 issue: Issue,614 entries: Entry[],615 view: View,616): string {617 const name = repo.settings.name;618 const base = `/${name}/issues/${issue.number}`;619 const open = issue.status === "open";620 const who = (entry: Entry) =>621 entry.author === "operator" ? "operator" : entry.name || "anonymous";622 const byId = new Map(entries.map((entry) => [entry.id, entry]));623 const answering = view.replyTo ? byId.get(view.replyTo) : undefined;624 const files = issueAttachments(ctx.kepr, issue.id);625 const stamp = formStamp(ctx.kepr, c, unixNow());626 627 const post = (key: number, author: string, operator: boolean, at: number, body: string) => {628 const id = key === 0 ? "issue" : `entry-${key}`;629 const answers = byId.get(byId.get(key)?.reply_to ?? -1);630 const attached = files.filter((file) => (file.entry_id ?? 0) === key);631 return html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}"632data-entry="${key === 0 ? "" : String(key)}" data-author="${author}">633<p class="issue-post-head mono">${634 operator635 ? html`<span class="issue-badge">operator</span>`636 : html`<span class="issue-author">${author}</span>`637 }${638 answers &&639 html`<span class="issue-replying">replying to640<a href="#entry-${answers.id}">${who(answers)}</a></span>`641 }<a class="history-when" href="#${id}" title="${formatDate(at)}">${relativeTime(at)}</a></p>642<div class="markdown issue-text">${renderComment(body)}</div>643${attached.length > 0 && attachmentsHtml(base, attached)}644${645 open &&646 html`<p class="issue-post-actions mono">647<a href="${base}?reply=${key}#reply" data-action="reply">reply</a>648<a href="${base}?quote=${key}#reply" data-action="quote">quote</a></p>`649}650</article>`;651 };652 const reporter = issue.reporter_name || "anonymous";653 const error = view.error && html`<p class="issues-error mono" role="alert">${view.error}</p>`;654 const honeypot = html`<input type="hidden" name="stamp" value="${stamp}">655<label class="issues-hp" aria-hidden="true">leave this empty656<input name="website" tabindex="-1" autocomplete="off"></label>`;657 return page(ctx, {658 title: `#${issue.number} ${issue.title} · ${name}`,659 description: issue.title,660 path: base,661 body: html`${crumbs(name, !open)}662<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>663<p class="issue-meta mono">664<span class="issue-symbol issue-symbol--${issue.status}">${open ? "◉ open" : "◈ closed"}</span>665${issue.type}${issue.version && ` · v${issue.version}`}666· filed by ${reporter} ${relativeTime(issue.filed_at)}667${entries.length > 0 && ` · ${entries.length} ${entries.length === 1 ? "reply" : "replies"}`}668${!open && fixedIn(repo, issue)}</p>669${view.notice && html`<p class="issue-notice mono" role="status">${view.notice}</p>`}670<div class="issue-thread">671${post(0, reporter, false, issue.filed_at, issue.body)}672${entries.map((entry) =>673 post(entry.id, who(entry), entry.author === "operator", entry.created_at, entry.body),674)}675</div>676${677 open678 ? html`<form class="issue-composer" id="reply" method="post" action="${base}"679enctype="multipart/form-data">680${honeypot}681<input type="hidden" name="reply_to" value="${answering ? String(answering.id) : ""}">682<p class="issue-composer-head mono"><label for="reply-body">add a reply</label>683<span class="issue-composer-to"${answering ? "" : html` hidden`}>replying to684<a class="issue-composer-name"685href="#entry-${answering ? String(answering.id) : ""}">${answering ? who(answering) : ""}</a>686<a class="issues-cancel" href="${base}#reply"687aria-label="reply to the issue instead">×</a></span></p>688${view.refused === "reply" && error}689<textarea class="issues-textarea" id="reply-body" name="body" rows="7" required690maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"691>${kept(view.body)}</textarea>692${markdownHint}693${attachField("reply-files")}694<div class="issue-composer-who">695<label class="issues-label" for="reply-name">name to show696<span class="muted">(optional)</span></label>697<input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"698autocomplete="nickname" value="${kept(view.name)}">699${700 view.mail &&701 html`<label class="issues-label" for="reply-email">email me new replies702<span class="muted">(optional, never shown)</span></label>703<input class="issues-input" id="reply-email" name="email" type="email" maxlength="254"704autocomplete="email" value="${view.refused === "reply" ? kept(view.email) : ""}">`705}706</div>707<p class="issues-actions"><button class="issues-submit" type="submit">post reply</button></p>708</form>`709 : html`${view.refused === "reply" && error}710<p class="issue-closed-note mono">This issue is closed, so it takes no new replies.</p>`711}712${713 view.mail &&714 html`<form class="issue-follow" id="follow" method="post" action="${base}/follow">715${honeypot}716<label class="issues-label" for="follow-email">follow by email717<span class="muted">(each reply, and when it closes; your address is never shown)</span></label>718${view.refused === "follow" && error}719<p class="issue-follow-row"><input class="issues-input" id="follow-email" name="email" type="email"720required maxlength="254" autocomplete="email" placeholder="[email protected]"721value="${view.refused === "follow" ? (view.email ?? "") : ""}">722<button class="issues-submit" type="submit">follow</button></p>723</form>`724}`,725 });726}727728function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {729 if (issue.fixed_in === "") return "";730 const save = saveByPrefix(repo.mirror, issue.fixed_in);731 732 const id =733 save && !save.is_checkpoint734 ? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`735 : html`<span class="history-id">${issue.fixed_in}</span>`;736 return html` · fixed in ${id}`;737}