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
7 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 −0
package.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": {

view file

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 ${

view file

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. */

view file

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);

view file

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. They

view file

src/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. */

view file

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+});

view file