pages.ts

12.5 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 { checkStamp, FILINGS_PER_HOUR, formStamp, ipHash, MAX_CHARS } from "./spam.ts";16import {17	addEntry,18	fileIssue,19	filingsSince,20	findIssue,21	hashToken,22	ISSUE_TYPES,23	type Issue,24	type IssueType,25	issueCounts,26	listIssues,27	threadEntries,28} from "./store.ts";2930type Fields = Record<string, string>;31type Notice = { error?: string; draft?: string };3233const TOKEN = /^[0-9a-f]{64}$/;34const FORM_FIELDS = ["title", "body", "type", "version", "name", "email", "website", "stamp"];35const unixNow = () => Math.floor(Date.now() / 1000);3637/** §23: the list, the filing form and the thread with its follow-up links. */38export function registerIssuePages(app: Hono, ctx: PageContext): void {39	app.get("/:repo/issues", (c) => {40		const repo = openPublicRepo(ctx, c.req.param("repo"));41		if (!repo) return notFound(c, ctx);42		const status = c.req.query("status") === "closed" ? "closed" : "open";43		cacheFor(c, false);44		return c.html(listPage(ctx, repo, status));45	});4647	app.get("/:repo/issues/new", (c) => {48		const repo = openPublicRepo(ctx, c.req.param("repo"));49		if (!repo) return notFound(c, ctx);50		const version = visibleReleases(ctx.kepr, repo.mirror, repo.settings.name)[0]?.version ?? "";51		return formPage(c, ctx, repo, { type: "bug", version }, "", 200);52	});5354	app.post("/:repo/issues/new", async (c) => {55		const repo = openPublicRepo(ctx, c.req.param("repo"));56		if (!repo) return notFound(c, ctx);57		const fields = await readFields(c, FORM_FIELDS);58		const now = unixNow();59		const refuse = (message: string, status: 400 | 403 | 429) =>60			formPage(c, ctx, repo, fields, message, status);61		// A bot that fills the hidden field learns nothing from the answer.62		if (fields.website !== "") return refuse("This issue could not be filed.", 400);63		const stamp = checkStamp(ctx.kepr, c, fields.stamp ?? "", now);64		if (stamp === "invalid") return refuse("This form expired. Please send it again.", 403);65		if (stamp === "too-fast") return refuse("That was quick. Please send it again.", 400);66		const title = fields.title?.trim() ?? "";67		const body = fields.body?.trim() ?? "";68		if (title === "" || body === "") return refuse("A title and what happened are needed.", 400);69		if (!ISSUE_TYPES.includes(fields.type as IssueType)) return refuse("Pick a type.", 400);70		if (Object.values(fields).some((value) => value.length > MAX_CHARS)) {71			return refuse(72				`Please keep each field under ${MAX_CHARS.toLocaleString("en-US")} characters.`,73				400,74			);75		}76		const hash = ipHash(ctx.kepr, c);77		if (filingsSince(ctx.kepr, hash, now - 3600) >= FILINGS_PER_HOUR) {78			return refuse("Too many issues from here this hour. Please try again later.", 429);79		}80		const name = repo.settings.name;81		const filed = fileIssue(ctx.kepr, {82			repo: name,83			title,84			body,85			type: fields.type as IssueType,86			version: fields.version?.trim() ?? "",87			reporterName: fields.name?.trim() ?? "",88			contactEmail: fields.email?.trim() ?? "",89			ipHash: hash,90			filedAt: now,91		});92		const link = `https://${ctx.domain}/${name}/issues/${filed.number}?token=${filed.token}`;93		privatePage(c);94		return c.html(95			page(ctx, {96				title: `issue #${filed.number} filed · ${name}`,97				description: "",98				path: c.req.path,99				body: html`${crumbs(name)}100<h1 class="file-page-title">Filed as <a href="/${name}/issues/${filed.number}">#${filed.number}</a></h1>101<p>This is your private link for adding to the issue later. It is shown only this once, so keep it; anyone who has it can post as you.</p>102<p class="issue-link mono"><a href="${link}">${link}</a></p>`,103			}),104		);105	});106107	app.get("/:repo/issues/:n{[0-9]{1,9}}", (c) => {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		if (!repo || !issue) return notFound(c, ctx);111		const token = c.req.query("token");112		if (token === undefined) {113			cacheFor(c, false);114			return c.html(threadPage(c, ctx, repo, issue, null, {}));115		}116		privatePage(c);117		return c.html(threadPage(c, ctx, repo, issue, token, {}));118	});119120	app.post("/:repo/issues/:n{[0-9]{1,9}}", async (c) => {121		const repo = openPublicRepo(ctx, c.req.param("repo"));122		const issue = repo && findIssue(ctx.kepr, repo.settings.name, Number(c.req.param("n")));123		if (!repo || !issue) return notFound(c, ctx);124		const { token = "", body = "", stamp = "" } = await readFields(c, ["token", "body", "stamp"]);125		privatePage(c);126		const refuse = (error: string, status: 400 | 403) =>127			c.html(threadPage(c, ctx, repo, issue, token, { error, draft: body }), status);128		if (!ownsIssue(issue, token))129			return refuse("This follow-up link does not match the issue.", 403);130		const check = checkStamp(ctx.kepr, c, stamp, unixNow());131		if (check === "invalid") return refuse("This form expired. Please send it again.", 403);132		if (check === "too-fast") return refuse("That was quick. Please send it again.", 400);133		const text = body.trim();134		if (text === "") return refuse("Write something to add.", 400);135		if (text.length > MAX_CHARS) {136			return refuse(`Please keep it under ${MAX_CHARS.toLocaleString("en-US")} characters.`, 400);137		}138		const entry = addEntry(ctx.kepr, issue.id, "reporter", text, unixNow());139		return c.redirect(140			`/${repo.settings.name}/issues/${issue.number}?token=${token}#entry-${entry}`,141			303,142		);143	});144}145146async function readFields(c: Context, names: string[]): Promise<Fields> {147	const form = await c.req.parseBody();148	const fields: Fields = {};149	for (const name of names) {150		const value = form[name];151		fields[name] = typeof value === "string" ? value : "";152	}153	return fields;154}155156function ownsIssue(issue: Issue, token: string): boolean {157	return TOKEN.test(token) && issue.token_hash === hashToken(token);158}159160// A page carrying a token or a form stamp is never cached, and its URL never leaks as a referrer.161function privatePage(c: Context): void {162	c.header("Cache-Control", "no-store");163	c.header("Referrer-Policy", "no-referrer");164}165166function crumbs(repo: string): Html {167	return html`<p class="breadcrumb mono"><a href="/${repo}">${repo}</a> / <a href="/${repo}/issues">issues</a></p>`;168}169170function listPage(ctx: PageContext, repo: PublicRepo, status: "open" | "closed"): string {171	const name = repo.settings.name;172	const counts = issueCounts(ctx.kepr, name);173	const issues = listIssues(ctx.kepr, name, status);174	const tab = (which: "open" | "closed") =>175		html`<a class="issues-tab${which === status ? " active" : ""}" href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">${which} <span class="muted">${counts[which]}</span></a>`;176	return page(ctx, {177		title: `issues · ${name}`,178		description: `Issues filed against ${name}.`,179		path: `/${name}/issues`,180		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>181<h1 class="file-page-title">issues</h1>182<p class="issues-bar mono">${tab("open")}${tab("closed")}<a class="issues-new" href="/${name}/issues/new">report an issue →</a></p>183<section class="repo-section">184${issues.length > 0 ? issues.map((issue) => html`<a class="issue-row" href="/${name}/issues/${issue.number}"><span class="issue-symbol issue-symbol--${issue.status}">${issue.status === "open" ? "◉" : "◈"}</span><span class="issue-title">${issue.title}</span><span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}</span></a>`) : html`<p class="muted mono save-empty">no ${status} issues</p>`}185</section>`,186	});187}188189function formPage(190	c: Context,191	ctx: PageContext,192	repo: PublicRepo,193	values: Fields,194	error: string,195	status: 200 | 400 | 403 | 429,196): Response {197	const name = repo.settings.name;198	privatePage(c);199	const typeOption = (type: string) =>200		html`<option value="${type}"${values.type === type ? html` selected` : ""}>${type}</option>`;201	const body = html`${crumbs(name)}202<h1 class="file-page-title">report an issue</h1>203${error && html`<p class="issues-error mono" role="alert">${error}</p>`}204<form class="issues-form" method="post" action="/${name}/issues/new">205<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">206<label class="issues-hp" aria-hidden="true">leave this empty <input name="website" tabindex="-1" autocomplete="off"></label>207<label class="issues-label" for="issue-title">title</label>208<input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">209<label class="issues-label" for="issue-body">what happened</label>210<textarea class="issues-textarea" id="issue-body" name="body" rows="8" required maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>211<label class="issues-label" for="issue-type">type</label>212<select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>213<label class="issues-label" for="issue-version">version you're on</label>214<input class="issues-input" id="issue-version" name="version" maxlength="200" value="${values.version ?? ""}">215<label class="issues-label" for="issue-name">name to show <span class="muted">(optional)</span></label>216<input class="issues-input" id="issue-name" name="name" maxlength="200" value="${values.name ?? ""}">217<label class="issues-label" for="issue-email">contact email <span class="muted">(optional, only the operator sees it)</span></label>218<input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">219<button class="issues-submit" type="submit">file issue</button>220</form>`;221	return c.html(222		page(ctx, {223			title: `report an issue · ${name}`,224			description: `File an issue against ${name}.`,225			path: `/${name}/issues/new`,226			body,227		}),228		status,229	);230}231232function threadPage(233	c: Context,234	ctx: PageContext,235	repo: PublicRepo,236	issue: Issue,237	token: string | null,238	notice: Notice,239): string {240	const name = repo.settings.name;241	const reporter = issue.reporter_name || "anonymous";242	const post = (id: string, operator: boolean, at: number, text: string) =>243		html`<article class="issue-post${operator ? " issue-post--operator" : ""}" id="${id}">244<p class="issue-post-head mono">${operator ? html`<span class="issue-badge">operator</span>` : html`<span class="issue-author">${reporter}</span>`}<span class="history-when">${formatDate(at)}</span></p>245<div class="issue-text">${text}</div>246</article>`;247	const owner = token !== null && ownsIssue(issue, token);248	return page(ctx, {249		title: `#${issue.number} ${issue.title} · ${name}`,250		description: issue.title,251		path: `/${name}/issues/${issue.number}`,252		body: html`${crumbs(name)}253<h1 class="file-page-title">${issue.title} <span class="muted">#${issue.number}</span></h1>254<p class="issue-meta mono"><span class="issue-symbol issue-symbol--${issue.status}">${issue.status === "open" ? "◉ open" : "◈ closed"}</span> · ${issue.type}${issue.version && ` · v${issue.version}`} · filed ${relativeTime(issue.filed_at)}${issue.status === "closed" && fixedIn(repo, issue)}</p>255${post("issue", false, issue.filed_at, issue.body)}256${threadEntries(ctx.kepr, issue.id).map((entry) => post(`entry-${entry.id}`, entry.author === "operator", entry.created_at, entry.body))}257${notice.error && html`<p class="issues-error mono" role="alert">${notice.error}</p>`}258${token !== null && !owner && !notice.error && html`<p class="issues-error mono">This follow-up link does not match the issue.</p>`}259${260	owner &&261	html`<form class="issues-form" method="post" action="/${name}/issues/${issue.number}">262<p class="muted">This is your private follow-up link; anyone who has it can post as you.</p>263<input type="hidden" name="token" value="${token}">264<input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">265<label class="issues-label" for="follow-up">add to this issue</label>266<textarea class="issues-textarea" id="follow-up" name="body" rows="5" required maxlength="${MAX_CHARS}">${notice.draft ?? ""}</textarea>267<button class="issues-submit" type="submit">post</button>268</form>`269}`,270	});271}272273function fixedIn(repo: PublicRepo, issue: Issue): Html | "" {274	if (issue.fixed_in === "") return "";275	const save = saveByPrefix(repo.mirror, issue.fixed_in);276	// Only a save with a page of its own is linked; a checkpoint or an unknown ID stays text.277	const id =278		save && !save.is_checkpoint279			? html`<a class="history-id" href="/${repo.settings.name}/saves/${save.id}">${save.id}</a>`280			: html`<span class="history-id">${issue.fixed_in}</span>`;281	return html` · fixed in ${id}`;282}