2b7a429de79a

apply the rebalanced budgets and R11's normal layout across Kepr, and close the five fixes: the frozen Koh 2 404 body, executable bits in tarballs, the per-address sign-in lockout, the sandbox on raw files and uploaded assets, and Koh's own strip rule in the mirror (v3.0.52)

time
2026-09-27 10:42 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
kepr 3 implementation
parent
dbdfebfae1f4
39 files package.json+1 −1public/js/kepr.js+2 −2scripts/budget.ts+6 −5scripts/import-kepr2.ts+126 −25src/admin/console.ts+10 −3src/admin/issues.ts+33 −9src/admin/releases.ts+16 −4src/admin/repos.ts+11 −4src/admin/site.ts+11 −4src/bucket/repo.ts+2 −1src/describe/describe.ts+5 −2src/issues/pages.ts+53 −15src/issues/spam.ts+2 −2src/issues/store.ts+29 −10src/mirror/queries.ts+8 −3src/mirror/store.ts+24 −3src/pages/archives.ts+18 −4src/pages/atlas.ts+33 −9src/pages/diff.ts+3 −2src/pages/files.ts+23 −8src/pages/front.ts+13 −3src/pages/housekeeping.ts+6 −2src/pages/layout.ts+8 −4src/pages/library.ts+9 −3src/pages/profile.ts+26 −8src/pages/releases.ts+14 −4src/pages/repo.ts+32 −8src/pages/save.ts+40 −12src/pages/timeline.ts+8 −3src/server/app.ts+5 −0src/server/config.ts+5 −2src/server/db.ts+2 −1src/server/login.ts+2 −4src/server/reserved.ts+3 −3tests/admin.test.ts+1 −3tests/bucket.test.ts+5 −1tests/contract.test.ts+12 −9tests/pages.test.ts+4 −1tests/tarball.test.ts+15 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.51",3+	"version": "3.0.52",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/js/kepr.js+2 −2
@@ -124,8 +124,8 @@124124 	panel.hidden = false;125125 }126126 127-// Storage can be missing, refused (private windows, blocked site data) or hold anything; only a list128-// of folder paths is trusted, and otherwise the tree starts closed.127+// Storage can be missing, refused (private windows, blocked windows) or hold anything; only a128+// list of folder paths is trusted, and otherwise the tree starts closed.129129 function remembered(key) {130130 	try {131131 		const value = JSON.parse(localStorage.getItem(key) ?? "[]");

view file

scripts/budget.ts+6 −5
@@ -1,4 +1,5 @@1-// §26: fails when any area passes its budget or the TypeScript total passes the 10,000-line ceiling.1+// §26: fails when any area passes its budget or the TypeScript total passes the2+// 10,000-line ceiling.23 const CEILING = 10_000;34 45 // Prefixes are matched in order, so the specific files come before the directories.@@ -9,14 +10,14 @@910 	["pages", ["src/pages/"], 2600],1011 	["releases", ["src/releases/"], 900],1112 	["tarball", ["src/tarball/"], 300],12-	["issues", ["src/issues/"], 600],13+	["issues", ["src/issues/"], 800],1314 	["admin", ["src/admin/"], 1100],14-	["import-kepr2", ["scripts/import-kepr2.ts"], 100],15-	["server", ["src/server/", "src/main.ts", "scripts/budget.ts"], 700],15+	["import-kepr2", ["scripts/import-kepr2.ts"], 300],16+	["server", ["src/server/", "src/main.ts", "scripts/budget.ts"], 1000],1617 ];1718 const ASSET_AREAS: [string, string, number][] = [1819 	["public/js", "public/js/**/*.js", 1200],19-	["public/css", "public/css/**/*.css", 2000],20+	["public/css", "public/css/**/*.css", 2600],2021 ];2122 2223 async function lineCount(path: string): Promise<number> {

view file

scripts/import-kepr2.ts+126 −25
@@ -1,4 +1,5 @@1-// One-time Kepr 2 import (§28), deleted after cutover; the only bucket writer. --dry-run writes nothing.1+// One-time Kepr 2 import (§28), deleted after cutover; the only bucket writer.2+// --dry-run writes nothing.23 import { dirname, join } from "node:path";34 import { blake3 } from "@noble/hashes/blake3.js";45 import { bytesToHex } from "@noble/hashes/utils.js";@@ -25,7 +26,8 @@2526 	console.log(had ? "have" : dry ? "would put" : "put", key);2627 	if (had && !Bun.deepEquals(had, bytes)) throw new Error(`${key} holds other bytes`);2728 }28-// Bun's S3 client can't write conditionally, so a presigned PUT does; a 412 returns the racer's bytes.29+// Bun's S3 client can't write conditionally, so a presigned PUT does;30+// a 412 returns the racer's bytes.2931 async function write(key: string, bytes: Uint8Array): Promise<Uint8Array | null> {3032 	if (folder) return Bun.write(join(folder, key), bytes).then(() => null);3133 	const url = new Bun.S3Client({ ...s3, bucket: name }).presign(key, { method: "PUT" });@@ -35,7 +37,10 @@3537 }3638 function insert(table: string, row: Row, tail: string): number | null {3739 	const cols = Object.keys(row);38-	const sql = `INSERT INTO ${table} (${cols}) VALUES (${cols.map((c) => `$${c}`)}) ${tail} RETURNING rowid AS id`;40+	const sql = `INSERT INTO ${table} (${cols})41+		VALUES (${cols.map((c) => `$${c}`)})42+		${tail}43+		RETURNING rowid AS id`;3944 	const id = kepr.query<{ id: number }, [Row]>(sql).get(row)?.id ?? null;4045 	console.log(table, JSON.stringify(row).slice(0, 80), id ? "set" : "have");4146 	return id;@@ -45,43 +50,139 @@4550 kepr.run("BEGIN");4651 const upsert = "ON CONFLICT DO UPDATE SET public = excluded.public, category = excluded.category";4752 for (const repo of await pg<Row[]>`48-	SELECT name, (private = 0)::int AS public, trim(split_part(COALESCE(categories, ''), ',', 1)) AS category FROM repos`)53+	SELECT54+		name,55+		(private = 0)::int AS public,56+		trim(split_part(COALESCE(categories, ''), ',', 1)) AS category57+	FROM repos`) {4958 	insert("repos", repo, upsert);50-// Kepr 2's four statuses become two (Phase 5); "enhancement" is an idea. Kepr 2 wrote the operator's51-// replies as "operator" (NULL before accounts); a third account's reply has no author kind here.59+}60+// Kepr 2's four statuses become two (Phase 5); "enhancement" is an idea.61+// Kepr 2 wrote the operator's replies as "operator" (NULL before accounts);62+// a third account's reply has no author kind here.5263 for (const { replies, ...i } of await pg<(Row & { replies: Row[] })[]>`53-	SELECT i.repo, i.number, i.title, i.description AS body, CASE WHEN i.type IN ('bug', 'question') THEN i.type ELSE 'idea' END AS type,54-		COALESCE(i.filed_ver, '') AS version, COALESCE(NULLIF(i.reporter_name, ''), '@' || a.username, '') AS reporter_name,55-		COALESCE(NULLIF(i.contact_email, ''), i.contact, '') AS contact_email, i.filed_at,56-		CASE WHEN i.status IN ('resolved', 'wontfix', 'closed') THEN 'closed' ELSE 'open' END AS status, CASE WHEN i.closed_save ~ '^[0-9a-f]{12}$' THEN i.closed_save ELSE '' END AS fixed_in,57-		CASE WHEN i.status IN ('resolved', 'wontfix', 'closed') THEN COALESCE(i.closed_at, i.filed_at) END AS closed_at,58-		(SELECT COALESCE(json_agg(json_build_object('author', CASE WHEN e.account_id IS NULL OR e.account_id = 'operator' OR o.tier = 'operator' THEN 'operator' WHEN e.account_id = i.account_id THEN 'reporter' END,59-			'body', e.body, 'created_at', e.created_at) ORDER BY e.created_at, e.id), '[]') FROM issue_entries e LEFT JOIN accounts o ON o.id = e.account_id WHERE e.issue_id = i.id AND e.entry_type = 'reply' AND e.body IS NOT NULL) AS replies60-	FROM issues i LEFT JOIN accounts a ON a.id = i.account_id ORDER BY i.repo, i.number`) {64+	SELECT65+		i.repo,66+		i.number,67+		i.title,68+		i.description AS body,69+		CASE WHEN i.type IN ('bug', 'question') THEN i.type ELSE 'idea' END AS type,70+		COALESCE(i.filed_ver, '') AS version,71+		COALESCE(NULLIF(i.reporter_name, ''), '@' || a.username, '') AS reporter_name,72+		COALESCE(NULLIF(i.contact_email, ''), i.contact, '') AS contact_email,73+		i.filed_at,74+		CASE75+			WHEN i.status IN ('resolved', 'wontfix', 'closed') THEN 'closed'76+			ELSE 'open'77+		END AS status,78+		CASE79+			WHEN i.closed_save ~ '^[0-9a-f]{12}$' THEN i.closed_save80+			ELSE ''81+		END AS fixed_in,82+		CASE83+			WHEN i.status IN ('resolved', 'wontfix', 'closed') THEN COALESCE(i.closed_at, i.filed_at)84+		END AS closed_at,85+		(86+			SELECT COALESCE(87+				json_agg(88+					json_build_object(89+						'author',90+						CASE91+							WHEN e.account_id IS NULL92+								OR e.account_id = 'operator'93+								OR o.tier = 'operator'94+								THEN 'operator'95+							WHEN e.account_id = i.account_id THEN 'reporter'96+						END,97+						'body', e.body,98+						'created_at', e.created_at99+					)100+					ORDER BY e.created_at, e.id101+				),102+				'[]'103+			)104+			FROM issue_entries e105+			LEFT JOIN accounts o ON o.id = e.account_id106+			WHERE e.issue_id = i.id107+				AND e.entry_type = 'reply'108+				AND e.body IS NOT NULL109+		) AS replies110+	FROM issues i111+	LEFT JOIN accounts a ON a.id = i.account_id112+	ORDER BY i.repo, i.number`) {61113 	const id = insert("issues", i, "ON CONFLICT DO NOTHING");62114 	if (replies.some((r) => !r.author)) errors.push(`${i.repo}#${i.number}: third-party reply`);63115 	else for (const r of id ? replies : []) insert("issue_entries", { issue_id: id, ...r }, "");64116 }65-// Kepr 2 counted Nix fetches as installs (kept 90 days). Its archive downloads have no home in Kepr 3.117+// Kepr 2 counted Nix fetches as installs (kept 90 days).118+// Its archive downloads have no home in Kepr 3.66119 for (const count of await pg<Row[]>`67-	SELECT repo_id AS repo, COUNT(*) AS count FROM engagement_events WHERE event_type = 'nix_install' AND repo_id IS NOT NULL GROUP BY repo_id`)120+	SELECT121+		repo_id AS repo,122+		COUNT(*) AS count123+	FROM engagement_events124+	WHERE event_type = 'nix_install'125+		AND repo_id IS NOT NULL126+	GROUP BY repo_id`) {68127 	insert("installs", count, "ON CONFLICT DO NOTHING");69-// Only what Kepr 2 served: successful and partial builds, a file stored as the repo's name as70-// <repo>-<platform>, older uploads named by platform; builds off hid a repo's. Save, notes: newest build's.128+}129+// Only what Kepr 2 served: successful and partial builds, a file stored as130+// the repo's name as <repo>-<platform>, older uploads named by platform;131+// builds off hid a repo's. Save, notes: newest build's.71132 type Release = { repo: string; version: string; hidden: boolean; record: Row; stored: string[] };72133 const newest = new Map<string, string>();73134 for (const { repo, version, hidden, record, stored } of await pg<Release[]>`74-	SELECT a.repo_id AS repo, a.version, bool_or(r.builds_enabled = 0) AS hidden, json_agg(concat_ws('/', 'releases', a.repo_id, a.version, a.platform, a.filename) ORDER BY a.platform) AS stored,75-		json_build_object('format', 1, 'repo', a.repo_id, 'version', a.version, 'save', (array_agg(b.save_id ORDER BY b.started_at DESC))[1],76-			'published_at', max(COALESCE(b.finished_at, a.built_at)), 'machine', 'kepr2', 'notes', COALESCE((array_agg(b.release_notes ORDER BY b.started_at DESC))[1], ''),77-			'files', json_agg(json_build_object('platform', a.platform, 'name', CASE WHEN a.filename = a.repo_id THEN a.repo_id || '-' || a.platform ELSE a.filename END,78-				'size', a.size_bytes, 'blake3', a.blake3, 'sha256', a.sha256) ORDER BY a.platform)) AS record79-	FROM release_artifacts a JOIN build_runs b ON b.id = a.build_run JOIN repos r ON r.name = a.repo_id WHERE b.status IN ('success', 'partial') GROUP BY a.repo_id, a.version ORDER BY a.repo_id, a.version`) {135+	SELECT136+		a.repo_id AS repo,137+		a.version,138+		bool_or(r.builds_enabled = 0) AS hidden,139+		json_agg(140+			concat_ws(141+				'/',142+				'releases',143+				a.repo_id,144+				a.version,145+				a.platform,146+				a.filename147+			)148+			ORDER BY a.platform149+		) AS stored,150+		json_build_object(151+			'format', 1,152+			'repo', a.repo_id,153+			'version', a.version,154+			'save', (array_agg(b.save_id ORDER BY b.started_at DESC))[1],155+			'published_at', max(COALESCE(b.finished_at, a.built_at)),156+			'machine', 'kepr2',157+			'notes', COALESCE((array_agg(b.release_notes ORDER BY b.started_at DESC))[1], ''),158+			'files',159+			json_agg(160+				json_build_object(161+					'platform', a.platform,162+					'name',163+					CASE164+						WHEN a.filename = a.repo_id THEN a.repo_id || '-' || a.platform165+						ELSE a.filename166+					END,167+					'size', a.size_bytes,168+					'blake3', a.blake3,169+					'sha256', a.sha256170+				)171+				ORDER BY a.platform172+			)173+		) AS record174+	FROM release_artifacts a175+	JOIN build_runs b ON b.id = a.build_run176+	JOIN repos r ON r.name = a.repo_id177+	WHERE b.status IN ('success', 'partial')178+	GROUP BY a.repo_id, a.version179+	ORDER BY a.repo_id, a.version`) {80180 	const prefix = `${repo}/releases/${version}`;81181 	try {82182 		for (const [n, f] of checkRelease(repo, version, record).files.entries()) {83183 			const path = join(env("KEPR2_DATA"), stored[n] ?? "");84-			const bytes = await Bun.file((await Bun.file(path).exists()) ? path : dirname(path)).bytes();184+			const file = Bun.file(path);185+			const bytes = await ((await file.exists()) ? file : Bun.file(dirname(path))).bytes();85186 			if (bytesToHex(blake3(bytes)) !== f.blake3) throw new Error(`${f.platform}: hash mismatch`);86187 			writes.push([`${prefix}/${f.platform}/${f.name}`, bytes]);87188 		}

view file

src/admin/console.ts+10 −3
@@ -42,7 +42,10 @@4242 		title: `${title} · admin`,4343 		description: "",4444 		path: c.req.path,45-		body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">${SECTIONS.map(tab)}${formField(c)}<button class="issues-tab issues-new" type="submit">sign out</button></form>45+		body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">46+${SECTIONS.map(tab)}${formField(c)}47+<button class="issues-tab issues-new" type="submit">sign out</button>48+</form>4649 <h1 class="file-page-title">${title}</h1>4750 ${body}`,4851 	});@@ -75,7 +78,8 @@7578 						await checkFormat(ctx.sync.bucket, repo);7679 						return html`<li><span class="mono">${repo}</span> <span class="muted">format ok</span></li>`;7780 					} catch (error) {78-						return html`<li><span class="mono">${repo}</span> <span class="issues-error">${String(error)}</span></li>`;81+						return html`<li><span class="mono">${repo}</span>82+<span class="issues-error">${String(error)}</span></li>`;7983 					}8084 				}),8185 			);@@ -89,7 +93,10 @@8993 		const lines = (await sourceLines()).toLocaleString("en-US");9094 		const newest = backups(ctx.dataDir).at(-1);9195 		const backup = newest92-			? html`<p>Last backup: ${newest.replace(/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/, "$1-$2-$3 $4:$5:$6 UTC")}. <a href="/admin/backup">download backup</a></p>`96+			? html`<p>Last backup: ${newest.replace(97+					/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,98+					"$1-$2-$3 $4:$5:$6 UTC",99+				)}. <a href="/admin/backup">download backup</a></p>`93100 			: html`<p>No backup yet.</p>`;94101 		return c.html(95102 			adminPage(

view file

src/admin/issues.ts+33 −9
@@ -29,11 +29,22 @@2929 	app.get("/admin/issues", (c) => {3030 		const filter = FILTERS.find((f) => f === c.req.query("filter")) ?? "open";3131 		const tab = (f: OperatorFilter) =>32-			html`<a class="issues-tab${f === filter ? " active" : ""}" href="/admin/issues?filter=${f}">${f}</a>`;32+			html`<a class="issues-tab${f === filter ? " active" : ""}"33+				href="/admin/issues?filter=${f}">${f}</a>`;3334 		const issues = operatorIssues(ctx.kepr, filter);3435 		const body = html`<p class="issues-bar mono">${FILTERS.map(tab)}</p>3536 <section class="repo-section">36-${issues.length > 0 ? issues.map((issue) => html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}"><span class="issue-title">${issue.title}</span><span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} · ${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`) : html`<p class="muted mono save-empty">no ${filter} issues</p>`}37+${38+	issues.length > 039+		? issues.map(40+				(issue) =>41+					html`<a class="issue-row" href="/admin/issues/${issue.repo}/${issue.number}">42+<span class="issue-title">${issue.title}</span>43+<span class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.type} ·44+${issue.status} · ${relativeTime(issue.filed_at)}</span></a>`,45+			)46+		: html`<p class="muted mono save-empty">no ${filter} issues</p>`47+}3748 </section>`;3849 		return c.html(adminPage(c, ctx, "issues", body));3950 	});@@ -91,30 +102,43 @@91102 function issuePage(c: Context, ctx: AdminContext, issue: OperatorIssue, error: string): string {92103 	const action = `/admin/issues/${issue.repo}/${issue.number}`;93104 	const button = (name: string, label: string) =>94-		html`<form method="post" action="${action}">${formField(c)}<input type="hidden" name="action" value="${name}"><button class="issues-submit" type="submit">${label}</button></form>`;105+		html`<form method="post" action="${action}">106+${formField(c)}<input type="hidden" name="action" value="${name}">107+<button class="issues-submit" type="submit">${label}</button></form>`;95108 	const post = (operator: boolean, at: number, text: string) =>96109 		html`<article class="issue-post${operator ? " issue-post--operator" : ""}">97-<p class="issue-post-head mono">${operator ? html`<span class="issue-badge">operator</span>` : html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`}<span class="history-when">${formatDate(at)}</span></p>110+<p class="issue-post-head mono">${111+			operator112+				? html`<span class="issue-badge">operator</span>`113+				: html`<span class="issue-author">${issue.reporter_name || "anonymous"}</span>`114+		}<span class="history-when">${formatDate(at)}</span></p>98115 <div class="issue-text">${text}</div>99116 </article>`;100117 	const typeOption = (type: string) =>101118 		html`<option value="${type}"${issue.type === type ? html` selected` : ""}>${type}</option>`;102119 	const body = html`${notice(error)}103-<p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${issue.fixed_in && ` · fixed in ${issue.fixed_in}`}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>104-<p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"} · contact: ${issue.contact_email || "none given"}</p>120+<p class="issue-meta mono">${issue.repo} #${issue.number} · ${issue.status}${121+		issue.fixed_in && ` · fixed in ${issue.fixed_in}`122+	}${issue.hidden ? " · hidden" : ""} · ${issue.version && `v${issue.version} · `}filed ${formatDate(issue.filed_at)}</p>123+<p class="issue-meta mono">reporter: ${issue.reporter_name || "anonymous"}124+· contact: ${issue.contact_email || "none given"}</p>105125 ${post(false, issue.filed_at, issue.body)}106-${threadEntries(ctx.kepr, issue.id).map((entry) => post(entry.author === "operator", entry.created_at, entry.body))}126+${threadEntries(ctx.kepr, issue.id).map((entry) =>127+	post(entry.author === "operator", entry.created_at, entry.body),128+)}107129 <form class="issues-form" method="post" action="${action}">${formField(c)}108130 <input type="hidden" name="action" value="reply">109131 <label class="issues-label" for="reply">reply</label>110-<textarea class="issues-textarea" id="reply" name="body" rows="5" required maxlength="${MAX_CHARS}"></textarea>132+<textarea class="issues-textarea" id="reply" name="body" rows="5" required133+maxlength="${MAX_CHARS}"></textarea>111134 <button class="issues-submit" type="submit">reply</button>112135 </form>113136 ${114137 	issue.status === "open"115138 		? html`<form class="issues-form" method="post" action="${action}">${formField(c)}116139 <input type="hidden" name="action" value="close">117-<label class="issues-label" for="fixed-in">fixed in save <span class="muted">(optional)</span></label>140+<label class="issues-label" for="fixed-in">fixed in save141+<span class="muted">(optional)</span></label>118142 <input class="issues-input" id="fixed-in" name="fixed_in" maxlength="12">119143 <button class="issues-submit" type="submit">close</button>120144 </form>`

view file

src/admin/releases.ts+16 −4
@@ -45,7 +45,13 @@4545 </section>`;4646 	});4747 	const body = html`${notice(error)}48-${sections.length > 0 ? sections : html`<p class="muted mono save-empty">no releases in the bucket yet</p>`}`;48+${49+	sections.length > 050+		? sections51+		: html`<p class="muted mono save-empty">52+no releases in the bucket yet53+</p>`54+}`;4955 	return adminPage(c, ctx, "releases", body);5056 }5157 @@ -53,11 +59,17 @@5359 	const total = release.downloads.reduce((sum, [, n]) => sum + n, 0);5460 	const id = `${repo}-${release.version}`;5561 	return html`<article class="issue-post" id="${id}">56-<p class="issue-post-head mono"><span class="issue-author">${release.version}</span><span>${release.hidden ? "hidden" : "shown"}</span><span>${plural(total, "download")}</span>${release.downloads.map(([platform, n]) => html`<span class="muted">${platform} ${n}</span>`)}</p>62+<p class="issue-post-head mono">63+${html`<span class="issue-author">${release.version}</span>`}${html`<span>${release.hidden ? "hidden" : "shown"}</span>`}${html`<span>${plural(total, "download")}</span>`}${release.downloads.map(64+	([platform, n]) => html`<span class="muted">${platform} ${n}</span>`,65+)}66+</p>5767 <form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}">5868 ${formField(c)}59-<label class="issues-label" for="${id}-notes">notes${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>60-<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3" maxlength="${MAX_CHARS}">${release.notes}</textarea>69+<label class="issues-label" for="${id}-notes">notes70+${release.notes !== release.recordNotes ? html` <span class="muted">(edited)</span>` : ""}</label>71+<textarea class="issues-textarea" id="${id}-notes" name="notes" rows="3"72+maxlength="${MAX_CHARS}">${release.notes}</textarea>6173 <label class="issues-label"><input type="checkbox" name="hidden" value="1"${release.hidden ? html` checked` : ""}> hidden</label>6274 <button class="issues-submit" type="submit">save ${release.version}</button>6375 </form>

view file

src/admin/repos.ts+11 −4
@@ -51,7 +51,8 @@5151 function reposPage(c: Context, ctx: AdminContext, error: string): string {5252 	const repos = allRepos(ctx.kepr);5353 	const body = html`${notice(error)}54-<form method="post" action="/admin/sync">${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>54+<form method="post" action="/admin/sync">55+${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>5556 ${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}5657 ${repos.map((repo) => repoForm(c, repo))}`;5758 	return adminPage(c, ctx, "repos", body);@@ -64,11 +65,17 @@6465 	const synced =6566 		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;6667 	return html`<article class="issue-post" id="repo-${repo.name}">67-<p class="issue-post-head mono"><span class="issue-author">${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span><span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>68-${repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`}68+<p class="issue-post-head mono">69+<span class="issue-author">70+${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>71+<span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>72+${73+	repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`74+}6975 <form class="issues-form" method="post" action="/admin/repos/${repo.name}">7076 ${formField(c)}71-<label class="issues-label"><input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>77+<label class="issues-label">78+<input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>7279 ${input("category", "category")}7380 ${input("homepage", "homepage")}7481 ${input("sort_order", "card order")}

view file

src/admin/site.ts+11 −4
@@ -92,17 +92,24 @@9292 		html`<label class="issues-label" for="${key}">${label}</label>9393 ${9494 	key === "bio"95-		? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="4" maxlength="${max}">${setting(ctx.kepr, key) ?? ""}</textarea>`96-		: html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}" value="${setting(ctx.kepr, key) ?? ""}">`95+		? html`<textarea class="issues-textarea" id="${key}" name="${key}" rows="4"96+maxlength="${max}">${setting(ctx.kepr, key) ?? ""}</textarea>`97+		: html`<input class="issues-input" id="${key}" name="${key}" maxlength="${max}"98+value="${setting(ctx.kepr, key) ?? ""}">`9799 }`;98100 	const body = html`${notice(error)}99101 <form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">100102 ${formField(c)}101103 ${FIELDS.map(input)}102-<label class="issues-label" for="signature">signature image <span class="muted">(PNG, JPEG or WebP, under 60 KB)</span></label>104+<label class="issues-label" for="signature">signature image105+<span class="muted">(PNG, JPEG or WebP, under 60 KB)</span></label>103106 ${signature && html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="current signature">`}104107 <input class="issues-input" id="signature" name="signature" type="file" accept="image/png,image/jpeg,image/webp">105-${signature && html`<label class="issues-label"><input type="checkbox" name="remove_signature" value="1"> remove the signature</label>`}108+${109+	signature &&110+	html`<label class="issues-label"><input type="checkbox" name="remove_signature" value="1">111+remove the signature</label>`112+}106113 <button class="issues-submit" type="submit">save</button>107114 </form>`;108115 	return adminPage(c, ctx, "site and profile", body);

view file

src/bucket/repo.ts+2 −1
@@ -304,7 +304,8 @@304304 		SAVE_ID.test(r.id) &&305305 		isOptionalSaveId(r.save_id) &&306306 		(r.path === "" || (typeof r.path === "string" && isSafePath(r.path))) &&307-		// A flag or clear is about one save and no path; a strip names a path, and one save or (with no ID) all.307+		// A flag or clear is about one save and no path; a strip names a path, and one save or308+		// (with no ID) all.308309 		(r.kind === "strip"309310 			? r.path !== ""310311 			: (r.kind === "flag" || r.kind === "clear") && r.save_id !== "" && r.path === "") &&

view file

src/describe/describe.ts+5 −2
@@ -28,8 +28,11 @@2828  * reserved". Only that one block is skipped, and the word "copyright" alone is not enough, so2929  * `// Renders copyright notices.` keeps its description.3030  */31-const LICENSE =32-	/SPDX-License-Identifier|\bcopyright\s*(\(c\)|©|\d{4})|©\s*\d{4}|\blicensed under\b|\ball rights reserved\b/i;31+const LICENSE = new RegExp(32+	String.raw`SPDX-License-Identifier|\bcopyright\s*(\(c\)|©|\d{4})|©\s*\d{4}|` +33+		String.raw`\blicensed under\b|\ball rights reserved\b`,34+	"i",35+);3336 /**3437  * Kotlin, Java and Swift put a file's words on its main declaration, after `package` and3538  * `import`; those lines only name the file's place, so they are passed over, not counted as code.

view file

src/issues/pages.ts+53 −15
@@ -97,8 +97,10 @@9797 				description: "",9898 				path: c.req.path,9999 				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>100+<h1 class="file-page-title">Filed as101+<a href="/${name}/issues/${filed.number}">#${filed.number}</a></h1>102+<p>This is your private link for adding to the issue later. It is shown only this once,103+so keep it; anyone who has it can post as you.</p>102104 <p class="issue-link mono"><a href="${link}">${link}</a></p>`,103105 			}),104106 		);@@ -164,7 +166,8 @@164166 }165167 166168 function crumbs(repo: string): Html {167-	return html`<p class="breadcrumb mono"><a href="/${repo}">${repo}</a> / <a href="/${repo}/issues">issues</a></p>`;169+	return html`<p class="breadcrumb mono">170+<a href="/${repo}">${repo}</a> / <a href="/${repo}/issues">issues</a></p>`;168171 }169172 170173 function listPage(ctx: PageContext, repo: PublicRepo, status: "open" | "closed"): string {@@ -172,16 +175,31 @@172175 	const counts = issueCounts(ctx.kepr, name);173176 	const issues = listIssues(ctx.kepr, name, status);174177 	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>`;178+		html`<a class="issues-tab${which === status ? " active" : ""}"179+			href="/${name}/issues${which === "closed" ? "?status=closed" : ""}">180+${which} <span class="muted">${counts[which]}</span></a>`;176181 	return page(ctx, {177182 		title: `issues · ${name}`,178183 		description: `Issues filed against ${name}.`,179184 		path: `/${name}/issues`,180185 		body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>181186 <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>187+<p class="issues-bar mono">${tab("open")}${tab("closed")}188+<a class="issues-new" href="/${name}/issues/new">report an issue →</a></p>183189 <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>`}190+${191+	issues.length > 0192+		? issues.map(193+				(issue) =>194+					html`<a class="issue-row" href="/${name}/issues/${issue.number}">195+<span class="issue-symbol issue-symbol--${issue.status}">196+${issue.status === "open" ? "◉" : "◈"}</span>197+<span class="issue-title">${issue.title}</span>198+<span class="issue-meta mono">#${issue.number} · ${issue.type}${issue.version && ` · v${issue.version}`} · ${relativeTime(issue.filed_at)}</span>199+</a>`,200+			)201+		: html`<p class="muted mono save-empty">no ${status} issues</p>`202+}185203 </section>`,186204 	});187205 }@@ -203,18 +221,22 @@203221 ${error && html`<p class="issues-error mono" role="alert">${error}</p>`}204222 <form class="issues-form" method="post" action="/${name}/issues/new">205223 <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>224+<label class="issues-hp" aria-hidden="true">leave this empty225+<input name="website" tabindex="-1" autocomplete="off"></label>207226 <label class="issues-label" for="issue-title">title</label>208227 <input class="issues-input" id="issue-title" name="title" required maxlength="${MAX_CHARS}" value="${values.title ?? ""}">209228 <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>229+<textarea class="issues-textarea" id="issue-body" name="body" rows="8" required230+maxlength="${MAX_CHARS}">${values.body ?? ""}</textarea>211231 <label class="issues-label" for="issue-type">type</label>212232 <select class="issues-input" id="issue-type" name="type">${ISSUE_TYPES.map(typeOption)}</select>213233 <label class="issues-label" for="issue-version">version you're on</label>214234 <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>235+<label class="issues-label" for="issue-name">name to show236+<span class="muted">(optional)</span></label>216237 <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>238+<label class="issues-label" for="issue-email">contact email239+<span class="muted">(optional, only the operator sees it)</span></label>218240 <input class="issues-input" id="issue-email" name="email" type="email" maxlength="320" value="${values.email ?? ""}">219241 <button class="issues-submit" type="submit">file issue</button>220242 </form>`;@@ -241,7 +263,11 @@241263 	const reporter = issue.reporter_name || "anonymous";242264 	const post = (id: string, operator: boolean, at: number, text: string) =>243265 		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>266+<p class="issue-post-head mono">${267+			operator268+				? html`<span class="issue-badge">operator</span>`269+				: html`<span class="issue-author">${reporter}</span>`270+		}<span class="history-when">${formatDate(at)}</span></p>245271 <div class="issue-text">${text}</div>246272 </article>`;247273 	const owner = token !== null && ownsIssue(issue, token);@@ -251,19 +277,31 @@251277 		path: `/${name}/issues/${issue.number}`,252278 		body: html`${crumbs(name)}253279 <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>280+<p class="issue-meta mono">281+<span class="issue-symbol issue-symbol--${issue.status}">282+${issue.status === "open" ? "◉ open" : "◈ closed"}</span>283+${issue.type}${issue.version && ` · v${issue.version}`} · filed ${relativeTime(issue.filed_at)}284+${issue.status === "closed" && fixedIn(repo, issue)}</p>255285 ${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))}286+${threadEntries(ctx.kepr, issue.id).map((entry) =>287+	post(`entry-${entry.id}`, entry.author === "operator", entry.created_at, entry.body),288+)}257289 ${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>`}259290 ${291+	token !== null &&292+	!owner &&293+	!notice.error &&294+	html`<p class="issues-error mono">This follow-up link does not match the issue.</p>`295+}296+${260297 	owner &&261298 	html`<form class="issues-form" method="post" action="/${name}/issues/${issue.number}">262299 <p class="muted">This is your private follow-up link; anyone who has it can post as you.</p>263300 <input type="hidden" name="token" value="${token}">264301 <input type="hidden" name="stamp" value="${formStamp(ctx.kepr, c, unixNow())}">265302 <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>303+<textarea class="issues-textarea" id="follow-up" name="body" rows="5" required304+maxlength="${MAX_CHARS}">${notice.draft ?? ""}</textarea>267305 <button class="issues-submit" type="submit">post</button>268306 </form>`269307 }`,

view file

src/issues/spam.ts+2 −2
@@ -31,8 +31,8 @@3131 }3232 3333 /**34- * The same-site token (§25) and the time-on-page clock in one: a random cookie, SameSite=Strict so a35- * cross-site post arrives without it, and a form stamp `<unix time>.<HMAC(time, cookie)>`.34+ * SameSite=Strict so a cross-site post arrives without it, and a form stamp35+ * `<unix time>.<HMAC(time, cookie)>`.3636  */3737 export function formStamp(db: Database, c: Context, now: number): string {3838 	let cookie = getCookie(c, FORM_COOKIE);

view file

src/issues/store.ts+29 −10
@@ -51,12 +51,16 @@5151 	const number = db.transaction(() => {5252 		const row = db5353 			.query<{ next: number }, [string]>(54-				"SELECT COALESCE(MAX(number), 0) + 1 AS next FROM issues WHERE repo = ?",54+				`SELECT COALESCE(MAX(number), 0) + 1 AS next55+FROM issues56+WHERE repo = ?`,5557 			)5658 			.get(issue.repo) as { next: number };5759 		db.query(58-			`INSERT INTO issues (repo, number, title, body, type, version, reporter_name, contact_email,59-				filed_at, ip_hash, token_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,60+			`INSERT INTO issues (61+repo, number, title, body, type, version, reporter_name, contact_email,62+filed_at, ip_hash, token_hash63+) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,6064 		).run(6165 			issue.repo,6266 			row.next,@@ -89,8 +93,10 @@8993 export function listIssues(db: Database, repo: string, status: "open" | "closed"): Issue[] {9094 	return db9195 		.query<Issue, [string, string]>(92-			`SELECT ${PUBLIC_COLUMNS} FROM issues WHERE repo = ? AND status = ? AND hidden = 093-			ORDER BY number DESC`,96+			`SELECT ${PUBLIC_COLUMNS}97+FROM issues98+WHERE repo = ? AND status = ? AND hidden = 099+ORDER BY number DESC`,94100 		)95101 		.all(repo, status);96102 }@@ -98,7 +104,10 @@98104 export function issueCounts(db: Database, repo: string): { open: number; closed: number } {99105 	const rows = db100106 		.query<{ status: "open" | "closed"; n: number }, [string]>(101-			"SELECT status, COUNT(*) AS n FROM issues WHERE repo = ? AND hidden = 0 GROUP BY status",107+			`SELECT status, COUNT(*) AS n108+FROM issues109+WHERE repo = ? AND hidden = 0110+GROUP BY status`,102111 		)103112 		.all(repo);104113 	const count = (status: string) => rows.find((row) => row.status === status)?.n ?? 0;@@ -109,7 +118,9 @@109118 export function findIssue(db: Database, repo: string, number: number): Issue | null {110119 	return db111120 		.query<Issue, [string, number]>(112-			`SELECT ${PUBLIC_COLUMNS} FROM issues WHERE repo = ? AND number = ? AND hidden = 0`,121+			`SELECT ${PUBLIC_COLUMNS}122+FROM issues123+WHERE repo = ? AND number = ? AND hidden = 0`,113124 		)114125 		.get(repo, number);115126 }@@ -141,7 +152,10 @@141152 	return (142153 		db143154 			.query<{ n: number }, []>(144-				"SELECT COUNT(*) AS n FROM issues i JOIN repos r ON r.name = i.repo WHERE r.public = 1 AND i.hidden = 0",155+				`SELECT COUNT(*) AS n156+FROM issues i157+JOIN repos r ON r.name = i.repo158+WHERE r.public = 1 AND i.hidden = 0`,145159 			)146160 			.get()?.n ?? 0147161 	);@@ -157,7 +171,10 @@157171 export function operatorIssues(db: Database, filter: OperatorFilter): OperatorIssue[] {158172 	const where = filter === "hidden" ? "hidden = 1" : "hidden = 0 AND status = ?";159173 	const query = db.query<OperatorIssue, string[]>(160-		`SELECT ${OPERATOR_COLUMNS} FROM issues WHERE ${where} ORDER BY filed_at DESC, id DESC`,174+		`SELECT ${OPERATOR_COLUMNS}175+FROM issues176+WHERE ${where}177+ORDER BY filed_at DESC, id DESC`,161178 	);162179 	return filter === "hidden" ? query.all() : query.all(filter);163180 }@@ -165,7 +182,9 @@165182 export function operatorIssue(db: Database, repo: string, number: number): OperatorIssue | null {166183 	return db167184 		.query<OperatorIssue, [string, number]>(168-			`SELECT ${OPERATOR_COLUMNS} FROM issues WHERE repo = ? AND number = ?`,185+			`SELECT ${OPERATOR_COLUMNS}186+FROM issues187+WHERE repo = ? AND number = ?`,169188 		)170189 		.get(repo, number);171190 }

view file

src/mirror/queries.ts+8 −3
@@ -16,7 +16,8 @@1616 1717 export type FileEntry = { path: string; hash: string; executable: boolean; size: number };1818 19-/** The saves that touched each path up to one save, oldest first; `line` is every one of them in line order. */19+/** The saves that touched each path up to one save, oldest first; `line` is every one of20+ * them in line order. */2021 export type Touches = { line: Save[]; byPath: Map<string, Save[]> };2122 2223 export type RepoStats = { saves: number; lines: number; language: string; updated: number | null };@@ -52,7 +53,8 @@5253 		.map(asSave);5354 }5455 55-/** The save a unique prefix of 6–12 hex characters names (§6.1), or null when none or several do. */56+/** The save a unique prefix of 6–12 hex characters names (§6.1), or null when none or57+ * several do. */5658 export function saveByPrefix(db: Database, prefix: string): Save | null {5759 	if (!ID_PREFIX.test(prefix)) return null;5860 	const rows = db@@ -171,7 +173,10 @@171173 export function objectHashes(db: Database): string[] {172174 	return db173175 		.query<{ hash: string }, []>(174-			`SELECT DISTINCT f.hash FROM save_files f JOIN saves s ON s.id = f.save_id WHERE s.${NOT_CHECKPOINT}`,176+			`SELECT DISTINCT f.hash177+			 FROM save_files f178+			 JOIN saves s ON s.id = f.save_id179+			 WHERE s.${NOT_CHECKPOINT}`,175180 		)176181 		.all()177182 		.map((row) => row.hash);

view file

src/mirror/store.ts+24 −3
@@ -116,7 +116,8 @@116116 	if (user_version !== 0 && user_version !== SCHEMA_VERSION) {117117 		db.close();118118 		throw new Error(119-			`${path} has mirror schema version ${user_version}; this Kepr reads version ${SCHEMA_VERSION}. The mirror is disposable: delete it and the next sync rebuilds it from the bucket.`,119+			`${path} has mirror schema version ${user_version}; this Kepr reads version ${SCHEMA_VERSION}. ` +120+				"The mirror is disposable: delete it and the next sync rebuilds it from the bucket.",120121 		);121122 	}122123 	if (user_version === 0) {@@ -157,11 +158,12 @@157158 	return new Set(rows.map((row) => row.version));158159 }159160 160-/** Lands a sync like Koh's steal: objects, then events, then saves, then strips, all or nothing. */161+/** Lands a sync like Koh's steal: objects, events, existing-save strips, saves, then all strips. */161162 export function land(db: Database, landing: Landing): void {162163 	const putObject = db.query("INSERT OR IGNORE INTO objects (hash, size, data) VALUES (?, ?, ?)");163164 	const putEvent = db.query(164-		"INSERT OR IGNORE INTO events (id, kind, save_id, path, reason, created_at, machine) VALUES (?, ?, ?, ?, ?, ?, ?)",165+		"INSERT OR IGNORE INTO events (id, kind, save_id, path, reason, created_at, machine) VALUES " +166+			"(?, ?, ?, ?, ?, ?, ?)",165167 	);166168 	const putSave = db.query(167169 		`INSERT OR IGNORE INTO saves (id, parent, message, created_at, machine, is_checkpoint, size_bytes,@@ -178,6 +180,7 @@178180 		for (const e of landing.events) {179181 			putEvent.run(e.id, e.kind, e.save_id, e.path, e.reason, e.created_at, e.machine);180182 		}183+		applyNewStrips(db, landing.events);181184 		for (const s of landing.saves) {182185 			putSave.run(183186 				s.id,@@ -200,6 +203,24 @@200203 		setState.run("releases_latest", landing.releasesLatest);201204 		refreshStats(db);202205 	})();206+}207+208+function applyNewStrips(db: Database, events: EventRecord[]): void {209+	const strips = events.filter((event) => event.kind === "strip");210+	if (strips.length === 0) return;211+	const remove = db.query(212+		`DELETE FROM save_files213+		WHERE (path = $path OR substr(path, 1, length($dir)) = $dir)214+		AND save_id IN (215+			SELECT id FROM saves216+			WHERE CASE WHEN $save = '' THEN 1 ELSE id = $save END217+		)`,218+	);219+	for (const strip of strips) {220+		const path = strip.path ?? "";221+		const dir = `${path.replace(/\/+$/, "")}/`;222+		remove.run({ path, dir, save: strip.save_id });223+	}203224 }204225 205226 // §10.2: a strip of one save reaches that save; a strip of every save reaches the saves made up to

view file

src/pages/archives.ts+18 −4
@@ -30,7 +30,14 @@3030 				body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>3131 <h1 class="file-page-title">archives</h1>3232 <section class="repo-section">33-${head ? [row(ctx, repo, head, true), ...recent.slice(0, RECENT).map((save) => row(ctx, repo, save, false))] : html`<p class="muted mono save-empty">no saves yet</p>`}33+${34+	head35+		? [36+				row(ctx, repo, head, true),37+				...recent.slice(0, RECENT).map((save) => row(ctx, repo, save, false)),38+			]39+		: html`<p class="muted mono save-empty">no saves yet</p>`40+}3441 </section>`,3542 			}),3643 		);@@ -50,8 +57,15 @@5057 		? html`<span class="history-id">${save.id}</span>`5158 		: html`<a class="history-id" href="/${name}/saves/${save.id}">${save.id}</a>`;5259 	return html`<div class="archive-row">53-<p class="archive-save">${isHead && html`<span class="archive-head mono">HEAD</span>`}${id}<span class="history-msg archive-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></p>54-<p class="archive-links mono"><a href="${tarball}">↓ ${name}-${isHead ? "HEAD" : save.id.slice(0, 8)}.tar.gz</a></p>55-${hasFlake && html`<div class="install-line"><code>${nix}</code><button type="button" class="copy-btn" data-copy="${nix}" hidden>copy</button></div>`}60+<p class="archive-save">${61+		isHead && html`<span class="archive-head mono">HEAD</span>`62+	}${id}${html`<span class="history-msg archive-msg">${save.message.split("\n")[0]}</span>`}${html`<span class="history-when">${relativeTime(save.created_at)}</span>`}</p>63+<p class="archive-links mono"><a href="${64+		tarball65+	}">↓ ${name}-${isHead ? "HEAD" : save.id.slice(0, 8)}.tar.gz</a></p>66+${67+	hasFlake &&68+	html`<div class="install-line"><code>${nix}</code><button type="button" class="copy-btn" data-copy="${nix}" hidden>copy</button></div>`69+}5670 </div>`;5771 }

view file

src/pages/atlas.ts+33 −9
@@ -88,11 +88,16 @@8888 	}8989 	const view = { repo: repo.settings.name, at };9090 	const where = at91-		? html`at <a href="/${view.repo}/saves/${at}">${at}</a> · <a href="/${view.repo}#atlas">back to HEAD</a>`91+		? html`at <a href="/${92+				view.repo93+			}/saves/${at}">${at}</a> · <a href="/${view.repo}#atlas">back to HEAD</a>`9294 		: "HEAD";9395 	return html`<section class="repo-section" id="atlas">9496 <details class="repo-section-toggle" open>95-<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(entries.length, "file")}</span></summary>97+<summary class="section-head"><span class="section-label">atlas</span><span class="section-count">${plural(98+		entries.length,99+		"file",100+	)}</span></summary>96101 <p class="atlas-where mono">${where}</p>97102 <input class="atlas-filter mono" type="search" placeholder="filter by filename" aria-label="Filter files by name" hidden>98103 <div class="atlas" data-repo="${view.repo}">@@ -116,7 +121,9 @@116121 	const { description } = file;117122 	const base = markdownBase(view, file.path);118123 	const href = `/${view.repo}/files/${pathUrl(file.path)}${view.at ? `?at=${view.at}` : ""}`;119-	return html`<a class="atlas-row atlas-file" href="${href}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, file.saves)}</a>124+	return html`<a class="atlas-row atlas-file" href="${125+		href126+	}" data-name="${file.name.toLowerCase()}">${row(file.name, description, base, file.size, file.saves)}</a>120127 <template>${panel(121128 		file.name,122129 		file.path,@@ -137,7 +144,13 @@137144 	const size = files.reduce((sum, f) => sum + f.size, 0);138145 	const lines = files.reduce((sum, f) => sum + (f.lines ?? 0), 0);139146 	return html`<details class="atlas-folder" data-path="${folder.path}">140-<summary class="atlas-row">${row(`${folder.name}/`, description, base, size, folder.saves)}</summary>147+<summary class="atlas-row">${row(148+		`${folder.name}/`,149+		description,150+		base,151+		size,152+		folder.saves,153+	)}</summary>141154 <template>${panel(142155 		`${folder.name}/`,143156 		folder.path,@@ -165,7 +178,9 @@165178 	const meta = [formatSize(size), last && relativeTime(last.created_at)]166179 		.filter(Boolean)167180 		.join(" · ");168-	return html`<span class="atlas-name">${name}</span><span class="atlas-desc">${description && renderLine(firstSentence(description), base)}</span><span class="atlas-meta mono">${meta}</span>`;181+	return html`<span class="atlas-name">${name}</span>${html`<span class="atlas-desc">${182+		description && renderLine(firstSentence(description), base)183+	}</span>`}${html`<span class="atlas-meta mono">${meta}</span>`}`;169184 }170185 171186 function panel(@@ -177,8 +192,13 @@177192 ): Html {178193 	return html`<h3 class="atlas-info-name">${name}</h3>179194 <p class="atlas-info-path mono">${path}</p>180-<dl class="atlas-facts mono">${facts.map(([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`)}</dl>181-${description && html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`}`;195+<dl class="atlas-facts mono">${facts.map(196+		([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,197+	)}</dl>198+${199+	description &&200+	html`<div class="markdown atlas-info-desc">${renderMarkdown(description, base)}</div>`201+}`;182202 }183203 184204 function saveFacts(saves: Save[], view: View): [string, Html | string][] {@@ -192,7 +212,9 @@192212 }193213 194214 function saveLink(save: Save, view: View): Html {195-	return html`<a href="/${view.repo}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;215+	return html`<a href="/${216+		view.repo217+	}/saves/${save.id}">${save.id}</a> ${formatDate(save.created_at)}`;196218 }197219 198220 function recent(saves: Save[], view: View): Html | null {@@ -202,7 +224,9 @@202224 		.reverse()203225 		.map(204226 			(save) =>205-				html`<li><a class="mono" href="/${view.repo}/saves/${save.id}">${save.id}</a> <span>${save.message.split("\n")[0]}</span> <span class="atlas-when mono">${relativeTime(save.created_at)}</span></li>`,227+				html`<li><a class="mono" href="/${228+					view.repo229+				}/saves/${save.id}">${save.id}</a> <span>${save.message.split("\n")[0]}</span> <span class="atlas-when mono">${relativeTime(save.created_at)}</span></li>`,206230 		)}</ul>`;207231 }208232 

view file

src/pages/diff.ts+3 −2
@@ -6,8 +6,9 @@66 77 export type Hunk = { header: string; edits: Edit[] };88 9-// Past this many differences the Myers trace (about D² numbers) stops paying for itself; the rest of10-// the change is shown as a plain replacement, which is still a correct diff, just not a minimal one.9+// Past this many differences the Myers trace (about D² numbers) stops paying for itself;10+// the rest of the change is shown as a plain replacement, which is still a correct diff,11+// just not a minimal one.1112 const MAX_DIFFERENCES = 2000;1213 1314 /** The line edits turning `a` into `b`: Myers' O(ND) diff between the common head and tail. */

view file

src/pages/files.ts+23 −8
@@ -17,7 +17,8 @@1717 const SHOWN_BYTES = 1024 * 1024;1818 const IMAGE = /\.(png|jpe?g|gif|webp|avif|ico|svg)$/i;1919 const MARKDOWN = /\.(md|markdown)$/i;20-// Binary types a browser can show or save as they are; anything else textual stays text/plain (§21.8).20+// Binary types a browser can show or save as they are; anything else textual stays21+// text/plain (§21.8).2122 const PASS_THROUGH = /^(image|audio|video|font)\/|^application\/(pdf|zip|gzip|wasm)$/;2223 2324 type Found = {@@ -51,7 +52,9 @@5152 		const found = findFile(c, ctx);5253 		if (!found) return notFound(c, ctx);5354 		cacheFor(c, found.at !== null);54-		c.header("Content-Type", contentType(found.path, found.bytes));55+		const type = contentType(found.path, found.bytes);56+		c.header("Content-Type", type);57+		if (type === "image/svg+xml") c.header("Content-Security-Policy", "sandbox");5558 		return c.body(found.bytes);5659 	});5760 }@@ -96,24 +99,34 @@9699 97100 function breadcrumb(found: Found): Html {98101 	const dirs = found.path.split("/").slice(0, -1);99-	return html`<p class="breadcrumb mono"><a href="/${found.repo}">${found.repo}</a>${dirs.map((dir) => ` / ${dir}`)}${found.at && html` @ <a href="/${found.repo}/saves/${found.at}">${found.at}</a>`}</p>`;102+	return html`<p class="breadcrumb mono"><a href="/${103+		found.repo104+	}">${found.repo}</a>${dirs.map((dir) => ` / ${dir}`)}${found.at && html` @ <a href="/${found.repo}/saves/${found.at}">${found.at}</a>`}</p>`;100105 }101106 102107 async function fileBody(found: Found, source: boolean): Promise<Html> {103108 	const size = formatSize(found.file.size);104109 	const raw = rawHref(found);105110 	if (IMAGE.test(found.path)) {106-		return html`<p class="file-meta mono">${size} · <a href="${raw}">raw</a></p><div class="file-image"><img src="${raw}" alt="${found.path}"></div>`;111+		return html`<p class="file-meta mono">${112+			size113+		} · <a href="${raw}">raw</a></p><div class="file-image"><img src="${raw}" alt="${found.path}"></div>`;107114 	}108115 	if (!isText(found.bytes)) {109-		return html`<p class="file-meta mono">binary file · ${size} · <a href="${raw}" download>download</a></p>`;116+		return html`<p class="file-meta mono">binary file · ${117+			size118+		} · <a href="${raw}" download>download</a></p>`;110119 	}111120 	const truncated = found.bytes.length > SHOWN_BYTES;112121 	const text = new TextDecoder().decode(found.bytes.subarray(0, SHOWN_BYTES));113-	const meta = html`<p class="file-meta mono">${size}${truncated && " · showing the first 1 MB"} · <a href="${raw}">raw</a></p>`;122+	const meta = html`<p class="file-meta mono">${123+		size124+	}${truncated && " · showing the first 1 MB"} · <a href="${raw}">raw</a></p>`;114125 	if (MARKDOWN.test(found.path)) {115126 		const at = found.at ? `at=${found.at}&` : "";116-		const toggle = html`<p class="file-toggle mono"><a href="?${found.at ? `at=${found.at}` : ""}"${!source && html` class="active"`}>rendered</a> · <a href="?${at}view=source"${source && html` class="active"`}>source</a></p>`;127+		const toggle = html`<p class="file-toggle mono"><a href="?${128+			found.at ? `at=${found.at}` : ""129+		}"${!source && html` class="active"`}>rendered</a> · <a href="?${at}view=source"${source && html` class="active"`}>source</a></p>`;117130 		if (!source) {118131 			const dir = found.path.split("/").slice(0, -1).join("/");119132 			const rendered = renderMarkdown(text, { repo: found.repo, dir, at: found.at });@@ -130,6 +143,8 @@130143 	const lines = await highlightLines(text, path);131144 	return html`<div class="file-view"><pre class="code"><code>${lines.map(132145 		(line, i) =>133-			html`<span class="line" id="L${i + 1}"><a class="ln" href="#L${i + 1}">${i + 1}</a><span class="lc">${line}</span></span>`,146+			html`<span class="line" id="L${147+				i + 1148+			}"><a class="ln" href="#L${i + 1}">${i + 1}</a><span class="lc">${line}</span></span>`,134149 	)}</code></pre></div>`;135150 }

view file

src/pages/front.ts+13 −3
@@ -34,7 +34,10 @@3434 </div>3535 ${wordCloud(library)}3636 </section>37-${latest.length > 0 && html`<section class="featured"><h2 class="featured-label mono">latest offered</h2>${repoCards(latest)}</section>`}37+${38+	latest.length > 0 &&39+	html`<section class="featured"><h2 class="featured-label mono">latest offered</h2>${repoCards(latest)}</section>`40+}3841 <p class="landing-self-host mono"><a href="/docs">run your own library →</a></p>`,3942 			}),4043 		);@@ -49,7 +52,11 @@4952 				path: "/browse",5053 				body: html`<h1 class="file-page-title">browse</h1>5154 <p class="muted mono">${plural(library.length, "repo")}</p>52-${library.length > 0 ? repoCards(library) : html`<p class="muted mono save-empty">nothing public yet</p>`}`,55+${56+	library.length > 057+		? repoCards(library)58+		: html`<p class="muted mono save-empty">nothing public yet</p>`59+}`,5360 			}),5461 		);5562 	});@@ -86,7 +93,10 @@8693 	const max = Math.max(...weights.values());8794 	// Ordered by a hash of the label, so the big words scatter instead of leading the cloud.8895 	const tags = [...weights].sort((a, b) => scatter(a[0]) - scatter(b[0]));89-	return html`<div class="headline-cloud" aria-label="file types and categories">${tags.map(([label, weight]) => html`<span class="tc-tag tc-${Math.ceil((weight / max) * 5)}">${label}</span>`)}</div>`;96+	return html`<div class="headline-cloud" aria-label="file types and categories">${tags.map(97+		([label, weight]) =>98+			html`<span class="tc-tag tc-${Math.ceil((weight / max) * 5)}">${label}</span>`,99+	)}</div>`;90100 }91101 92102 function scatter(label: string): number {

view file

src/pages/housekeeping.ts+6 −2
@@ -17,7 +17,9 @@1717 	);1818 	app.get("/sitemap.xml", (c) => {1919 		const url = (path: string, changefreq: string, priority: string) =>20-			`  <url><loc>${origin}${path}</loc><changefreq>${changefreq}</changefreq><priority>${priority}</priority></url>\n`;20+			`  <url><loc>${21+				origin22+			}${path}</loc><changefreq>${changefreq}</changefreq><priority>${priority}</priority></url>\n`;2123 		const urls = [2224 			url("/", "daily", "1.0"),2325 			url("/browse", "daily", "0.9"),@@ -26,7 +28,9 @@2628 			...publicRepoNames(ctx.kepr).map((name) => url(`/${name}`, "daily", "0.7")),2729 		];2830 		return c.body(29-			`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("")}</urlset>\n`,31+			`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join(32+				"",33+			)}</urlset>\n`,3034 			200,3135 			{ "Content-Type": "application/xml; charset=UTF-8", "Cache-Control": "public, max-age=3600" },3236 		);

view file

src/pages/layout.ts+8 −4
@@ -9,13 +9,15 @@99 export type PublicRepo = { settings: RepoSettings; mirror: Database };1010 1111 const HEAD_CACHE = "public, max-age=60";12-// Content at a save never changes, but strip can remove it, so it is long-lived rather than immutable,13-// and `private`, so no shared cache keeps it after the repo turns private (§20).12+// Content at a save never changes, but strip can remove it, so it is long-lived rather than13+// immutable, and `private`, so no shared cache keeps it after the repo turns private (§20).1414 const SAVE_CACHE = "private, max-age=31536000";1515 const FONTS =1616 	"https://fonts.bunny.net/css?family=bellota:700|comfortaa:700|martian-mono:400|quicksand:400,600|sacramento:400&display=swap";1717 18-/** The gate of §20: a private repo and an unknown one both come back null, and so both 404 alike. */18+/**19+ * The gate of §20: a private repo and an unknown one both come back null, and so both 404 alike.20+ */1921 export function openPublicRepo(ctx: PageContext, name: string): PublicRepo | null {2022 	const settings = publicRepo(ctx.kepr, name);2123 	return settings ? { settings, mirror: openMirror(ctx.mirrorDir, name) } : null;@@ -65,7 +67,9 @@6567 </head>6668 <body>6769 <nav>68-<a class="wordmark" href="/" aria-label="${site}"><svg viewBox="0 0 32 32" class="wordmark-icon" aria-hidden="true"><g class="tri-outer"><polygon points="16,3 27,23 5,23"/></g><g class="tri-inner"><polygon points="16,10 22,21 10,21"/></g></svg></a>70+<a class="wordmark" href="/" aria-label="${71+		site72+	}"><svg viewBox="0 0 32 32" class="wordmark-icon" aria-hidden="true"><g class="tri-outer"><polygon points="16,3 27,23 5,23"/></g><g class="tri-inner"><polygon points="16,10 22,21 10,21"/></g></svg></a>6973 ${branding && html`<span class="wordmark-by">${branding}</span>`}7074 <span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a><a href="/support">support</a></span>7175 </nav>

view file

src/pages/library.ts+9 −3
@@ -41,10 +41,14 @@4141 	const { name, category } = repo.settings;4242 	const { stats } = repo;4343 	return html`<a class="repo-card" href="/${name}">44-<div class="repo-card-head"><span class="repo-card-name">${name}</span><span class="repo-card-saves mono">${plural(stats.saves, "save")}</span></div>44+<div class="repo-card-head"><span class="repo-card-name">${45+		name46+	}</span><span class="repo-card-saves mono">${plural(stats.saves, "save")}</span></div>4547 ${repo.description && html`<p class="repo-card-desc">${repo.description}</p>`}4648 ${ticks(repo.saves)}47-<div class="repo-card-foot">${category && html`<span class="cat-chip">${category}</span>`}${stats.language && html`<span class="repo-card-lang">${stats.language}</span>`}${stats.updated !== null && html`<span class="repo-card-when">${relativeTime(stats.updated)}</span>`}</div>49+<div class="repo-card-foot">${50+		category && html`<span class="cat-chip">${category}</span>`51+	}${stats.language && html`<span class="repo-card-lang">${stats.language}</span>`}${stats.updated !== null && html`<span class="repo-card-when">${relativeTime(stats.updated)}</span>`}</div>4852 </a>`;4953 }5054 @@ -58,5 +62,7 @@5862 		const x = span === 0 ? 50 : 1 + (98 * (save.created_at - first)) / span;5963 		return html`<line x1="${x.toFixed(2)}" x2="${x.toFixed(2)}" y1="9" y2="27"/>`;6064 	});61-	return html`<svg class="repo-card-ticks" viewBox="0 0 100 36" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>`;65+	return html`<svg class="repo-card-ticks" viewBox="0 0 100 36" preserveAspectRatio="none" aria-hidden="true">${66+		lines67+	}</svg>`;6268 }

view file

src/pages/profile.ts+26 −8
@@ -66,10 +66,14 @@6666 	// Without an uploaded signature, the handle in Sacramento stands in for it, as on Kepr 2.6767 	const name =6868 		signature && isSafePath(signature) && !signature.includes("/")69-			? html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="${handle}">`69+			? html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(70+					signature,71+				)}" alt="${handle}">`7072 			: handle;7173 	return html`<section class="profile-cover">72-<div class="hex-backdrop" aria-hidden="true">${columns.map((id) => html`<span class="hex-col">${id}</span>`)}</div>74+<div class="hex-backdrop" aria-hidden="true">${columns.map(75+		(id) => html`<span class="hex-col">${id}</span>`,76+	)}</div>7377 <div class="profile-cover-fade"></div>7478 <div class="profile-identity">7579 <h1 class="profile-username">${name}</h1>@@ -87,7 +91,10 @@8791 	return html`<div class="profile-identity-card">8892 ${bio && html`<p class="profile-identity-bio">${bio}</p>`}8993 ${location && html`<p class="profile-identity-location">${location}</p>`}90-${hasLink && html`<a class="profile-website-pill" href="${link}" rel="me noopener noreferrer">${link}</a>`}94+${95+	hasLink &&96+	html`<a class="profile-website-pill" href="${link}" rel="me noopener noreferrer">${link}</a>`97+}9198 </div>`;9299 }93100 @@ -98,7 +105,10 @@98105 		["issues", publicIssueCount(ctx.kepr)],99106 		["downloads", publicDownloadCount(ctx.kepr)],100107 	];101-	return html`<div class="profile-stats-grid">${tiles.map(([label, n]) => html`<div class="profile-stat-tile"><div class="profile-stat-number">${n.toLocaleString("en-US")}</div><div class="profile-stat-label">${label}</div></div>`)}</div>`;108+	return html`<div class="profile-stats-grid">${tiles.map(109+		([label, n]) =>110+			html`<div class="profile-stat-tile"><div class="profile-stat-number">${n.toLocaleString("en-US")}</div><div class="profile-stat-label">${label}</div></div>`,111+	)}</div>`;102112 }103113 104114 // Lines per language summed across public repos, as SVG so the widths need no inline style (§25).@@ -115,13 +125,19 @@115125 	let x = 0;116126 	const segments = top.map(([language, n]) => {117127 		const width = (100 * n) / total;118-		const segment = html`<rect x="${x.toFixed(2)}" width="${width.toFixed(2)}" height="6"><title>${language} ${Math.round(width)}%</title></rect>`;128+		const segment = html`<rect x="${x.toFixed(129+			2,130+		)}" width="${width.toFixed(2)}" height="6"><title>${language} ${Math.round(width)}%</title></rect>`;119131 		x += width;120132 		return segment;121133 	});122134 	return html`<div class="profile-languages-bar">123-<svg class="profile-lang-bar" viewBox="0 0 100 6" preserveAspectRatio="none" role="img" aria-label="languages by lines">${segments}</svg>124-<div class="profile-lang-labels">${top.slice(0, 3).map(([language]) => html`<span class="profile-lang-label">${language}</span>`)}</div>135+<svg class="profile-lang-bar" viewBox="0 0 100 6" preserveAspectRatio="none" role="img" aria-label="languages by lines">${136+		segments137+	}</svg>138+<div class="profile-lang-labels">${top139+		.slice(0, 3)140+		.map(([language]) => html`<span class="profile-lang-label">${language}</span>`)}</div>125141 </div>`;126142 }127143 @@ -129,6 +145,8 @@129145 	if (recent.length === 0) return html`<p class="profile-empty-state">no activity yet</p>`;130146 	return html`<div class="profile-timeline">${recent.map(131147 		({ repo, save }) =>132-			html`<div class="profile-event"><a class="profile-event-repo" href="/${repo}">${repo}</a><span class="profile-event-glyph">✦</span><a class="profile-event-message" href="/${repo}/saves/${save.id}">${save.message.split("\n")[0]}</a><span class="profile-event-time">${relativeTime(save.created_at)}</span></div>`,148+			html`<div class="profile-event"><a class="profile-event-repo" href="/${149+				repo150+			}">${repo}</a><span class="profile-event-glyph">✦</span><a class="profile-event-message" href="/${repo}/saves/${save.id}">${save.message.split("\n")[0]}</a><span class="profile-event-time">${relativeTime(save.created_at)}</span></div>`,133151 	)}</div>`;134152 }

view file

src/pages/releases.ts+14 −4
@@ -44,7 +44,11 @@4444 				body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>4545 <h1 class="file-page-title">builds</h1>4646 <section class="repo-section">47-${releases.length > 0 ? releases.map((release) => releaseBlock(repo, release, counts)) : html`<p class="muted mono save-empty">no releases yet</p>`}47+${48+	releases.length > 049+		? releases.map((release) => releaseBlock(repo, release, counts))50+		: html`<p class="muted mono save-empty">no releases yet</p>`51+}4852 </section>`,4953 			}),5054 		);@@ -65,10 +69,14 @@6569 			? renderMarkdown(release.notes, { repo: name, dir: "", at: null })6670 			: html`<p>${save?.message ?? `Release ${release.version} of ${name}.`}</p>`;6771 	return html`<article class="release" id="${release.version}">68-<p class="release-head"><span class="release-version">v${release.version}</span>${saveLink}<span class="history-when">${formatDate(release.publishedAt)}</span></p>72+<p class="release-head"><span class="release-version">v${73+		release.version74+	}</span>${saveLink}<span class="history-when">${formatDate(release.publishedAt)}</span></p>6975 <div class="release-notes markdown">${notes}</div>7076 <div class="release-files">71-${release.files.map((file) => fileRow(name, release.version, file, counts.get(`${release.version}/${file.platform}`) ?? 0))}77+${release.files.map((file) =>78+	fileRow(name, release.version, file, counts.get(`${release.version}/${file.platform}`) ?? 0),79+)}7280 </div>7381 </article>`;7482 }@@ -77,7 +85,9 @@7785 	const hint = installHint(file.name);7886 	// The `<repo>-<platform>` form, as Kepr 2 linked it, so `curl -O` saves a self-describing name.7987 	return html`<div class="release-file">80-<p class="release-file-head"><span class="release-platform">${PLATFORM_LABELS[file.platform]}</span><a class="release-download mono" href="/${repo}/releases/${version}/${repo}-${file.platform}">↓ ${file.name}</a><span class="release-meta mono">${formatSize(file.size)} · ${plural(downloads, "download")}</span></p>88+<p class="release-file-head"><span class="release-platform">${89+		PLATFORM_LABELS[file.platform]90+	}</span>${html`<a class="release-download mono" href="${`/${repo}/releases/${version}/${repo}-${file.platform}`}">↓ ${file.name}</a>`}${html`<span class="release-meta mono">${formatSize(file.size)} · ${plural(downloads, "download")}</span>`}</p>8191 <p class="release-sha mono"><span class="muted">sha256</span> ${file.sha256}</p>8292 ${hint && html`<p class="release-hint muted">${hint}</p>`}8393 </div>`;

view file

src/pages/repo.ts+32 −8
@@ -90,12 +90,26 @@9090 <h1 class="repo-name">${name}</h1>9191 ${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}9292 ${intro && html`<div class="repo-desc"><p>${intro}</p></div>`}93-${HTTP_URL.test(homepage) && html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`}94-${stats.language && html`<div class="lang-badges"><span class="lang-badge">${stats.language}</span></div>`}93+${94+	HTTP_URL.test(homepage) &&95+	html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`96+}97+${98+	stats.language &&99+	html`<div class="lang-badges"><span class="lang-badge">${stats.language}</span></div>`100+}95101 <p class="repo-meta">${meta.join(" · ")}</p>96102 <div class="repo-tabbar">97-<nav class="repo-tabs" aria-label="Repository panels"><a class="repo-tab" href="/${name}/issues">issues</a><a class="repo-tab" href="/${name}/releases">builds</a><a class="repo-tab" href="/${name}/archives">archives</a></nav>98-${installLines.length > 0 && html`<div class="install-lines">${installLines.map((line) => html`<div class="install-line"><code>${line}</code><button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button></div>`)}</div>`}103+<nav class="repo-tabs" aria-label="Repository panels"><a class="repo-tab" href="/${104+		name105+	}/issues">issues</a><a class="repo-tab" href="/${name}/releases">builds</a><a class="repo-tab" href="/${name}/archives">archives</a></nav>106+${107+	installLines.length > 0 &&108+	html`<div class="install-lines">${installLines.map(109+		(line) =>110+			html`<div class="install-line"><code>${line}</code>${html`<button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button>`}</div>`,111+	)}</div>`112+}99113 </div>100114 </header>`;101115 }@@ -111,16 +125,26 @@111125 	const older = pageNumber * PAGE_SIZE < saves.length;112126 	return html`<section class="repo-section">113127 <details class="repo-section-toggle" open>114-<summary class="section-head"><span class="section-label">timeline</span><span class="section-count">${count}</span></summary>128+<summary class="section-head"><span class="section-label">timeline</span><span class="section-count">${129+		count130+	}</span></summary>115131 ${timelineSvg(name, saves)}116132 </details>117133 </section>118134 ${atlas}119135 <section class="repo-section" id="history">120136 <details class="repo-section-toggle" open>121-<summary class="section-head"><span class="section-label">history</span><span class="section-count">${count}</span></summary>122-${rows.map((save) => html`<a class="history-row" href="/${name}/saves/${save.id}"><span class="history-id">${save.id}</span><span class="history-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></a>`)}123-${(pageNumber > 1 || older) && html`<p class="pager mono">${pageNumber > 1 && html`<a href="?page=${pageNumber - 1}#history">← newer</a>`}${older && html`<a href="?page=${pageNumber + 1}#history">older →</a>`}</p>`}137+<summary class="section-head"><span class="section-label">history</span><span class="section-count">${138+		count139+	}</span></summary>140+${rows.map(141+	(save) =>142+		html`<a class="history-row" href="/${name}/saves/${save.id}"><span class="history-id">${save.id}</span><span class="history-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></a>`,143+)}144+${145+	(pageNumber > 1 || older) &&146+	html`<p class="pager mono">${pageNumber > 1 && html`<a href="?page=${pageNumber - 1}#history">← newer</a>`}${older && html`<a href="?page=${pageNumber + 1}#history">older →</a>`}</p>`147+}124148 </details>125149 </section>`;126150 }

view file

src/pages/save.ts+40 −12
@@ -68,12 +68,21 @@6868 				title: `${title} · ${name}`,6969 				description: `${save.id} · ${plural(diffs.length, "file")} changed`,7070 				path: `/${name}/saves/${save.id}`,71-				body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a> / <a href="/${name}#history">history</a></p>71+				body: html`<p class="breadcrumb mono"><a href="/${72+					name73+				}">${name}</a> / <a href="/${name}#history">history</a></p>7274 ${saveHeader(repo.mirror, name, save, parent, title, rest.join("\n").trim())}7375 <section class="repo-section">7476 <details class="repo-section-toggle" open>75-<summary class="section-head"><span class="section-label">files changed</span><span class="section-count">${plural(diffs.length, "file")}</span></summary>76-${diffs.length === 0 ? html`<p class="muted mono save-empty">no files changed</p>` : diffs.map((d, i) => changeRow(d, i))}77+<summary class="section-head"><span class="section-label">files changed</span><span class="section-count">${plural(78+					diffs.length,79+					"file",80+				)}</span></summary>81+${82+	diffs.length === 083+		? html`<p class="muted mono save-empty">no files changed</p>`84+		: diffs.map((d, i) => changeRow(d, i))85+}7786 </details>7887 </section>7988 <section class="repo-section">${await diffSections(name, save, parent, diffs)}</section>`,@@ -104,11 +113,18 @@104113 		],105114 	];106115 	return html`<p class="save-id mono">${save.id}</p>107-${reason !== null && html`<p class="save-flag"><span class="save-flag-label mono">flagged</span> ${reason || "no reason given"}</p>`}116+${117+	reason !== null &&118+	html`<p class="save-flag"><span class="save-flag-label mono">flagged</span> ${reason || "no reason given"}</p>`119+}108120 <h1 class="save-message-head">${title}</h1>109121 ${body && html`<p class="save-message-body">${body}</p>`}110-<dl class="save-facts mono">${facts.map(([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`)}</dl>111-<p class="save-links mono"><a href="/${name}/archive/${save.id}.tar.gz">↓ ${name}-${save.id.slice(0, 8)}.tar.gz</a><a href="/${name}?at=${save.id}#atlas">atlas at this save</a></p>`;122+<dl class="save-facts mono">${facts.map(123+		([term, value]) => html`<dt>${term}</dt><dd>${value}</dd>`,124+	)}</dl>125+<p class="save-links mono"><a href="/${126+		name127+	}/archive/${save.id}.tar.gz">↓ ${name}-${save.id.slice(0, 8)}.tar.gz</a><a href="/${name}?at=${save.id}#atlas">atlas at this save</a></p>`;112128 }113129 114130 function changes(db: Database, save: Save, parent: Save | null): Change[] {@@ -159,7 +175,9 @@159175 }160176 161177 function changeRow(d: FileDiff, index: number): Html {162-	return html`<a class="change-row change-${d.status}" href="#diff-${index}"><span class="change-path">${d.path}</span><span class="change-stat mono">${stat(d)}</span></a>`;178+	return html`<a class="change-row change-${179+		d.status180+	}" href="#diff-${index}"><span class="change-path">${d.path}</span><span class="change-stat mono">${stat(d)}</span></a>`;163181 }164182 165183 async function diffSections(@@ -177,7 +195,9 @@177195 		const length = hunkList.reduce((sum, h) => sum + h.edits.length + 1, 0);178196 		const mode =179197 			d.before && d.after && d.before.executable !== d.after.executable180-				? html`<p class="diff-note mono">${d.after.executable ? "now executable" : "no longer executable"}</p>`198+				? html`<p class="diff-note mono">${199+						d.after.executable ? "now executable" : "no longer executable"200+					}</p>`181201 				: "";182202 		let content: Html | string = "";183203 		if (d.kind === "binary") content = html`<p class="diff-note mono">binary file, not shown</p>`;@@ -188,10 +208,16 @@188208 			shown += length;189209 			content = await diffBody(d, hunkList);190210 		}191-		sections.push(html`<details class="diff-file change-${d.status}" id="diff-${index}"${length <= COLLAPSE_LINES && " open"}>192-<summary class="diff-head"><span class="change-path">${d.path}</span><span class="change-stat mono">${stat(d)}</span></summary>211+		sections.push(html`<details class="diff-file change-${212+			d.status213+		}" id="diff-${index}"${length <= COLLAPSE_LINES && " open"}>214+<summary class="diff-head"><span class="change-path">${215+			d.path216+		}</span><span class="change-stat mono">${stat(d)}</span></summary>193217 ${mode}${content}194-<p class="diff-view mono"><a href="${view}">view file${d.status === "removed" && " at the parent"}</a></p>218+<p class="diff-view mono"><a href="${219+			view220+		}">view file${d.status === "removed" && " at the parent"}</a></p>195221 </details>`);196222 	}197223 	return sections;@@ -211,7 +237,9 @@211237 		const code = e.kind === "-" ? oldLines[e.a] : newLines[e.b];212238 		const kind = e.kind === "+" ? "add" : e.kind === "-" ? "del" : "ctx";213239 		const bare = (e.kind !== "+" && e.a === oldEnd) || (e.kind !== "-" && e.b === newEnd);214-		return html`<span class="dl dl-${kind}"><span class="ln">${e.kind !== "+" && e.a + 1}</span><span class="ln">${e.kind !== "-" && e.b + 1}</span><span class="ds">${e.kind}</span><span class="lc">${code}</span></span>${bare && html`<span class="dh">\\ No newline at end of file</span>`}`;240+		return html`<span class="dl dl-${241+			kind242+		}"><span class="ln">${e.kind !== "+" && e.a + 1}</span><span class="ln">${e.kind !== "-" && e.b + 1}</span><span class="ds">${e.kind}</span><span class="lc">${code}</span></span>${bare && html`<span class="dh">\\ No newline at end of file</span>`}`;215243 	};216244 	return html`<div class="file-view"><pre class="code diff"><code>${hunkList.map(217245 		(h) => html`<span class="dh">${h.header}</span>${h.edits.map(row)}`,

view file

src/pages/timeline.ts+8 −3
@@ -23,18 +23,23 @@2323 	const reach = Math.max(span / 40, 3600);2424 	const dots = saves.map((save) => {2525 		const x = span === 0 ? 50 : 1 + (98 * (save.created_at - first)) / span;26-		// ponytail: O(n²) neighbour count; a sliding window over sorted times if a repo reaches ~5,000 saves.26+		// ponytail: O(n²) neighbour count; a sliding window over sorted times if a repo27+		// reaches ~5,000 saves.2728 		const near = times.filter((t) => Math.abs(t - save.created_at) <= reach).length;2829 		const level = near >= 8 ? 3 : near >= 4 ? 2 : near >= 2 ? 1 : 0;2930 		const meta = [formatDate(save.created_at), save.agent, save.session]3031 			.filter(Boolean)3132 			.join(" · ");3233 		const tip = `${save.id}\n${save.message.split("\n")[0]}\n${meta}`;33-		return html`<a href="/${repo}/saves/${save.id}" class="dot d${level}"><title>${tip}</title><circle cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/></a>`;34+		return html`<a href="/${35+			repo36+		}/saves/${save.id}" class="dot d${level}"><title>${tip}</title><circle cx="${x.toFixed(3)}%" cy="28" r="${2.5 + level * 0.5}"/></a>`;3437 	});3538 	const perDay = saves.length / Math.max(span / DAY, 1);3639 	const pace = PACE.find(([min]) => perDay > min)?.[1] ?? "quiet";37-	return html`<svg class="timeline" width="100%" height="56" role="img" aria-label="${saves.length} saves over time">40+	return html`<svg class="timeline" width="100%" height="56" role="img" aria-label="${41+		saves.length42+	} saves over time">3843 <line class="timeline-line" x1="0" x2="100%" y1="28" y2="28"/>3944 ${dots}4045 </svg>

view file

src/server/app.ts+5 −0
@@ -37,6 +37,10 @@3737 	// §25 allows images from the presigned host, which is the bucket endpoint's origin.3838 	const imgSrc =3939 		config.bucket.kind === "s3" ? ["'self'", new URL(config.bucket.endpoint).origin] : ["'self'"];40+	app.use("*", async (c, next) => {41+		await next();42+		if (c.req.path.endsWith(".svg")) c.header("Content-Security-Policy", "sandbox");43+	});4044 	app.use(4145 		secureHeaders({4246 			contentSecurityPolicy: {@@ -66,6 +70,7 @@6670 		);6771 		if (!(await file.exists())) return c.notFound();6872 		c.header("Cache-Control", "public, max-age=3600");73+		if (file.type === "image/svg+xml") c.header("Content-Security-Policy", "sandbox");6974 		return c.body(await file.bytes(), 200, { "Content-Type": file.type });7075 	});7176 	const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };

view file

src/server/config.ts+5 −2
@@ -19,8 +19,11 @@1919 2020 export class ConfigError extends Error {}2121 22-const HOSTNAME =23-	/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:([1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?$/i;22+const HOSTNAME = new RegExp(23+	String.raw`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*` +24+		String.raw`(:([1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?$`,25+	"i",26+);2427 2528 export function parseConfig(text: string): Config {2629 	let raw: unknown;

view file

src/server/db.ts+2 −1
@@ -136,7 +136,8 @@136136 export function publicRepo(db: Database, name: string): RepoSettings | null {137137 	const row = db138138 		.query<RepoRow, [string]>(139-			"SELECT name, category, homepage, install1, install2, install3 FROM repos WHERE name = ? AND public = 1",139+			"SELECT name, category, homepage, install1, install2, install3 " +140+				"FROM repos WHERE name = ? AND public = 1",140141 		)141142 		.get(name);142143 	if (!row) return null;

view file

src/server/login.ts+2 −4
@@ -12,8 +12,6 @@1212 const HEX64 = /^[0-9a-f]{64}$/;1313 const FAILURE_WINDOW_MS = 15 * 60 * 1000;1414 const FAILURES_PER_ADDRESS = 5;15-// The global backoff: past this many failures from anywhere, every sign-in waits out the window.16-const FAILURES_ANYWHERE = 30;1715 1816 // Failed sign-ins, oldest first, in memory: a restart forgets them, but an attacker can't cause one.1917 const failures: { at: number; address: string }[] = [];@@ -87,7 +85,7 @@8785 		while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift();8886 		const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex");8987 		const mine = failures.filter((failure) => failure.address === address).length;90-		if (failures.length >= FAILURES_ANYWHERE || mine >= FAILURES_PER_ADDRESS) {88+		if (mine >= FAILURES_PER_ADDRESS) {9189 			c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));9290 			return signInPage(c, ctx, "Too many attempts. Try again later.", 429);9391 		}@@ -116,7 +114,7 @@116114 	});117115 }118116 119-// A double-submit token: the form repeats a SameSite=Strict cookie that a cross-site post never has.117+// A double-submit token repeats a SameSite=Strict cookie that a cross-site post never has.120118 function signInPage(c: Context, ctx: PageContext, error: string, status: 200 | 401 | 429) {121119 	let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";122120 	if (!HEX64.test(cookie)) {

view file

src/server/reserved.ts+3 −3
@@ -1,7 +1,7 @@11 /**2- * First path segments Kepr's own routes take (`docs/routes.txt`, §28's old paths included). A bucket3- * repo of the same name would be shadowed by them, so the sync refuses it. A test holds this list to4- * the app's registered routes.2+ * First path segments Kepr's own routes (`docs/routes.txt`, §28's old paths included). A bucket3+ * repo of the same name would be shadowed by them, so the sync refuses it. A test holds this4+ * list to the app's registered routes.55  */66 export const RESERVED_NAMES = new Set([77 	"admin",

view file

tests/admin.test.ts+1 −3
@@ -95,12 +95,10 @@9595 		expect((await browser.signIn()).status).toBe(303);9696 	});9797 98-	test("thirty failures from anywhere back every sign-in off until the window passes", async () => {98+	test("failures are limited per address, with no global cap", async () => {9999 		for (let i = 0; i < 30; i++) {100100 			expect((await new Browser(inst.app, `192.0.2.${i}`).signIn("nope")).status).toBe(401);101101 		}102-		expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(429);103-		tick(901);104102 		expect((await new Browser(inst.app, "192.0.2.200").signIn()).status).toBe(303);105103 	});106104 

view file

tests/bucket.test.ts+5 −1
@@ -88,7 +88,11 @@8888 		});8989 		expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));9090 		expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);91-		expect(await s3.get(`${fx.repo}/format`)).toBeNull();91+		// koh3 writes `<repo>/format` on its first offer (§9.2), so it is present;92+		// a key that is not there is what reads as null.93+		const format = await s3.get(`${fx.repo}/format`);94+		expect(format === null ? null : new TextDecoder().decode(format)).toBe("koh-remote 1");95+		expect(await s3.get(`${fx.repo}/no-such-key`)).toBeNull();9296 		const url = new URL(9397 			s3.downloadUrl("demo/releases/1.0/android-arm64/a b.apk", "a b.apk", "t/x"),9498 		);

view file

tests/contract.test.ts+12 −9
@@ -313,20 +313,23 @@313313 		expect(await (await get("/koh/releases/latest/version.txt")).text()).toBe("3.0.0");314314 	});315315 316-	test("its download URLs redirect to the file, and without 2.1.16 it has nothing to offer", async () => {317-		const res = await get(`/api/koh/releases/${FROZEN}/macos-aarch64`);318-		expect(res.status).toBe(302);319-		expect(res.headers.get("location")).toEndWith(`/koh/releases/${FROZEN}/macos-aarch64/koh`);316+	test("returns the frozen Koh 2 no-releases body when 2.1.16 is unavailable", async () => {320317 		app = await serve(bucketDir, (kepr) => {321318 			kepr.run("UPDATE repos SET public = 1");322319 			kepr.run(323320 				`INSERT INTO release_settings (repo, version, hidden) VALUES ('koh', '${FROZEN}', 1)`,324321 			);325322 		});326-		const hidden = await get("/api/koh/releases/latest");327-		expect([hidden.status, await hidden.text()]).toEqual([328-			404,329-			'{"error":"no_releases","message":"no releases available"}',330-		]);323+		const res = await get("/api/koh/releases/latest");324+		expect(res.status).toBe(404);325+		// Required compatibility body: Koh 2's updater parses this exact JSON response.326+		expect(await res.text()).toBe('{"error":"no_releases","message":"no releases available"}');327+	});328+329+	test("its download URLs redirect to the file", async () => {330+		app = await serve(bucketDir, (kepr) => kepr.run("UPDATE repos SET public = 1"));331+		const res = await get(`/api/koh/releases/${FROZEN}/macos-aarch64`);332+		expect(res.status).toBe(302);333+		expect(res.headers.get("location")).toEndWith(`/koh/releases/${FROZEN}/macos-aarch64/koh`);331334 	});332335 });

view file

tests/pages.test.ts+4 −1
@@ -413,10 +413,13 @@413413 	});414414 });415415 416-test("static assets are served and the CSP allows Bunny Fonts but no inline script", async () => {416+test("static assets are served and SVG responses are sandboxed", async () => {417417 	const css = await get("/static/css/kepr.css");418418 	expect(css.status).toBe(200);419419 	expect(css.headers.get("content-type")).toContain("text/css");420+	const svg = await get("/favicon.svg");421+	expect(svg.headers.get("content-type")).toBe("image/svg+xml");422+	expect(svg.headers.get("content-security-policy")).toBe("sandbox");420423 	expect((await get("/static/../package.json")).status).toBe(404);421424 	const csp = (await get("/demo")).headers.get("content-security-policy") ?? "";422425 	expect(csp).toContain("style-src 'self' https://fonts.bunny.net");

view file

tests/tarball.test.ts+15 −0
@@ -112,6 +112,21 @@112112 		expect(t.length).toBe(6 * 512);113113 	});114114 115+	test("writes executable archives at 0755 and plain archives at 0644", () => {116+		const executableTar = gunzipSync(117+			buildTarball(118+				"r",119+				[{ path: "run.sh", bytes: new TextEncoder().encode("#!/bin/sh\n") }],120+				0o755,121+			),122+		);123+		const plainTar = gunzipSync(124+			buildTarball("r", [{ path: "README.md", bytes: new TextEncoder().encode("read me\n") }]),125+		);126+		expect(field(header(executableTar, 1), 100, 108)).toBe("0000755\0");127+		expect(field(header(plainTar, 1), 100, 108)).toBe("0000644\0");128+	});129+115130 	test("splits a long path into the ustar prefix, and refuses one that can't be split", () => {116131 		const long = `${"d".repeat(40)}/${"x".repeat(85)}`;117132 		const t = gunzipSync(buildTarball("r", [{ path: long, bytes: new Uint8Array() }]));

view file