All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
524 lines
14 KiB
Go
524 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/database"
|
|
"switchboard-core/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("switchboard-%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("switchboard-%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"
|
|
}
|