m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async export) into a new "Backup Mode" surface gated by adminGate. m's calls (all 4 material picks per design §2): - Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only) - Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved - paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally - Scheduler (Slice B): nightly 03:00 UTC, env-tunable Wiring: - mig 123 adds paliad.backups catalog table (kind/status/storage_uri/ size/row_counts/warnings/error/deleted_at + admin-only RLS). - ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for snapshot consistency (design §3.3). - writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx (org snapshot path) work. - BackupRunner orchestrates: catalog INSERT → audit INSERT (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch catalog + audit on success/failure. - ArtifactStore interface + LocalDiskStore impl (defense-in-depth key validation + URI-outside-dir guard). - Sentinel actor for scheduled runs: actor_email='system@paliad', actor_id=NULL — no phantom user in paliad.users. - Admin handlers POST /api/admin/backups/run + GET list/get/download behind adminGate(users, …); /admin/backups page + sidebar entry + bilingual i18n keys. - BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return 503 otherwise (same shape as requireDB). Tests: 8 pure-function tests cover registry shape (no dups, paliadin absent both as sheet name and SQL substring, ref__* sheets unscoped, every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key rejection, URI-traversal rejection, mkdir on construction). go build ./... + go test ./internal/... clean. bun run build clean. Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish) are separate follow-ups per head's instruction.
248 lines
8.1 KiB
Go
248 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|