Files
parking_solution/apps/server/src/routes/backup.ts
T
julian 84f00db48b
Build desktop / desktop (push) Successful in 4m17s
Build & push images / images (push) Failing after 39s
CI / check (push) Successful in 39s
feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Retention (keep-last / keep-daily-days) is operational policy the on-site admin
should tune, not a server env var requiring a redeploy -- same reasoning that moved
the target directory to the UI.

- Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable;
  null = code default 7 / 30 per field).
- BackupService reads retention fresh each run; status() exposes keepLast +
  keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone).
- PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null
  to reset to default; 400 on negative).
- UI: two retention fields on the Backup config card; one Save covers target +
  retention. i18n sq + en.

BACKUP_KEY wired into Komodo:
- komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret,
  alongside JWT / signing keys).
- komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it
  offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are
  admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY.

Also carries the small in-progress setup-intro i18n copy trim.

Tests: 218 server tests green, incl. retention persist / reset-to-default / reject-
negative and the updated status shape. Migration applies cleanly (needed a
statement-breakpoint between the two ALTERs). Wiki backup-recovery updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 12:52:18 +02:00

93 lines
3.9 KiB
TypeScript

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<void> {
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 });
}
});
}