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$/;192021function stamp(ms: number): string {22 return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");23}242526export 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}343536373839export 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 45 mkdirSync(tmp, { recursive: true, mode: 0o700 });46 47 kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));48 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}626364export 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}686970export function startBackupLoop(kepr: Database, dataDir: string): void {71 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}878889export 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 102 return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };103}