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
↓ kepr-2b7a429d.tar.gzatlas at this save
files changed39 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 −0package.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": {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) ?? "[]");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> {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 }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(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>`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>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")}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);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 === "") &&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.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 }`,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);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 }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);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 tosrc/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 }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 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. */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 }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 {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 );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>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 }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 }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>`;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 }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)}`,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>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") };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;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;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)) {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",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 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 );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 });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");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() }]));