feat(backup): admin UI with admin-chosen target directory
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s

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:
2026-06-29 12:21:26 +02:00
parent 0c218179c4
commit d5e41500a8
16 changed files with 630 additions and 74 deletions
+68 -36
View File
@@ -1,23 +1,35 @@
import type { Db } from "@parking/db";
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): resolves config once, runs a backup
// (manual or scheduled), and remembers the last outcome so the route + UI can show last-success
// / last-error without re-deriving it. One instance is shared by the daily timer and the
// "back up now" route, so a concurrent manual+timer run can't overlap (a single in-flight guard).
// See wiki/concepts/backup-recovery.md.
// 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.
export interface BackupConfig {
/** Mounted directory backups are written to (local/USB/SATA/SMB/NFS). Empty = disabled. */
readonly targetDir: string;
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). */
readonly key: string;
/** 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 + key are configured (otherwise backups are a no-op). */
/** 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;
@@ -25,18 +37,28 @@ export interface BackupStatus {
readonly lastError: string | null;
}
/** Resolve backup config from env. (First cut: env-driven, like EVENT_SIGNING_KEY + snapshot
* retention; a future admin-UI knob can override the target dir.) */
export function backupConfigFromEnv(): BackupConfig {
return {
targetDir: (process.env.BACKUP_TARGET_DIR ?? "").trim(),
key: process.env.BACKUP_KEY ?? "",
};
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
const trimmed = dir.trim();
if (!trimmed) return { ok: false, reason: "empty" };
const path = resolve(trimmed);
let st: Awaited<ReturnType<typeof stat>>;
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 #config: BackupConfig;
readonly #logger?: FastifyBaseLogger;
#running = false;
@@ -45,19 +67,31 @@ export class BackupService {
#lastErrorAt: string | null = null;
#lastError: string | null = null;
constructor(db: Db, config: BackupConfig, logger?: FastifyBaseLogger) {
constructor(db: Db, logger?: FastifyBaseLogger) {
this.#db = db;
this.#config = config;
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.#config.targetDir.length > 0 && this.#config.key.length >= 16;
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
@@ -69,26 +103,24 @@ export class BackupService {
}
/**
* Run one backup. `trigger` is just for the log line ("manual" | "scheduled"). Serialized:
* if one is already in flight, this resolves to that same promise rather than starting a
* second. Records last-success/last-error on the instance. Re-throws on failure so a manual
* caller (the route) can surface it; the scheduled timer wraps + swallows.
* 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<BackupResult> | null = null;
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
if (this.#inflight) return this.#inflight;
if (!this.configured) {
throw new Error("backup: not configured (set BACKUP_TARGET_DIR and BACKUP_KEY ≥16 chars)");
}
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})`);
const res = await runBackup(
this.#db,
{ targetDir: this.#config.targetDir, key: this.#config.key, retention: DEFAULT_BACKUP_RETENTION },
this.#logger,
);
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;