V0.6.2 docs openapi (#37)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #37.
This commit is contained in:
523
server/handlers/backup.go
Normal file
523
server/handlers/backup.go
Normal file
@@ -0,0 +1,523 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user