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$/;202122function stamp(ms: number): string {23 return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");24}252627export function backups(dataDir: string): string[] {28 const root = join(dataDir, "backups");29 30 return existsSync(root) && statSync(root).isDirectory()31 ? readdirSync(root)32 .filter((name) => NAME.test(name))33 .sort()34 : [];35}363738394041export 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 47 mkdirSync(tmp, { recursive: true, mode: 0o700 });48 49 kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));50 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}646566export 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}707172export function startBackupLoop(kepr: Database, dataDir: string): void {73 const tick = () => attemptBackup(kepr, () => backupIfDue(kepr, dataDir));74 tick();75 setInterval(tick, CHECK_MS);76}777879808182export 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}109110111export function backupBytes(dataDir: string, name: string): number {112 return backupFiles(dataDir, name).reduce((sum, file) => sum + statSync(file.full).size, 0);113}114115116117118119export function backupTarball(120 dataDir: string,121 wanted?: string,122): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {123 const kept = backups(dataDir);124 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 132 return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };133}