backup.ts

3.7 KB · raw

1import { Database } from "bun:sqlite";2import {3	cpSync,4	existsSync,5	mkdirSync,6	readdirSync,7	readFileSync,8	renameSync,9	rmSync,10} from "node:fs";11import { join, relative } from "node:path";12import { buildTarball } from "../tarball/tar.ts";13import { notify } from "./db.ts";1415const KEEP = 14;16const DAY_MS = 24 * 3600 * 1000;17const CHECK_MS = 3600 * 1000;18const NAME = /^\d{8}T\d{6}Z$/;1920/** A backup's name: its UTC time, `20260926T031500Z`, so names sort in time order. */21function stamp(ms: number): string {22	return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");23}2425/** The backups in `data/backups/`, oldest first. */26export function backups(dataDir: string): string[] {27	const root = join(dataDir, "backups");28	return existsSync(root)29		? readdirSync(root)30				.filter((name) => NAME.test(name))31				.sort()32		: [];33}3435/**36 * §19's backup: `VACUUM INTO` of kepr.db and a copy of `assets/`, built in a hidden folder and37 * renamed into `data/backups/<time>/`, so a half-made backup is never the newest. Keeps the last 14.38 */39export function makeBackup(kepr: Database, dataDir: string, now = Date.now()): string {40	const root = join(dataDir, "backups");41	const name = stamp(now);42	const tmp = join(root, `.${name}`);43	rmSync(tmp, { recursive: true, force: true });44	// It holds reporters' contact emails, so only Kepr's user may read it.45	mkdirSync(tmp, { recursive: true, mode: 0o700 });46	// Synchronous from here to the rename: no request can change kepr.db or assets/ in between.47	kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));48	// A restored backup must not bring live sign-ins with it.49	const copy = new Database(join(tmp, "kepr.db"));50	copy.run("DELETE FROM sessions");51	copy.run("VACUUM");52	copy.close();53	const assets = join(dataDir, "assets");54	if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });55	rmSync(join(root, name), { recursive: true, force: true });56	renameSync(tmp, join(root, name));57	for (const old of backups(dataDir).slice(0, -KEEP)) {58		rmSync(join(root, old), { recursive: true, force: true });59	}60	return name;61}6263/** Makes a backup when the newest is a day old or there is none; returns its name, else null. */64export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {65	const newest = backups(dataDir).at(-1);66	return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);67}6869/** Daily backups: checked at startup and every hour, so a restart never skips a day. */70export 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	};84	tick();85	setInterval(tick, CHECK_MS);86}8788/** The newest backup as a `.tar.gz` for "download backup" (§24), or null when there is none. */89export function newestBackupTarball(90	dataDir: string,91): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {92	const name = backups(dataDir).at(-1);93	if (!name) return null;94	const dir = join(dataDir, "backups", name);95	const files = readdirSync(dir, { recursive: true, withFileTypes: true })96		.filter((entry) => entry.isFile())97		.map((entry) => {98			const path = join(entry.parentPath, entry.name);99			return { path: relative(dir, path), bytes: readFileSync(path) };100		});101	// Owner-only once unpacked: kepr.db holds reporters' contact emails.102	return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };103}