1305a0bb9d6d
hide files and folders per repo from every page, the stats and tarballs, handing the list to Koh under .kepr/hidden (v3.0.148)
- time
- 2026-09-30 13:58 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- admin repo settings
- parent
- 3c2984c576fc
↓ kepr-1305a0bb.tar.gzatlas at this save
files changed7 files
package.json+1 −1src/admin/repos.ts+34 −2src/admin/store.ts+13 −2src/bucket/sync.ts+13 −2src/mirror/store.ts+36 −11src/server/db.ts+10 −0tests/hidden.test.ts+90 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.147",3+ "version": "3.0.148",44 "private": true,55 "type": "module",66 "scripts": {src/admin/repos.ts+34 −2
@@ -1,6 +1,7 @@11 import type { Context, Hono } from "hono";2-import { BucketWriteError } from "../bucket/reader.ts";3-import { syncAll } from "../bucket/sync.ts";2+import { BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";3+import { isSafePath } from "../bucket/repo.ts";4+import { rebuildRepo, syncAll } from "../bucket/sync.ts";45 import { MAX_CHARS } from "../issues/spam.ts";56 import { operatorIssues } from "../issues/store.ts";67 import { languageLines, repoStats } from "../mirror/queries.ts";@@ -24,6 +25,7 @@2425 const HTTP_URL = /^https?:\/\/[^\s]+$/i;2526 const MAX_LANGUAGES = 5;2627 const MAX_LANGUAGE = 30;28+const MAX_HIDDEN = 100;2729 2830 /** Shown languages as typed, comma-separated; blanks between commas are dropped. */2931 function languageNames(value: string): string[] {@@ -48,6 +50,10 @@4850 const fields = await textFields(c);4951 const text = (name: string) => (fields[name] ?? "").trim();5052 const languages = languageNames(fields.languages ?? "");53+ const hidden = (fields.hidden_paths ?? "")54+ .split(/\r?\n/)55+ .map((path) => path.trim().replace(/\/+$/, ""))56+ .filter((path) => path !== "");5157 const edit: RepoEdit = {5258 public: fields.public === "1" ? 1 : 0,5359 category: text("category"),@@ -55,6 +61,7 @@5561 // Joined with the separator `publicRepo` splits on, so a name never contains it.5662 languages: languages.join(", "),5763 issues_paused: fields.issues_paused === "1" ? 1 : 0,64+ hidden_paths: hidden.join("\n"),5865 };5966 const refuse = (error: string) =>6067 c.html(reposPage(c, ctx, `${repo.name}: ${error}`, repo.name), 400);@@ -80,8 +87,12 @@8087 if (languages.some((name) => /\p{C}/u.test(name))) {8188 return refuse("a language name is printable text.");8289 }90+ if (hidden.length > MAX_HIDDEN) return refuse(`up to ${MAX_HIDDEN} hidden paths.`);91+ const unsafe = hidden.find((path) => path.length > MAX_FIELD || !isSafePath(path));92+ if (unsafe !== undefined) return refuse(`"${unsafe}" is not a path in the repo.`);8393 updateRepo(ctx.kepr, repo.name, edit);8494 for (const [field, value] of texts) setRepoText(ctx.kepr, repo.name, field, value);95+ if (edit.hidden_paths !== repo.hidden_paths) await applyHidden(ctx, repo.name, hidden);8596 return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);8697 });8798 @@ -135,6 +146,22 @@135146 });136147 }137148 149+/**150+ * Hands a changed hidden list to Koh at `.kepr/hidden/<repo>`, one path per line, for it to add to151+ * `.kohignore`; then rebuilds the mirror and drops kept tarballs, so hiding and unhiding both show152+ * at once. Without a write key the list stays in Kepr: Koh reads it only from the bucket anyway.153+ */154+async function applyHidden(ctx: AdminContext, repo: string, hidden: string[]): Promise<void> {155+ const list = new TextEncoder().encode(hidden.map((path) => `${path}\n`).join(""));156+ try {157+ await ctx.sync.bucket.put(`${KEPR_PREFIX}hidden/${repo}`, list, "text/plain");158+ await dropTarballs(ctx.sync.bucket, repo);159+ } catch (error) {160+ if (!(error instanceof BucketWriteError)) throw error;161+ }162+ await rebuildRepo(ctx.sync, repo);163+}164+138165 /** A build of a known repo, as the console lists it, or null. */139166 function findRelease(ctx: AdminContext, name: string, version: string) {140167 const repo = findRepo(ctx.kepr, name);@@ -233,6 +260,11 @@233260 ${textArea(repo.name, "description", "description", 3, own.description)}234261 ${textArea(repo.name, "details", "details", 12, own.details)}235262 <p class="issue-meta mono">Markdown. Leave either blank to use the README's.</p>263+<label class="issues-label" for="${repo.name}-hidden_paths">hidden files and folders</label>264+<textarea class="issues-textarea mono" id="${repo.name}-hidden_paths" name="hidden_paths"265+rows="4">${repo.hidden_paths}</textarea>266+<p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,267+the stats and tarballs, and passed to Koh for .kohignore when there is a write key.</p>236268 <button class="issues-submit" type="submit">save ${repo.name}</button>237269 </form>238270 ${src/admin/store.ts+13 −2
@@ -9,6 +9,8 @@99 /** Shown languages, joined by ", "; '' shows the detected one. */1010 languages: string;1111 issues_paused: number;12+ /** Hidden paths, one per line; '' hides nothing. */13+ hidden_paths: string;1214 last_sync_at: number | null;1315 last_sync_error: string | null;1416 };@@ -25,9 +27,18 @@2527 2628 export function updateRepo(db: Database, name: string, edit: RepoEdit): void {2729 db.query(28- `UPDATE repos SET public = ?, category = ?, homepage = ?, languages = ?, issues_paused = ?30+ `UPDATE repos SET public = ?, category = ?, homepage = ?, languages = ?, issues_paused = ?,31+ hidden_paths = ?2932 WHERE name = ?`,30- ).run(edit.public, edit.category, edit.homepage, edit.languages, edit.issues_paused, name);33+ ).run(34+ edit.public,35+ edit.category,36+ edit.homepage,37+ edit.languages,38+ edit.issues_paused,39+ edit.hidden_paths,40+ name,41+ );3142 }3243 3344 /** Writes site settings in one transaction; an empty value removes the key, so defaults apply. */src/bucket/sync.ts+13 −2
@@ -1,5 +1,7 @@11 import type { Database } from "bun:sqlite";22 import {3+ discardMirror,4+ hidePaths,35 knownEventIds,46 knownObject,57 knownReleases,@@ -9,7 +11,7 @@911 openMirror,1012 type StoredObject,1113 } from "../mirror/store.ts";12-import { recordSync, registerRepo } from "../server/db.ts";14+import { hiddenPaths, recordSync, registerRepo } from "../server/db.ts";1315 import { RESERVED_NAMES } from "../server/reserved.ts";1416 import { dropTarballs } from "../tarball/cache.ts";1517 import type { Bucket } from "./reader.ts";@@ -51,6 +53,13 @@5153 return running;5254 }5355 56+/** Rebuilds one repo's mirror from the bucket, once any sync under way has finished. */57+export async function rebuildRepo(target: SyncTarget, repo: string): Promise<void> {58+ await syncAll(target);59+ discardMirror(target.mirrorDir, repo);60+ await syncAll(target);61+}62+5463 export function startSyncLoop(target: SyncTarget): void {5564 const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));5665 tick();@@ -65,7 +74,9 @@6574 error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;6675 } else {6776 try {68- const events = await syncRepo(target.bucket, openMirror(target.mirrorDir, repo), repo);77+ const mirror = openMirror(target.mirrorDir, repo);78+ const events = await syncRepo(target.bucket, mirror, repo);79+ hidePaths(mirror, hiddenPaths(target.kepr, repo));6980 if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);7081 } catch (cause) {7182 error = cause instanceof Error ? cause.message : String(cause);src/mirror/store.ts+36 −11
@@ -1,5 +1,5 @@11 import { Database } from "bun:sqlite";2-import { mkdirSync } from "node:fs";2+import { mkdirSync, rmSync } from "node:fs";33 import { join } from "node:path";44 import { inflateSync } from "node:zlib";55 import type { EventRecord, ReleaseRecord, SaveRecord } from "../bucket/repo.ts";@@ -234,17 +234,42 @@234234 )235235 .all();236236 if (strips.length === 0) return;237- const remove = db.query(238- `DELETE FROM save_files239- WHERE (path = $path OR substr(path, 1, length($dir)) = $dir)240- AND ($save = '' OR save_id = $save)`,241- );242- for (const strip of strips) {243- // Koh compares the prefix exactly, never with LIKE, so `_` and `%` in a path match only themselves.244- const dir = `${strip.path.replace(/\/+$/, "")}/`;245- remove.run({ path: strip.path, dir, save: strip.save_id });246- }237+ for (const strip of strips) removePath(db, strip.path, strip.save_id);247238 db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");239+}240+241+/** Removes a path, file or folder, from one save or ('') every save; returns the rows removed. */242+function removePath(db: Database, path: string, save: string): number {243+ // Koh compares the prefix exactly, never with LIKE, so `_` and `%` in a path match only themselves.244+ const dir = `${path.replace(/\/+$/, "")}/`;245+ return db246+ .query(247+ `DELETE FROM save_files248+ WHERE (path = $path OR substr(path, 1, length($dir)) = $dir)249+ AND ($save = '' OR save_id = $save)`,250+ )251+ .run({ path, dir, save }).changes;252+}253+254+/**255+ * The operator's hidden paths, removed from every save as a strip would be (June, 2026-09-30).256+ * Applied after each sync, so saves arriving later lose them too; unhiding rebuilds the mirror.257+ */258+export function hidePaths(db: Database, paths: string[]): void {259+ let removed = 0;260+ db.transaction(() => {261+ for (const path of paths) removed += removePath(db, path, "");262+ if (removed > 0) db.run("DELETE FROM objects WHERE hash NOT IN (SELECT hash FROM save_files)");263+ })();264+ if (removed > 0) refreshStats(db);265+}266+267+/** Closes and deletes a repo's mirror, so the next sync rebuilds it from the bucket. */268+export function discardMirror(dir: string, repo: string): void {269+ const path = join(dir, `${repo}.db`);270+ open.get(path)?.close();271+ open.delete(path);272+ for (const suffix of ["", "-wal", "-shm"]) rmSync(`${path}${suffix}`, { force: true });248273 }249274 250275 // Lines and languages never change between syncs, so they are counted once here, not per view. Theysrc/server/db.ts+10 −0
@@ -15,6 +15,7 @@1515 homepage TEXT NOT NULL DEFAULT '',1616 languages TEXT NOT NULL DEFAULT '',1717 issues_paused INTEGER NOT NULL DEFAULT 0,18+ hidden_paths TEXT NOT NULL DEFAULT '',1819 last_sync_at INTEGER,1920 last_sync_error TEXT2021 );@@ -126,6 +127,7 @@126127 ["repos", "languages", "TEXT NOT NULL DEFAULT ''"],127128 ["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],128129 ["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],130+ ["repos", "hidden_paths", "TEXT NOT NULL DEFAULT ''"],129131 ];130132 // Columns an older kepr.db still has; card order and install lines were removed (June, 2026-09-30).131133 const DROPPED_COLUMNS: [table: string, column: string][] = [@@ -266,6 +268,14 @@266268 description: row.description ?? "",267269 details: row.details ?? "",268270 };271+}272+273+/** The paths the operator hid from a repo (June, 2026-09-30), one per line in kepr.db. */274+export function hiddenPaths(db: Database, repo: string): string[] {275+ const row = db276+ .query<{ hidden_paths: string }, [string]>("SELECT hidden_paths FROM repos WHERE name = ?")277+ .get(repo);278+ return row?.hidden_paths ? row.hidden_paths.split("\n") : [];269279 }270280 271281 /** A repo's own texts, public or not; '' where the README speaks. */tests/hidden.test.ts+90 −0
@@ -0,0 +1,90 @@1+// Covers per-repo hidden paths (June, 2026-09-30): gone from every page, the stats and new2+// tarballs, kept hidden through later syncs, handed to Koh under `.kepr/hidden/`, and back on unhide.3+4+import { beforeAll, describe, expect, test } from "bun:test";5+import { mkdir, writeFile } from "node:fs/promises";6+import { join } from "node:path";7+import { openBucket } from "../src/bucket/reader.ts";8+import { syncAll } from "../src/bucket/sync.ts";9+import { Browser, type Instance, instance } from "./browser.ts";10+import { makeRepos } from "./fixture.ts";11+12+describe("hidden paths", () => {13+ let inst: Instance;14+ let browser: Browser;15+ let repos: Awaited<ReturnType<typeof makeRepos>>;16+ let work: string;17+ const status = async (path: string) => (await inst.app.request(path)).status;18+ const text = async (path: string) => (await inst.app.request(path)).text();19+ const hide = (hidden_paths: string) =>20+ browser.post("/admin/repos/app", { public: "1", hidden_paths });21+ const sync = () =>22+ syncAll({23+ bucket: openBucket({ kind: "folder", path: repos.bucketDir }),24+ kepr: inst.kepr,25+ mirrorDir: join(inst.dataDir, "mirror"),26+ });27+28+ beforeAll(async () => {29+ repos = await makeRepos(["app"]);30+ work = join(repos.bucketDir, "..", "work", "app");31+ await mkdir(join(work, "private"));32+ await writeFile(join(work, "private", "notes.md"), "# private\n");33+ await writeFile(join(work, "tool.py"), "print(1)\n");34+ await writeFile(join(work, "main.ts"), "export const a = 1;\n");35+ repos.koh("app", "save", "add sources");36+ repos.koh("app", "offer");37+ inst = await instance(repos.bucketDir);38+ browser = new Browser(inst.app);39+ await browser.signIn();40+ await hide("");41+ });42+43+ test("refuses unsafe paths and changes nothing", async () => {44+ for (const path of ["../x", "/etc", "a//b", "x".repeat(501)]) {45+ expect((await hide(path)).status).toBe(400);46+ }47+ expect(await status("/app/files/tool.py")).toBe(200);48+ });49+50+ test("a hidden file or folder is gone everywhere and handed to Koh", async () => {51+ const saved = await hide("tool.py\r\nprivate/\n");52+ expect(saved.status).toBe(303);53+ for (const path of ["/app/files/tool.py", "/app/raw/tool.py", "/app/raw/private/notes.md"]) {54+ expect(`${path} ${await status(path)}`).toBe(`${path} 404`);55+ }56+ expect(await status("/app/files/main.ts")).toBe(200);57+ const page = await text("/app");58+ expect(page).not.toContain("tool.py");59+ expect(page).not.toContain("notes.md");60+ expect(page).not.toContain("Python");61+ const head = (await inst.app.request("/app/archive/HEAD.tar.gz")).headers.get("location");62+ const tarball = new Uint8Array(await (await inst.app.request(head ?? "")).arrayBuffer());63+ const listed = Bun.spawnSync(["tar", "-tzf", "-"], { stdin: tarball }).stdout.toString();64+ expect(listed).toContain("main.ts");65+ expect(listed).not.toContain("tool.py");66+ expect(await Bun.file(join(repos.bucketDir, ".kepr/hidden/app")).text()).toBe(67+ "tool.py\nprivate\n",68+ );69+ expect(await (await browser.get("/admin/repos?repo=app")).text()).toContain(70+ ">tool.py\nprivate</textarea>",71+ );72+ });73+74+ test("stays hidden in saves that arrive later", async () => {75+ await writeFile(join(work, "tool.py"), "print(2)\n");76+ repos.koh("app", "save", "change the tool");77+ repos.koh("app", "offer");78+ await sync();79+ expect(await status("/app/files/tool.py")).toBe(404);80+ expect(await text("/app")).toContain("change the tool");81+ });82+83+ test("unhiding brings the files back", async () => {84+ expect((await hide("")).status).toBe(303);85+ expect(await status("/app/files/tool.py")).toBe(200);86+ expect(await text("/app/raw/tool.py")).toBe("print(2)\n");87+ expect(await status("/app/raw/private/notes.md")).toBe(200);88+ expect(await Bun.file(join(repos.bucketDir, ".kepr/hidden/app")).text()).toBe("");89+ });90+});