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 type { BackupBucket, Config } from "./config.ts";15import { notify, setting } from "./db.ts";1617const KEEP = 14;18const DAY_MS = 24 * 3600 * 1000;19const CHECK_MS = 3600 * 1000;20const NAME = /^\d{8}T\d{6}Z$/;212223function stamp(ms: number): string {24 return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");25}262728export function backups(dataDir: string): string[] {29 const root = join(dataDir, "backups");30 31 return existsSync(root) && statSync(root).isDirectory()32 ? readdirSync(root)33 .filter((name) => NAME.test(name))34 .sort()35 : [];36}373839404142export function makeBackup(kepr: Database, dataDir: string, now = Date.now()): string {43 const root = join(dataDir, "backups");44 const name = stamp(now);45 const tmp = join(root, `.${name}`);46 rmSync(tmp, { recursive: true, force: true });47 48 mkdirSync(tmp, { recursive: true, mode: 0o700 });49 50 kepr.query("VACUUM INTO ?").run(join(tmp, "kepr.db"));51 52 const copy = new Database(join(tmp, "kepr.db"));53 copy.run("DELETE FROM sessions");54 copy.run("VACUUM");55 copy.close();56 const assets = join(dataDir, "assets");57 if (existsSync(assets)) cpSync(assets, join(tmp, "assets"), { recursive: true });58 rmSync(join(root, name), { recursive: true, force: true });59 renameSync(tmp, join(root, name));60 for (const old of backups(dataDir).slice(0, -KEEP)) {61 rmSync(join(root, old), { recursive: true, force: true });62 }63 return name;64}656667export function backupIfDue(kepr: Database, dataDir: string, now = Date.now()): string | null {68 const newest = backups(dataDir).at(-1);69 return newest && newest > stamp(now - DAY_MS) ? null : makeBackup(kepr, dataDir, now);70}717273747576export function startBackupLoop(kepr: Database, config: Config): void {77 const tick = () => {78 attemptBackup(kepr, () => backupIfDue(kepr, config.dataDir));79 attemptUpload(kepr, config.dataDir, config.backup).catch((error) =>80 console.error(`kepr: backup upload failed: ${error}`),81 );82 };83 tick();84 setInterval(tick, CHECK_MS);85}868788899091export function attemptBackup(kepr: Database, make: () => unknown): string | null {92 try {93 make();94 kepr.query("DELETE FROM settings WHERE key = 'backup_error'").run();95 return null;96 } catch (error) {97 return keepFailure(kepr, "backup_error", "Backup failed", error);98 }99}100101102function keepFailure(kepr: Database, key: string, what: string, error: unknown): string {103 const message = String(error);104 console.error(`kepr: ${what.toLowerCase()}: ${message}`);105 if (!setting(kepr, key)) {106 notify(kepr, "backup_failed", `${what}: ${message}`, "/admin?section=backups");107 }108 kepr.query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(key, message);109 return message;110}111112const OFFSITE = /^kepr-backup-(\d{8}T\d{6}Z)\.tar\.gz$/;113const offsiteKey = (name: string) => `kepr-backup-${name}.tar.gz`;114115function backupClient(bucket: BackupBucket): Bun.S3Client {116 return new Bun.S3Client({117 endpoint: bucket.endpoint,118 bucket: bucket.name,119 region: bucket.region,120 accessKeyId: bucket.accessKeyId,121 secretAccessKey: bucket.secretAccessKey,122 });123}124125126export async function offsiteBackups(127 bucket: BackupBucket,128): Promise<{ name: string; size: number }[]> {129 const found: { name: string; size: number }[] = [];130 const client = backupClient(bucket);131 let continuationToken: string | undefined;132 do {133 const page = await client.list({ prefix: "kepr-backup-", continuationToken });134 for (const object of page.contents ?? []) {135 const name = OFFSITE.exec(object.key)?.[1];136 if (name) found.push({ name, size: object.size ?? 0 });137 }138 continuationToken = page.isTruncated ? page.nextContinuationToken : undefined;139 } while (continuationToken);140 return found.sort((a, b) => a.name.localeCompare(b.name));141}142143144export async function checkBackupBucket(bucket: BackupBucket): Promise<string | null> {145 const client = backupClient(bucket);146 try {147 await client.write("kepr-backup-test", "test", { type: "text/plain" });148 await offsiteBackups(bucket);149 await client.delete("kepr-backup-test");150 return null;151 } catch (error) {152 return `The backup bucket did not take a test file: ${error instanceof Error ? error.message : String(error)}`;153 }154}155156157158159160export async function attemptUpload(161 kepr: Database,162 dataDir: string,163 bucket: BackupBucket | undefined,164): Promise<string | null> {165 const newest = backups(dataDir).at(-1);166 if (!bucket || !newest || setting(kepr, "backup_uploaded") === newest) return null;167 try {168 const tarball = backupTarball(dataDir, newest);169 if (!tarball) return null;170 const client = backupClient(bucket);171 await client.write(offsiteKey(newest), tarball.bytes, { type: "application/gzip" });172 for (const old of (await offsiteBackups(bucket)).slice(0, -KEEP)) {173 await client.delete(offsiteKey(old.name));174 }175 kepr176 .query("INSERT OR REPLACE INTO settings (key, value) VALUES ('backup_uploaded', ?)")177 .run(newest);178 kepr.query("DELETE FROM settings WHERE key = 'backup_upload_error'").run();179 return null;180 } catch (error) {181 return keepFailure(kepr, "backup_upload_error", "Backup upload failed", error);182 }183}184185function backupFiles(dataDir: string, name: string): { path: string; full: string }[] {186 const dir = join(dataDir, "backups", name);187 return readdirSync(dir, { recursive: true, withFileTypes: true })188 .filter((entry) => entry.isFile())189 .map((entry) => {190 const full = join(entry.parentPath, entry.name);191 return { path: relative(dir, full), full };192 });193}194195196export function backupBytes(dataDir: string, name: string): number {197 return backupFiles(dataDir, name).reduce((sum, file) => sum + statSync(file.full).size, 0);198}199200201202203204export function backupTarball(205 dataDir: string,206 wanted?: string,207): { name: string; bytes: Uint8Array<ArrayBuffer> } | null {208 const kept = backups(dataDir);209 210 const name = wanted === undefined ? kept.at(-1) : kept.find((known) => known === wanted);211 if (!name) return null;212 const files = backupFiles(dataDir, name).map((file) => ({213 path: file.path,214 bytes: readFileSync(file.full),215 }));216 217 return { name, bytes: buildTarball(`kepr-backup-${name}`, files, 0o600) };218}