feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (apps/web/src/BackupSettings.tsx): - A Setup -> Backup tab (gated backup:read): an editable target-path field with a Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,18 +1,60 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { eq, siteConfig, type Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import type { BackupService } from "../backup-service.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-present flag + last-run success/error. (backup:read)
|
||||
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
|
||||
// RESTORE is intentionally absent — it's an out-of-band runbook action on a fresh appliance
|
||||
// (a restore replaces the live signed chain → operator-adversary surface), never a console call.
|
||||
// - 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.
|
||||
|
||||
export async function backupRoutes(app: FastifyInstance, backups: BackupService): Promise<void> {
|
||||
interface ConfigBody {
|
||||
targetDir?: string | 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 raw = req.body?.targetDir;
|
||||
if (raw != null && typeof raw !== "string") {
|
||||
return reply.code(400).send({ error: "targetDir must be a string or null" });
|
||||
}
|
||||
const next = raw == null ? null : raw.trim() || 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({ backupTargetDir: next, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, backupTargetDir: next, 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" });
|
||||
|
||||
Reference in New Issue
Block a user