backup.ts

4.8 KB · raw

1import { Database } from "bun:sqlite";2import {3	cpSync,4	existsSync,5	mkdirSync,6	readdirSync,7	readFileSync,8	renameSync,9	rmSync,10	statSync,11} from "node:fs";12import { join, relative } from "node:path";13import { buildTarball } from "../tarball/tar.ts";14import { notify, setting } from "./db.ts";1516const KEEP = 14;17const DAY_MS = 24 * 3600 * 1000;18const CHECK_MS = 3600 * 1000;19const NAME = /^\d{8}T\d{6}Z$/;2021/** A backup's name: its UTC time, `20260926T031500Z`, so names sort in time order. */22function stamp(ms: number): string {23	return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");24}2526/** The backups in `data/backups/`, oldest first. */27export function backups(dataDir: string): string[] {28	const root = join(dataDir, "backups");29	// Not a folder is no backups, so the console still shows why the next one fails.30	return existsSync(root) && statSync(root).isDirectory()31		? readdirSync(root)32				.filter((name) => NAME.test(name))33				.sort()34		: [];35}3637/**38 * §19's backup: `VACUUM INTO` of kepr.db and a copy of `assets/`, built in a hidden folder and39 * renamed into `data/backups/<time>/`, so a half-made backup is never the newest. Keeps the last 14.40 */41export function makeBackup(kepr: Database, dataDir: string, now = Date.now()): string {42	const root = join(dataDir, "backups");43	const name = stamp(now);44	const tmp = join(root, `.${name}`);45	rmSync(tmp, { recursive: true, force: true });46	// It holds reporters' contact emails, so only Kepr's user may read it.47	mkdirSync(tmp, { recursive: true, mode: 0o700 });48	// Synchronous from here to the rename: no request can change kepr.db or assets/ in between.49	kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));50	// A restored backup must not bring live sign-ins with it.51	const copy = new Database(join(tmp, "kepr.db"));52	copy.run("DELETE FROM sessions");53	copy.run("VACUUM");54	copy.close();55	const assets = join(dataDir, "assets");56	if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });57	rmSync(join(root, name), { recursive: true, force: true });58	renameSync(tmp, join(root, name));59	for (const old of backups(dataDir).slice(0, -KEEP)) {60		rmSync(join(root, old), { recursive: true, force: true });61	}62	return name;63}6465/** Makes a backup when the newest is a day old or there is none; returns its name, else null. */66export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {67	const newest = backups(dataDir).at(-1);68	return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);69}7071/** Daily backups: checked at startup and every hour, so a restart never skips a day. */72export function startBackupLoop(kepr: Database, dataDir: string): void {73	const tick = () => attemptBackup(kepr, () => backupIfDue(kepr, dataDir));74	tick();75	setInterval(tick, CHECK_MS);76}7778/**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 */82export 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}99100function backupFiles(dataDir: string, name: string): { path: string; full: string }[] {101	const dir = join(dataDir, "backups", name);102	return readdirSync(dir, { recursive: true, withFileTypes: true })103		.filter((entry) => entry.isFile())104		.map((entry) => {105			const full = join(entry.parentPath, entry.name);106			return { path: relative(dir, full), full };107		});108}109110/** What one backup takes on disk. */111export function backupBytes(dataDir: string, name: string): number {112	return backupFiles(dataDir, name).reduce((sum, file) => sum + statSync(file.full).size, 0);113}114115/**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 */119export 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	}));131	// Owner-only once unpacked: kepr.db holds reporters' contact emails.132	return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };133}