import type { FastifyInstance } from "fastify"; import { eq, siteConfig, type Db } from "@parking/db"; import { requirePermission } from "../auth.js"; import { checkTargetDir, type BackupService } from "../backup-service.js"; // On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md. // - GET /api/backup/status : config + last-run success/error. (backup:read) // - PUT /api/backup/config : set the admin-chosen target directory. (backup:update) // - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update) // - POST /api/backup/run : trigger a manual "back up now". (backup:create) // The target dir lives in site_config (admin picks it from the UI); the encryption key stays an // env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance. interface ConfigBody { targetDir?: string | null; /** Retention: keep this many newest backups. null = reset to the code default. */ keepLast?: number | null; /** Retention: keep one-per-day within this many days. null = reset to the code default. */ keepDailyDays?: number | null; } interface TestBody { targetDir?: string; } export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise { app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () => backups.status(), ); // Set (or clear) the target directory. Empty/null clears it (backups become a no-op). app.put<{ Body: ConfigBody }>( "/api/backup/config", { preHandler: requirePermission("backup:update") }, async (req, reply) => { const body = req.body ?? {}; const patch: { backupTargetDir?: string | null; backupKeepLast?: number | null; backupKeepDailyDays?: number | null } = {}; if ("targetDir" in body) { const raw = body.targetDir; if (raw != null && typeof raw !== "string") { return reply.code(400).send({ error: "targetDir must be a string or null" }); } patch.backupTargetDir = raw == null ? null : raw.trim() || null; } // Retention: a non-negative integer, or null to reset to the code default. for (const [field, col] of [ ["keepLast", "backupKeepLast"], ["keepDailyDays", "backupKeepDailyDays"], ] as const) { if (field in body) { const v = body[field]; if (v != null && (!Number.isInteger(v) || v < 0)) { return reply.code(400).send({ error: `${field} must be a non-negative integer or null` }); } patch[col] = v ?? null; } } const updatedAt = new Date().toISOString(); // Single-row site_config (id=1): upsert, since a fresh install may not have it yet. const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); if (existing) { db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); } else { db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run(); } return backups.status(); }, ); // Probe a candidate path before relying on it (the UI "Test target" button). app.post<{ Body: TestBody }>( "/api/backup/test", { preHandler: requirePermission("backup:update") }, async (req) => { const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : ""; return checkTargetDir(dir); }, ); app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => { if (!backups.configured) { return reply.code(409).send({ error: "backup_not_configured" }); } try { const res = await backups.run("manual"); return reply.send({ ok: true, path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }); } catch (err) { return reply.code(500).send({ error: "backup_failed", message: (err as Error).message }); } }); }