pages.ts

21.8 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 { notify } from "../server/db.ts";18import {19	type Attachment,20	attachFiles,21	FILE_BYTES_PER_HOUR,22	FILE_EXTENSIONS,23	FILES_PER_POST,24	fileBytesSince,25	issueAttachments,26	readUploads,27	type Upload,28	visibleFile,29} from "./files.ts";30import {31	checkStamp,32	FILINGS_PER_HOUR,33	formStamp,34	ipHash,35	MAX_CHARS,36	REPLIES_PER_HOUR,37} from "./spam.ts";38import {39	addEntry,40	type Entry,41	fileIssue,42	filingsSince,43	findIssue,44	ISSUE_TYPES,45	type Issue,46	type IssueType,47	isBlocked,48	issueCounts,49	listIssues,50	repliesSince,51	threadEntries,52} from "./store.ts";5354type Fields = Record<string, string>;55// What a refused reply hands back to the composer, so nothing typed is lost.56type Draft = { error?: string; body?: string; name?: string; replyTo?: number | null };5758const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];59const REPLY_FIELDS = ["body", "name", "reply_to", "website", "stamp"];60const NAME_CHARS = 200;61const unixNow = () => Math.floor(Date.now() / 1000);62// The same words whatever the reason, so a blocked address learns nothing more.63const BLOCKED = "This address can't post here.";6465/** §23: the list, the filing form, and the thread anyone can reply to. */66export function registerIssuePages(app: Hono, ctx: PageContext): void {67	app.get("/:repo/issues", (c) => {68		const repo = openPublicRepo(ctx, c.req.param("repo"));69		if (!repo) return notFound(c, ctx);70		const status = c.req.query("status") === "closed" ? "closed" : "open";71		cacheFor(c, false);72		return c.html(listPage(ctx, repo, status, c.req.query("q") ?? "", c.req.query("type") ?? ""));73	});7475	app.get("/:repo/issues/new", (c) => {76		const repo = openPublicRepo(ctx, c.req.param("repo"));77		if (!repo) return notFound(c, ctx);78		const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";79		return formPage(c, ctx, repo, { type: "bug", version }, "", 200);80	});8182	app.post("/:repo/issues/new", async (c) => {83		const repo = openPublicRepo(ctx, c.req.param("repo"));84		if (!repo) return notFound(c, ctx);85		if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403);86		const { fields, files } = await readForm(c, FORM_FIELDS);87		const now = unixNow();88		const refuse = (message: string, status: 400 | 403 | 429) =>89			formPage(c, ctx, repo, fields, attachAgain(message, files), status);90		// A bot that fills the hidden field learns nothing from the answer.91		if (fields.website !== "") return refuse("This issue could not be filed.", 400);92		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);93		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);94		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);95		const title = fields.title?.trim() ?? "";96		const body = fields.body?.trim() ?? "";97		if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);98		if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);99		if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {100			return refuse(101				`Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,102				400,103			);104		}105		const hash = ipHash(ctx.kepr, c);106		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);107		if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {108			return refuse("Too many issues from here this hour. Please try again later.", 429);109		}110		const uploads = await checkUploads(ctx, files, hash, now);111		if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status);112		const name = repo.settings.name;113		const filed = fileIssue(ctx.kepr, {114			repo: name,115			title,116			body,117			type: fields.type as IssueType,118			version: fields.version?.trim() ?? "",119			reporterName: fields.name?.trim() ?? "",120			contactEmail: fields.email?.trim() ?? "",121			ipHash: hash,122			filedAt: now,123		});124		attachFiles(ctx.kepr, filed.id, null, uploads, hash, now);125		notify(126			ctx.kepr,127			"issue_filed",128			`New issue ${name} #${filed.number}: ${title}`,129			`/admin/issues/${name}/${filed.number}`,130		);131		return c.redirect(`/${name}/issues/${filed.number}`, 303);132	});133134	app.get("/:repo/issues/:n{[0-9]{1,9}}/files/:file{[0-9]{1,9}\\.[a-z]{3,4}}", (c) => {135		const repo = openPublicRepo(ctx, c.req.param("repo"));136		const [id, ext] = c.req.param("file").split(".");137		const file =138			repo && visibleFile(ctx.kepr, repo.settings.name, Number(c.req.param("n")), Number(id));139		// One address per file: a name ending in another type's extension is as missing as no file.140		if (!repo || !file || FILE_EXTENSIONS[file.type] !== ext) return notFound(c, ctx);141		return c.body(file.data, 200, {142			"Content-Type": file.type === "text/plain" ? "text/plain; charset=utf-8" : file.type,143			"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`,144			// Short, so a file hidden as spam soon leaves shared caches too.145			"Cache-Control": "public, max-age=300",146		});147	});148149	app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {150		const repo = openPublicRepo(ctx, c.req.param("repo"));151		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));152		if (!repo || !issue) return notFound(c, ctx);153		const entries = threadEntries(ctx.kepr, issue.id);154		// `?reply=<id>` answers a reply; `?quote=<id>` also quotes it, and 0 is the issue itself.155		const quoting = c.req.query("quote");156		const asked = Number(quoting ?? c.req.query("reply"));157		const target = entries.find((entry) => entry.id === asked);158		const quoted = quoting === undefined ? null : asked === 0 ? issue.body : target?.body;159		privatePage(c);160		return c.html(161			threadPage(c, ctx, repo, issue, entries, {162				replyTo: target?.id ?? null,163				body: quoted ? `${quoted.replace(/^/gm, "> ")}\n\n` : "",164			}),165		);166	});167168	app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {169		const repo = openPublicRepo(ctx, c.req.param("repo"));170		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));171		if (!repo || !issue) return notFound(c, ctx);172		const { fields, files } = await readForm(c, REPLY_FIELDS);173		const entries = threadEntries(ctx.kepr, issue.id);174		const replyTo = entries.find((entry) => String(entry.id) === fields.reply_to)?.id ?? null;175		const body = fields.body?.trim() ?? "";176		const name = fields.name?.trim() ?? "";177		const now = unixNow();178		privatePage(c);179		const refuse = (message: string, status: 400 | 403 | 429) => {180			const error = attachAgain(message, files);181			const draft = { error, body: fields.body, name, replyTo };182			return c.html(threadPage(c, ctx, repo, issue, entries, draft), status);183		};184		if (fields.website !== "") return refuse("This reply could not be posted.", 400);185		if (issue.status === "closed") return refuse("This issue is closed to new replies.", 403);186		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);187		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);188		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);189		if (body === "") return refuse("Write something to reply with.", 400);190		if (body.length > MAX_CHARS || name.length > NAME_CHARS) {191			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);192		}193		const hash = ipHash(ctx.kepr, c);194		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);195		if (repliesSince(ctx.kepr, hash, now - 3600) >= REPLIES_PER_HOUR) {196			return refuse("Too many replies from here this hour. Please try again later.", 429);197		}198		const uploads = await checkUploads(ctx, files, hash, now);199		if (!Array.isArray(uploads)) return refuse(uploads.error, uploads.status);200		const entry = addEntry(ctx.kepr, issue.id, {201			author: "reporter",202			name,203			body,204			at: now,205			ipHash: hash,206			replyTo,207		});208		attachFiles(ctx.kepr, issue.id, entry, uploads, hash, now);209		notify(210			ctx.kepr,211			"issue_followup",212			`Reply on ${repo.settings.name} #${issue.number}: ${issue.title}`,213			`/admin/issues/${repo.settings.name}/${issue.number}`,214		);215		return c.redirect(`/${repo.settings.name}/issues/${issue.number}#entry-${entry}`, 303);216	});217}218219async function readForm(c: Context, names: string[]): Promise<{ fields: Fields; files: File[] }> {220	const form = await c.req.parseBody({ all: true });221	const fields: Fields = {};222	for (const name of names) {223		const value = form[name];224		fields[name] = typeof value === "string" ? value : "";225	}226	// An empty file input still sends one nameless, empty part.227	const files = [form.files ?? []]228		.flat()229		.filter((file): file is File => file instanceof File && file.size > 0);230	return { fields, files };231}232233/** A post's files, checked last so the post's own refusals come first; or why they're refused. */234async function checkUploads(235	ctx: PageContext,236	files: File[],237	hash: string,238	now: number,239): Promise<Upload[] | { error: string; status: 400 | 429 }> {240	const uploads = await readUploads(files);241	if (typeof uploads === "string") return { error: uploads, status: 400 };242	const bytes = uploads.reduce((sum, upload) => sum + upload.data.length, 0);243	if (bytes > 0 && fileBytesSince(ctx.kepr, hash, now - 3600) + bytes > FILE_BYTES_PER_HOUR) {244		return {245			error: "Too many attachments from here this hour. Please try again later.",246			status: 429,247		};248	}249	return uploads;250}251252// A browser can't refill a file input, so a refused post says its files need choosing again.253function attachAgain(message: string, files: File[]): string {254	return files.length > 0 ? `${message} Please attach your files again.` : message;255}256257// A page carrying a form stamp is never cached, and its URL never leaks as a referrer.258function privatePage(c: Context): void {259	c.header("Cache-Control", "no-store");260	c.header("Referrer-Policy", "no-referrer");261}262263function crumbs(repo: string, closed = false): Html {264	return html`<p class="breadcrumb mono">265<a href="/${repo}">${repo}</a> /266<a href="/${repo}/issues${closed ? "?status=closed" : ""}">back to issues</a></p>`;267}268269function listPage(270	ctx: PageContext,271	repo: PublicRepo,272	status: "open" | "closed",273	query: string,274	type: string,275): string {276	const name = repo.settings.name;277	const counts = issueCounts(ctx.kepr, name);278	const filter = ISSUE_TYPES.includes(type as IssueType) ? type : "";279	const issues = listIssues(ctx.kepr, name, status).filter(280		(issue) =>281			(!filter || issue.type === filter) &&282			issue.title.toLowerCase().includes(query.trim().toLowerCase()),283	);284	const tab = (which: "open" | "closed") =>285		html`<a class="issues-tab${which === status ? " active" : ""}"286			href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">287${which} <span class="muted">${counts[which]}</span></a>`;288	return page(ctx, {289		title: `issues · ${name}`,290		description: `Issues filed against ${name}.`,291		path: `/${name}/issues`,292		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>293<h1 class="file-page-title">issues</h1>294<p class="issues-bar mono">${tab("open")}${tab("closed")}295${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>296<form class="issues-filter" method="get" action="/${name}/issues">297<input type="hidden" name="status" value="${status}">298<input class="issues-input issues-search" name="q" type="search" aria-label="search issue titles"299placeholder="search issues" value="${query}">300<select class="issues-input issues-type" name="type" aria-label="issue type">301<option value="">all types</option>302${ISSUE_TYPES.map(303	(value) =>304		html`<option value="${value}"${filter === value ? html` selected` : ""}>${value}</option>`,305)}306</select><button class="issues-submit" type="submit">filter</button></form>307<section class="repo-section">308${309	issues.length > 0310		? issues.map(311				(issue) =>312					html`<a class="issue-row" href="/${name}/issues/${issue.number}">313<span class="issue-symbol issue-symbol--${issue.status}">314${issue.status === "open" ? "◉" : "◈"}</span>315<span class="issue-title">${issue.title}</span>316${/* 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>317<span class="issue-reporter mono">${issue.reporter_name || "anonymous"}</span>318</a>`,319			)320		: html`<p class="muted mono save-empty">${query || filter ? "no matching issues" : `no ${status} issues`}</p>`321}322</section>`,323	});324}325326function formPage(327	c: Context,328	ctx: PageContext,329	repo: PublicRepo,330	values: Fields,331	error: string,332	status: 200 | 400 | 403 | 429,333): Response {334	const name = repo.settings.name;335	privatePage(c);336	const typeOption = (type: string) =>337		html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;338	const body = repo.settings.issuesPaused339		? html`${crumbs(name)}340<h1 class="file-page-title">report an issue</h1>341<p>${name} is not taking new issues right now.</p>342<p><a href="/${name}/issues">see the existing issues →</a></p>`343		: html`${crumbs(name)}344<h1 class="file-page-title">report an issue</h1>345${error && html`<p class="issues-error mono" role="alert">${error}</p>`}346<form class="issues-form" method="post" action="/${name}/issues/new"347enctype="multipart/form-data">348<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">349<label class="issues-hp" aria-hidden="true">leave this empty350<input name="website" tabindex="-1" autocomplete="off"></label>351<label class="issues-label" for="issue-title">title</label>352<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">353<label class="issues-label" for="issue-body">what happened</label>354<textarea class="issues-textarea" id="issue-body" name="body" rows="10" required355maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>356${markdownHint}357${attachField("issue-files")}358<label class="issues-label" for="issue-type">type</label>359<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>360<label class="issues-label" for="issue-version">version you're on</label>361<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">362<label class="issues-label" for="issue-name">name to show363<span class="muted">(optional)</span></label>364<input class="issues-input" id="issue-name" name="name" maxlength="${NAME_CHARS}"365autocomplete="nickname" value="${values.name ?? ""}">366<label class="issues-label" for="issue-email">contact email367<span class="muted">(optional, only the operator sees it)</span></label>368<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">369<p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>370<a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>371</form>`;372	return c.html(373		page(ctx, {374			title: `report an issue · ${name}`,375			description: `File an issue against ${name}.`,376			path: `/${name}/issues/new`,377			body,378		}),379		status,380	);381}382383const markdownHint = html`<p class="issue-hint mono">Markdown works: **bold**, \`code\`,384\`\`\` around logs, &gt; to quote.</p>`;385386function attachField(id: string): Html {387	return html`<label class="issues-label" for="${id}">screenshots or logs388<span class="muted">(optional, up to ${FILES_PER_POST})</span></label>389<input class="issues-file" id="${id}" name="files" type="file" multiple390accept="image/png,image/jpeg,image/gif,image/webp,.txt,.log">391<ul class="issue-attached mono" aria-live="polite"></ul>392<p class="issue-hint mono">PNG, JPEG, GIF or WebP up to 8 MB; .txt or .log up to 2 MB.393Pictures can be pasted or dropped into the text box too.</p>`;394}395396/** A post's pictures side by side, and each log as its start with the whole file a link away. */397export function attachmentsHtml(base: string, files: Attachment[]): Html {398	const url = (file: Attachment) => `${base}/files/${file.id}.${FILE_EXTENSIONS[file.type]}`;399	const images = files.filter((file) => file.type !== "text/plain");400	const logs = files.filter((file) => file.type === "text/plain");401	return html`${402		images.length > 0 &&403		html`<div class="issue-shots">${images.map(404			(file) => html`<a class="issue-shot" href="${url(file)}" target="_blank" rel="noopener">405<img src="${url(file)}" alt="${file.name}" loading="lazy"></a>`,406		)}</div>`407	}${logs.map(408		(file) => html`<details class="issue-log">409<summary class="issue-log-name mono">${file.name}410<span class="muted">${formatSize(file.size)}</span></summary>411<pre><code>${new TextDecoder().decode(file.head ?? new Uint8Array())}</code></pre>412<p class="issue-log-foot mono">413<a class="issue-log-whole" href="${url(file)}">open the whole log</a>${414			file.size > (file.head?.length ?? 0) &&415			` · showing its first ${formatSize(file.head?.length ?? 0)}`416		}</p>417</details>`,418	)}`;419}420421function threadPage(422	c: Context,423	ctx: PageContext,424	repo: PublicRepo,425	issue: Issue,426	entries: Entry[],427	draft: Draft,428): string {429	const name = repo.settings.name;430	const base = `/${name}/issues/${issue.number}`;431	const open = issue.status === "open";432	const who = (entry: Entry) =>433		entry.author === "operator" ? "operator" : entry.name || "anonymous";434	const byId = new Map(entries.map((entry) => [entry.id, entry]));435	const answering = draft.replyTo ? byId.get(draft.replyTo) : undefined;436	const files = issueAttachments(ctx.kepr, issue.id);437	// The issue itself is post 0: quoting it quotes what was filed.438	const post = (key: number, author: string, operator: boolean, at: number, body: string) => {439		const id = key === 0 ? "issue" : `entry-${key}`;440		const answers = byId.get(byId.get(key)?.reply_to ?? -1);441		const attached = files.filter((file) => (file.entry_id ?? 0) === key);442		return html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}"443data-entry="${key === 0 ? "" : String(key)}" data-author="${author}">444<p class="issue-post-head mono">${445			operator446				? html`<span class="issue-badge">operator</span>`447				: html`<span class="issue-author">${author}</span>`448		}${449			answers &&450			html`<span class="issue-replying">replying to451<a href="#entry-${answers.id}">${who(answers)}</a></span>`452		}<a class="history-when" href="#${id}" title="${formatDate(at)}">${relativeTime(at)}</a></p>453<div class="markdown issue-text">${renderComment(body)}</div>454${attached.length > 0 && attachmentsHtml(base, attached)}455${456	open &&457	html`<p class="issue-post-actions mono">458<a href="${base}?reply=${key}#reply" data-action="reply">reply</a>459<a href="${base}?quote=${key}#reply" data-action="quote">quote</a></p>`460}461</article>`;462	};463	const reporter = issue.reporter_name || "anonymous";464	return page(ctx, {465		title: `#${issue.number} ${issue.title} · ${name}`,466		description: issue.title,467		path: base,468		body: html`${crumbs(name, !open)}469<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>470<p class="issue-meta mono">471<span class="issue-symbol issue-symbol--${issue.status}">${open ? "◉ open" : "◈ closed"}</span>472${issue.type}${issue.version && ` · v${issue.version}`}473· filed by ${reporter} ${relativeTime(issue.filed_at)}474${entries.length > 0 && ` · ${entries.length} ${entries.length === 1 ? "reply" : "replies"}`}475${!open && fixedIn(repo, issue)}</p>476<div class="issue-thread">477${post(0, reporter, false, issue.filed_at, issue.body)}478${entries.map((entry) =>479	post(entry.id, who(entry), entry.author === "operator", entry.created_at, entry.body),480)}481</div>482${483	open484		? html`<form class="issue-composer" id="reply" method="post" action="${base}"485enctype="multipart/form-data">486<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">487<label class="issues-hp" aria-hidden="true">leave this empty488<input name="website" tabindex="-1" autocomplete="off"></label>489<input type="hidden" name="reply_to" value="${answering ? String(answering.id) : ""}">490<p class="issue-composer-head mono"><label for="reply-body">add a reply</label>491<span class="issue-composer-to"${answering ? "" : html` hidden`}>replying to492<a class="issue-composer-name"493href="#entry-${answering ? String(answering.id) : ""}">${answering ? who(answering) : ""}</a>494<a class="issues-cancel" href="${base}#reply"495aria-label="reply to the issue instead">×</a></span></p>496${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}497<textarea class="issues-textarea" id="reply-body" name="body" rows="7" required498maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"499>${draft.body ?? ""}</textarea>500${markdownHint}501${attachField("reply-files")}502<label class="issues-label" for="reply-name">name to show503<span class="muted">(optional)</span></label>504<input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"505autocomplete="nickname" value="${draft.name ?? ""}">506<p class="issues-actions"><button class="issues-submit" type="submit">post reply</button></p>507</form>`508		: html`${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}509<p class="issue-closed-note mono">This issue is closed, so it takes no new replies.</p>`510}`,511	});512}513514function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {515	if (issue.fixed_in === "") return "";516	const save = saveByPrefix(repo.mirror, issue.fixed_in);517	// Only a save with a page of its own is linked; a checkpoint or an unknown ID stays text.518	const id =519		save && !save.is_checkpoint520			? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`521			: html`<span class="history-id">${issue.fixed_in}</span>`;522	return html` · fixed in ${id}`;523}