pages.ts

29.1 KB · raw

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	followsSince,35	isEmail,36	tellFollowers,37	unfollow,38} from "./followers.ts";39import {40	checkStamp,41	FILINGS_PER_HOUR,42	followTokenMatches,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>;65// How a thread is shown: whether email is offered, a notice, and what a refused reply typed.66type View = {67	mail: boolean;68	notice?: string;69	/** Which form a refusal belongs to, so its error and what was typed show there. */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);92// The same words whatever the reason, so a blocked address learns nothing more.93const BLOCKED = "This address can't post here.";94const BAD_EMAIL = "That email address doesn't look right.";95// What a thread says after a follow form or link, named by `?notice=`.96const 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};102103/** §23: the list, the filing form, and the thread anyone can reply to and follow by email. */104export 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		// A bot that fills the hidden field learns nothing from the answer.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		// One address per file: a name ending in another type's extension is as missing as no file.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			// Short, so a file hidden as spam soon leaves shared caches too.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		// `?reply=<id>` answers a reply; `?quote=<id>` also quotes it, and 0 is the issue itself.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 =297			mail() &&298			email !== "" &&299			follow(ctx.kepr, site(), repo.settings.name, issue, email, hash, now);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		// The same answer whether the address was new, so a page never says who follows what; it330		// opens at the top, where the notice is.331		return c.redirect(`${base}?notice=check`, 303);332	});333334	// The link in a confirmation email (§23); its token is the proof the inbox is theirs.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	// A stop link asks first, so a mail scanner opening it changes nothing.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 (!followTokenMatches(ctx.kepr, id, token)) return c.redirect(`${base}?notice=expired`, 303);357		const query = new URLSearchParams({ f: String(id), t: token });358		return c.html(359			page(ctx, {360				title: `stop emails · #${issue.number} ${issue.title}`,361				description: "",362				path: `${base}/unfollow`,363				body: html`${crumbs(repo.settings.name)}364<h1 class="file-page-title">stop emails about #${issue.number}?</h1>365<p>You follow “${issue.title}” by email. Stopping takes effect at once.</p>366<form class="issues-actions" method="post" action="${base}/unfollow?${query.toString()}">367<button class="issues-submit" type="submit">stop emails</button>368<a class="issues-cancel mono" href="${base}">keep them</a></form>`,369			}),370		);371	});372373	// Both the page's button and an inbox's one-click unsubscribe (RFC 8058) post here.374	app.post("/:repo/issues/:n{[0-9]{1,9}}/unfollow", async (c) => {375		const found = open(c);376		if (!found) return notFound(c, ctx);377		const stopped = unfollow(378			ctx.kepr,379			found.issue.id,380			Number(c.req.query("f")),381			c.req.query("t") ?? "",382		);383		const form = await c.req.parseBody();384		if (form["List-Unsubscribe"] === "One-Click") {385			return c.text(stopped ? "Unsubscribed." : "Unknown link.", stopped ? 200 : 404);386		}387		return c.redirect(`${found.base}?notice=${stopped ? "stopped" : "expired"}`, 303);388	});389}390391async function readForm(c: Context, names: string[]): Promise<{ fields: Fields; files: File[] }> {392	const form = await c.req.parseBody({ all: true });393	const fields: Fields = {};394	for (const name of names) {395		const value = form[name];396		fields[name] = typeof value === "string" ? value : "";397	}398	// An empty file input still sends one nameless, empty part.399	const files = [form.files ?? []]400		.flat()401		.filter((file): file is File => file instanceof File && file.size > 0);402	return { fields, files };403}404405/** A post's files, checked last so the post's own refusals come first; or why they're refused. */406async function checkUploads(407	ctx: PageContext,408	files: File[],409	hash: string,410	now: number,411): Promise<Upload[] | { error: string; status: 400 | 429 }> {412	const uploads = await readUploads(files);413	if (typeof uploads === "string") return { error: uploads, status: 400 };414	const bytes = uploads.reduce((sum, upload) => sum + upload.data.length, 0);415	if (bytes > 0 && fileBytesSince(ctx.kepr, hash, now - 3600) + bytes > FILE_BYTES_PER_HOUR) {416		return {417			error: "Too many attachments from here this hour. Please try again later.",418			status: 429,419		};420	}421	return uploads;422}423424// A browser can't refill a file input, so a refused post says its files need choosing again.425function attachAgain(message: string, files: File[]): string {426	return files.length > 0 ? `${message} Please attach your files again.` : message;427}428429// A page carrying a form stamp or a follow link is never cached, nor sent on as a referrer.430function privatePage(c: Context): void {431	c.header("Cache-Control", "no-store");432	c.header("Referrer-Policy", "no-referrer");433}434435function crumbs(repo: string, closed = false): Html {436	return html`<p class="breadcrumb mono">437<a href="/${repo}">${repo}</a> /438<a href="/${repo}/issues${closed ? "?status=closed" : ""}">back to issues</a></p>`;439}440441function listPage(442	ctx: PageContext,443	repo: PublicRepo,444	status: "open" | "closed",445	query: string,446	type: string,447): string {448	const name = repo.settings.name;449	const counts = issueCounts(ctx.kepr, name);450	const filter = ISSUE_TYPES.includes(type as IssueType) ? type : "";451	const issues = listIssues(ctx.kepr, name, status).filter(452		(issue) =>453			(!filter || issue.type === filter) &&454			issue.title.toLowerCase().includes(query.trim().toLowerCase()),455	);456	const tab = (which: "open" | "closed") =>457		html`<a class="issues-tab${which === status ? " active" : ""}"458			href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">459${which} <span class="muted">${counts[which]}</span></a>`;460	return page(ctx, {461		title: `issues · ${name}`,462		description: `Issues filed against ${name}.`,463		path: `/${name}/issues`,464		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>465<h1 class="file-page-title">issues</h1>466<p class="issues-bar mono">${tab("open")}${tab("closed")}467${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>468<form class="issues-filter" method="get" action="/${name}/issues">469<input type="hidden" name="status" value="${status}">470<input class="issues-input issues-search" name="q" type="search" aria-label="search issue titles"471placeholder="search issues" value="${query}">472<select class="issues-input issues-type" name="type" aria-label="issue type">473<option value="">all types</option>474${ISSUE_TYPES.map(475	(value) =>476		html`<option value="${value}"${filter === value ? html` selected` : ""}>${value}</option>`,477)}478</select><button class="issues-submit" type="submit">filter</button></form>479<section class="repo-section">480${481	issues.length > 0482		? issues.map(483				(issue) =>484					html`<a class="issue-row" href="/${name}/issues/${issue.number}">485<span class="issue-symbol issue-symbol--${issue.status}">486${issue.status === "open" ? "◉" : "◈"}</span>487<span class="issue-title">${issue.title}</span>488${/* this line stays whole: splitting it changes the text tests/issues.test.ts asserts */ ""}<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>489<span class="issue-reporter mono">${issue.reporter_name || "anonymous"}</span>490</a>`,491			)492		: html`<p class="muted mono save-empty">${query || filter ? "no matching issues" : `no ${status} issues`}</p>`493}494</section>`,495	});496}497498function formPage(499	c: Context,500	ctx: PageContext,501	repo: PublicRepo,502	values: Fields,503	error: string,504	status: 200 | 400 | 403 | 429,505	mail: boolean,506): Response {507	const name = repo.settings.name;508	privatePage(c);509	const typeOption = (type: string) =>510		html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;511	const body = repo.settings.issuesPaused512		? html`${crumbs(name)}513<h1 class="file-page-title">report an issue</h1>514<p>${name} is not taking new issues right now.</p>515<p><a href="/${name}/issues">see the existing issues →</a></p>`516		: html`${crumbs(name)}517<h1 class="file-page-title">report an issue</h1>518${error && html`<p class="issues-error mono" role="alert">${error}</p>`}519<form class="issues-form" method="post" action="/${name}/issues/new"520enctype="multipart/form-data">521<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">522<label class="issues-hp" aria-hidden="true">leave this empty523<input name="website" tabindex="-1" autocomplete="off"></label>524<label class="issues-label" for="issue-title">title</label>525<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">526<label class="issues-label" for="issue-body">what happened</label>527<textarea class="issues-textarea" id="issue-body" name="body" rows="10" required528maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>529${markdownHint}530${attachField("issue-files")}531<label class="issues-label" for="issue-type">type</label>532<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>533<label class="issues-label" for="issue-version">version you're on</label>534<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">535<label class="issues-label" for="issue-name">name to show536<span class="muted">(optional)</span></label>537<input class="issues-input" id="issue-name" name="name" maxlength="${NAME_CHARS}"538autocomplete="nickname" value="${values.name ?? ""}">539<label class="issues-label" for="issue-email">contact email540<span class="muted">(optional, only the operator sees it)</span></label>541<input class="issues-input" id="issue-email" name="email" type="email" maxlength="254"542autocomplete="email" value="${values.email ?? ""}">543${544	mail &&545	html`<label class="issues-label issues-check">546<input type="checkbox" name="follow" value="1"${values.follow === "1" ? html` checked` : ""}>547email me each reply, and when it's closed</label>`548}549<p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>550<a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>551</form>`;552	return c.html(553		page(ctx, {554			title: `report an issue · ${name}`,555			description: `File an issue against ${name}.`,556			path: `/${name}/issues/new`,557			body,558		}),559		status,560	);561}562563const markdownHint = html`<p class="issue-hint mono">Markdown works: **bold**, \`code\`,564\`\`\` around logs, &gt; to quote.</p>`;565566function attachField(id: string): Html {567	return html`<label class="issues-label" for="${id}">screenshots or logs568<span class="muted">(optional, up to ${FILES_PER_POST})</span></label>569<input class="issues-file" id="${id}" name="files" type="file" multiple570accept="image/png,image/jpeg,image/gif,image/webp,.txt,.log">571<ul class="issue-attached mono" aria-live="polite"></ul>572<p class="issue-hint mono">PNG, JPEG, GIF or WebP up to 8 MB; .txt or .log up to 2 MB.573Pictures can be pasted or dropped into the text box too.</p>`;574}575576/** A post's pictures side by side, and each log as its start with the whole file a link away. */577export function attachmentsHtml(base: string, files: Attachment[]): Html {578	const url = (file: Attachment) => `${base}/files/${file.id}.${FILE_EXTENSIONS[file.type]}`;579	const images = files.filter((file) => file.type !== "text/plain");580	const logs = files.filter((file) => file.type === "text/plain");581	return html`${582		images.length > 0 &&583		html`<div class="issue-shots">${images.map(584			(file) => html`<a class="issue-shot" href="${url(file)}" target="_blank" rel="noopener">585<img src="${url(file)}" alt="${file.name}" loading="lazy"></a>`,586		)}</div>`587	}${logs.map(588		(file) => html`<details class="issue-log">589<summary class="issue-log-name mono">${file.name}590<span class="muted">${formatSize(file.size)}</span></summary>591<pre><code>${new TextDecoder().decode(file.head ?? new Uint8Array())}</code></pre>592<p class="issue-log-foot mono">593<a class="issue-log-whole" href="${url(file)}">open the whole log</a>${594			file.size > (file.head?.length ?? 0) &&595			` · showing its first ${formatSize(file.head?.length ?? 0)}`596		}</p>597</details>`,598	)}`;599}600601function threadPage(602	c: Context,603	ctx: PageContext,604	repo: PublicRepo,605	issue: Issue,606	entries: Entry[],607	view: View,608): string {609	const name = repo.settings.name;610	const base = `/${name}/issues/${issue.number}`;611	const open = issue.status === "open";612	const who = (entry: Entry) =>613		entry.author === "operator" ? "operator" : entry.name || "anonymous";614	const byId = new Map(entries.map((entry) => [entry.id, entry]));615	const answering = view.replyTo ? byId.get(view.replyTo) : undefined;616	const files = issueAttachments(ctx.kepr, issue.id);617	const stamp = formStamp(ctx.kepr, c, unixNow());618	// The issue itself is post 0: quoting it quotes what was filed.619	const post = (key: number, author: string, operator: boolean, at: number, body: string) => {620		const id = key === 0 ? "issue" : `entry-${key}`;621		const answers = byId.get(byId.get(key)?.reply_to ?? -1);622		const attached = files.filter((file) => (file.entry_id ?? 0) === key);623		return html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}"624data-entry="${key === 0 ? "" : String(key)}" data-author="${author}">625<p class="issue-post-head mono">${626			operator627				? html`<span class="issue-badge">operator</span>`628				: html`<span class="issue-author">${author}</span>`629		}${630			answers &&631			html`<span class="issue-replying">replying to632<a href="#entry-${answers.id}">${who(answers)}</a></span>`633		}<a class="history-when" href="#${id}" title="${formatDate(at)}">${relativeTime(at)}</a></p>634<div class="markdown issue-text">${renderComment(body)}</div>635${attached.length > 0 && attachmentsHtml(base, attached)}636${637	open &&638	html`<p class="issue-post-actions mono">639<a href="${base}?reply=${key}#reply" data-action="reply">reply</a>640<a href="${base}?quote=${key}#reply" data-action="quote">quote</a></p>`641}642</article>`;643	};644	const reporter = issue.reporter_name || "anonymous";645	const error = view.error && html`<p class="issues-error mono" role="alert">${view.error}</p>`;646	const honeypot = html`<input type="hidden" name="stamp" value="${stamp}">647<label class="issues-hp" aria-hidden="true">leave this empty648<input name="website" tabindex="-1" autocomplete="off"></label>`;649	return page(ctx, {650		title: `#${issue.number} ${issue.title} · ${name}`,651		description: issue.title,652		path: base,653		body: html`${crumbs(name, !open)}654<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>655<p class="issue-meta mono">656<span class="issue-symbol issue-symbol--${issue.status}">${open ? "◉ open" : "◈ closed"}</span>657${issue.type}${issue.version && ` · v${issue.version}`}658· filed by ${reporter} ${relativeTime(issue.filed_at)}659${entries.length > 0 && ` · ${entries.length} ${entries.length === 1 ? "reply" : "replies"}`}660${!open && fixedIn(repo, issue)}</p>661${view.notice && html`<p class="issue-notice mono" role="status">${view.notice}</p>`}662<div class="issue-thread">663${post(0, reporter, false, issue.filed_at, issue.body)}664${entries.map((entry) =>665	post(entry.id, who(entry), entry.author === "operator", entry.created_at, entry.body),666)}667</div>668${669	open670		? html`<form class="issue-composer" id="reply" method="post" action="${base}"671enctype="multipart/form-data">672${honeypot}673<input type="hidden" name="reply_to" value="${answering ? String(answering.id) : ""}">674<p class="issue-composer-head mono"><label for="reply-body">add a reply</label>675<span class="issue-composer-to"${answering ? "" : html` hidden`}>replying to676<a class="issue-composer-name"677href="#entry-${answering ? String(answering.id) : ""}">${answering ? who(answering) : ""}</a>678<a class="issues-cancel" href="${base}#reply"679aria-label="reply to the issue instead">×</a></span></p>680${view.refused === "reply" && error}681<textarea class="issues-textarea" id="reply-body" name="body" rows="7" required682maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"683>${view.body ?? ""}</textarea>684${markdownHint}685${attachField("reply-files")}686<div class="issue-composer-who">687<label class="issues-label" for="reply-name">name to show688<span class="muted">(optional)</span></label>689<input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"690autocomplete="nickname" value="${view.name ?? ""}">691${692	view.mail &&693	html`<label class="issues-label" for="reply-email">email me new replies694<span class="muted">(optional, never shown)</span></label>695<input class="issues-input" id="reply-email" name="email" type="email" maxlength="254"696autocomplete="email" value="${view.refused === "reply" ? (view.email ?? "") : ""}">`697}698</div>699<p class="issues-actions"><button class="issues-submit" type="submit">post reply</button></p>700</form>`701		: html`${view.refused === "reply" && error}702<p class="issue-closed-note mono">This issue is closed, so it takes no new replies.</p>`703}704${705	view.mail &&706	html`<form class="issue-follow" id="follow" method="post" action="${base}/follow">707${honeypot}708<label class="issues-label" for="follow-email">follow by email709<span class="muted">(each reply, and when it closes; your address is never shown)</span></label>710${view.refused === "follow" && error}711<p class="issue-follow-row"><input class="issues-input" id="follow-email" name="email" type="email"712required maxlength="254" autocomplete="email" placeholder="[email protected]"713value="${view.refused === "follow" ? (view.email ?? "") : ""}">714<button class="issues-submit" type="submit">follow</button></p>715</form>`716}`,717	});718}719720function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {721	if (issue.fixed_in === "") return "";722	const save = saveByPrefix(repo.mirror, issue.fixed_in);723	// Only a save with a page of its own is linked; a checkpoint or an unknown ID stays text.724	const id =725		save && !save.is_checkpoint726			? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`727			: html`<span class="history-id">${issue.fixed_in}</span>`;728	return html` · fixed in ${id}`;729}