pages.ts

14.4 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 { visibleReleases } from "../releases/releases.ts";15import { notify } from "../server/db.ts";16import { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts";17import {18	addEntry,19	fileIssue,20	filingsSince,21	findIssue,22	hashToken,23	ISSUE_TYPES,24	type Issue,25	type IssueType,26	issueCounts,27	listIssues,28	threadEntries,29} from "./store.ts";3031type Fields = Record<string, string>;32type Notice = { error?: string; draft?: string };3334const TOKEN = /^[0-9a-f]{64}$/;35const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];36const unixNow = () => Math.floor(Date.now() / 1000);3738/** §23: the list, the filing form and the thread with its follow-up links. */39export function registerIssuePages(app: Hono, ctx: PageContext): void {40	app.get("/:repo/issues", (c) => {41		const repo = openPublicRepo(ctx, c.req.param("repo"));42		if (!repo) return notFound(c, ctx);43		const status = c.req.query("status") === "closed" ? "closed" : "open";44		cacheFor(c, false);45		return c.html(listPage(ctx, repo, status, c.req.query("q") ?? "", c.req.query("type") ?? ""));46	});4748	app.get("/:repo/issues/new", (c) => {49		const repo = openPublicRepo(ctx, c.req.param("repo"));50		if (!repo) return notFound(c, ctx);51		const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";52		return formPage(c, ctx, repo, { type: "bug", version }, "", 200);53	});5455	app.post("/:repo/issues/new", async (c) => {56		const repo = openPublicRepo(ctx, c.req.param("repo"));57		if (!repo) return notFound(c, ctx);58		if (repo.settings.issuesPaused) return formPage(c, ctx, repo, {}, "", 403);59		const fields = await readFields(c, FORM_FIELDS);60		const now = unixNow();61		const refuse = (message: string, status: 400 | 403 | 429) =>62			formPage(c, ctx, repo, fields, message, status);63		// A bot that fills the hidden field learns nothing from the answer.64		if (fields.website !== "") return refuse("This issue could not be filed.", 400);65		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);66		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);67		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);68		const title = fields.title?.trim() ?? "";69		const body = fields.body?.trim() ?? "";70		if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);71		if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);72		if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {73			return refuse(74				`Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,75				400,76			);77		}78		const hash = ipHash(ctx.kepr, c);79		if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {80			return refuse("Too many issues from here this hour. Please try again later.", 429);81		}82		const name = repo.settings.name;83		const filed = fileIssue(ctx.kepr, {84			repo: name,85			title,86			body,87			type: fields.type as IssueType,88			version: fields.version?.trim() ?? "",89			reporterName: fields.name?.trim() ?? "",90			contactEmail: fields.email?.trim() ?? "",91			ipHash: hash,92			filedAt: now,93		});94		notify(95			ctx.kepr,96			"issue_filed",97			`New issue ${name} #${filed.number}: ${title}`,98			`/admin/issues/${name}/${filed.number}`,99		);100		const link = `https://${ctx.domain}/${name}/issues/${filed.number}?token=${filed.token}`;101		privatePage(c);102		return c.html(103			page(ctx, {104				title: `issue #${filed.number} filed · ${name}`,105				description: "",106				path: c.req.path,107				body: html`${crumbs(name)}108<h1 class="file-page-title">Filed as109<a href="/${name}/issues/${filed.number}">#${filed.number}</a></h1>110<p>This is your private link for adding to the issue later. It is shown only this once,111so keep it; anyone who has it can post as you.</p>112<p class="issue-link mono"><a href="${link}">${link}</a></p>`,113			}),114		);115	});116117	app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {118		const repo = openPublicRepo(ctx, c.req.param("repo"));119		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));120		if (!repo || !issue) return notFound(c, ctx);121		const token = c.req.query("token");122		if (token === undefined) {123			cacheFor(c, false);124			return c.html(threadPage(c, ctx, repo, issue, null, {}));125		}126		privatePage(c);127		return c.html(threadPage(c, ctx, repo, issue, token, {}));128	});129130	app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {131		const repo = openPublicRepo(ctx, c.req.param("repo"));132		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));133		if (!repo || !issue) return notFound(c, ctx);134		const { token = "", body = "", stamp = "" } = await readFields(c, ["token", "body", "stamp"]);135		privatePage(c);136		const refuse = (error: string, status: 400 | 403) =>137			c.html(threadPage(c, ctx, repo, issue, token, { error, draft: body }), status);138		if (!ownsIssue(issue, token))139			return refuse("This follow-up link does not match the issue.", 403);140		const check = checkStamp(ctx.kepr, c, stamp, unixNow());141		if (check === "invalid") return refuse("This form expired. Please send it again.", 403);142		if (check === "too-fast") return refuse("That was quick. Please send it again.", 400);143		const text = body.trim();144		if (text === "") return refuse("Write something to add.", 400);145		if (text.length > MAX_CHARS) {146			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);147		}148		const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow());149		notify(150			ctx.kepr,151			"issue_followup",152			`Follow-up on ${repo.settings.name} #${issue.number}: ${issue.title}`,153			`/admin/issues/${repo.settings.name}/${issue.number}`,154		);155		return c.redirect(156			`/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`,157			303,158		);159	});160}161162async function readFields(c: Context, names: string[]): Promise<Fields> {163	const form = await c.req.parseBody();164	const fields: Fields = {};165	for (const name of names) {166		const value = form[name];167		fields[name] = typeof value === "string" ? value : "";168	}169	return fields;170}171172function ownsIssue(issue: Issue, token: string): boolean {173	return TOKEN.test(token) && issue.token_hash === hashToken(token);174}175176// A page carrying a token or a form stamp is never cached, and its URL never leaks as a referrer.177function privatePage(c: Context): void {178	c.header("Cache-Control", "no-store");179	c.header("Referrer-Policy", "no-referrer");180}181182function crumbs(repo: string): Html {183	return html`<p class="breadcrumb mono">184<a href="/${repo}">${repo}</a> / <a href="/${repo}/issues">issues</a></p>`;185}186187function listPage(188	ctx: PageContext,189	repo: PublicRepo,190	status: "open" | "closed",191	query: string,192	type: string,193): string {194	const name = repo.settings.name;195	const counts = issueCounts(ctx.kepr, name);196	const filter = ISSUE_TYPES.includes(type as IssueType) ? type : "";197	const issues = listIssues(ctx.kepr, name, status).filter(198		(issue) =>199			(!filter || issue.type === filter) &&200			issue.title.toLowerCase().includes(query.trim().toLowerCase()),201	);202	const tab = (which: "open" | "closed") =>203		html`<a class="issues-tab${which === status ? " active" : ""}"204			href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">205${which} <span class="muted">${counts[which]}</span></a>`;206	return page(ctx, {207		title: `issues · ${name}`,208		description: `Issues filed against ${name}.`,209		path: `/${name}/issues`,210		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>211<h1 class="file-page-title">issues</h1>212<p class="issues-bar mono">${tab("open")}${tab("closed")}213${!repo.settings.issuesPaused && html`<a class="issues-new" href="/${name}/issues/new">report an issue →</a>`}</p>214<form class="issues-filter" method="get" action="/${name}/issues">215<input type="hidden" name="status" value="${status}">216<input class="issues-input" name="q" type="search" aria-label="search issue titles"217placeholder="search issues" value="${query}">218<select class="issues-input" name="type" aria-label="issue type">219<option value="">all types</option>220${ISSUE_TYPES.map(221	(value) =>222		html`<option value="${value}"${filter === value ? html` selected` : ""}>${value}</option>`,223)}224</select><button class="issues-submit" type="submit">filter</button></form>225<section class="repo-section">226${227	issues.length > 0228		? issues.map(229				(issue) =>230					html`<a class="issue-row" href="/${name}/issues/${issue.number}">231<span class="issue-symbol issue-symbol--${issue.status}">232${issue.status === "open" ? "◉" : "◈"}</span>233<span class="issue-title">${issue.title}</span>234${/* 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)}</span>235<span class="issue-reporter mono">${issue.reporter_name || "anonymous"}</span>236</a>`,237			)238		: html`<p class="muted mono save-empty">${query || filter ? "no matching issues" : `no ${status} issues`}</p>`239}240</section>`,241	});242}243244function formPage(245	c: Context,246	ctx: PageContext,247	repo: PublicRepo,248	values: Fields,249	error: string,250	status: 200 | 400 | 403 | 429,251): Response {252	const name = repo.settings.name;253	privatePage(c);254	const typeOption = (type: string) =>255		html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;256	const body = repo.settings.issuesPaused257		? html`${crumbs(name)}258<h1 class="file-page-title">report an issue</h1>259<p>${name} is not taking new issues right now.</p>260<p><a href="/${name}/issues">see the existing issues →</a></p>`261		: html`${crumbs(name)}262<h1 class="file-page-title">report an issue</h1>263${error && html`<p class="issues-error mono" role="alert">${error}</p>`}264<form class="issues-form" method="post" action="/${name}/issues/new">265<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">266<label class="issues-hp" aria-hidden="true">leave this empty267<input name="website" tabindex="-1" autocomplete="off"></label>268<label class="issues-label" for="issue-title">title</label>269<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">270<label class="issues-label" for="issue-body">what happened</label>271<textarea class="issues-textarea" id="issue-body" name="body" rows="8" required272maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>273<label class="issues-label" for="issue-type">type</label>274<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>275<label class="issues-label" for="issue-version">version you're on</label>276<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">277<label class="issues-label" for="issue-name">name to show278<span class="muted">(optional)</span></label>279<input class="issues-input" id="issue-name" name="name" maxlength="200" value="${values.name ?? ""}">280<label class="issues-label" for="issue-email">contact email281<span class="muted">(optional, only the operator sees it)</span></label>282<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">283<p class="issues-actions"><button class="issues-submit" type="submit">file issue</button>284<a class="issues-cancel mono" href="/${name}/issues">cancel</a></p>285</form>`;286	return c.html(287		page(ctx, {288			title: `report an issue · ${name}`,289			description: `File an issue against ${name}.`,290			path: `/${name}/issues/new`,291			body,292		}),293		status,294	);295}296297function threadPage(298	c: Context,299	ctx: PageContext,300	repo: PublicRepo,301	issue: Issue,302	token: string | null,303	notice: Notice,304): string {305	const name = repo.settings.name;306	const reporter = issue.reporter_name || "anonymous";307	const post = (id: string, operator: boolean, at: number, text: string) =>308		html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}">309<p class="issue-post-head mono">${310			operator311				? html`<span class="issue-badge">operator</span>`312				: html`<span class="issue-author">${reporter}</span>`313		}<span class="history-when">${formatDate(at)}</span></p>314<div class="issue-text">${text}</div>315</article>`;316	const owner = token !== null && ownsIssue(issue, token);317	return page(ctx, {318		title: `#${issue.number} ${issue.title} · ${name}`,319		description: issue.title,320		path: `/${name}/issues/${issue.number}`,321		body: html`${crumbs(name)}322<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>323<p class="issue-meta mono">324<span class="issue-symbol issue-symbol--${issue.status}">325${issue.status === "open" ? "◉ open" : "◈ closed"}</span>326${issue.type}${issue.version && ` · v${issue.version}`} · filed ${relativeTime(issue.filed_at)}327${issue.status === "closed" && fixedIn(repo, issue)}</p>328${post("issue", false, issue.filed_at, issue.body)}329${threadEntries(ctx.kepr, issue.id).map((entry) =>330	post(`entry-${entry.id}`, entry.author === "operator", entry.created_at, entry.body),331)}332${notice.error && html`<p class="issues-error mono" role="alert">${notice.error}</p>`}333${334	token !== null &&335	!owner &&336	!notice.error &&337	html`<p class="issues-error mono">This follow-up link does not match the issue.</p>`338}339${340	owner &&341	html`<form class="issues-form" method="post" action="/${name}/issues/${issue.number}">342<p class="muted">This is your private follow-up link; anyone who has it can post as you.</p>343<input type="hidden" name="token" value="${token}">344<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">345<label class="issues-label" for="follow-up">add to this issue</label>346<textarea class="issues-textarea" id="follow-up" name="body" rows="5" required347maxlength="${MAX_CHARS}">${notice.draft ?? ""}</textarea>348<button class="issues-submit" type="submit">post</button>349</form>`350}`,351	});352}353354function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {355	if (issue.fixed_in === "") return "";356	const save = saveByPrefix(repo.mirror, issue.fixed_in);357	// Only a save with a page of its own is linked; a checkpoint or an unknown ID stays text.358	const id =359		save && !save.is_checkpoint360			? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`361			: html`<span class="history-id">${issue.fixed_in}</span>`;362	return html` · fixed in ${id}`;363}