pages.ts

17.2 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	notFound,8	openPublicRepo,9	type PageContext,10	type PublicRepo,11	page,12	relativeTime,13} from "../pages/layout.ts";14import { renderComment } from "../pages/markdown.ts";15import { visibleReleases } from "../releases/releases.ts";16import { notify } from "../server/db.ts";17import {18	checkStamp,19	FILINGS_PER_HOUR,20	formStamp,21	ipHash,22	MAX_CHARS,23	REPLIES_PER_HOUR,24} from "./spam.ts";25import {26	addEntry,27	type Entry,28	fileIssue,29	filingsSince,30	findIssue,31	ISSUE_TYPES,32	type Issue,33	type IssueType,34	isBlocked,35	issueCounts,36	listIssues,37	repliesSince,38	threadEntries,39} from "./store.ts";4041type Fields = Record<string, string>;42// What a refused reply hands back to the composer, so nothing typed is lost.43type Draft = { error?: string; body?: string; name?: string; replyTo?: number | null };4445const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];46const REPLY_FIELDS = ["body", "name", "reply_to", "website", "stamp"];47const NAME_CHARS = 200;48const unixNow = () => Math.floor(Date.now() / 1000);49// The same words whatever the reason, so a blocked address learns nothing more.50const BLOCKED = "This address can't post here.";5152/** §23: the list, the filing form, and the thread anyone can reply to. */53export function registerIssuePages(app: Hono, ctx: PageContext): void {54	app.get("/:repo/issues", (c) => {55		const repo = openPublicRepo(ctx, c.req.param("repo"));56		if (!repo) return notFound(c, ctx);57		const status = c.req.query("status") === "closed" ? "closed" : "open";58		cacheFor(c, false);59		return c.html(listPage(ctx, repo, status, c.req.query("q") ?? "", c.req.query("type") ?? ""));60	});6162	app.get("/:repo/issues/new", (c) => {63		const repo = openPublicRepo(ctx, c.req.param("repo"));64		if (!repo) return notFound(c, ctx);65		const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";66		return formPage(c, ctx, repo, { type: "bug", version }, "", 200);67	});6869	app.post("/:repo/issues/new", async (c) => {70		const repo = openPublicRepo(ctx, c.req.param("repo"));71		if (!repo) return notFound(c, ctx);72		if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403);73		const fields = await readFields(c, FORM_FIELDS);74		const now = unixNow();75		const refuse = (message: string, status: 400 | 403 | 429) =>76			formPage(c, ctx, repo, fields, message, status);77		// A bot that fills the hidden field learns nothing from the answer.78		if (fields.website !== "") return refuse("This issue could not be filed.", 400);79		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);80		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);81		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);82		const title = fields.title?.trim() ?? "";83		const body = fields.body?.trim() ?? "";84		if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);85		if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);86		if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {87			return refuse(88				`Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,89				400,90			);91		}92		const hash = ipHash(ctx.kepr, c);93		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);94		if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {95			return refuse("Too many issues from here this hour. Please try again later.", 429);96		}97		const name = repo.settings.name;98		const number = fileIssue(ctx.kepr, {99			repo: name,100			title,101			body,102			type: fields.type as IssueType,103			version: fields.version?.trim() ?? "",104			reporterName: fields.name?.trim() ?? "",105			contactEmail: fields.email?.trim() ?? "",106			ipHash: hash,107			filedAt: now,108		});109		notify(110			ctx.kepr,111			"issue_filed",112			`New issue ${name} #${number}: ${title}`,113			`/admin/issues/${name}/${number}`,114		);115		return c.redirect(`/${name}/issues/${number}`, 303);116	});117118	app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {119		const repo = openPublicRepo(ctx, c.req.param("repo"));120		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));121		if (!repo || !issue) return notFound(c, ctx);122		const entries = threadEntries(ctx.kepr, issue.id);123		// `?reply=<id>` answers a reply; `?quote=<id>` also quotes it, and 0 is the issue itself.124		const quoting = c.req.query("quote");125		const asked = Number(quoting ?? c.req.query("reply"));126		const target = entries.find((entry) => entry.id === asked);127		const quoted = quoting === undefined ? null : asked === 0 ? issue.body : target?.body;128		privatePage(c);129		return c.html(130			threadPage(c, ctx, repo, issue, entries, {131				replyTo: target?.id ?? null,132				body: quoted ? `${quoted.replace(/^/gm, "> ")}\n\n` : "",133			}),134		);135	});136137	app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {138		const repo = openPublicRepo(ctx, c.req.param("repo"));139		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));140		if (!repo || !issue) return notFound(c, ctx);141		const fields = await readFields(c, REPLY_FIELDS);142		const entries = threadEntries(ctx.kepr, issue.id);143		const replyTo = entries.find((entry) => String(entry.id) === fields.reply_to)?.id ?? null;144		const body = fields.body?.trim() ?? "";145		const name = fields.name?.trim() ?? "";146		const now = unixNow();147		privatePage(c);148		const refuse = (error: string, status: 400 | 403 | 429) =>149			c.html(150				threadPage(c, ctx, repo, issue, entries, { error, body: fields.body, name, replyTo }),151				status,152			);153		if (fields.website !== "") return refuse("This reply could not be posted.", 400);154		if (issue.status === "closed") return refuse("This issue is closed to new replies.", 403);155		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);156		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);157		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);158		if (body === "") return refuse("Write something to reply with.", 400);159		if (body.length > MAX_CHARS || name.length > NAME_CHARS) {160			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);161		}162		const hash = ipHash(ctx.kepr, c);163		if (isBlocked(ctx.kepr, hash)) return refuse(BLOCKED, 403);164		if (repliesSince(ctx.kepr, hash, now - 3600) >= REPLIES_PER_HOUR) {165			return refuse("Too many replies from here this hour. Please try again later.", 429);166		}167		const entry = addEntry(ctx.kepr, issue.id, {168			author: "reporter",169			name,170			body,171			at: now,172			ipHash: hash,173			replyTo,174		});175		notify(176			ctx.kepr,177			"issue_followup",178			`Reply on ${repo.settings.name} #${issue.number}: ${issue.title}`,179			`/admin/issues/${repo.settings.name}/${issue.number}`,180		);181		return c.redirect(`/${repo.settings.name}/issues/${issue.number}#entry-${entry}`, 303);182	});183}184185async function readFields(c: Context, names: string[]): Promise<Fields> {186	const form = await c.req.parseBody();187	const fields: Fields = {};188	for (const name of names) {189		const value = form[name];190		fields[name] = typeof value === "string" ? value : "";191	}192	return fields;193}194195// A page carrying a form stamp is never cached, and its URL never leaks as a referrer.196function privatePage(c: Context): void {197	c.header("Cache-Control", "no-store");198	c.header("Referrer-Policy", "no-referrer");199}200201function crumbs(repo: string, closed = false): Html {202	return html`<p class="breadcrumb mono">203<a href="/${repo}">${repo}</a> /204<a href="/${repo}/issues${closed ? "?status=closed" : ""}">back to issues</a></p>`;205}206207function listPage(208	ctx: PageContext,209	repo: PublicRepo,210	status: "open" | "closed",211	query: string,212	type: string,213): string {214	const name = repo.settings.name;215	const counts = issueCounts(ctx.kepr, name);216	const filter = ISSUE_TYPES.includes(type as IssueType) ? type : "";217	const issues = listIssues(ctx.kepr, name, status).filter(218		(issue) =>219			(!filter || issue.type === filter) &&220			issue.title.toLowerCase().includes(query.trim().toLowerCase()),221	);222	const tab = (which: "open" | "closed") =>223		html`<a class="issues-tab${which === status ? " active" : ""}"224			href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">225${which} <span class="muted">${counts[which]}</span></a>`;226	return page(ctx, {227		title: `issues · ${name}`,228		description: `Issues filed against ${name}.`,229		path: `/${name}/issues`,230		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>231<h1 class="file-page-title">issues</h1>232<p class="issues-bar mono">${tab("open")}${tab("closed")}233${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>234<form class="issues-filter" method="get" action="/${name}/issues">235<input type="hidden" name="status" value="${status}">236<input class="issues-input issues-search" name="q" type="search" aria-label="search issue titles"237placeholder="search issues" value="${query}">238<select class="issues-input issues-type" name="type" aria-label="issue type">239<option value="">all types</option>240${ISSUE_TYPES.map(241	(value) =>242		html`<option value="${value}"${filter === value ? html` selected` : ""}>${value}</option>`,243)}244</select><button class="issues-submit" type="submit">filter</button></form>245<section class="repo-section">246${247	issues.length > 0248		? issues.map(249				(issue) =>250					html`<a class="issue-row" href="/${name}/issues/${issue.number}">251<span class="issue-symbol issue-symbol--${issue.status}">252${issue.status === "open" ? "◉" : "◈"}</span>253<span class="issue-title">${issue.title}</span>254${/* 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>255<span class="issue-reporter mono">${issue.reporter_name || "anonymous"}</span>256</a>`,257			)258		: html`<p class="muted mono save-empty">${query || filter ? "no matching issues" : `no ${status} issues`}</p>`259}260</section>`,261	});262}263264function formPage(265	c: Context,266	ctx: PageContext,267	repo: PublicRepo,268	values: Fields,269	error: string,270	status: 200 | 400 | 403 | 429,271): Response {272	const name = repo.settings.name;273	privatePage(c);274	const typeOption = (type: string) =>275		html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;276	const body = repo.settings.issuesPaused277		? html`${crumbs(name)}278<h1 class="file-page-title">report an issue</h1>279<p>${name} is not taking new issues right now.</p>280<p><a href="/${name}/issues">see the existing issues →</a></p>`281		: html`${crumbs(name)}282<h1 class="file-page-title">report an issue</h1>283${error && html`<p class="issues-error mono" role="alert">${error}</p>`}284<form class="issues-form" method="post" action="/${name}/issues/new">285<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">286<label class="issues-hp" aria-hidden="true">leave this empty287<input name="website" tabindex="-1" autocomplete="off"></label>288<label class="issues-label" for="issue-title">title</label>289<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">290<label class="issues-label" for="issue-body">what happened</label>291<textarea class="issues-textarea" id="issue-body" name="body" rows="10" required292maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>293${markdownHint}294<label class="issues-label" for="issue-type">type</label>295<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>296<label class="issues-label" for="issue-version">version you're on</label>297<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">298<label class="issues-label" for="issue-name">name to show299<span class="muted">(optional)</span></label>300<input class="issues-input" id="issue-name" name="name" maxlength="${NAME_CHARS}"301autocomplete="nickname" value="${values.name ?? ""}">302<label class="issues-label" for="issue-email">contact email303<span class="muted">(optional, only the operator sees it)</span></label>304<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">305<p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>306<a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>307</form>`;308	return c.html(309		page(ctx, {310			title: `report an issue · ${name}`,311			description: `File an issue against ${name}.`,312			path: `/${name}/issues/new`,313			body,314		}),315		status,316	);317}318319const markdownHint = html`<p class="issue-hint mono">Markdown works: **bold**, \`code\`,320\`\`\` around logs, &gt; to quote.</p>`;321322function threadPage(323	c: Context,324	ctx: PageContext,325	repo: PublicRepo,326	issue: Issue,327	entries: Entry[],328	draft: Draft,329): string {330	const name = repo.settings.name;331	const base = `/${name}/issues/${issue.number}`;332	const open = issue.status === "open";333	const who = (entry: Entry) =>334		entry.author === "operator" ? "operator" : entry.name || "anonymous";335	const byId = new Map(entries.map((entry) => [entry.id, entry]));336	const answering = draft.replyTo ? byId.get(draft.replyTo) : undefined;337	// The issue itself is post 0: quoting it quotes what was filed.338	const post = (key: number, author: string, operator: boolean, at: number, body: string) => {339		const id = key === 0 ? "issue" : `entry-${key}`;340		const answers = byId.get(byId.get(key)?.reply_to ?? -1);341		return html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}"342data-entry="${key === 0 ? "" : String(key)}" data-author="${author}">343<p class="issue-post-head mono">${344			operator345				? html`<span class="issue-badge">operator</span>`346				: html`<span class="issue-author">${author}</span>`347		}${348			answers &&349			html`<span class="issue-replying">replying to350<a href="#entry-${answers.id}">${who(answers)}</a></span>`351		}<a class="history-when" href="#${id}" title="${formatDate(at)}">${relativeTime(at)}</a></p>352<div class="markdown issue-text">${renderComment(body)}</div>353${354	open &&355	html`<p class="issue-post-actions mono">356<a href="${base}?reply=${key}#reply" data-action="reply">reply</a>357<a href="${base}?quote=${key}#reply" data-action="quote">quote</a></p>`358}359</article>`;360	};361	const reporter = issue.reporter_name || "anonymous";362	return page(ctx, {363		title: `#${issue.number} ${issue.title} · ${name}`,364		description: issue.title,365		path: base,366		body: html`${crumbs(name, !open)}367<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>368<p class="issue-meta mono">369<span class="issue-symbol issue-symbol--${issue.status}">${open ? "◉ open" : "◈ closed"}</span>370${issue.type}${issue.version && ` · v${issue.version}`}371· filed by ${reporter} ${relativeTime(issue.filed_at)}372${entries.length > 0 && ` · ${entries.length} ${entries.length === 1 ? "reply" : "replies"}`}373${!open && fixedIn(repo, issue)}</p>374<div class="issue-thread">375${post(0, reporter, false, issue.filed_at, issue.body)}376${entries.map((entry) =>377	post(entry.id, who(entry), entry.author === "operator", entry.created_at, entry.body),378)}379</div>380${381	open382		? html`<form class="issue-composer" id="reply" method="post" action="${base}">383<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">384<label class="issues-hp" aria-hidden="true">leave this empty385<input name="website" tabindex="-1" autocomplete="off"></label>386<input type="hidden" name="reply_to" value="${answering ? String(answering.id) : ""}">387<p class="issue-composer-head mono"><label for="reply-body">add a reply</label>388<span class="issue-composer-to"${answering ? "" : html` hidden`}>replying to389<a class="issue-composer-name"390href="#entry-${answering ? String(answering.id) : ""}">${answering ? who(answering) : ""}</a>391<a class="issues-cancel" href="${base}#reply"392aria-label="reply to the issue instead">×</a></span></p>393${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}394<textarea class="issues-textarea" id="reply-body" name="body" rows="7" required395maxlength="${MAX_CHARS}" placeholder="More detail, another angle, what you tried…"396>${draft.body ?? ""}</textarea>397${markdownHint}398<label class="issues-label" for="reply-name">name to show399<span class="muted">(optional)</span></label>400<input class="issues-input" id="reply-name" name="name" maxlength="${NAME_CHARS}"401autocomplete="nickname" value="${draft.name ?? ""}">402<p class="issues-actions"><button class="issues-submit" type="submit">post reply</button></p>403</form>`404		: html`${draft.error && html`<p class="issues-error mono" role="alert">${draft.error}</p>`}405<p class="issue-closed-note mono">This issue is closed, so it takes no new replies.</p>`406}`,407	});408}409410function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {411	if (issue.fixed_in === "") return "";412	const save = saveByPrefix(repo.mirror, issue.fixed_in);413	// Only a save with a page of its own is linked; a checkpoint or an unknown ID stays text.414	const id =415		save && !save.is_checkpoint416			? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`417			: html`<span class="history-id">${issue.fixed_in}</span>`;418	return html` · fixed in ${id}`;419}