Merge: t-paliad-246 — Backup Mode Slice A (on-demand admin org export, local disk, .zip bundle, mig 123) (m/paliad#77)
This commit is contained in:
@@ -220,6 +220,23 @@ func main() {
|
|||||||
Export: services.NewExportService(pool, branding.Name),
|
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
|
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||||
// for the inbox-approvals widget. Done post-construction to avoid
|
// for the inbox-approvals widget. Done post-construction to avoid
|
||||||
// a circular constructor dependency (ApprovalService doesn't need
|
// a circular constructor dependency (ApprovalService doesn't need
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { renderAdminRulesEdit } from "./src/admin-rules-edit";
|
|||||||
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
import { renderAdminRulesExport } from "./src/admin-rules-export";
|
||||||
import { renderPaliadin } from "./src/paliadin";
|
import { renderPaliadin } from "./src/paliadin";
|
||||||
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
import { renderAdminPaliadin } from "./src/admin-paliadin";
|
||||||
|
import { renderAdminBackups } from "./src/admin-backups";
|
||||||
import { renderNotFound } from "./src/notfound";
|
import { renderNotFound } from "./src/notfound";
|
||||||
|
|
||||||
const DIST = join(import.meta.dir, "dist");
|
const DIST = join(import.meta.dir, "dist");
|
||||||
@@ -291,6 +292,7 @@ async function build() {
|
|||||||
// skip the re-fetch.
|
// skip the re-fetch.
|
||||||
join(import.meta.dir, "src/client/paliadin-widget.ts"),
|
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-paliadin.ts"),
|
||||||
|
join(import.meta.dir, "src/client/admin-backups.ts"),
|
||||||
join(import.meta.dir, "src/client/notfound.ts"),
|
join(import.meta.dir, "src/client/notfound.ts"),
|
||||||
],
|
],
|
||||||
outdir: join(DIST, "assets"),
|
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, "admin-rules-export.html"), renderAdminRulesExport());
|
||||||
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
|
||||||
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
|
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());
|
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||||
|
|
||||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||||
|
|||||||
96
frontend/src/admin-backups.tsx
Normal file
96
frontend/src/admin-backups.tsx
Normal file
@@ -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 "<!DOCTYPE html>" + (
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#BFF355" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<PWAHead />
|
||||||
|
<title data-i18n="admin.backups.title">Backups — Paliad</title>
|
||||||
|
<link rel="stylesheet" href="/assets/global.css" />
|
||||||
|
</head>
|
||||||
|
<body className="has-sidebar">
|
||||||
|
<Sidebar currentPath="/admin/backups" />
|
||||||
|
<BottomNav currentPath="/admin/backups" />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className="tool-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="tool-header">
|
||||||
|
<div>
|
||||||
|
<h1 data-i18n="admin.backups.heading">Backups</h1>
|
||||||
|
<p className="tool-subtitle" data-i18n="admin.backups.subtitle">
|
||||||
|
Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
id="admin-backups-run-btn"
|
||||||
|
type="button"
|
||||||
|
data-i18n="admin.backups.run_now"
|
||||||
|
>
|
||||||
|
Backup jetzt erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="admin-backups-feedback" className="form-msg" style="display:none" />
|
||||||
|
|
||||||
|
<div className="entity-table-wrap">
|
||||||
|
<table className="entity-table entity-table--readonly">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-i18n="admin.backups.col.started">Erstellt</th>
|
||||||
|
<th data-i18n="admin.backups.col.kind">Auslöser</th>
|
||||||
|
<th data-i18n="admin.backups.col.status">Status</th>
|
||||||
|
<th data-i18n="admin.backups.col.requested_by">Angefordert von</th>
|
||||||
|
<th data-i18n="admin.backups.col.size">Größe</th>
|
||||||
|
<th data-i18n="admin.backups.col.rows">Zeilen</th>
|
||||||
|
<th data-i18n="admin.backups.col.actions">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="admin-backups-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan={7} data-i18n="admin.backups.loading">Lade …</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entity-empty" id="admin-backups-empty" style="display:none">
|
||||||
|
<p data-i18n="admin.backups.empty">Noch keine Backups vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="tool-footer-note" id="admin-backups-footer">
|
||||||
|
<span data-i18n="admin.backups.footer.note">
|
||||||
|
Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
<PaliadinWidget />
|
||||||
|
<script src="/assets/admin-backups.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/client/admin-backups.ts
Normal file
192
frontend/src/client/admin-backups.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { initI18n, t } from "./i18n";
|
||||||
|
import { initSidebar } from "./sidebar";
|
||||||
|
|
||||||
|
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
|
||||||
|
//
|
||||||
|
// Reads /api/admin/backups (chronological list) and wires the
|
||||||
|
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
|
||||||
|
// Synchronous: the server holds the connection for the duration of
|
||||||
|
// the backup (sub-second at firm-scale today), then returns the new
|
||||||
|
// catalog row inline. No polling needed at v1's data shape; if the
|
||||||
|
// run takes > 5 minutes the handler returns 500 and the UI surfaces
|
||||||
|
// the error.
|
||||||
|
|
||||||
|
interface BackupRow {
|
||||||
|
id: string;
|
||||||
|
kind: "scheduled" | "on_demand";
|
||||||
|
status: "running" | "done" | "failed";
|
||||||
|
requested_by?: string;
|
||||||
|
requested_by_email: string;
|
||||||
|
audit_id?: string;
|
||||||
|
storage_uri?: string;
|
||||||
|
size_bytes?: number;
|
||||||
|
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
|
||||||
|
sheet_count?: number;
|
||||||
|
warnings?: unknown;
|
||||||
|
error?: string;
|
||||||
|
started_at: string;
|
||||||
|
finished_at?: string;
|
||||||
|
deleted_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
initI18n();
|
||||||
|
initSidebar();
|
||||||
|
|
||||||
|
await refreshList();
|
||||||
|
wireRunButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
function wireRunButton(): void {
|
||||||
|
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = t("admin.backups.running") || "Läuft …";
|
||||||
|
clearFeedback();
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/admin/backups/run", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({ error: "request failed" }));
|
||||||
|
showFeedback("error", body.error || `HTTP ${r.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The created row is in the response; refresh the list to land it.
|
||||||
|
await refreshList();
|
||||||
|
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
|
||||||
|
} catch (e) {
|
||||||
|
showFeedback("error", (e as Error).message || "network error");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshList(): Promise<void> {
|
||||||
|
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
|
||||||
|
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
|
||||||
|
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
if (empty) empty.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (empty) empty.style.display = "none";
|
||||||
|
tbody.innerHTML = rows.map(renderRow).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(b: BackupRow): string {
|
||||||
|
const started = formatTimestamp(b.started_at);
|
||||||
|
const kind =
|
||||||
|
b.kind === "scheduled"
|
||||||
|
? t("admin.backups.kind.scheduled") || "Geplant"
|
||||||
|
: t("admin.backups.kind.on_demand") || "Manuell";
|
||||||
|
const status = renderStatus(b);
|
||||||
|
const requestedBy =
|
||||||
|
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
|
||||||
|
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
|
||||||
|
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
|
||||||
|
const action = renderAction(b);
|
||||||
|
return `<tr>
|
||||||
|
<td>${started}</td>
|
||||||
|
<td>${kind}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
<td>${requestedBy}</td>
|
||||||
|
<td>${size}</td>
|
||||||
|
<td>${rows}</td>
|
||||||
|
<td>${action}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(b: BackupRow): string {
|
||||||
|
switch (b.status) {
|
||||||
|
case "done":
|
||||||
|
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
|
||||||
|
case "running":
|
||||||
|
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
|
||||||
|
case "failed":
|
||||||
|
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
|
||||||
|
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
|
||||||
|
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
|
||||||
|
default:
|
||||||
|
return escapeHTML(b.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAction(b: BackupRow): string {
|
||||||
|
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
const label = t("admin.backups.download") || "Download";
|
||||||
|
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
async function fetchJSON<T>(url: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return (await r.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return escapeHTML(iso);
|
||||||
|
const yyyy = d.getUTCFullYear();
|
||||||
|
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, (c) => {
|
||||||
|
switch (c) {
|
||||||
|
case "&": return "&";
|
||||||
|
case "<": return "<";
|
||||||
|
case ">": return ">";
|
||||||
|
case '"': return """;
|
||||||
|
case "'": return "'";
|
||||||
|
default: return c;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return escapeHTML(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFeedback(kind: "success" | "error", text: string): void {
|
||||||
|
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.remove("form-msg-success", "form-msg-error");
|
||||||
|
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
|
||||||
|
el.style.display = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFeedback(): void {
|
||||||
|
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = "none";
|
||||||
|
el.textContent = "";
|
||||||
|
el.classList.remove("form-msg-success", "form-msg-error");
|
||||||
|
}
|
||||||
@@ -2350,6 +2350,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
// Admin audit log (t-paliad-071)
|
// Admin audit log (t-paliad-071)
|
||||||
"nav.admin.audit": "Audit-Log",
|
"nav.admin.audit": "Audit-Log",
|
||||||
"nav.admin.partner_units": "Partner Units",
|
"nav.admin.partner_units": "Partner Units",
|
||||||
|
|
||||||
|
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||||
|
"nav.admin.backups": "Backups",
|
||||||
|
"admin.backups.title": "Backups — Paliad",
|
||||||
|
"admin.backups.heading": "Backups",
|
||||||
|
"admin.backups.subtitle": "Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.",
|
||||||
|
"admin.backups.run_now": "Backup jetzt erstellen",
|
||||||
|
"admin.backups.running": "Läuft …",
|
||||||
|
"admin.backups.success": "Backup erfolgreich erstellt.",
|
||||||
|
"admin.backups.empty": "Noch keine Backups vorhanden.",
|
||||||
|
"admin.backups.loading": "Lade …",
|
||||||
|
"admin.backups.col.started": "Erstellt",
|
||||||
|
"admin.backups.col.kind": "Auslöser",
|
||||||
|
"admin.backups.col.status": "Status",
|
||||||
|
"admin.backups.col.requested_by": "Angefordert von",
|
||||||
|
"admin.backups.col.size": "Größe",
|
||||||
|
"admin.backups.col.rows": "Sheets",
|
||||||
|
"admin.backups.col.actions": "Aktion",
|
||||||
|
"admin.backups.kind.scheduled": "Geplant",
|
||||||
|
"admin.backups.kind.on_demand": "Manuell",
|
||||||
|
"admin.backups.status.running": "Läuft …",
|
||||||
|
"admin.backups.status.done": "✓ Fertig",
|
||||||
|
"admin.backups.status.failed": "✗ Fehlgeschlagen",
|
||||||
|
"admin.backups.download": "Download",
|
||||||
|
"admin.backups.footer.note": "Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.",
|
||||||
"admin.audit.title": "Audit-Log — Paliad",
|
"admin.audit.title": "Audit-Log — Paliad",
|
||||||
"admin.audit.heading": "Audit-Log",
|
"admin.audit.heading": "Audit-Log",
|
||||||
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
|
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
|
||||||
@@ -5293,6 +5318,31 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
// Admin audit log (t-paliad-071)
|
// Admin audit log (t-paliad-071)
|
||||||
"nav.admin.audit": "Audit Log",
|
"nav.admin.audit": "Audit Log",
|
||||||
"nav.admin.partner_units": "Partner Units",
|
"nav.admin.partner_units": "Partner Units",
|
||||||
|
|
||||||
|
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
|
||||||
|
"nav.admin.backups": "Backups",
|
||||||
|
"admin.backups.title": "Backups — Paliad",
|
||||||
|
"admin.backups.heading": "Backups",
|
||||||
|
"admin.backups.subtitle": "Full snapshots of all data — manual or scheduled.",
|
||||||
|
"admin.backups.run_now": "Run backup now",
|
||||||
|
"admin.backups.running": "Running …",
|
||||||
|
"admin.backups.success": "Backup created successfully.",
|
||||||
|
"admin.backups.empty": "No backups yet.",
|
||||||
|
"admin.backups.loading": "Loading …",
|
||||||
|
"admin.backups.col.started": "Started",
|
||||||
|
"admin.backups.col.kind": "Trigger",
|
||||||
|
"admin.backups.col.status": "Status",
|
||||||
|
"admin.backups.col.requested_by": "Requested by",
|
||||||
|
"admin.backups.col.size": "Size",
|
||||||
|
"admin.backups.col.rows": "Sheets",
|
||||||
|
"admin.backups.col.actions": "Action",
|
||||||
|
"admin.backups.kind.scheduled": "Scheduled",
|
||||||
|
"admin.backups.kind.on_demand": "Manual",
|
||||||
|
"admin.backups.status.running": "Running …",
|
||||||
|
"admin.backups.status.done": "✓ Done",
|
||||||
|
"admin.backups.status.failed": "✗ Failed",
|
||||||
|
"admin.backups.download": "Download",
|
||||||
|
"admin.backups.footer.note": "Scheduled backups land in a later slice. Manual backups are available now.",
|
||||||
"admin.audit.title": "Audit Log — Paliad",
|
"admin.audit.title": "Audit Log — Paliad",
|
||||||
"admin.audit.heading": "Audit Log",
|
"admin.audit.heading": "Audit Log",
|
||||||
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
|
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
|||||||
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
||||||
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
|
||||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||||
|
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
||||||
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
||||||
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
||||||
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
||||||
|
|||||||
@@ -90,6 +90,28 @@ export type I18nKey =
|
|||||||
| "admin.audit.source.reminder_log"
|
| "admin.audit.source.reminder_log"
|
||||||
| "admin.audit.subtitle"
|
| "admin.audit.subtitle"
|
||||||
| "admin.audit.title"
|
| "admin.audit.title"
|
||||||
|
| "admin.backups.col.actions"
|
||||||
|
| "admin.backups.col.kind"
|
||||||
|
| "admin.backups.col.requested_by"
|
||||||
|
| "admin.backups.col.rows"
|
||||||
|
| "admin.backups.col.size"
|
||||||
|
| "admin.backups.col.started"
|
||||||
|
| "admin.backups.col.status"
|
||||||
|
| "admin.backups.download"
|
||||||
|
| "admin.backups.empty"
|
||||||
|
| "admin.backups.footer.note"
|
||||||
|
| "admin.backups.heading"
|
||||||
|
| "admin.backups.kind.on_demand"
|
||||||
|
| "admin.backups.kind.scheduled"
|
||||||
|
| "admin.backups.loading"
|
||||||
|
| "admin.backups.run_now"
|
||||||
|
| "admin.backups.running"
|
||||||
|
| "admin.backups.status.done"
|
||||||
|
| "admin.backups.status.failed"
|
||||||
|
| "admin.backups.status.running"
|
||||||
|
| "admin.backups.subtitle"
|
||||||
|
| "admin.backups.success"
|
||||||
|
| "admin.backups.title"
|
||||||
| "admin.broadcasts.col.count"
|
| "admin.broadcasts.col.count"
|
||||||
| "admin.broadcasts.col.sender"
|
| "admin.broadcasts.col.sender"
|
||||||
| "admin.broadcasts.col.sent_at"
|
| "admin.broadcasts.col.sent_at"
|
||||||
@@ -1894,6 +1916,7 @@ export type I18nKey =
|
|||||||
| "login.title"
|
| "login.title"
|
||||||
| "modal.close.label"
|
| "modal.close.label"
|
||||||
| "nav.admin.audit"
|
| "nav.admin.audit"
|
||||||
|
| "nav.admin.backups"
|
||||||
| "nav.admin.bereich"
|
| "nav.admin.bereich"
|
||||||
| "nav.admin.event_types"
|
| "nav.admin.event_types"
|
||||||
| "nav.admin.paliadin"
|
| "nav.admin.paliadin"
|
||||||
|
|||||||
11
internal/db/migrations/123_backups.down.sql
Normal file
11
internal/db/migrations/123_backups.down.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- t-paliad-246 / m/paliad#77 — revert Backup Mode catalog table.
|
||||||
|
|
||||||
|
SELECT set_config(
|
||||||
|
'paliad.audit_reason',
|
||||||
|
'mig 123 down: drop paliad.backups catalog (t-paliad-246 / m/paliad#77 Slice A)',
|
||||||
|
true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
||||||
|
DROP INDEX IF EXISTS paliad.backups_kind_status_idx;
|
||||||
|
DROP INDEX IF EXISTS paliad.backups_started_at_desc_idx;
|
||||||
|
DROP TABLE IF EXISTS paliad.backups;
|
||||||
86
internal/db/migrations/123_backups.up.sql
Normal file
86
internal/db/migrations/123_backups.up.sql
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table.
|
||||||
|
--
|
||||||
|
-- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup
|
||||||
|
-- run (on-demand or scheduled). The catalog is operational metadata for
|
||||||
|
-- the /admin/backups UI (size, row counts, storage URI, status). The
|
||||||
|
-- audit chain stays on paliad.system_audit_log — this table is the
|
||||||
|
-- richer-shape duplicate that the UI lists from without parsing JSON.
|
||||||
|
--
|
||||||
|
-- INSERT/UPDATE happen only through the Go service path (BackupRunner)
|
||||||
|
-- under the migration-runner role, so we don't add a write RLS policy
|
||||||
|
-- for end users. SELECT is admin-only, mirroring system_audit_log.
|
||||||
|
--
|
||||||
|
-- Idempotent: CREATE TABLE / INDEX / POLICY all guarded.
|
||||||
|
|
||||||
|
SELECT set_config(
|
||||||
|
'paliad.audit_reason',
|
||||||
|
'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)',
|
||||||
|
true);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS paliad.backups (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')),
|
||||||
|
status text NOT NULL CHECK (status IN ('running', 'done', 'failed')),
|
||||||
|
-- requested_by is NULL for kind='scheduled' (no human caller).
|
||||||
|
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||||
|
-- requested_by_email is captured at write time so the row survives
|
||||||
|
-- a subsequent user deletion. For scheduled runs we write a sentinel
|
||||||
|
-- like 'system@paliad' (no real user attached).
|
||||||
|
requested_by_email text NOT NULL,
|
||||||
|
-- audit_id back-references the system_audit_log row written before
|
||||||
|
-- the artifact is generated. Nullable so a catalog row can still be
|
||||||
|
-- INSERTed if the audit write itself fails (defense-in-depth).
|
||||||
|
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
|
||||||
|
-- storage_uri is populated when status flips to 'done'. Resolves
|
||||||
|
-- through the Go-side ArtifactStore interface ('file://...' for
|
||||||
|
-- LocalDiskStore today; future stores get their own URI scheme).
|
||||||
|
storage_uri text,
|
||||||
|
size_bytes bigint,
|
||||||
|
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
sheet_count int,
|
||||||
|
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
-- error is NULL unless status='failed'. Free-form, captured from
|
||||||
|
-- the Go-side error.Error().
|
||||||
|
error text,
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
finished_at timestamptz,
|
||||||
|
-- deleted_at marks artifacts the lifecycle cleanup removed from
|
||||||
|
-- storage (Slice B). The catalog row itself stays forever — it's
|
||||||
|
-- part of the audit chain. NULL means "still on disk".
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Read patterns:
|
||||||
|
-- - "show me recent backups" — started_at DESC
|
||||||
|
-- - "find last successful scheduled backup today" — kind + status + started_at
|
||||||
|
CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx
|
||||||
|
ON paliad.backups (started_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS backups_kind_status_idx
|
||||||
|
ON paliad.backups (kind, status);
|
||||||
|
|
||||||
|
ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Admin-only read. INSERT/UPDATE/DELETE happen via the Go service path
|
||||||
|
-- under the migration-runner role (no end-user write surface).
|
||||||
|
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
|
||||||
|
CREATE POLICY backups_select_admin ON paliad.backups
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM paliad.users u
|
||||||
|
WHERE u.id = auth.uid()
|
||||||
|
AND u.global_role = 'global_admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE paliad.backups IS
|
||||||
|
'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN paliad.backups.requested_by_email IS
|
||||||
|
'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN paliad.backups.storage_uri IS
|
||||||
|
'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN paliad.backups.deleted_at IS
|
||||||
|
'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';
|
||||||
247
internal/handlers/backups.go
Normal file
247
internal/handlers/backups.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// Admin Backup Mode handlers (t-paliad-246 / m/paliad#77 Slice A).
|
||||||
|
//
|
||||||
|
// POST /api/admin/backups/run — kick off an on-demand backup
|
||||||
|
// GET /api/admin/backups — chronological list
|
||||||
|
// GET /api/admin/backups/{id} — single catalog row
|
||||||
|
// GET /api/admin/backups/{id}/file — stream the artifact (records
|
||||||
|
// a backup_downloaded audit row)
|
||||||
|
// GET /admin/backups — admin page (SPA shell)
|
||||||
|
//
|
||||||
|
// Authorisation: every route registers behind adminGate(users, …) in
|
||||||
|
// handlers.go, so every handler in this file can assume the caller is a
|
||||||
|
// global_admin and only validate the request shape.
|
||||||
|
//
|
||||||
|
// The runner is wired in cmd/server/main.go only when PALIAD_EXPORT_DIR
|
||||||
|
// is set. When unset, every handler returns 503 — same shape as
|
||||||
|
// requireDB.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/paliad/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// backupRequestTimeout caps a single on-demand backup. At firm-scale
|
||||||
|
// data shapes (today: ~600 user-content rows + ~1000 reference rows)
|
||||||
|
// a backup runs sub-second; the watchdog surfaces "stuck" as a 500
|
||||||
|
// instead of letting the client hang forever.
|
||||||
|
const backupRequestTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// requireBackup writes a 503 if the BackupRunner is not wired (typically
|
||||||
|
// PALIAD_EXPORT_DIR is unset) and returns false. Mirrors requireDB.
|
||||||
|
func requireBackup(w http.ResponseWriter) bool {
|
||||||
|
if dbSvc == nil || dbSvc.backup == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"error": "backup service not configured — set PALIAD_EXPORT_DIR on the server",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminBackupsPage renders the /admin/backups SPA shell. The
|
||||||
|
// catalog rows are fetched client-side via /api/admin/backups.
|
||||||
|
func handleAdminBackupsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, "dist/admin-backups.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminRunBackup kicks off a synchronous on-demand backup and
|
||||||
|
// returns the resulting BackupSummary as JSON. Synchronous: at firm-
|
||||||
|
// scale the whole run is under 5s; an async path with polling is Slice
|
||||||
|
// B (the scheduler reuses the same runner internally).
|
||||||
|
//
|
||||||
|
// Returns 201 on success with the catalog row, 500 on failure (the
|
||||||
|
// catalog/audit rows are still flipped to failed/backup_failed before
|
||||||
|
// the response).
|
||||||
|
func handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) || !requireBackup(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), backupRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("backup: user lookup failed for %s: %v", uid, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "user lookup failed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := services.BackupActor{
|
||||||
|
ID: &uid,
|
||||||
|
Email: user.Email,
|
||||||
|
Label: user.DisplayName,
|
||||||
|
}
|
||||||
|
result, err := dbSvc.backup.Run(ctx, services.BackupKindOnDemand, actor)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("backup: Run failed for admin=%s: %v", uid, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "backup generation failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the freshly-written catalog row so the UI doesn't need a
|
||||||
|
// follow-up GET to render the new line item.
|
||||||
|
row, err := dbSvc.backup.GetBackup(ctx, result.ID)
|
||||||
|
if err != nil {
|
||||||
|
// The backup did succeed — log + return the bare result.
|
||||||
|
log.Printf("backup: post-run GetBackup failed for %s: %v", result.ID, err)
|
||||||
|
writeJSON(w, http.StatusCreated, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminListBackups returns the most recent N catalog rows as
|
||||||
|
// JSON. ?limit=N caps the page (default 100).
|
||||||
|
func handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) || !requireBackup(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 100
|
||||||
|
if q := strings.TrimSpace(r.URL.Query().Get("limit")); q != "" {
|
||||||
|
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 500 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := dbSvc.backup.ListBackups(r.Context(), limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("backup: list failed: %v", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "list failed",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == nil {
|
||||||
|
rows = []services.BackupSummary{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminGetBackup returns one catalog row. Used by the UI for
|
||||||
|
// "is the backup I just kicked off done yet?" polling — though at the
|
||||||
|
// synchronous shape today this rarely matters.
|
||||||
|
func handleAdminGetBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) || !requireBackup(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("backup: get failed for %s: %v", id, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminDownloadBackup streams the artifact bytes through the
|
||||||
|
// ArtifactStore (LocalDiskStore for v1). Records a backup_downloaded
|
||||||
|
// audit row before flushing.
|
||||||
|
//
|
||||||
|
// 404 if the catalog row is missing; 410 (Gone) if the artifact was
|
||||||
|
// already lifecycle-deleted; 409 if status is not 'done'; 500 on any
|
||||||
|
// store/IO error.
|
||||||
|
func handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireDB(w) || !requireBackup(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := dbSvc.backup.GetBackup(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("backup: download GetBackup failed for %s: %v", id, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.Status != services.BackupStatusDone || row.StorageURI == nil {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]string{
|
||||||
|
"error": "backup not available for download",
|
||||||
|
"status": row.Status,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if row.DeletedAt != nil {
|
||||||
|
// 410 Gone — the artifact is past its retention window. Catalog
|
||||||
|
// row stays as the audit trail; clients should not retry.
|
||||||
|
writeJSON(w, http.StatusGone, map[string]string{
|
||||||
|
"error": "artifact has been removed (retention)",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, size, err := dbSvc.backup.Store().Get(r.Context(), *row.StorageURI)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("backup: download store.Get failed for %s: %v", id, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store read failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Record the download audit row before flushing. If the audit
|
||||||
|
// write fails we still serve the file (the user can see it; the
|
||||||
|
// chain just missed a row — surface in logs).
|
||||||
|
user, uErr := dbSvc.users.GetByID(r.Context(), uid)
|
||||||
|
if uErr == nil && user != nil {
|
||||||
|
auditErr := dbSvc.backup.RecordDownload(r.Context(), id, services.BackupActor{
|
||||||
|
ID: &uid,
|
||||||
|
Email: user.Email,
|
||||||
|
Label: user.DisplayName,
|
||||||
|
})
|
||||||
|
if auditErr != nil {
|
||||||
|
log.Printf("backup: RecordDownload failed for %s by %s: %v", id, uid, auditErr)
|
||||||
|
}
|
||||||
|
} else if uErr != nil {
|
||||||
|
log.Printf("backup: user lookup for audit failed (%s): %v", uid, uErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("paliad-backup-%s.zip", row.StartedAt.UTC().Format("20060102T1504Z"))
|
||||||
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||||
|
w.Header().Set("X-Paliad-Backup-Id", id.String())
|
||||||
|
if _, err := io.Copy(w, rc); err != nil {
|
||||||
|
log.Printf("backup: response write failed for %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,11 @@ type Services struct {
|
|||||||
Projection *services.ProjectionService
|
Projection *services.ProjectionService
|
||||||
Export *services.ExportService
|
Export *services.ExportService
|
||||||
|
|
||||||
|
// t-paliad-246 — Backup Mode (org-scope admin backups). Nil when
|
||||||
|
// DATABASE_URL or PALIAD_EXPORT_DIR is unset; the /admin/backups
|
||||||
|
// routes return 503 in that case.
|
||||||
|
Backup *services.BackupRunner
|
||||||
|
|
||||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||||
SubmissionDraft *services.SubmissionDraftService
|
SubmissionDraft *services.SubmissionDraftService
|
||||||
|
|
||||||
@@ -162,6 +167,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
|||||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||||
projection: svc.Projection,
|
projection: svc.Projection,
|
||||||
export: svc.Export,
|
export: svc.Export,
|
||||||
|
backup: svc.Backup,
|
||||||
submissionDraft: svc.SubmissionDraft,
|
submissionDraft: svc.SubmissionDraft,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,6 +576,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
|||||||
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
|
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
|
||||||
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
|
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
|
||||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||||
|
|
||||||
|
// t-paliad-246 / m/paliad#77 Slice A — Backup Mode admin page +
|
||||||
|
// API. Routes only register when Users is wired (matches the
|
||||||
|
// other admin routes); per-request 503 if BackupRunner itself
|
||||||
|
// is unwired (PALIAD_EXPORT_DIR unset).
|
||||||
|
protected.HandleFunc("GET /admin/backups", adminGate(users, gateOnboarded(handleAdminBackupsPage)))
|
||||||
|
protected.HandleFunc("POST /api/admin/backups/run", adminGate(users, handleAdminRunBackup))
|
||||||
|
protected.HandleFunc("GET /api/admin/backups", adminGate(users, handleAdminListBackups))
|
||||||
|
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
|
||||||
|
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
|
||||||
|
|
||||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ type dbServices struct {
|
|||||||
projection *services.ProjectionService
|
projection *services.ProjectionService
|
||||||
export *services.ExportService
|
export *services.ExportService
|
||||||
|
|
||||||
|
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
|
||||||
|
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
|
||||||
|
backup *services.BackupRunner
|
||||||
|
|
||||||
// t-paliad-238 — submission draft editor.
|
// t-paliad-238 — submission draft editor.
|
||||||
submissionDraft *services.SubmissionDraftService
|
submissionDraft *services.SubmissionDraftService
|
||||||
}
|
}
|
||||||
|
|||||||
555
internal/services/backup_service.go
Normal file
555
internal/services/backup_service.go
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
// Backup Mode runtime (t-paliad-246 / m/paliad#77 Slice A).
|
||||||
|
//
|
||||||
|
// One file because all four pieces are tightly coupled:
|
||||||
|
//
|
||||||
|
// - ArtifactStore interface + LocalDiskStore implementation
|
||||||
|
// (storage abstraction; m picked local disk for v1, the interface
|
||||||
|
// stays so a future swap to Supabase Storage is one impl away).
|
||||||
|
//
|
||||||
|
// - BackupRunner — the orchestration the on-demand handler and the
|
||||||
|
// (Slice B) scheduler share. Wraps the export pipeline:
|
||||||
|
// 1. INSERT paliad.backups (status='running')
|
||||||
|
// 2. INSERT paliad.system_audit_log (event_type='backup_created')
|
||||||
|
// 3. ExportService.WriteOrg → in-memory buffer
|
||||||
|
// 4. ArtifactStore.Put → file
|
||||||
|
// 5. UPDATE paliad.backups (status='done', storage_uri, …)
|
||||||
|
// 6. PATCH paliad.system_audit_log metadata
|
||||||
|
//
|
||||||
|
// Design: docs/design-backup-mode-2026-05-25.md.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ArtifactStore interface + LocalDiskStore impl
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ArtifactStore persists the bytes of a backup artifact. The interface
|
||||||
|
// is deliberately small so Slice B can drop in a SupabaseStorageStore
|
||||||
|
// (or any object-store implementation) without changing the runner.
|
||||||
|
//
|
||||||
|
// URIs returned by Put are opaque to callers — they round-trip through
|
||||||
|
// Get/Delete. v1's LocalDiskStore uses `file://<absolute-path>`.
|
||||||
|
type ArtifactStore interface {
|
||||||
|
// Put writes the given body to the store under the given key and
|
||||||
|
// returns the URI for later retrieval. Implementations must overwrite
|
||||||
|
// an existing object at the same key (catalog rows make keys unique
|
||||||
|
// in practice, but the contract is overwrite-on-conflict to keep
|
||||||
|
// retries idempotent).
|
||||||
|
Put(ctx context.Context, key string, body []byte) (uri string, err error)
|
||||||
|
// Get streams the artifact bytes at the given URI.
|
||||||
|
Get(ctx context.Context, uri string) (rc io.ReadCloser, size int64, err error)
|
||||||
|
// Delete removes the artifact at the given URI. Returns nil if the
|
||||||
|
// artifact is already absent (idempotent).
|
||||||
|
Delete(ctx context.Context, uri string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalDiskStore is the v1 ArtifactStore — writes artifacts to a local
|
||||||
|
// directory specified at construction time. Mode 0700 on the directory
|
||||||
|
// + 0600 on artifact files keeps the files private to the paliad
|
||||||
|
// process owner on the Dokploy host.
|
||||||
|
type LocalDiskStore struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalDiskStore creates a LocalDiskStore rooted at dir. Creates the
|
||||||
|
// directory (0700) if it doesn't exist. Returns an error if dir is
|
||||||
|
// empty or the mkdir fails.
|
||||||
|
func NewLocalDiskStore(dir string) (*LocalDiskStore, error) {
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
return nil, errors.New("LocalDiskStore: empty directory")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("LocalDiskStore mkdir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("LocalDiskStore abs %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
return &LocalDiskStore{dir: abs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put writes body to <dir>/<key>. Returns a file:// URI.
|
||||||
|
func (s *LocalDiskStore) Put(_ context.Context, key string, body []byte) (string, error) {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
full := filepath.Join(s.dir, key)
|
||||||
|
if err := os.WriteFile(full, body, 0o600); err != nil {
|
||||||
|
return "", fmt.Errorf("LocalDiskStore write %q: %w", full, err)
|
||||||
|
}
|
||||||
|
return "file://" + full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get opens the file referenced by uri. Returns a *os.File (io.ReadCloser)
|
||||||
|
// + the file's size in bytes.
|
||||||
|
func (s *LocalDiskStore) Get(_ context.Context, uri string) (io.ReadCloser, int64, error) {
|
||||||
|
path, err := s.pathFromURI(uri)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("LocalDiskStore stat %q: %w", path, err)
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("LocalDiskStore open %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return f, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the file referenced by uri. Idempotent — missing file
|
||||||
|
// is treated as success.
|
||||||
|
func (s *LocalDiskStore) Delete(_ context.Context, uri string) error {
|
||||||
|
path, err := s.pathFromURI(uri)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("LocalDiskStore remove %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathFromURI parses a file:// URI and validates that the resolved
|
||||||
|
// path is inside this store's directory. Defense-in-depth against a
|
||||||
|
// malformed catalog row pointing at an arbitrary file.
|
||||||
|
func (s *LocalDiskStore) pathFromURI(uri string) (string, error) {
|
||||||
|
u, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("LocalDiskStore parse uri %q: %w", uri, err)
|
||||||
|
}
|
||||||
|
if u.Scheme != "file" {
|
||||||
|
return "", fmt.Errorf("LocalDiskStore: unsupported uri scheme %q (want file://)", u.Scheme)
|
||||||
|
}
|
||||||
|
// url.Parse drops the leading "/" for file:// URIs into u.Path.
|
||||||
|
path := u.Path
|
||||||
|
if u.Host != "" {
|
||||||
|
// "file://host/path" — we don't issue these. Reject.
|
||||||
|
return "", fmt.Errorf("LocalDiskStore: file:// uri with host is unsupported (%q)", uri)
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
rel, err := filepath.Rel(s.dir, clean)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return "", fmt.Errorf("LocalDiskStore: uri %q resolves outside store dir %q", uri, s.dir)
|
||||||
|
}
|
||||||
|
return clean, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateKey rejects keys that would escape the store dir (path
|
||||||
|
// separators, "..", absolute paths). Backup runner uses
|
||||||
|
// "<uuid>.zip" so this is a defensive guard.
|
||||||
|
func validateKey(key string) error {
|
||||||
|
if key == "" {
|
||||||
|
return errors.New("ArtifactStore: empty key")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(key, "/\\") {
|
||||||
|
return fmt.Errorf("ArtifactStore: key %q contains path separator", key)
|
||||||
|
}
|
||||||
|
if strings.Contains(key, "..") {
|
||||||
|
return fmt.Errorf("ArtifactStore: key %q contains traversal", key)
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(key) {
|
||||||
|
return fmt.Errorf("ArtifactStore: key %q is absolute", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BackupRunner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// BackupKind discriminates a scheduled run from an on-demand one.
|
||||||
|
const (
|
||||||
|
BackupKindOnDemand = "on_demand"
|
||||||
|
BackupKindScheduled = "scheduled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackupStatus values mirror the paliad.backups status check constraint.
|
||||||
|
const (
|
||||||
|
BackupStatusRunning = "running"
|
||||||
|
BackupStatusDone = "done"
|
||||||
|
BackupStatusFailed = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemActorEmail is the sentinel actor_email written for scheduled
|
||||||
|
// backups (kind='scheduled'). Matches design §3.4 — we don't seed a
|
||||||
|
// phantom user, we just stamp the audit row with a stable sentinel.
|
||||||
|
const SystemActorEmail = "system@paliad"
|
||||||
|
|
||||||
|
// BackupActor identifies who requested a backup. For kind='scheduled'
|
||||||
|
// pass (nil, SystemActorEmail, "Paliad Backup System"). For on-demand
|
||||||
|
// pass the calling admin's id/email/display_name.
|
||||||
|
type BackupActor struct {
|
||||||
|
ID *uuid.UUID
|
||||||
|
Email string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupResult is what Run returns to the caller. Empty on failure
|
||||||
|
// (the error gets the failure detail; the catalog/audit rows are
|
||||||
|
// already updated).
|
||||||
|
type BackupResult struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
AuditID uuid.UUID
|
||||||
|
StorageURI string
|
||||||
|
SizeBytes int64
|
||||||
|
RowCounts map[string]int
|
||||||
|
SheetCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupRunner orchestrates one backup run. Stateless except for the
|
||||||
|
// wired dependencies; safe to share across goroutines (the handler
|
||||||
|
// holds one instance; the Slice B scheduler will hold the same one).
|
||||||
|
type BackupRunner struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
export *ExportService
|
||||||
|
store ArtifactStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackupRunner wires the runner. All three deps are required; the
|
||||||
|
// caller (cmd/server/main.go) is responsible for instantiating the
|
||||||
|
// ArtifactStore from env config.
|
||||||
|
func NewBackupRunner(db *sqlx.DB, export *ExportService, store ArtifactStore) *BackupRunner {
|
||||||
|
return &BackupRunner{db: db, export: export, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store returns the configured store. Exposed for the download handler
|
||||||
|
// to stream artifacts via Get.
|
||||||
|
func (r *BackupRunner) Store() ArtifactStore { return r.store }
|
||||||
|
|
||||||
|
// Run performs one backup. Writes catalog + audit rows, generates the
|
||||||
|
// bundle via ExportService.WriteOrg, uploads to the configured store,
|
||||||
|
// patches catalog + audit on success/failure.
|
||||||
|
//
|
||||||
|
// On any error after the catalog/audit rows are written, the rows are
|
||||||
|
// patched to status='failed' / event_type='backup_failed' before
|
||||||
|
// returning. The returned error is always the export/upload failure —
|
||||||
|
// catalog-update failures during the failure-recovery path are best-
|
||||||
|
// effort logged but not surfaced (the real error is the one to bubble).
|
||||||
|
func (r *BackupRunner) Run(ctx context.Context, kind string, actor BackupActor) (BackupResult, error) {
|
||||||
|
if kind != BackupKindOnDemand && kind != BackupKindScheduled {
|
||||||
|
return BackupResult{}, fmt.Errorf("BackupRunner.Run: invalid kind %q", kind)
|
||||||
|
}
|
||||||
|
if actor.Email == "" {
|
||||||
|
return BackupResult{}, errors.New("BackupRunner.Run: empty actor email")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
spec := ExportSpec{
|
||||||
|
Scope: ExportScopeOrg,
|
||||||
|
ActorID: uuid.Nil, // overwritten below when actor.ID != nil
|
||||||
|
ActorEmail: actor.Email,
|
||||||
|
ActorLabel: actor.Label,
|
||||||
|
GeneratedAt: now,
|
||||||
|
}
|
||||||
|
if actor.ID != nil {
|
||||||
|
spec.ActorID = *actor.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1+2: catalog row (status='running') + audit row
|
||||||
|
// (event_type='backup_created'). Both happen before the export
|
||||||
|
// generation so failure paths can always find them.
|
||||||
|
catalogID, err := r.insertCatalogRow(ctx, kind, actor, uuid.Nil, now)
|
||||||
|
if err != nil {
|
||||||
|
return BackupResult{}, fmt.Errorf("backup catalog insert: %w", err)
|
||||||
|
}
|
||||||
|
auditID, err := r.insertAuditRow(ctx, kind, actor, catalogID, now)
|
||||||
|
if err != nil {
|
||||||
|
// Best-effort patch on the catalog row so it doesn't sit
|
||||||
|
// "running" forever.
|
||||||
|
r.patchCatalogRowFailed(context.Background(), catalogID, fmt.Errorf("audit insert: %w", err))
|
||||||
|
return BackupResult{}, fmt.Errorf("backup audit insert: %w", err)
|
||||||
|
}
|
||||||
|
// Back-link the audit id into the catalog row so the UI can JOIN.
|
||||||
|
if err := r.linkAuditID(ctx, catalogID, auditID); err != nil {
|
||||||
|
// Non-fatal — the link is for UI convenience, not correctness.
|
||||||
|
// The error is logged via the patch path; we keep going.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: generate the bundle into an in-memory buffer. We materialise
|
||||||
|
// fully before uploading so a partial upload doesn't strand bytes in
|
||||||
|
// the store under a "done" catalog row.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
meta, err := r.export.WriteOrg(ctx, &buf, spec)
|
||||||
|
if err != nil {
|
||||||
|
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("generate: %w", err))
|
||||||
|
return BackupResult{}, fmt.Errorf("backup generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: upload to storage. Key = "<catalog_id>.zip".
|
||||||
|
key := catalogID.String() + ".zip"
|
||||||
|
uri, err := r.store.Put(ctx, key, buf.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("upload: %w", err))
|
||||||
|
return BackupResult{}, fmt.Errorf("backup upload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5+6: patch catalog + audit on success.
|
||||||
|
size := int64(buf.Len())
|
||||||
|
sheetCount := len(meta.RowCounts)
|
||||||
|
if err := r.patchCatalogRowDone(ctx, catalogID, uri, size, sheetCount, meta); err != nil {
|
||||||
|
// At this point the artifact is on disk, the audit row was
|
||||||
|
// inserted, and the only thing that failed is the catalog
|
||||||
|
// flip. Surface as an error so the handler can log; the
|
||||||
|
// artifact is recoverable manually via the audit metadata.
|
||||||
|
return BackupResult{}, fmt.Errorf("backup catalog patch: %w", err)
|
||||||
|
}
|
||||||
|
if err := r.patchAuditRowDone(ctx, auditID, uri, size, sheetCount, meta); err != nil {
|
||||||
|
// Non-fatal — the catalog row is already authoritative; the
|
||||||
|
// audit row is the audit-trail twin. Log via the caller.
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackupResult{
|
||||||
|
ID: catalogID,
|
||||||
|
AuditID: auditID,
|
||||||
|
StorageURI: uri,
|
||||||
|
SizeBytes: size,
|
||||||
|
RowCounts: meta.RowCounts,
|
||||||
|
SheetCount: sheetCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordDownload writes a paliad.system_audit_log row of
|
||||||
|
// event_type='backup_downloaded' when an admin downloads a backup
|
||||||
|
// via /api/admin/backups/{id}/file. Separate row per click — the
|
||||||
|
// existing 'backup_created' row stays untouched.
|
||||||
|
func (r *BackupRunner) RecordDownload(ctx context.Context, backupID uuid.UUID, by BackupActor) error {
|
||||||
|
if by.Email == "" {
|
||||||
|
return errors.New("BackupRunner.RecordDownload: empty actor email")
|
||||||
|
}
|
||||||
|
meta, _ := json.Marshal(map[string]any{
|
||||||
|
"backup_id": backupID.String(),
|
||||||
|
"downloaded_by_email": by.Email,
|
||||||
|
"downloaded_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
var actorID any
|
||||||
|
if by.ID != nil {
|
||||||
|
actorID = *by.ID
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO paliad.system_audit_log
|
||||||
|
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||||
|
VALUES ('backup_downloaded', $1, $2, 'org', NULL, $3::jsonb)`,
|
||||||
|
actorID, by.Email, string(meta),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup_downloaded audit insert: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalog read helpers (List + Get for the admin UI)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// BackupSummary is the row shape returned by ListBackups + GetBackup —
|
||||||
|
// shaped for the /admin/backups UI. Nullable columns are pointers.
|
||||||
|
type BackupSummary struct {
|
||||||
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
Kind string `db:"kind" json:"kind"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
RequestedBy *uuid.UUID `db:"requested_by" json:"requested_by,omitempty"`
|
||||||
|
RequestedByEmail string `db:"requested_by_email" json:"requested_by_email"`
|
||||||
|
AuditID *uuid.UUID `db:"audit_id" json:"audit_id,omitempty"`
|
||||||
|
StorageURI *string `db:"storage_uri" json:"storage_uri,omitempty"`
|
||||||
|
SizeBytes *int64 `db:"size_bytes" json:"size_bytes,omitempty"`
|
||||||
|
RowCounts []byte `db:"row_counts" json:"row_counts,omitempty"`
|
||||||
|
SheetCount *int `db:"sheet_count" json:"sheet_count,omitempty"`
|
||||||
|
Warnings []byte `db:"warnings" json:"warnings,omitempty"`
|
||||||
|
Error *string `db:"error" json:"error,omitempty"`
|
||||||
|
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||||
|
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
|
||||||
|
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBackups returns the most recent backups (highest started_at first),
|
||||||
|
// capped at limit. limit <= 0 means default (100).
|
||||||
|
func (r *BackupRunner) ListBackups(ctx context.Context, limit int) ([]BackupSummary, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
var rows []BackupSummary
|
||||||
|
err := r.db.SelectContext(ctx, &rows,
|
||||||
|
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
|
||||||
|
storage_uri, size_bytes, row_counts, sheet_count, warnings,
|
||||||
|
error, started_at, finished_at, deleted_at
|
||||||
|
FROM paliad.backups
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT $1`,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list backups: %w", err)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackup fetches one backup by id. Returns sql.ErrNoRows when not
|
||||||
|
// found (caller maps to 404).
|
||||||
|
func (r *BackupRunner) GetBackup(ctx context.Context, id uuid.UUID) (BackupSummary, error) {
|
||||||
|
var row BackupSummary
|
||||||
|
err := r.db.GetContext(ctx, &row,
|
||||||
|
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
|
||||||
|
storage_uri, size_bytes, row_counts, sheet_count, warnings,
|
||||||
|
error, started_at, finished_at, deleted_at
|
||||||
|
FROM paliad.backups
|
||||||
|
WHERE id = $1`,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return BackupSummary{}, err
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Catalog + audit SQL helpers (private — used by Run + RecordDownload).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (r *BackupRunner) insertCatalogRow(ctx context.Context, kind string, actor BackupActor, auditID uuid.UUID, now time.Time) (uuid.UUID, error) {
|
||||||
|
var actorID any
|
||||||
|
if actor.ID != nil {
|
||||||
|
actorID = *actor.ID
|
||||||
|
}
|
||||||
|
var auditArg any
|
||||||
|
if auditID != uuid.Nil {
|
||||||
|
auditArg = auditID
|
||||||
|
}
|
||||||
|
var id uuid.UUID
|
||||||
|
err := r.db.QueryRowxContext(ctx,
|
||||||
|
`INSERT INTO paliad.backups
|
||||||
|
(kind, status, requested_by, requested_by_email, audit_id, started_at)
|
||||||
|
VALUES ($1, 'running', $2, $3, $4, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
kind, actorID, actor.Email, auditArg, now,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BackupRunner) insertAuditRow(ctx context.Context, kind string, actor BackupActor, catalogID uuid.UUID, now time.Time) (uuid.UUID, error) {
|
||||||
|
meta, _ := json.Marshal(map[string]any{
|
||||||
|
"kind": kind,
|
||||||
|
"catalog_id": catalogID.String(),
|
||||||
|
"requested_by_email": actor.Email,
|
||||||
|
"requested_at": now.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
var actorID any
|
||||||
|
if actor.ID != nil {
|
||||||
|
actorID = *actor.ID
|
||||||
|
}
|
||||||
|
var id uuid.UUID
|
||||||
|
err := r.db.QueryRowxContext(ctx,
|
||||||
|
`INSERT INTO paliad.system_audit_log
|
||||||
|
(event_type, actor_id, actor_email, scope, scope_root, metadata)
|
||||||
|
VALUES ('backup_created', $1, $2, 'org', NULL, $3::jsonb)
|
||||||
|
RETURNING id`,
|
||||||
|
actorID, actor.Email, string(meta),
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BackupRunner) linkAuditID(ctx context.Context, catalogID, auditID uuid.UUID) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.backups SET audit_id = $2 WHERE id = $1`,
|
||||||
|
catalogID, auditID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BackupRunner) patchCatalogRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
|
||||||
|
rcJSON, _ := json.Marshal(meta.RowCounts)
|
||||||
|
warnJSON, _ := json.Marshal(meta.Warnings)
|
||||||
|
if meta.Warnings == nil {
|
||||||
|
warnJSON = []byte("[]")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.backups
|
||||||
|
SET status = 'done',
|
||||||
|
storage_uri = $2,
|
||||||
|
size_bytes = $3,
|
||||||
|
sheet_count = $4,
|
||||||
|
row_counts = $5::jsonb,
|
||||||
|
warnings = $6::jsonb,
|
||||||
|
finished_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, uri, size, sheetCount, string(rcJSON), string(warnJSON),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BackupRunner) patchCatalogRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
|
||||||
|
_, _ = r.db.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.backups
|
||||||
|
SET status = 'failed',
|
||||||
|
error = $2,
|
||||||
|
finished_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, runErr.Error(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BackupRunner) patchAuditRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"row_counts": meta.RowCounts,
|
||||||
|
"file_size_bytes": size,
|
||||||
|
"sheet_count": sheetCount,
|
||||||
|
"storage_uri": uri,
|
||||||
|
"warnings": meta.Warnings,
|
||||||
|
"completed_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.system_audit_log
|
||||||
|
SET metadata = metadata || $2::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, string(payload),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *BackupRunner) patchAuditRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
|
||||||
|
payload, _ := json.Marshal(map[string]any{
|
||||||
|
"error": runErr.Error(),
|
||||||
|
"failed_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
_, _ = r.db.ExecContext(ctx,
|
||||||
|
`UPDATE paliad.system_audit_log
|
||||||
|
SET event_type = 'backup_failed',
|
||||||
|
metadata = metadata || $2::jsonb,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1`,
|
||||||
|
id, string(payload),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failRun is the shared failure-recovery path: patch the catalog +
|
||||||
|
// audit rows to their failed states. Uses a context.Background so the
|
||||||
|
// patch happens even if the original ctx is already cancelled.
|
||||||
|
func (r *BackupRunner) failRun(ctx context.Context, catalogID, auditID uuid.UUID, runErr error) {
|
||||||
|
r.patchCatalogRowFailed(ctx, catalogID, runErr)
|
||||||
|
r.patchAuditRowFailed(ctx, auditID, runErr)
|
||||||
|
}
|
||||||
193
internal/services/backup_service_test.go
Normal file
193
internal/services/backup_service_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
// Pure-function tests for the Backup Mode runtime (t-paliad-246 / m/paliad#77).
|
||||||
|
//
|
||||||
|
// Live DB behaviour (the actual org dump end-to-end) needs a Postgres;
|
||||||
|
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
|
||||||
|
// This file covers the bits that don't need a database:
|
||||||
|
//
|
||||||
|
// - orgSheetQueries registry shape: no duplicates, no excluded
|
||||||
|
// paliadin sheets, predictable prefix split between entity and ref.
|
||||||
|
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
|
||||||
|
// URI traversal rejection.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// orgSheetQueries registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, sq := range orgSheetQueries() {
|
||||||
|
if seen[sq.SheetName] {
|
||||||
|
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
|
||||||
|
}
|
||||||
|
seen[sq.SheetName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
|
||||||
|
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
|
||||||
|
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
|
||||||
|
// from the registry (structural exclusion, not just column-drop).
|
||||||
|
for _, sq := range orgSheetQueries() {
|
||||||
|
name := sq.SheetName
|
||||||
|
if strings.Contains(name, "paliadin") {
|
||||||
|
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
|
||||||
|
}
|
||||||
|
// Belt-and-braces: SQL bodies should not reference the tables
|
||||||
|
// either (no UNION joins, no subqueries pulling them in).
|
||||||
|
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
|
||||||
|
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
|
||||||
|
// Every sheet whose data is read-only reference material is
|
||||||
|
// expected to use the `ref__` prefix. The writer's downstream
|
||||||
|
// consumers rely on this convention to group reference data
|
||||||
|
// visually in the workbook.
|
||||||
|
for _, sq := range orgSheetQueries() {
|
||||||
|
if !strings.HasPrefix(sq.SheetName, "ref__") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Reference sheets shouldn't carry per-row WHERE clauses (they
|
||||||
|
// dump the whole reference table for portability).
|
||||||
|
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
|
||||||
|
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
|
||||||
|
// Every sheet must specify an ORDER BY so the byte-deterministic
|
||||||
|
// contract from t-paliad-214 §3 holds across runs.
|
||||||
|
for _, sq := range orgSheetQueries() {
|
||||||
|
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
|
||||||
|
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LocalDiskStore round-trip
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestLocalDiskStore_RoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewLocalDiskStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
want := []byte("hello backup\n")
|
||||||
|
|
||||||
|
uri, err := store.Put(ctx, "test.zip", want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(uri, "file://") {
|
||||||
|
t.Fatalf("expected file:// uri, got %q", uri)
|
||||||
|
}
|
||||||
|
rc, size, err := store.Get(ctx, uri)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
if size != int64(len(want)) {
|
||||||
|
t.Fatalf("Get size = %d, want %d", size, len(want))
|
||||||
|
}
|
||||||
|
got, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("Get body = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if err := store.Delete(ctx, uri); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
// File should be gone; Get returns an error.
|
||||||
|
if _, _, err := store.Get(ctx, uri); err == nil {
|
||||||
|
t.Fatalf("Get after Delete should fail")
|
||||||
|
}
|
||||||
|
// Delete is idempotent.
|
||||||
|
if err := store.Delete(ctx, uri); err != nil {
|
||||||
|
t.Fatalf("idempotent Delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalDiskStore_RejectsBadKeys(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewLocalDiskStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
cases := []string{
|
||||||
|
"",
|
||||||
|
"sub/dir/file.zip",
|
||||||
|
"..\\evil.zip",
|
||||||
|
"../escape.zip",
|
||||||
|
"/abs/path.zip",
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
if _, err := store.Put(ctx, k, []byte("x")); err == nil {
|
||||||
|
t.Fatalf("Put with bad key %q should fail", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalDiskStore_RejectsURIOutsideDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewLocalDiskStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalDiskStore: %v", err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
// A file:// URI pointing outside the store dir must be rejected
|
||||||
|
// by both Get and Delete (defense in depth against a corrupted
|
||||||
|
// catalog row).
|
||||||
|
outside := "file://" + filepath.Join(filepath.Dir(dir), "elsewhere.zip")
|
||||||
|
if _, _, err := store.Get(ctx, outside); err == nil {
|
||||||
|
t.Fatalf("Get outside store dir should fail")
|
||||||
|
}
|
||||||
|
if err := store.Delete(ctx, outside); err == nil {
|
||||||
|
t.Fatalf("Delete outside store dir should fail")
|
||||||
|
}
|
||||||
|
// Wrong scheme is also rejected.
|
||||||
|
if _, _, err := store.Get(ctx, "https://example.com/foo.zip"); err == nil {
|
||||||
|
t.Fatalf("Get with non-file:// scheme should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalDiskStore_CreatesDir(t *testing.T) {
|
||||||
|
// A non-existent parent gets created at construction; mode 0700.
|
||||||
|
base := t.TempDir()
|
||||||
|
target := filepath.Join(base, "nested", "exports")
|
||||||
|
store, err := NewLocalDiskStore(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLocalDiskStore(non-existent): %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected store dir to exist: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Fatalf("expected directory, got file")
|
||||||
|
}
|
||||||
|
// Smoke-write to confirm the dir is actually usable.
|
||||||
|
if _, err := store.Put(context.Background(), "ok.zip", []byte{}); err != nil {
|
||||||
|
t.Fatalf("Put into fresh dir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
@@ -185,7 +186,7 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
|
|||||||
}
|
}
|
||||||
|
|
||||||
sheets := personalSheetQueries(spec.ActorID)
|
sheets := personalSheetQueries(spec.ActorID)
|
||||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
|
||||||
return meta, err
|
return meta, err
|
||||||
}
|
}
|
||||||
return meta, nil
|
return meta, nil
|
||||||
@@ -238,7 +239,7 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
|
|||||||
}
|
}
|
||||||
|
|
||||||
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
|
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
|
||||||
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
|
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
|
||||||
return meta, err
|
return meta, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +255,55 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
|
|||||||
return meta, nil
|
return meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteOrg streams the full org-scope backup bundle into w. Bypasses
|
||||||
|
// paliad.can_see_project — admin-only, gated at the handler layer (the
|
||||||
|
// service trusts the caller has been authorised).
|
||||||
|
//
|
||||||
|
// Wraps the entire read pass in a REPEATABLE READ READ ONLY transaction
|
||||||
|
// so every sheet sees the same snapshot. Without this a backup that runs
|
||||||
|
// while users are editing can land internally inconsistent rows (e.g. a
|
||||||
|
// deadlines.project_id pointing at a project the projects sheet just
|
||||||
|
// missed). Design §3.3.
|
||||||
|
//
|
||||||
|
// The handler is responsible for the audit-row INSERT / PATCH (the
|
||||||
|
// org-scope backup uses BackupRunner.Run, not WriteAuditRow, because the
|
||||||
|
// event_type is 'backup_created' not 'data_export').
|
||||||
|
func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
|
||||||
|
if spec.Scope == "" {
|
||||||
|
spec.Scope = ExportScopeOrg
|
||||||
|
}
|
||||||
|
if spec.GeneratedAt.IsZero() {
|
||||||
|
spec.GeneratedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
meta := ExportMeta{
|
||||||
|
SchemaVersion: ExportSchemaVersion,
|
||||||
|
FirmName: s.firmName,
|
||||||
|
Scope: spec.Scope,
|
||||||
|
GeneratedAt: spec.GeneratedAt,
|
||||||
|
GeneratedByID: spec.ActorID,
|
||||||
|
GeneratedByEml: spec.ActorEmail,
|
||||||
|
GeneratedByLbl: spec.ActorLabel,
|
||||||
|
RowCounts: map[string]int{},
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTxx(ctx, &sql.TxOptions{
|
||||||
|
Isolation: sql.LevelRepeatableRead,
|
||||||
|
ReadOnly: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return meta, fmt.Errorf("backup snapshot tx: %w", err)
|
||||||
|
}
|
||||||
|
// Always rollback — the tx is read-only by construction, the rollback
|
||||||
|
// is just bookkeeping that releases the snapshot.
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
sheets := orgSheetQueries()
|
||||||
|
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
|
||||||
|
return meta, err
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
|
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
|
||||||
// point outside the subtree (today: only projects.counterclaim_of). One
|
// point outside the subtree (today: only projects.counterclaim_of). One
|
||||||
// warning row per outbound reference. Best-effort: a query error here
|
// warning row per outbound reference. Best-effort: a query error here
|
||||||
@@ -300,13 +350,17 @@ type collectedSheet struct {
|
|||||||
// xlsx sheet + one JSON branch + one CSV per sheet, packs everything into
|
// xlsx sheet + one JSON branch + one CSV per sheet, packs everything into
|
||||||
// the outer zip in sorted file-list order so two runs of the same row
|
// the outer zip in sorted file-list order so two runs of the same row
|
||||||
// state produce byte-identical bundles.
|
// state produce byte-identical bundles.
|
||||||
func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
|
//
|
||||||
|
// queryer is the executor for sheet queries — typically s.db, but
|
||||||
|
// WriteOrg passes a REPEATABLE READ *sqlx.Tx so the org dump sees a
|
||||||
|
// consistent snapshot across all sheets (design §3.3).
|
||||||
|
func (s *ExportService) writeBundle(ctx context.Context, queryer sqlx.QueryerContext, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
|
||||||
collectedSheets := make([]collectedSheet, 0, len(sheets))
|
collectedSheets := make([]collectedSheet, 0, len(sheets))
|
||||||
jsonTables := make(map[string][]map[string]string, len(sheets))
|
jsonTables := make(map[string][]map[string]string, len(sheets))
|
||||||
warnings := []string{}
|
warnings := []string{}
|
||||||
|
|
||||||
for _, sq := range sheets {
|
for _, sq := range sheets {
|
||||||
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, sq)
|
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, queryer, sq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("export sheet %q: %w", sq.SheetName, err)
|
return fmt.Errorf("export sheet %q: %w", sq.SheetName, err)
|
||||||
}
|
}
|
||||||
@@ -421,11 +475,13 @@ func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSheetQuery executes one sheetQuery and returns the kept columns,
|
// runSheetQuery executes one sheetQuery against the given queryer and
|
||||||
// row matrix (pre-stringified per the design's value-as-string convention),
|
// returns the kept columns, row matrix (pre-stringified per the design's
|
||||||
// and the list of columns that were dropped by the PII filter.
|
// value-as-string convention), and the list of columns that were dropped
|
||||||
func (s *ExportService) runSheetQuery(ctx context.Context, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
|
// by the PII filter. queryer is typically s.db, but WriteOrg passes a
|
||||||
rs, err := s.db.QueryxContext(ctx, sq.SQL, sq.Args...)
|
// REPEATABLE READ *sqlx.Tx (see writeBundle docs).
|
||||||
|
func (s *ExportService) runSheetQuery(ctx context.Context, queryer sqlx.QueryerContext, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
|
||||||
|
rs, err := queryer.QueryxContext(ctx, sq.SQL, sq.Args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, fmt.Errorf("query: %w", err)
|
return nil, nil, nil, fmt.Errorf("query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1470,3 +1526,107 @@ SELECT 'partner_unit_default'::text AS source,
|
|||||||
}
|
}
|
||||||
return queries
|
return queries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Org-scope sheet registry (Slice 3 / Backup Mode — t-paliad-246).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Full-schema dump. Bypasses paliad.can_see_project — admin-only,
|
||||||
|
// gated at the handler layer (BackupRunner trusts the caller).
|
||||||
|
//
|
||||||
|
// Sheet ordering: entity sheets first (alphabetical), then ref__*
|
||||||
|
// reference sheets (alphabetical). The xlsx writer iterates the slice
|
||||||
|
// in order; downstream consumers get the same order across runs.
|
||||||
|
//
|
||||||
|
// Hard exclusions (per design §5.2 / m's Q3 decision):
|
||||||
|
//
|
||||||
|
// - paliadin_turns
|
||||||
|
// - paliadin_aichat_conversation
|
||||||
|
//
|
||||||
|
// AI conversation history is the most-sensitive personal data paliad
|
||||||
|
// carries; m's prior Q5 decision in t-paliad-214 made the exclusion
|
||||||
|
// structural. The two tables are absent from the registry — not just
|
||||||
|
// column-level redacted — so a future schema addition cannot
|
||||||
|
// accidentally re-include them.
|
||||||
|
//
|
||||||
|
// Also excluded unconditionally (operational / shadow):
|
||||||
|
//
|
||||||
|
// - *_pre_NNN shadow tables (CREATE TABLE … AS SELECT backups
|
||||||
|
// written by destructive migrations)
|
||||||
|
// - paliad_schema_migrations (operational)
|
||||||
|
// - auth.* (Supabase Auth schema — not ours)
|
||||||
|
//
|
||||||
|
// The PII column deny-regex (piiColumnDenyRegex) catches
|
||||||
|
// secret|token|password|api_key|private_key on every sheet as a
|
||||||
|
// belt-and-braces filter. user_caldav_config.password_encrypted is
|
||||||
|
// explicitly named in DropColumns too.
|
||||||
|
func orgSheetQueries() []sheetQuery {
|
||||||
|
return []sheetQuery{
|
||||||
|
// --- entity sheets (alphabetical) ---
|
||||||
|
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
|
||||||
|
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
|
||||||
|
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
|
||||||
|
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
|
||||||
|
// backups is self-reflexive — including it makes "what backups
|
||||||
|
// have we taken" recoverable from any prior backup. Tiny table.
|
||||||
|
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
|
||||||
|
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
|
||||||
|
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
|
||||||
|
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
|
||||||
|
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
|
||||||
|
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
|
||||||
|
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
|
||||||
|
// documents: ai_extracted jsonb dropped (verbose AI prompts;
|
||||||
|
// matches the personal/project precedent). Binaries are not in
|
||||||
|
// the export — only metadata.
|
||||||
|
{
|
||||||
|
SheetName: "documents",
|
||||||
|
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
|
||||||
|
FROM paliad.documents
|
||||||
|
ORDER BY id`,
|
||||||
|
},
|
||||||
|
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
|
||||||
|
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
|
||||||
|
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
|
||||||
|
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
|
||||||
|
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
|
||||||
|
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
|
||||||
|
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
|
||||||
|
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
|
||||||
|
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
|
||||||
|
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
|
||||||
|
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
|
||||||
|
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
|
||||||
|
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
|
||||||
|
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
|
||||||
|
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
|
||||||
|
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
|
||||||
|
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
|
||||||
|
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
|
||||||
|
{
|
||||||
|
SheetName: "user_caldav_config",
|
||||||
|
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
|
||||||
|
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
|
||||||
|
},
|
||||||
|
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
|
||||||
|
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
|
||||||
|
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
|
||||||
|
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
|
||||||
|
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
|
||||||
|
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
|
||||||
|
|
||||||
|
// --- reference data (alphabetical, prefixed ref__) ---
|
||||||
|
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
|
||||||
|
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
|
||||||
|
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
|
||||||
|
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
|
||||||
|
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
|
||||||
|
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
|
||||||
|
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
|
||||||
|
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
|
||||||
|
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
|
||||||
|
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
|
||||||
|
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
|
||||||
|
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user