58d7fe07cda0

list and download every backup, back up now, and show the last failure and restore steps (v3.0.138)

time
2026-09-29 22:45 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
backup workspace
parent
301147eeaa38
6 files package.json+1 −1src/admin/backups.ts+65 −0src/admin/console.ts+3 −30src/server/app.ts+2 −0src/server/backup.ts+54 −24tests/backupworkspace.test.ts+77 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.137",3+	"version": "3.0.138",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/backups.ts+65 −0
@@ -0,0 +1,65 @@1+import type { Context, Hono } from "hono";2+import { html } from "../pages/html.ts";3+import { formatSize } from "../pages/layout.ts";4+import {5+	attemptBackup,6+	backupBytes,7+	backups,8+	backupTarball,9+	makeBackup,10+} from "../server/backup.ts";11+import { setting } from "../server/db.ts";12+import { type AdminContext, adminPage, formField, notice } from "./console.ts";13+14+/** `20260926T031500Z` as `2026-09-26 03:15:00 UTC`. */15+function backupTime(name: string): string {16+	return name.replace(/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/, "$1-$2-$3 $4:$5:$6 UTC");17+}18+19+/** §24's Backups section: every kept backup, back up now, the last failure, and how to restore. */20+export function backupsPage(c: Context, ctx: AdminContext, error = ""): string {21+	const kept = backups(ctx.dataDir).reverse();22+	const newest = kept[0];23+	const failure = setting(ctx.kepr, "backup_error");24+	const body = html`${notice(error)}25+${failure && !error && html`<p class="issues-error mono" role="alert">The last backup failed: ${failure}</p>`}26+${27+	newest28+		? html`<p>Last backup: ${backupTime(newest)}. <a href="/admin/backup">download backup</a></p>`29+		: html`<p>No backup yet.</p>`30+}31+<form method="post" action="/admin/backup">${formField(c)}32+<button class="issues-submit" type="submit">back up now</button></form>33+<p>Kepr keeps the last 14 daily backups of its database and assets.</p>34+<ul class="admin-releases mono">${kept.map(35+		(name) => html`<li><a href="/admin/backup?name=${name}">${backupTime(name)}</a>36+<span class="muted">${formatSize(backupBytes(ctx.dataDir, name))}</span></li>`,37+	)}</ul>38+<section class="repo-section"><h2 class="section-label mono">restoring one</h2>39+<div class="markdown"><ol>40+<li>Stop Kepr.</li>41+<li>Unpack the download: it holds a folder with kepr.db and assets/.</li>42+<li>Put kepr.db and assets/ in the data directory named in kepr.toml, over the old ones.</li>43+<li>Start Kepr. Its first sync rebuilds the mirrors, and everyone signs in again.</li>44+</ol>45+<p class="muted">Backups stay on this server. Keep a copy elsewhere, along with kepr.toml.</p>46+<p class="muted">Sessions and bucket credentials are not included.</p></div></section>`;47+	return adminPage(c, ctx, "backups", body);48+}49+50+export function registerBackups(app: Hono, ctx: AdminContext): void {51+	app.get("/admin/backup", (c) => {52+		const backup = backupTarball(ctx.dataDir, c.req.query("name"));53+		if (!backup) return c.notFound();54+		return c.body(backup.bytes, 200, {55+			"Content-Type": "application/gzip",56+			"Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,57+		});58+	});59+60+	app.post("/admin/backup", (c) => {61+		const failure = attemptBackup(ctx.kepr, () => makeBackup(ctx.kepr, ctx.dataDir));62+		if (failure) return c.html(backupsPage(c, ctx, `The backup failed: ${failure}`), 500);63+		return c.redirect("/admin?section=backups&saved=1", 303);64+	});65+}

view file

src/admin/console.ts+3 −30
@@ -4,11 +4,12 @@44 import { operatorIssues } from "../issues/store.ts";55 import { type Html, html } from "../pages/html.ts";66 import { type PageContext, page, plural, relativeTime } from "../pages/layout.ts";7-import { backups, newestBackupTarball } from "../server/backup.ts";7+import { backups } from "../server/backup.ts";88 import type { Config } from "../server/config.ts";99 import { connectKeys, MACHINES_LISTED } from "../server/connect.ts";1010 import { setting } from "../server/db.ts";1111 import { formToken } from "../server/login.ts";12+import { backupsPage } from "./backups.ts";1213 import { allRepos } from "./store.ts";1314 1415 export type AdminContext = PageContext & {@@ -132,26 +133,7 @@132133 /** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */133134 export function registerStatus(app: Hono, ctx: AdminContext): void {134135 	app.get("/admin", async (c) => {135-		if (c.req.query("section") === "backups") {136-			const newest = backups(ctx.dataDir).at(-1);137-			const backup = newest138-				? html`<p>Last backup: ${newest.replace(139-						/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,140-						"$1-$2-$3 $4:$5:$6 UTC",141-					)}. <a href="/admin/backup">download backup</a></p>`142-				: html`<p>No backup yet.</p>`;143-			return c.html(144-				adminPage(145-					c,146-					ctx,147-					"backups",148-					html`${backup}149-<p>Kepr keeps the last 14 daily backups of its database and assets.</p>150-<p class="muted">Backups stay on this server. Keep a copy elsewhere, along with kepr.toml.</p>151-<p class="muted">Sessions and bucket credentials are not included.</p>`,152-				),153-			);154-		}136+		if (c.req.query("section") === "backups") return c.html(backupsPage(c, ctx));155137 		let bucket: Html;156138 		try {157139 			const repos = await listRepos(ctx.sync.bucket);@@ -218,14 +200,5 @@218200 <p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,219201 			),220202 		);221-	});222-223-	app.get("/admin/backup", (c) => {224-		const backup = newestBackupTarball(ctx.dataDir);225-		if (!backup) return c.notFound();226-		return c.body(backup.bytes, 200, {227-			"Content-Type": "application/gzip",228-			"Content-Disposition": `attachment; filename="kepr-backup-${backup.name}.tar.gz"`,229-		});230203 	});231204 }

view file

src/server/app.ts+2 −0
@@ -3,6 +3,7 @@33 import { Hono } from "hono";44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";6+import { registerBackups } from "../admin/backups.ts";67 import { registerBucket } from "../admin/bucket.ts";78 import { registerStatus } from "../admin/console.ts";89 import { registerEmail } from "../admin/email.ts";@@ -129,6 +130,7 @@129130 	registerBucket(app, admin);130131 	registerNotifications(app, admin);131132 	registerEmail(app, admin);133+	registerBackups(app, admin);132134 	registerRepoText(app, admin);133135 	registerHousekeeping(app, ctx);134136 	registerFrontPages(app, ctx);

view file

src/server/backup.ts+54 −24
@@ -7,10 +7,11 @@77 	readFileSync,88 	renameSync,99 	rmSync,10+	statSync,1011 } from "node:fs";1112 import { join, relative } from "node:path";1213 import { buildTarball } from "../tarball/tar.ts";13-import { notify } from "./db.ts";14+import { notify, setting } from "./db.ts";1415 1516 const KEEP = 14;1617 const DAY_MS = 24 * 3600 * 1000;@@ -25,7 +26,8 @@2526 /** The backups in `data/backups/`, oldest first. */2627 export function backups(dataDir: string): string[] {2728 	const root = join(dataDir, "backups");28-	return existsSync(root)29+	// Not a folder is no backups, so the console still shows why the next one fails.30+	return existsSync(root) && statSync(root).isDirectory()2931 		? readdirSync(root)3032 				.filter((name) => NAME.test(name))3133 				.sort()@@ -68,36 +70,64 @@6870 6971 /** Daily backups: checked at startup and every hour, so a restart never skips a day. */7072 export function startBackupLoop(kepr: Database, dataDir: string): void {71-	// Only a backup that starts failing is told, not every hourly retry of one that keeps failing.72-	let failing = false;73-	const tick = () => {74-		try {75-			backupIfDue(kepr, dataDir);76-			failing = false;77-		} catch (error) {78-			console.error(`kepr: backup failed: ${error}`);79-			if (!failing)80-				notify(kepr, "backup_failed", `Backup failed: ${error}`, "/admin?section=backups");81-			failing = true;82-		}83-	};73+	const tick = () => attemptBackup(kepr, () => backupIfDue(kepr, dataDir));8474 	tick();8575 	setInterval(tick, CHECK_MS);8676 }8777 88-/** The newest backup as a `.tar.gz` for "download backup" (§24), or null when there is none. */89-export function newestBackupTarball(90-	dataDir: string,91-): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {92-	const name = backups(dataDir).at(-1);93-	if (!name) return null;78+/**79+ * One backup attempt. A failure is kept for the console until a backup succeeds (§24), and only a80+ * backup that starts failing is told, not every hourly retry. Returns the failure, or null.81+ */82+export function attemptBackup(kepr: Database, make: () => unknown): string | null {83+	try {84+		make();85+		kepr.query("DELETE FROM settings WHERE key = 'backup_error'").run();86+		return null;87+	} catch (error) {88+		const message = String(error);89+		console.error(`kepr: backup failed: ${message}`);90+		if (!setting(kepr, "backup_error")) {91+			notify(kepr, "backup_failed", `Backup failed: ${message}`, "/admin?section=backups");92+		}93+		kepr94+			.query("INSERT OR REPLACE INTO settings (key, value) VALUES ('backup_error', ?)")95+			.run(message);96+		return message;97+	}98+}99+100+function backupFiles(dataDir: string, name: string): { path: string; full: string }[] {94101 	const dir = join(dataDir, "backups", name);95-	const files = readdirSync(dir, { recursive: true, withFileTypes: true })102+	return readdirSync(dir, { recursive: true, withFileTypes: true })96103 		.filter((entry) => entry.isFile())97104 		.map((entry) => {98-			const path = join(entry.parentPath, entry.name);99-			return { path: relative(dir, path), bytes: readFileSync(path) };105+			const full = join(entry.parentPath, entry.name);106+			return { path: relative(dir, full), full };100107 		});108+}109+110+/** What one backup takes on disk. */111+export function backupBytes(dataDir: string, name: string): number {112+	return backupFiles(dataDir, name).reduce((sum, file) => sum + statSync(file.full).size, 0);113+}114+115+/**116+ * A kept backup as a `.tar.gz` for the console's downloads (§24), the newest when no name is117+ * given; null when there is no such backup.118+ */119+export function backupTarball(120+	dataDir: string,121+	wanted?: string,122+): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {123+	const kept = backups(dataDir);124+	// Only a name from the list, so a request can never reach outside data/backups/.125+	const name = wanted === undefined ? kept.at(-1) : kept.find((known) => known === wanted);126+	if (!name) return null;127+	const files = backupFiles(dataDir, name).map((file) => ({128+		path: file.path,129+		bytes: readFileSync(file.full),130+	}));101131 	// Owner-only once unpacked: kepr.db holds reporters' contact emails.102132 	return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };103133 }

view file

tests/backupworkspace.test.ts+77 −0
@@ -0,0 +1,77 @@1+// Covers §24's backup workspace: every kept backup listed and downloadable, back up now, the last2+// failure until a backup succeeds, and the written restore steps.3+4+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5+import { unlinkSync, writeFileSync } from "node:fs";6+import { backups, makeBackup, startBackupLoop } from "../src/server/backup.ts";7+import { setting } from "../src/server/db.ts";8+import { Browser, type Instance, instance } from "./browser.ts";9+import { makeRepos } from "./fixture.ts";10+11+describe("the backup workspace (§24)", () => {12+	let inst: Instance;13+	let operator: Browser;14+	let clock = Date.parse("2026-09-27T03:00:00Z");15+	const page = async () => (await operator.get("/admin?section=backups")).text();16+17+	beforeAll(async () => {18+		setSystemTime(new Date(clock));19+		const repos = await makeRepos(["app"]);20+		inst = await instance(repos.bucketDir);21+		for (let day = 3; day > 0; day--) {22+			makeBackup(inst.kepr, inst.dataDir, clock - day * 86_400_000);23+		}24+		operator = new Browser(inst.app);25+		await operator.signIn();26+	});27+	afterAll(() => setSystemTime());28+29+	test("lists every kept backup, newest first, with its size and its own download", async () => {30+		const text = await page();31+		const listed = [...text.matchAll(/href="\/admin\/backup\?name=(\d{8}T\d{6}Z)"/g)].map(32+			(match) => match[1],33+		);34+		expect(listed).toEqual(["20260926T030000Z", "20260925T030000Z", "20260924T030000Z"]);35+		expect(text).toMatch(/2026-09-24 03:00:00 UTC<\/a>\s*<span class="muted">[\d.]+ KB<\/span>/);36+		const download = await operator.request("/admin/backup?name=20260925T030000Z");37+		expect(download.status).toBe(200);38+		expect(download.headers.get("content-disposition")).toBe(39+			'attachment; filename="kepr-backup-20260925T030000Z.tar.gz"',40+		);41+		expect((await operator.request("/admin/backup?name=20260101T000000Z")).status).toBe(404);42+		expect((await operator.request("/admin/backup?name=../kepr.db")).status).toBe(404);43+	});44+45+	test("says how to restore one by hand", async () => {46+		const text = await page();47+		expect(text).toContain("Stop Kepr.");48+		expect(text).toContain("kepr.db and assets/");49+	});50+51+	test("back up now makes one at once, even with today's done", async () => {52+		clock += 3600_000;53+		setSystemTime(new Date(clock));54+		const before = backups(inst.dataDir).length;55+		const made = await operator.post("/admin/backup", {});56+		expect(made.status).toBe(303);57+		expect(made.headers.get("location")).toBe("/admin?section=backups&saved=1");58+		expect(backups(inst.dataDir).length).toBe(before + 1);59+		expect(backups(inst.dataDir).at(-1)).toBe("20260927T040000Z");60+	});61+62+	test("the last failure stays shown until a backup succeeds", async () => {63+		const saved = `${inst.dataDir}/backups-kept`;64+		Bun.spawnSync(["mv", `${inst.dataDir}/backups`, saved]);65+		writeFileSync(`${inst.dataDir}/backups`, "");66+		startBackupLoop(inst.kepr, inst.dataDir);67+		expect(setting(inst.kepr, "backup_error")).toContain("ENOTDIR");68+		const failed = await operator.post("/admin/backup", {});69+		expect(failed.status).toBe(500);70+		expect(await page()).toContain("The last backup failed:");71+		unlinkSync(`${inst.dataDir}/backups`);72+		Bun.spawnSync(["mv", saved, `${inst.dataDir}/backups`]);73+		expect((await operator.post("/admin/backup", {})).status).toBe(303);74+		expect(setting(inst.kepr, "backup_error")).toBeNull();75+		expect(await page()).not.toContain("The last backup failed:");76+	});77+});

view file