package handlers import ( "archive/zip" "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/gin-gonic/gin" "armature/database" "armature/store" ) // BackupHandler provides backup/restore endpoints for admin users. type BackupHandler struct { stores store.Stores packagesDir string storagePath string } // NewBackupHandler creates a new BackupHandler. func NewBackupHandler(stores store.Stores, packagesDir, storagePath string) *BackupHandler { return &BackupHandler{ stores: stores, packagesDir: packagesDir, storagePath: storagePath, } } // backupMeta is written as meta.json inside the .swb archive. type backupMeta struct { Version string `json:"version"` Timestamp time.Time `json:"timestamp"` SchemaVersion string `json:"schema_version"` Dialect string `json:"dialect"` CoreTables []string `json:"core_tables"` ExtTables []string `json:"ext_tables,omitempty"` } func (h *BackupHandler) backupsDir() string { if h.storagePath == "" { return "" } return filepath.Join(h.storagePath, "backups") } // ── CreateBackup ───────────────────────────── // POST /api/v1/admin/backup // Streams a .swb ZIP archive containing all core + ext_data tables as JSONL, // plus package asset files from disk. func (h *BackupHandler) CreateBackup(c *gin.Context) { ctx := c.Request.Context() db := database.DB // Determine schema version schemaVersion := "" row := db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1") row.Scan(&schemaVersion) dialect := "sqlite" if database.IsPostgres() { dialect = "postgres" } // Enumerate ext_data tables extEntries, err := listExtDataTables(ctx, db) if err != nil { log.Printf("[backup] warning: list ext_data tables: %v", err) } extTableNames := make([]string, len(extEntries)) for i, e := range extEntries { extTableNames[i] = e.PhysicalName } meta := backupMeta{ Version: readVersionFile(), Timestamp: time.Now().UTC(), SchemaVersion: schemaVersion, Dialect: dialect, CoreTables: coreTableOrder, ExtTables: extTableNames, } // Determine if we should store server-side or stream directly storeSide := c.Query("store") == "true" if storeSide { h.createStoredBackup(c, ctx, db, meta, extEntries) return } // Stream directly to client filename := fmt.Sprintf("armature-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405")) c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) zw := zip.NewWriter(c.Writer) defer zw.Close() h.writeBackupContents(c, zw, meta, extEntries) } func (h *BackupHandler) createStoredBackup(c *gin.Context, ctx interface{ Deadline() (time.Time, bool) }, db interface{}, meta backupMeta, extEntries []extDataTableEntry) { dir := h.backupsDir() if dir == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "no storage path configured"}) return } os.MkdirAll(dir, 0755) filename := fmt.Sprintf("armature-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405")) path := filepath.Join(dir, filename) f, err := os.Create(path) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "create backup file: " + err.Error()}) return } defer f.Close() zw := zip.NewWriter(f) defer zw.Close() h.writeBackupContents(c, zw, meta, extEntries) c.JSON(http.StatusOK, gin.H{"data": gin.H{ "filename": filename, "path": path, }}) } func (h *BackupHandler) writeBackupContents(c *gin.Context, zw *zip.Writer, meta backupMeta, extEntries []extDataTableEntry) { ctx := c.Request.Context() db := database.DB // Write meta.json mw, err := zw.Create("meta.json") if err != nil { log.Printf("[backup] create meta.json: %v", err) return } enc := json.NewEncoder(mw) enc.SetIndent("", " ") enc.Encode(meta) // Dump core tables for _, table := range coreTableOrder { if !tableExists(ctx, db, table) { continue } fw, err := zw.Create(fmt.Sprintf("core/%s.jsonl", table)) if err != nil { log.Printf("[backup] create core/%s.jsonl: %v", table, err) continue } count, err := dumpTable(ctx, db, table, fw) if err != nil { log.Printf("[backup] dump %s: %v", table, err) } else { log.Printf("[backup] dumped %s: %d rows", table, count) } } // Dump ext_data tables for _, entry := range extEntries { if !tableExists(ctx, db, entry.PhysicalName) { continue } path := fmt.Sprintf("ext_data/%s/%s.jsonl", entry.PackageID, entry.TableName) fw, err := zw.Create(path) if err != nil { log.Printf("[backup] create %s: %v", path, err) continue } count, err := dumpTable(ctx, db, entry.PhysicalName, fw) if err != nil { log.Printf("[backup] dump %s: %v", entry.PhysicalName, err) } else { log.Printf("[backup] dumped %s: %d rows", entry.PhysicalName, count) } } // Walk package assets if h.packagesDir != "" { filepath.Walk(h.packagesDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } relPath, err := filepath.Rel(h.packagesDir, path) if err != nil { return nil } relPath = filepath.ToSlash(relPath) fw, err := zw.Create("packages/" + relPath) if err != nil { return nil } f, err := os.Open(path) if err != nil { return nil } defer f.Close() io.Copy(fw, f) return nil }) } } // ── ListBackups ────────────────────────────── // GET /api/v1/admin/backups func (h *BackupHandler) ListBackups(c *gin.Context) { dir := h.backupsDir() if dir == "" { c.JSON(http.StatusOK, gin.H{"data": []interface{}{}}) return } entries, err := os.ReadDir(dir) if err != nil { c.JSON(http.StatusOK, gin.H{"data": []interface{}{}}) return } type backupEntry struct { Name string `json:"name"` Size int64 `json:"size"` CreatedAt time.Time `json:"created_at"` } var backups []backupEntry for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".swb") { continue } info, err := e.Info() if err != nil { continue } backups = append(backups, backupEntry{ Name: e.Name(), Size: info.Size(), CreatedAt: info.ModTime(), }) } if backups == nil { backups = []backupEntry{} } c.JSON(http.StatusOK, gin.H{"data": backups}) } // ── DownloadBackup ─────────────────────────── // GET /api/v1/admin/backups/:name func (h *BackupHandler) DownloadBackup(c *gin.Context) { name := c.Param("name") if !isValidBackupName(name) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"}) return } dir := h.backupsDir() if dir == "" { c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"}) return } path := filepath.Join(dir, name) if _, err := os.Stat(path); os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) return } c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) c.File(path) } // ── DeleteBackup ───────────────────────────── // DELETE /api/v1/admin/backups/:name func (h *BackupHandler) DeleteBackup(c *gin.Context) { name := c.Param("name") if !isValidBackupName(name) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"}) return } dir := h.backupsDir() if dir == "" { c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"}) return } path := filepath.Join(dir, name) if err := os.Remove(path); err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } return } c.JSON(http.StatusOK, gin.H{"data": gin.H{"deleted": name}}) } // ── RestoreBackup ──────────────────────────── // POST /api/v1/admin/restore // Accepts a .swb file upload, validates meta, wipes DB, restores data. func (h *BackupHandler) RestoreBackup(c *gin.Context) { ctx := c.Request.Context() db := database.DB file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file required"}) return } defer file.Close() // Read entire file into memory for zip.NewReader data, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "read file: " + err.Error()}) return } zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()}) return } // Read meta.json meta, err := readBackupMeta(zr) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup: " + err.Error()}) return } // Validate schema version — reject backups from future schema currentSchema := "" db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(¤tSchema) if meta.SchemaVersion > currentSchema && currentSchema != "" { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("backup schema %s is newer than current %s — upgrade the application first", meta.SchemaVersion, currentSchema), }) return } log.Printf("[backup] restoring from %s (schema %s, %d core tables, %d ext tables)", header.Filename, meta.SchemaVersion, len(meta.CoreTables), len(meta.ExtTables)) // Build full table list for wipe (core + ext_data) allTables := make([]string, 0, len(coreTableOrder)+len(meta.ExtTables)) allTables = append(allTables, coreTableOrder...) allTables = append(allTables, meta.ExtTables...) // Wipe all data if err := wipeTables(ctx, db, allTables); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "wipe failed: " + err.Error()}) return } // Restore core tables in order restored := 0 for _, table := range coreTableOrder { path := fmt.Sprintf("core/%s.jsonl", table) f := findInZip(zr, path) if f == nil { continue } rc, err := f.Open() if err != nil { log.Printf("[backup] open %s: %v", path, err) continue } count, err := restoreTable(ctx, db, table, rc) rc.Close() if err != nil { log.Printf("[backup] restore %s: %v", table, err) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("restore %s: %v", table, err)}) return } log.Printf("[backup] restored %s: %d rows", table, count) restored += count } // Restore ext_data tables for _, extTable := range meta.ExtTables { // Parse package_id and table_name from physical name: ext_{pkg}_{table} pkgID, tblName := parseExtTableName(extTable) if pkgID == "" { continue } path := fmt.Sprintf("ext_data/%s/%s.jsonl", pkgID, tblName) f := findInZip(zr, path) if f == nil { continue } // Ensure physical table exists before restoring if !tableExists(ctx, db, extTable) { log.Printf("[backup] skip ext table %s (not in schema)", extTable) continue } rc, err := f.Open() if err != nil { log.Printf("[backup] open %s: %v", path, err) continue } count, err := restoreTable(ctx, db, extTable, rc) rc.Close() if err != nil { log.Printf("[backup] restore ext %s: %v", extTable, err) } else { log.Printf("[backup] restored ext %s: %d rows", extTable, count) restored += count } } // Restore package files if h.packagesDir != "" { for _, f := range zr.File { if !strings.HasPrefix(f.Name, "packages/") || f.FileInfo().IsDir() { continue } relPath := strings.TrimPrefix(f.Name, "packages/") destPath := filepath.Join(h.packagesDir, filepath.FromSlash(relPath)) os.MkdirAll(filepath.Dir(destPath), 0755) rc, err := f.Open() if err != nil { continue } out, err := os.Create(destPath) if err != nil { rc.Close() continue } io.Copy(out, rc) out.Close() rc.Close() } } log.Printf("[backup] restore complete: %d total rows", restored) c.JSON(http.StatusOK, gin.H{"data": gin.H{ "rows_restored": restored, "schema": meta.SchemaVersion, }}) } // ── Helpers ────────────────────────────────── func readBackupMeta(zr *zip.Reader) (*backupMeta, error) { f := findInZip(zr, "meta.json") if f == nil { return nil, fmt.Errorf("meta.json not found in archive") } rc, err := f.Open() if err != nil { return nil, err } defer rc.Close() var meta backupMeta if err := json.NewDecoder(rc).Decode(&meta); err != nil { return nil, fmt.Errorf("decode meta.json: %w", err) } return &meta, nil } func findInZip(zr *zip.Reader, name string) *zip.File { for _, f := range zr.File { if f.Name == name { return f } } return nil } // parseExtTableName parses "ext_{pkg}_{table}" into (pkg, table). func parseExtTableName(physical string) (string, string) { if !strings.HasPrefix(physical, "ext_") { return "", "" } rest := physical[4:] idx := strings.Index(rest, "_") if idx < 0 { return "", "" } return rest[:idx], rest[idx+1:] } var validBackupNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+\.swb$`) func isValidBackupName(name string) bool { return validBackupNameRe.MatchString(name) && !strings.Contains(name, "..") } // readVersionFile reads the VERSION file from the project root. func readVersionFile() string { // Try common locations for _, path := range []string{"VERSION", "/app/VERSION", "../VERSION"} { data, err := os.ReadFile(path) if err == nil { return strings.TrimSpace(string(data)) } } return "unknown" }