From 99c9d89daad0de7284a062143249dd50195890fc Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 15:28:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(backups):=20t-paliad-246=20=E2=80=94=20Bac?= =?UTF-8?q?kup=20Mode=20Slice=20A=20(on-demand=20admin=20org=20export)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async export) into a new "Backup Mode" surface gated by adminGate. m's calls (all 4 material picks per design §2): - Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only) - Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved - paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally - Scheduler (Slice B): nightly 03:00 UTC, env-tunable Wiring: - mig 123 adds paliad.backups catalog table (kind/status/storage_uri/ size/row_counts/warnings/error/deleted_at + admin-only RLS). - ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for snapshot consistency (design §3.3). - writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx (org snapshot path) work. - BackupRunner orchestrates: catalog INSERT → audit INSERT (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch catalog + audit on success/failure. - ArtifactStore interface + LocalDiskStore impl (defense-in-depth key validation + URI-outside-dir guard). - Sentinel actor for scheduled runs: actor_email='system@paliad', actor_id=NULL — no phantom user in paliad.users. - Admin handlers POST /api/admin/backups/run + GET list/get/download behind adminGate(users, …); /admin/backups page + sidebar entry + bilingual i18n keys. - BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return 503 otherwise (same shape as requireDB). Tests: 8 pure-function tests cover registry shape (no dups, paliadin absent both as sheet name and SQL substring, ref__* sheets unscoped, every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key rejection, URI-traversal rejection, mkdir on construction). go build ./... + go test ./internal/... clean. bun run build clean. Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish) are separate follow-ups per head's instruction. --- cmd/server/main.go | 17 + frontend/build.ts | 3 + frontend/src/admin-backups.tsx | 96 ++++ frontend/src/client/admin-backups.ts | 192 +++++++ frontend/src/client/i18n.ts | 50 ++ frontend/src/components/Sidebar.tsx | 1 + frontend/src/i18n-keys.ts | 23 + internal/db/migrations/123_backups.down.sql | 11 + internal/db/migrations/123_backups.up.sql | 86 +++ internal/handlers/backups.go | 247 +++++++++ internal/handlers/handlers.go | 17 + internal/handlers/projects.go | 4 + internal/services/backup_service.go | 555 ++++++++++++++++++++ internal/services/backup_service_test.go | 193 +++++++ internal/services/export_service.go | 178 ++++++- 15 files changed, 1664 insertions(+), 9 deletions(-) create mode 100644 frontend/src/admin-backups.tsx create mode 100644 frontend/src/client/admin-backups.ts create mode 100644 internal/db/migrations/123_backups.down.sql create mode 100644 internal/db/migrations/123_backups.up.sql create mode 100644 internal/handlers/backups.go create mode 100644 internal/services/backup_service.go create mode 100644 internal/services/backup_service_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index bdc87d5..fd28dee 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -220,6 +220,23 @@ func main() { Export: services.NewExportService(pool, branding.Name), } + // t-paliad-246 Slice A — Backup Mode runner. Wired only when + // PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target + // directory). Without it the /admin/backups handlers return 503 + // in the same shape as Paliadin's gate. The directory is created + // (0700) on first use; a malformed path fails fast at boot so + // misconfig surfaces before the server starts taking traffic. + if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" { + store, err := services.NewLocalDiskStore(exportDir) + if err != nil { + log.Fatalf("PALIAD_EXPORT_DIR: %v", err) + } + svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store) + log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir) + } else { + log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503") + } + // t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService // for the inbox-approvals widget. Done post-construction to avoid // a circular constructor dependency (ApprovalService doesn't need diff --git a/frontend/build.ts b/frontend/build.ts index bd4ff24..c22076e 100644 --- a/frontend/build.ts +++ b/frontend/build.ts @@ -49,6 +49,7 @@ import { renderAdminRulesEdit } from "./src/admin-rules-edit"; import { renderAdminRulesExport } from "./src/admin-rules-export"; import { renderPaliadin } from "./src/paliadin"; import { renderAdminPaliadin } from "./src/admin-paliadin"; +import { renderAdminBackups } from "./src/admin-backups"; import { renderNotFound } from "./src/notfound"; const DIST = join(import.meta.dir, "dist"); @@ -291,6 +292,7 @@ async function build() { // skip the re-fetch. join(import.meta.dir, "src/client/paliadin-widget.ts"), join(import.meta.dir, "src/client/admin-paliadin.ts"), + join(import.meta.dir, "src/client/admin-backups.ts"), join(import.meta.dir, "src/client/notfound.ts"), ], outdir: join(DIST, "assets"), @@ -417,6 +419,7 @@ async function build() { await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport()); await Bun.write(join(DIST, "paliadin.html"), renderPaliadin()); await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin()); + await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups()); await Bun.write(join(DIST, "notfound.html"), renderNotFound()); // Append ?v= to every /assets/*.js and /assets/*.css URL in diff --git a/frontend/src/admin-backups.tsx b/frontend/src/admin-backups.tsx new file mode 100644 index 0000000..b7151a4 --- /dev/null +++ b/frontend/src/admin-backups.tsx @@ -0,0 +1,96 @@ +import { h } from "./jsx"; +import { Sidebar } from "./components/Sidebar"; +import { PaliadinWidget } from "./components/PaliadinWidget"; +import { BottomNav } from "./components/BottomNav"; +import { Footer } from "./components/Footer"; +import { PWAHead } from "./components/PWAHead"; + +// Backup Mode admin page (t-paliad-246 / m/paliad#77 Slice A). +// +// global_admin only — gated by adminGate(...) in handlers.go. Shows the +// chronological list of backup runs (one row per kind in +// {scheduled, on_demand}) plus a button to kick off an on-demand backup. +// Catalog rows + the "run now" action are fetched client-side via +// /api/admin/backups. +export function renderAdminBackups(): string { + return "" + ( + + + + + + + + + Backups — Paliad + + + + + + +
+
+
+
+
+

Backups

+

+ Vollständige Snapshots aller Daten — manuell oder zeitgesteuert. +

+
+
+ +
+
+ + +
+
+ +