backup.ts

3.2 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	kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));46	// A restored backup must not bring live sign-ins with it.47	const copy = new Database(join(tmp, "kepr.db"));48	copy.run("DELETE FROM sessions");49	copy.run("VACUUM");50	copy.close();51	const assets = join(dataDir, "assets");52	if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });53	rmSync(join(root, name), { recursive: true, force: true });54	renameSync(tmp, join(root, name));55	for (const old of backups(dataDir).slice(0, -KEEP)) {56		rmSync(join(root, old), { recursive: true, force: true });57	}58	return name;59}6061/** Makes a backup when the newest is a day old or there is none; returns its name, else null. */62export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {63	const newest = backups(dataDir).at(-1);64	return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);65}6667/** Daily backups: checked at startup and every hour, so a restart never skips a day. */68export function startBackupLoop(kepr: Database, dataDir: string): void {69	const tick = () => {70		try {71			backupIfDue(kepr, dataDir);72		} catch (error) {73			console.error(`kepr: backup failed: ${error}`);74		}75	};76	tick();77	setInterval(tick, CHECK_MS);78}7980/** The newest backup as a `.tar.gz` for "download backup" (§24), or null when there is none. */81export function newestBackupTarball(82	dataDir: string,83): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {84	const name = backups(dataDir).at(-1);85	if (!name) return null;86	const dir = join(dataDir, "backups", name);87	const files = readdirSync(dir, { recursive: true, withFileTypes: true })88		.filter((entry) => entry.isFile())89		.map((entry) => {90			const path = join(entry.parentPath, entry.name);91			return { path: relative(dir, path), bytes: readFileSync(path) };92		});93	return { name, bytes: buildTarball(`kepr-backup-${name}`, files) };94}