import { constants } from "node:fs"; import { access, stat } from "node:fs/promises"; import { resolve } from "node:path"; import { eq, siteConfig, type Db } from "@parking/db"; import type { FastifyBaseLogger } from "fastify"; import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js"; // Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen // and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI // takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) — // a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can // show last-success / last-error, and serializes concurrent runs (manual + timer). See // wiki/concepts/backup-recovery.md. /** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */ export function backupKeyFromEnv(): string { return process.env.BACKUP_KEY ?? ""; } export interface TargetCheck { readonly ok: boolean; /** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */ readonly reason?: string; } export interface BackupStatus { /** True once a target dir is set AND a usable key is present (else backups are a no-op). */ readonly configured: boolean; /** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */ readonly targetDir: string | null; /** Whether the env key is present + long enough (the UI flags a missing key distinctly). */ readonly keyPresent: boolean; readonly running: boolean; readonly lastSuccessAt: string | null; readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null; readonly lastErrorAt: string | null; readonly lastError: string | null; } /** Probe a candidate target path server-side: exists, is a directory, is writable. */ export async function checkTargetDir(dir: string): Promise { const trimmed = dir.trim(); if (!trimmed) return { ok: false, reason: "empty" }; const path = resolve(trimmed); let st: Awaited>; try { st = await stat(path); } catch { return { ok: false, reason: "missing" }; } if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" }; try { await access(path, constants.W_OK); } catch { return { ok: false, reason: "not_writable" }; } return { ok: true }; } export class BackupService { readonly #db: Db; readonly #logger?: FastifyBaseLogger; #running = false; #lastSuccessAt: string | null = null; #lastResult: BackupResult | null = null; #lastErrorAt: string | null = null; #lastError: string | null = null; constructor(db: Db, logger?: FastifyBaseLogger) { this.#db = db; this.#logger = logger; } /** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */ targetDir(): string | null { const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const dir = row?.backupTargetDir?.trim(); return dir ? dir : null; } get keyPresent(): boolean { return backupKeyFromEnv().length >= 16; } get configured(): boolean { return this.targetDir() !== null && this.keyPresent; } status(): BackupStatus { return { configured: this.configured, targetDir: this.targetDir(), keyPresent: this.keyPresent, running: this.#running, lastSuccessAt: this.#lastSuccessAt, lastResult: this.#lastResult ? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles } : null, lastErrorAt: this.#lastErrorAt, lastError: this.#lastError, }; } /** * Run one backup. `trigger` is just for the log line. Serialized: if one is already in * flight, resolves to that same promise. Reads the target dir + key at run time. Records * last-success/last-error. Re-throws on failure so a manual caller (the route) can surface * it; the scheduled timer wraps + swallows. */ #inflight: Promise | null = null; async run(trigger: "manual" | "scheduled"): Promise { if (this.#inflight) return this.#inflight; const targetDir = this.targetDir(); const key = backupKeyFromEnv(); if (!targetDir) throw new Error("backup: no target directory configured"); if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)"); this.#running = true; this.#inflight = (async () => { try { this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`); const res = await runBackup(this.#db, { targetDir, key, retention: DEFAULT_BACKUP_RETENTION }, this.#logger); this.#lastResult = res; this.#lastSuccessAt = new Date().toISOString(); this.#lastError = null; return res; } catch (err) { this.#lastError = (err as Error).message; this.#lastErrorAt = new Date().toISOString(); this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`); throw err; } finally { this.#running = false; this.#inflight = null; } })(); return this.#inflight; } /** Scheduled-run wrapper: never throws (a timer must not crash the process). */ async runScheduled(): Promise { if (!this.configured) return; // silent no-op when backups aren't set up try { await this.run("scheduled"); } catch { /* recorded in last-error; already logged */ } } }