backup.ts

3.4 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";1314const KEEP = 14;15const DAY_MS = 24 * 3600 * 1000;16const CHECK_MS = 3600 * 1000;17const NAME = /^\d{8}T\d{6}Z$/;1819/** A backup's name: its UTC time, `20260926T031500Z`, so names sort in time order. */20function stamp(ms: number): string {21	return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");22}2324/** The backups in `data/backups/`, oldest first. */25export function backups(dataDir: string): string[] {26	const root = join(dataDir, "backups");27	return existsSync(root)28		? readdirSync(root)29				.filter((name) => NAME.test(name))30				.sort()31		: [];32}3334/**35 * §19's backup: `VACUUM INTO` of kepr.db and a copy of `assets/`, built in a hidden folder and36 * renamed into `data/backups/<time>/`, so a half-made backup is never the newest. Keeps the last 14.37 */38export function makeBackup(kepr: Database, dataDir: string, now = Date.now()): string {39	const root = join(dataDir, "backups");40	const name = stamp(now);41	const tmp = join(root, `.${name}`);42	rmSync(tmp, { recursive: true, force: true });43	// It holds reporters' contact emails, so only Kepr's user may read it.44	mkdirSync(tmp, { recursive: true, mode: 0o700 });45	// Synchronous from here to the rename: no request can change kepr.db or assets/ in between.46	kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));47	// A restored backup must not bring live sign-ins with it.48	const copy = new Database(join(tmp, "kepr.db"));49	copy.run("DELETE FROM sessions");50	copy.run("VACUUM");51	copy.close();52	const assets = join(dataDir, "assets");53	if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });54	rmSync(join(root, name), { recursive: true, force: true });55	renameSync(tmp, join(root, name));56	for (const old of backups(dataDir).slice(0, -KEEP)) {57		rmSync(join(root, old), { recursive: true, force: true });58	}59	return name;60}6162/** Makes a backup when the newest is a day old or there is none; returns its name, else null. */63export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {64	const newest = backups(dataDir).at(-1);65	return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);66}6768/** Daily backups: checked at startup and every hour, so a restart never skips a day. */69export function startBackupLoop(kepr: Database, dataDir: string): void {70	const tick = () => {71		try {72			backupIfDue(kepr, dataDir);73		} catch (error) {74			console.error(`kepr: backup failed: ${error}`);75		}76	};77	tick();78	setInterval(tick, CHECK_MS);79}8081/** The newest backup as a `.tar.gz` for "download backup" (§24), or null when there is none. */82export function newestBackupTarball(83	dataDir: string,84): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {85	const name = backups(dataDir).at(-1);86	if (!name) return null;87	const dir = join(dataDir, "backups", name);88	const files = readdirSync(dir, { recursive: true, withFileTypes: true })89		.filter((entry) => entry.isFile())90		.map((entry) => {91			const path = join(entry.parentPath, entry.name);92			return { path: relative(dir, path), bytes: readFileSync(path) };93		});94	// Owner-only once unpacked: kepr.db holds reporters' contact emails.95	return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };96}