84f00db48b
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
93 lines
3.9 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
}
|