From 7e9d8dceef40659d816d3a3dbf7629b4c1a53e7a Mon Sep 17 00:00:00 2001 From: AoaoMH Date: Wed, 7 Jan 2026 14:29:54 +0800 Subject: [PATCH 1/3] feat: implement CLI Proxy API server with backup and restore functionalities --- cmd/server/main.go | 40 +- internal/api/handlers/management/backup.go | 578 +++++++++++++++++++++ internal/api/server.go | 8 + internal/cmd/backup.go | 398 ++++++++++++++ 4 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 internal/api/handlers/management/backup.go create mode 100644 internal/cmd/backup.go diff --git a/cmd/server/main.go b/cmd/server/main.go index f9bb20807..7a2dc7602 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -66,6 +66,15 @@ func main() { var vertexImport string var configPath string var password string + var backupCreate bool + var backupRestore string + var backupList bool + var backupName string + var backupPath string + var backupEnv bool + var backupConfig bool + var backupAuths bool + var restoreAuthsMode string // Define command-line flags for different operation modes. flag.BoolVar(&login, "login", false, "Login Google Account") @@ -81,6 +90,17 @@ func main() { flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") flag.StringVar(&password, "password", "", "") + // Backup and restore flags + flag.BoolVar(&backupCreate, "backup", false, "Create a backup") + flag.StringVar(&backupRestore, "restore", "", "Restore from a backup file") + flag.BoolVar(&backupList, "list-backups", false, "List available backups") + flag.StringVar(&backupName, "backup-name", "", "Custom backup name") + flag.StringVar(&backupPath, "backup-path", "", "Custom backup directory") + flag.BoolVar(&backupEnv, "backup-env", false, "Include .env in backup") + flag.BoolVar(&backupConfig, "backup-config", true, "Include config.yaml in backup") + flag.BoolVar(&backupAuths, "backup-auths", true, "Include auths folder in backup") + flag.StringVar(&restoreAuthsMode, "restore-auths-mode", "overwrite", "Auths restore mode: overwrite or incremental") + flag.CommandLine.Usage = func() { out := flag.CommandLine.Output() _, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0]) @@ -444,7 +464,25 @@ func main() { // Handle different command modes based on the provided flags. - if vertexImport != "" { + if backupList { + // List available backups + cmd.ListBackups(backupPath) + } else if backupCreate { + // Create a backup + cmd.DoBackup(cfg, configFilePath, &cmd.BackupOptions{ + Name: backupName, + BackupPath: backupPath, + IncludeEnv: backupEnv, + IncludeConfig: backupConfig, + IncludeAuths: backupAuths, + }) + } else if backupRestore != "" { + // Restore from a backup + cmd.DoRestore(cfg, configFilePath, backupRestore, &cmd.RestoreOptions{ + BackupPath: backupPath, + AuthsMode: restoreAuthsMode, + }) + } else if vertexImport != "" { // Handle Vertex service account import cmd.DoVertexImport(cfg, vertexImport) } else if login { diff --git a/internal/api/handlers/management/backup.go b/internal/api/handlers/management/backup.go new file mode 100644 index 000000000..ba23e8cd7 --- /dev/null +++ b/internal/api/handlers/management/backup.go @@ -0,0 +1,578 @@ +// Package management provides the management API handlers and middleware. +// This file implements backup and restore functionality for .env, config.yaml, and auths folder. +package management + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// BackupContent represents the contents that can be backed up +type BackupContent struct { + Env bool `json:"env"` + Config bool `json:"config"` + Auths bool `json:"auths"` +} + +// BackupMetadata represents metadata about a backup +type BackupMetadata struct { + Name string `json:"name"` + Date time.Time `json:"date"` + Content BackupContent `json:"content"` + Size int64 `json:"size"` + FilePath string `json:"-"` // Internal use only +} + +// BackupCreateRequest represents a request to create a backup +type BackupCreateRequest struct { + Name string `json:"name"` // Custom backup name (optional) + Content BackupContent `json:"content"` // What to backup + BackupPath string `json:"backupPath"` // Custom backup directory (optional) +} + +// BackupRestoreRequest represents a request to restore a backup +type BackupRestoreRequest struct { + Name string `json:"name"` // Backup name to restore + AuthsMode string `json:"authsMode"` // "overwrite" or "incremental" +} + +// BackupListResponse represents the response for listing backups +type BackupListResponse struct { + Backups []BackupMetadata `json:"backups"` + BackupPath string `json:"backupPath"` +} + +// getBackupDir returns the backup directory path +func (h *Handler) getBackupDir() string { + // Default to ./backup relative to working directory + wd, err := os.Getwd() + if err != nil { + wd = filepath.Dir(h.configFilePath) + } + return filepath.Join(wd, "backup") +} + +// getBackupDirFromConfig returns the backup directory, allowing custom path +func (h *Handler) getBackupDirFromConfig(customPath string) string { + if customPath != "" { + if filepath.IsAbs(customPath) { + return customPath + } + wd, _ := os.Getwd() + return filepath.Join(wd, customPath) + } + return h.getBackupDir() +} + +// ListBackups returns a list of all available backups +func (h *Handler) ListBackups(c *gin.Context) { + backupDir := h.getBackupDir() + + // Ensure backup directory exists + if err := os.MkdirAll(backupDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create backup directory: %v", err)}) + return + } + + entries, err := os.ReadDir(backupDir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read backup directory: %v", err)}) + return + } + + var backups []BackupMetadata + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".zip") { + continue + } + + filePath := filepath.Join(backupDir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + + // Try to read metadata from zip + metadata, err := readBackupMetadata(filePath) + if err != nil { + // Fallback: parse name for date + name := strings.TrimSuffix(entry.Name(), ".zip") + metadata = &BackupMetadata{ + Name: name, + Date: info.ModTime(), + Size: info.Size(), + FilePath: filePath, + Content: BackupContent{Config: true, Auths: true}, // Default assumption + } + } else { + metadata.Size = info.Size() + metadata.FilePath = filePath + } + backups = append(backups, *metadata) + } + + // Sort by date descending (newest first) + sort.Slice(backups, func(i, j int) bool { + return backups[i].Date.After(backups[j].Date) + }) + + c.JSON(http.StatusOK, BackupListResponse{ + Backups: backups, + BackupPath: backupDir, + }) +} + +// CreateBackup creates a new backup +func (h *Handler) CreateBackup(c *gin.Context) { + var req BackupCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request body: %v", err)}) + return + } + + // Default content selection: config.yaml and auths + if !req.Content.Env && !req.Content.Config && !req.Content.Auths { + req.Content.Config = true + req.Content.Auths = true + } + + backupDir := h.getBackupDirFromConfig(req.BackupPath) + + // Ensure backup directory exists + if err := os.MkdirAll(backupDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create backup directory: %v", err)}) + return + } + + // Generate backup name if not provided + backupName := req.Name + if backupName == "" { + backupName = fmt.Sprintf("cliProxyApi_backup_%s", time.Now().Format("20060102_150405")) + } + + // Ensure unique filename + zipPath := filepath.Join(backupDir, backupName+".zip") + if _, err := os.Stat(zipPath); err == nil { + // File exists, append timestamp + backupName = fmt.Sprintf("%s_%s", backupName, time.Now().Format("150405")) + zipPath = filepath.Join(backupDir, backupName+".zip") + } + + // Create zip file + zipFile, err := os.Create(zipPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create backup file: %v", err)}) + return + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + wd, _ := os.Getwd() + + // Add .env if selected + if req.Content.Env { + envPath := filepath.Join(wd, ".env") + if _, err := os.Stat(envPath); err == nil { + if err := addFileToZip(zipWriter, envPath, ".env"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add .env to backup: %v", err)}) + return + } + } + } + + // Add config.yaml if selected + if req.Content.Config { + configPath := h.configFilePath + if configPath == "" { + configPath = filepath.Join(wd, "config.yaml") + } + if _, err := os.Stat(configPath); err == nil { + if err := addFileToZip(zipWriter, configPath, "config.yaml"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add config.yaml to backup: %v", err)}) + return + } + } + } + + // Add auths folder if selected + if req.Content.Auths { + authDir := h.cfg.AuthDir + if authDir == "" { + authDir = filepath.Join(wd, "auths") + } + if _, err := os.Stat(authDir); err == nil { + if err := addDirToZip(zipWriter, authDir, "auths"); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add auths to backup: %v", err)}) + return + } + } + } + + // Write metadata + metadata := BackupMetadata{ + Name: backupName, + Date: time.Now(), + Content: req.Content, + } + metadataBytes, _ := json.Marshal(metadata) + metaWriter, err := zipWriter.Create("backup_metadata.json") + if err == nil { + metaWriter.Write(metadataBytes) + } + + // Close zip to flush + zipWriter.Close() + zipFile.Close() + + // Get file size + info, _ := os.Stat(zipPath) + metadata.Size = info.Size() + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "backup": metadata, + "filepath": zipPath, + }) +} + +// DeleteBackup deletes a backup +func (h *Handler) DeleteBackup(c *gin.Context) { + name := c.Query("name") + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "backup name is required"}) + return + } + + backupDir := h.getBackupDir() + + // Sanitize name to prevent path traversal + name = filepath.Base(name) + if !strings.HasSuffix(name, ".zip") { + name = name + ".zip" + } + + zipPath := filepath.Join(backupDir, name) + + // Check if file exists + if _, err := os.Stat(zipPath); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) + return + } + + if err := os.Remove(zipPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete backup: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +// DownloadBackup downloads a backup file +func (h *Handler) DownloadBackup(c *gin.Context) { + name := c.Query("name") + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "backup name is required"}) + return + } + + backupDir := h.getBackupDir() + + // Sanitize name to prevent path traversal + name = filepath.Base(name) + if !strings.HasSuffix(name, ".zip") { + name = name + ".zip" + } + + zipPath := filepath.Join(backupDir, name) + + // Check if file exists + if _, err := os.Stat(zipPath); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) + return + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", name)) + c.Header("Content-Type", "application/zip") + c.File(zipPath) +} + +// RestoreBackup restores from a backup +func (h *Handler) RestoreBackup(c *gin.Context) { + var req BackupRestoreRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request body: %v", err)}) + return + } + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "backup name is required"}) + return + } + + // Default auths mode is overwrite + if req.AuthsMode == "" { + req.AuthsMode = "overwrite" + } + + if req.AuthsMode != "overwrite" && req.AuthsMode != "incremental" { + c.JSON(http.StatusBadRequest, gin.H{"error": "authsMode must be 'overwrite' or 'incremental'"}) + return + } + + backupDir := h.getBackupDir() + + // Sanitize name + name := filepath.Base(req.Name) + if !strings.HasSuffix(name, ".zip") { + name = name + ".zip" + } + + zipPath := filepath.Join(backupDir, name) + + if _, err := os.Stat(zipPath); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) + return + } + + // Extract and restore + if err := h.restoreFromZip(zipPath, req.AuthsMode); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to restore backup: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "backup restored successfully"}) +} + +// UploadAndRestoreBackup handles uploading a backup file and restoring it +func (h *Handler) UploadAndRestoreBackup(c *gin.Context) { + authsMode := c.DefaultPostForm("authsMode", "overwrite") + if authsMode != "overwrite" && authsMode != "incremental" { + c.JSON(http.StatusBadRequest, gin.H{"error": "authsMode must be 'overwrite' or 'incremental'"}) + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) + return + } + defer file.Close() + + // Validate file extension + if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") { + c.JSON(http.StatusBadRequest, gin.H{"error": "only zip files are allowed"}) + return + } + + // Save to temp file + tempFile, err := os.CreateTemp("", "backup_upload_*.zip") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) + return + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + + if _, err := io.Copy(tempFile, file); err != nil { + tempFile.Close() + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save uploaded file"}) + return + } + tempFile.Close() + + // Restore from uploaded file + if err := h.restoreFromZip(tempPath, authsMode); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to restore backup: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "backup uploaded and restored successfully"}) +} + +// restoreFromZip extracts and restores files from a zip backup +func (h *Handler) restoreFromZip(zipPath, authsMode string) error { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return fmt.Errorf("failed to open zip: %w", err) + } + defer reader.Close() + + wd, _ := os.Getwd() + authDir := h.cfg.AuthDir + if authDir == "" { + authDir = filepath.Join(wd, "auths") + } + + // If overwrite mode for auths, clear the directory first + if authsMode == "overwrite" { + // We'll handle this when we encounter auths files + authsCleared := false + for _, f := range reader.File { + if strings.HasPrefix(f.Name, "auths/") && !authsCleared { + // Clear auths directory + os.RemoveAll(authDir) + os.MkdirAll(authDir, 0755) + authsCleared = true + break + } + } + } + + for _, f := range reader.File { + // Skip metadata file + if f.Name == "backup_metadata.json" { + continue + } + + // Determine destination path + var destPath string + switch { + case f.Name == ".env": + destPath = filepath.Join(wd, ".env") + case f.Name == "config.yaml": + destPath = h.configFilePath + if destPath == "" { + destPath = filepath.Join(wd, "config.yaml") + } + case strings.HasPrefix(f.Name, "auths/"): + relativePath := strings.TrimPrefix(f.Name, "auths/") + if relativePath == "" { + continue // Skip directory entry + } + destPath = filepath.Join(authDir, relativePath) + default: + continue // Skip unknown files + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", f.Name, err) + } + + // Handle directory entries + if f.FileInfo().IsDir() { + os.MkdirAll(destPath, 0755) + continue + } + + // Extract file + srcFile, err := f.Open() + if err != nil { + return fmt.Errorf("failed to open file in zip %s: %w", f.Name, err) + } + + destFile, err := os.Create(destPath) + if err != nil { + srcFile.Close() + return fmt.Errorf("failed to create destination file %s: %w", destPath, err) + } + + _, err = io.Copy(destFile, srcFile) + srcFile.Close() + destFile.Close() + + if err != nil { + return fmt.Errorf("failed to extract file %s: %w", f.Name, err) + } + } + + return nil +} + +// Helper functions + +func addFileToZip(zipWriter *zip.Writer, filePath, zipPath string) error { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = zipPath + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + _, err = io.Copy(writer, file) + return err +} + +func addDirToZip(zipWriter *zip.Writer, dirPath, zipBasePath string) error { + return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + + zipPath := filepath.Join(zipBasePath, relPath) + // Normalize path separators for zip + zipPath = strings.ReplaceAll(zipPath, "\\", "/") + + if info.IsDir() { + // Add directory entry + if relPath != "." { + _, err := zipWriter.Create(zipPath + "/") + return err + } + return nil + } + + return addFileToZip(zipWriter, path, zipPath) + }) +} + +func readBackupMetadata(zipPath string) (*BackupMetadata, error) { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return nil, err + } + defer reader.Close() + + for _, f := range reader.File { + if f.Name == "backup_metadata.json" { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + + var metadata BackupMetadata + if err := json.NewDecoder(rc).Decode(&metadata); err != nil { + return nil, err + } + return &metadata, nil + } + } + + return nil, fmt.Errorf("metadata not found") +} diff --git a/internal/api/server.go b/internal/api/server.go index 05bb2fee8..17fa5f44d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -622,6 +622,14 @@ func (s *Server) registerManagementRoutes() { mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) + + // Backup management routes + mgmt.GET("/backups", s.mgmt.ListBackups) + mgmt.POST("/backups", s.mgmt.CreateBackup) + mgmt.DELETE("/backups", s.mgmt.DeleteBackup) + mgmt.GET("/backups/download", s.mgmt.DownloadBackup) + mgmt.POST("/backups/restore", s.mgmt.RestoreBackup) + mgmt.POST("/backups/upload", s.mgmt.UploadAndRestoreBackup) } } diff --git a/internal/cmd/backup.go b/internal/cmd/backup.go new file mode 100644 index 000000000..6625b3217 --- /dev/null +++ b/internal/cmd/backup.go @@ -0,0 +1,398 @@ +// Package cmd provides command-line interface functionality for backup and restore operations. +package cmd + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + log "github.com/sirupsen/logrus" +) + +// BackupContent represents the contents that can be backed up +type BackupContent struct { + Env bool `json:"env"` + Config bool `json:"config"` + Auths bool `json:"auths"` +} + +// BackupMetadata represents metadata about a backup +type BackupMetadata struct { + Name string `json:"name"` + Date time.Time `json:"date"` + Content BackupContent `json:"content"` +} + +// BackupOptions contains options for backup operations +type BackupOptions struct { + Name string + BackupPath string + IncludeEnv bool + IncludeConfig bool + IncludeAuths bool +} + +// RestoreOptions contains options for restore operations +type RestoreOptions struct { + BackupPath string + AuthsMode string // "overwrite" or "incremental" +} + +// DoBackup performs a backup operation +func DoBackup(cfg *config.Config, configPath string, opts *BackupOptions) { + wd, err := os.Getwd() + if err != nil { + log.Errorf("failed to get working directory: %v", err) + return + } + + // Set defaults + if opts == nil { + opts = &BackupOptions{ + IncludeConfig: true, + IncludeAuths: true, + } + } + + // Default content selection + if !opts.IncludeEnv && !opts.IncludeConfig && !opts.IncludeAuths { + opts.IncludeConfig = true + opts.IncludeAuths = true + } + + // Determine backup directory + backupDir := opts.BackupPath + if backupDir == "" { + backupDir = filepath.Join(wd, "backup") + } + if !filepath.IsAbs(backupDir) { + backupDir = filepath.Join(wd, backupDir) + } + + // Ensure backup directory exists + if err := os.MkdirAll(backupDir, 0755); err != nil { + log.Errorf("failed to create backup directory: %v", err) + return + } + + // Generate backup name + backupName := opts.Name + if backupName == "" { + backupName = fmt.Sprintf("cliProxyApi_backup_%s", time.Now().Format("20060102_150405")) + } + + // Ensure unique filename + zipPath := filepath.Join(backupDir, backupName+".zip") + if _, err := os.Stat(zipPath); err == nil { + backupName = fmt.Sprintf("%s_%s", backupName, time.Now().Format("150405")) + zipPath = filepath.Join(backupDir, backupName+".zip") + } + + // Create zip file + zipFile, err := os.Create(zipPath) + if err != nil { + log.Errorf("failed to create backup file: %v", err) + return + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + content := BackupContent{ + Env: opts.IncludeEnv, + Config: opts.IncludeConfig, + Auths: opts.IncludeAuths, + } + + // Add .env if selected + if opts.IncludeEnv { + envPath := filepath.Join(wd, ".env") + if _, err := os.Stat(envPath); err == nil { + if err := addFileToZip(zipWriter, envPath, ".env"); err != nil { + log.Errorf("failed to add .env to backup: %v", err) + return + } + log.Info("Added .env to backup") + } + } + + // Add config.yaml if selected + if opts.IncludeConfig { + cfgPath := configPath + if cfgPath == "" { + cfgPath = filepath.Join(wd, "config.yaml") + } + if _, err := os.Stat(cfgPath); err == nil { + if err := addFileToZip(zipWriter, cfgPath, "config.yaml"); err != nil { + log.Errorf("failed to add config.yaml to backup: %v", err) + return + } + log.Info("Added config.yaml to backup") + } + } + + // Add auths folder if selected + if opts.IncludeAuths { + authDir := cfg.AuthDir + if authDir == "" { + authDir = filepath.Join(wd, "auths") + } + if _, err := os.Stat(authDir); err == nil { + if err := addDirToZip(zipWriter, authDir, "auths"); err != nil { + log.Errorf("failed to add auths to backup: %v", err) + return + } + log.Info("Added auths folder to backup") + } + } + + // Write metadata + metadata := BackupMetadata{ + Name: backupName, + Date: time.Now(), + Content: content, + } + metadataBytes, _ := json.Marshal(metadata) + metaWriter, err := zipWriter.Create("backup_metadata.json") + if err == nil { + metaWriter.Write(metadataBytes) + } + + log.Infof("Backup created successfully: %s", zipPath) +} + +// DoRestore performs a restore operation +func DoRestore(cfg *config.Config, configPath string, backupFile string, opts *RestoreOptions) { + if opts == nil { + opts = &RestoreOptions{ + AuthsMode: "overwrite", + } + } + + if opts.AuthsMode == "" { + opts.AuthsMode = "overwrite" + } + + wd, err := os.Getwd() + if err != nil { + log.Errorf("failed to get working directory: %v", err) + return + } + + // Determine backup file path + zipPath := backupFile + if !filepath.IsAbs(zipPath) { + // Check if it's in the backup directory + backupDir := filepath.Join(wd, "backup") + if opts.BackupPath != "" { + backupDir = opts.BackupPath + } + + possiblePath := filepath.Join(backupDir, zipPath) + if _, err := os.Stat(possiblePath); err == nil { + zipPath = possiblePath + } else if !strings.HasSuffix(zipPath, ".zip") { + possiblePath = filepath.Join(backupDir, zipPath+".zip") + if _, err := os.Stat(possiblePath); err == nil { + zipPath = possiblePath + } + } + } + + if _, err := os.Stat(zipPath); os.IsNotExist(err) { + log.Errorf("backup file not found: %s", zipPath) + return + } + + reader, err := zip.OpenReader(zipPath) + if err != nil { + log.Errorf("failed to open backup file: %v", err) + return + } + defer reader.Close() + + authDir := cfg.AuthDir + if authDir == "" { + authDir = filepath.Join(wd, "auths") + } + + cfgPath := configPath + if cfgPath == "" { + cfgPath = filepath.Join(wd, "config.yaml") + } + + // If overwrite mode for auths, clear the directory first + if opts.AuthsMode == "overwrite" { + authsCleared := false + for _, f := range reader.File { + if strings.HasPrefix(f.Name, "auths/") && !authsCleared { + log.Info("Clearing auths directory for overwrite...") + os.RemoveAll(authDir) + os.MkdirAll(authDir, 0755) + authsCleared = true + break + } + } + } + + for _, f := range reader.File { + // Skip metadata file + if f.Name == "backup_metadata.json" { + continue + } + + // Determine destination path + var destPath string + switch { + case f.Name == ".env": + destPath = filepath.Join(wd, ".env") + log.Info("Restoring .env (overwrite)") + case f.Name == "config.yaml": + destPath = cfgPath + log.Info("Restoring config.yaml (overwrite)") + case strings.HasPrefix(f.Name, "auths/"): + relativePath := strings.TrimPrefix(f.Name, "auths/") + if relativePath == "" { + continue + } + destPath = filepath.Join(authDir, relativePath) + if opts.AuthsMode == "incremental" { + log.Infof("Restoring auths/%s (incremental)", relativePath) + } + default: + continue + } + + // Create parent directories + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + log.Errorf("failed to create directory for %s: %v", f.Name, err) + return + } + + // Handle directory entries + if f.FileInfo().IsDir() { + os.MkdirAll(destPath, 0755) + continue + } + + // Extract file + srcFile, err := f.Open() + if err != nil { + log.Errorf("failed to open file in zip %s: %v", f.Name, err) + return + } + + destFile, err := os.Create(destPath) + if err != nil { + srcFile.Close() + log.Errorf("failed to create destination file %s: %v", destPath, err) + return + } + + _, err = io.Copy(destFile, srcFile) + srcFile.Close() + destFile.Close() + + if err != nil { + log.Errorf("failed to extract file %s: %v", f.Name, err) + return + } + } + + log.Info("Backup restored successfully!") +} + +// ListBackups lists all available backups +func ListBackups(backupPath string) { + wd, _ := os.Getwd() + backupDir := backupPath + if backupDir == "" { + backupDir = filepath.Join(wd, "backup") + } + + entries, err := os.ReadDir(backupDir) + if err != nil { + log.Errorf("failed to read backup directory: %v", err) + return + } + + fmt.Println("Available backups:") + fmt.Println("------------------") + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".zip") { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + name := strings.TrimSuffix(entry.Name(), ".zip") + fmt.Printf(" %s (%.2f KB, %s)\n", name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05")) + } +} + +// Helper functions + +func addFileToZip(zipWriter *zip.Writer, filePath, zipPath string) error { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = zipPath + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + _, err = io.Copy(writer, file) + return err +} + +func addDirToZip(zipWriter *zip.Writer, dirPath, zipBasePath string) error { + return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + + zipPath := filepath.Join(zipBasePath, relPath) + zipPath = strings.ReplaceAll(zipPath, "\\", "/") + + if info.IsDir() { + if relPath != "." { + _, err := zipWriter.Create(zipPath + "/") + return err + } + return nil + } + + return addFileToZip(zipWriter, path, zipPath) + }) +} From 838b01e2cfca6c2868a07b01f92645a60c73cef5 Mon Sep 17 00:00:00 2001 From: AoaoMH Date: Wed, 7 Jan 2026 15:21:45 +0800 Subject: [PATCH 2/3] refactor(backup): extract shared backup logic to internal/backup package - Move backup/restore/list/delete logic to shared package - API and CLI now reuse the same core implementation - Add 100MB upload size limit for API - Add path validation (API disallows absolute paths) - Improve list output to show backup content types --- internal/api/handlers/management/backup.go | 450 +++++-------------- internal/backup/backup.go | 490 +++++++++++++++++++++ internal/cmd/backup.go | 331 +++----------- 3 files changed, 659 insertions(+), 612 deletions(-) create mode 100644 internal/backup/backup.go diff --git a/internal/api/handlers/management/backup.go b/internal/api/handlers/management/backup.go index ba23e8cd7..01048b1fc 100644 --- a/internal/api/handlers/management/backup.go +++ b/internal/api/handlers/management/backup.go @@ -3,8 +3,6 @@ package management import ( - "archive/zip" - "encoding/json" "fmt" "io" "net/http" @@ -15,29 +13,14 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/backup" ) -// BackupContent represents the contents that can be backed up -type BackupContent struct { - Env bool `json:"env"` - Config bool `json:"config"` - Auths bool `json:"auths"` -} - -// BackupMetadata represents metadata about a backup -type BackupMetadata struct { - Name string `json:"name"` - Date time.Time `json:"date"` - Content BackupContent `json:"content"` - Size int64 `json:"size"` - FilePath string `json:"-"` // Internal use only -} - // BackupCreateRequest represents a request to create a backup type BackupCreateRequest struct { - Name string `json:"name"` // Custom backup name (optional) - Content BackupContent `json:"content"` // What to backup - BackupPath string `json:"backupPath"` // Custom backup directory (optional) + Name string `json:"name"` // Custom backup name (optional) + Content backup.BackupContent `json:"content"` // What to backup + BackupPath string `json:"backupPath"` // Custom backup directory (optional) } // BackupRestoreRequest represents a request to restore a backup @@ -48,13 +31,20 @@ type BackupRestoreRequest struct { // BackupListResponse represents the response for listing backups type BackupListResponse struct { - Backups []BackupMetadata `json:"backups"` - BackupPath string `json:"backupPath"` + Backups []BackupMetadataResponse `json:"backups"` + BackupPath string `json:"backupPath"` +} + +// BackupMetadataResponse is the API response format for backup metadata +type BackupMetadataResponse struct { + Name string `json:"name"` + Date time.Time `json:"date"` + Content backup.BackupContent `json:"content"` + Size int64 `json:"size"` } // getBackupDir returns the backup directory path func (h *Handler) getBackupDir() string { - // Default to ./backup relative to working directory wd, err := os.Getwd() if err != nil { wd = filepath.Dir(h.configFilePath) @@ -62,16 +52,14 @@ func (h *Handler) getBackupDir() string { return filepath.Join(wd, "backup") } -// getBackupDirFromConfig returns the backup directory, allowing custom path -func (h *Handler) getBackupDirFromConfig(customPath string) string { - if customPath != "" { - if filepath.IsAbs(customPath) { - return customPath - } +// getAuthDir returns the auth directory path +func (h *Handler) getAuthDir() string { + authDir := h.cfg.AuthDir + if authDir == "" { wd, _ := os.Getwd() - return filepath.Join(wd, customPath) + authDir = filepath.Join(wd, "auths") } - return h.getBackupDir() + return authDir } // ListBackups returns a list of all available backups @@ -84,50 +72,31 @@ func (h *Handler) ListBackups(c *gin.Context) { return } - entries, err := os.ReadDir(backupDir) + backups, err := backup.ListBackups(backupDir) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read backup directory: %v", err)}) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list backups: %v", err)}) return } - var backups []BackupMetadata - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".zip") { - continue - } - - filePath := filepath.Join(backupDir, entry.Name()) - info, err := entry.Info() - if err != nil { - continue - } - - // Try to read metadata from zip - metadata, err := readBackupMetadata(filePath) - if err != nil { - // Fallback: parse name for date - name := strings.TrimSuffix(entry.Name(), ".zip") - metadata = &BackupMetadata{ - Name: name, - Date: info.ModTime(), - Size: info.Size(), - FilePath: filePath, - Content: BackupContent{Config: true, Auths: true}, // Default assumption - } - } else { - metadata.Size = info.Size() - metadata.FilePath = filePath - } - backups = append(backups, *metadata) + // Convert to API response format + var response []BackupMetadataResponse + for _, b := range backups { + dateTime, _ := time.Parse(time.RFC3339, b.Date) + response = append(response, BackupMetadataResponse{ + Name: b.Name, + Date: dateTime, + Content: b.Content, + Size: b.Size, + }) } // Sort by date descending (newest first) - sort.Slice(backups, func(i, j int) bool { - return backups[i].Date.After(backups[j].Date) + sort.Slice(response, func(i, j int) bool { + return response[i].Date.After(response[j].Date) }) c.JSON(http.StatusOK, BackupListResponse{ - Backups: backups, + Backups: response, BackupPath: backupDir, }) } @@ -146,104 +115,46 @@ func (h *Handler) CreateBackup(c *gin.Context) { req.Content.Auths = true } - backupDir := h.getBackupDirFromConfig(req.BackupPath) + wd, _ := os.Getwd() - // Ensure backup directory exists - if err := os.MkdirAll(backupDir, 0755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create backup directory: %v", err)}) + // Validate backup path (no absolute paths allowed for API) + backupDir, err := backup.ValidateBackupPath(req.BackupPath, wd) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Generate backup name if not provided - backupName := req.Name - if backupName == "" { - backupName = fmt.Sprintf("cliProxyApi_backup_%s", time.Now().Format("20060102_150405")) - } - - // Ensure unique filename - zipPath := filepath.Join(backupDir, backupName+".zip") - if _, err := os.Stat(zipPath); err == nil { - // File exists, append timestamp - backupName = fmt.Sprintf("%s_%s", backupName, time.Now().Format("150405")) - zipPath = filepath.Join(backupDir, backupName+".zip") + // Create backup using shared package + opts := backup.BackupOptions{ + Name: req.Name, + BackupPath: backupDir, + Content: req.Content, + WorkDir: wd, + AuthDir: h.getAuthDir(), } - // Create zip file - zipFile, err := os.Create(zipPath) + backupPath, err := backup.CreateBackup(opts) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create backup file: %v", err)}) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create backup: %v", err)}) return } - defer zipFile.Close() - - zipWriter := zip.NewWriter(zipFile) - defer zipWriter.Close() - - wd, _ := os.Getwd() - - // Add .env if selected - if req.Content.Env { - envPath := filepath.Join(wd, ".env") - if _, err := os.Stat(envPath); err == nil { - if err := addFileToZip(zipWriter, envPath, ".env"); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add .env to backup: %v", err)}) - return - } - } - } - // Add config.yaml if selected - if req.Content.Config { - configPath := h.configFilePath - if configPath == "" { - configPath = filepath.Join(wd, "config.yaml") - } - if _, err := os.Stat(configPath); err == nil { - if err := addFileToZip(zipWriter, configPath, "config.yaml"); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add config.yaml to backup: %v", err)}) - return - } - } - } - - // Add auths folder if selected - if req.Content.Auths { - authDir := h.cfg.AuthDir - if authDir == "" { - authDir = filepath.Join(wd, "auths") - } - if _, err := os.Stat(authDir); err == nil { - if err := addDirToZip(zipWriter, authDir, "auths"); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add auths to backup: %v", err)}) - return - } - } - } - - // Write metadata - metadata := BackupMetadata{ - Name: backupName, - Date: time.Now(), - Content: req.Content, - } - metadataBytes, _ := json.Marshal(metadata) - metaWriter, err := zipWriter.Create("backup_metadata.json") - if err == nil { - metaWriter.Write(metadataBytes) + // Get file info for response + info, err := os.Stat(backupPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to stat backup file: %v", err)}) + return } - // Close zip to flush - zipWriter.Close() - zipFile.Close() - - // Get file size - info, _ := os.Stat(zipPath) - metadata.Size = info.Size() - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "backup": metadata, - "filepath": zipPath, + "status": "ok", + "backup": BackupMetadataResponse{ + Name: filepath.Base(backupPath), + Date: time.Now(), + Content: req.Content, + Size: info.Size(), + }, + "filepath": backupPath, }) } @@ -257,21 +168,16 @@ func (h *Handler) DeleteBackup(c *gin.Context) { backupDir := h.getBackupDir() - // Sanitize name to prevent path traversal - name = filepath.Base(name) + // Ensure .zip extension if !strings.HasSuffix(name, ".zip") { name = name + ".zip" } - zipPath := filepath.Join(backupDir, name) - - // Check if file exists - if _, err := os.Stat(zipPath); os.IsNotExist(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) - return - } - - if err := os.Remove(zipPath); err != nil { + if err := backup.DeleteBackup(backupDir, name); err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to delete backup: %v", err)}) return } @@ -333,6 +239,7 @@ func (h *Handler) RestoreBackup(c *gin.Context) { } backupDir := h.getBackupDir() + wd, _ := os.Getwd() // Sanitize name name := filepath.Base(req.Name) @@ -340,15 +247,23 @@ func (h *Handler) RestoreBackup(c *gin.Context) { name = name + ".zip" } + // Check if backup exists zipPath := filepath.Join(backupDir, name) - if _, err := os.Stat(zipPath); os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"}) return } - // Extract and restore - if err := h.restoreFromZip(zipPath, req.AuthsMode); err != nil { + // Restore using shared package + opts := backup.RestoreOptions{ + BackupPath: backupDir, + BackupName: name, + AuthsMode: req.AuthsMode, + WorkDir: wd, + AuthDir: h.getAuthDir(), + } + + if err := backup.RestoreBackup(opts); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to restore backup: %v", err)}) return } @@ -377,7 +292,14 @@ func (h *Handler) UploadAndRestoreBackup(c *gin.Context) { return } - // Save to temp file + // Limit upload size (100MB) + const maxUploadSize = 100 * 1024 * 1024 + if header.Size > maxUploadSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "file size exceeds maximum allowed size of 100MB"}) + return + } + + // Save to temp file with restrictive permissions tempFile, err := os.CreateTemp("", "backup_upload_*.zip") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"}) @@ -386,193 +308,35 @@ func (h *Handler) UploadAndRestoreBackup(c *gin.Context) { tempPath := tempFile.Name() defer os.Remove(tempPath) - if _, err := io.Copy(tempFile, file); err != nil { - tempFile.Close() - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save uploaded file"}) - return - } + // Use LimitReader to enforce size limit + limitedReader := io.LimitReader(file, maxUploadSize+1) + n, err := io.Copy(tempFile, limitedReader) tempFile.Close() - // Restore from uploaded file - if err := h.restoreFromZip(tempPath, authsMode); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to restore backup: %v", err)}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save uploaded file"}) return } - - c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "backup uploaded and restored successfully"}) -} - -// restoreFromZip extracts and restores files from a zip backup -func (h *Handler) restoreFromZip(zipPath, authsMode string) error { - reader, err := zip.OpenReader(zipPath) - if err != nil { - return fmt.Errorf("failed to open zip: %w", err) + if n > maxUploadSize { + c.JSON(http.StatusBadRequest, gin.H{"error": "file size exceeds maximum allowed size of 100MB"}) + return } - defer reader.Close() wd, _ := os.Getwd() - authDir := h.cfg.AuthDir - if authDir == "" { - authDir = filepath.Join(wd, "auths") - } - - // If overwrite mode for auths, clear the directory first - if authsMode == "overwrite" { - // We'll handle this when we encounter auths files - authsCleared := false - for _, f := range reader.File { - if strings.HasPrefix(f.Name, "auths/") && !authsCleared { - // Clear auths directory - os.RemoveAll(authDir) - os.MkdirAll(authDir, 0755) - authsCleared = true - break - } - } - } - - for _, f := range reader.File { - // Skip metadata file - if f.Name == "backup_metadata.json" { - continue - } - - // Determine destination path - var destPath string - switch { - case f.Name == ".env": - destPath = filepath.Join(wd, ".env") - case f.Name == "config.yaml": - destPath = h.configFilePath - if destPath == "" { - destPath = filepath.Join(wd, "config.yaml") - } - case strings.HasPrefix(f.Name, "auths/"): - relativePath := strings.TrimPrefix(f.Name, "auths/") - if relativePath == "" { - continue // Skip directory entry - } - destPath = filepath.Join(authDir, relativePath) - default: - continue // Skip unknown files - } - - // Create parent directories - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { - return fmt.Errorf("failed to create directory for %s: %w", f.Name, err) - } - - // Handle directory entries - if f.FileInfo().IsDir() { - os.MkdirAll(destPath, 0755) - continue - } - - // Extract file - srcFile, err := f.Open() - if err != nil { - return fmt.Errorf("failed to open file in zip %s: %w", f.Name, err) - } - - destFile, err := os.Create(destPath) - if err != nil { - srcFile.Close() - return fmt.Errorf("failed to create destination file %s: %w", destPath, err) - } - - _, err = io.Copy(destFile, srcFile) - srcFile.Close() - destFile.Close() - if err != nil { - return fmt.Errorf("failed to extract file %s: %w", f.Name, err) - } + // Restore using shared package + opts := backup.RestoreOptions{ + BackupPath: filepath.Dir(tempPath), + BackupName: filepath.Base(tempPath), + AuthsMode: authsMode, + WorkDir: wd, + AuthDir: h.getAuthDir(), } - return nil -} - -// Helper functions - -func addFileToZip(zipWriter *zip.Writer, filePath, zipPath string) error { - file, err := os.Open(filePath) - if err != nil { - return err - } - defer file.Close() - - info, err := file.Stat() - if err != nil { - return err - } - - header, err := zip.FileInfoHeader(info) - if err != nil { - return err - } - header.Name = zipPath - header.Method = zip.Deflate - - writer, err := zipWriter.CreateHeader(header) - if err != nil { - return err - } - - _, err = io.Copy(writer, file) - return err -} - -func addDirToZip(zipWriter *zip.Writer, dirPath, zipBasePath string) error { - return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Get relative path - relPath, err := filepath.Rel(dirPath, path) - if err != nil { - return err - } - - zipPath := filepath.Join(zipBasePath, relPath) - // Normalize path separators for zip - zipPath = strings.ReplaceAll(zipPath, "\\", "/") - - if info.IsDir() { - // Add directory entry - if relPath != "." { - _, err := zipWriter.Create(zipPath + "/") - return err - } - return nil - } - - return addFileToZip(zipWriter, path, zipPath) - }) -} - -func readBackupMetadata(zipPath string) (*BackupMetadata, error) { - reader, err := zip.OpenReader(zipPath) - if err != nil { - return nil, err - } - defer reader.Close() - - for _, f := range reader.File { - if f.Name == "backup_metadata.json" { - rc, err := f.Open() - if err != nil { - return nil, err - } - defer rc.Close() - - var metadata BackupMetadata - if err := json.NewDecoder(rc).Decode(&metadata); err != nil { - return nil, err - } - return &metadata, nil - } + if err := backup.RestoreBackup(opts); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to restore backup: %v", err)}) + return } - return nil, fmt.Errorf("metadata not found") + c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "backup uploaded and restored successfully"}) } diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 000000000..f87462e05 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,490 @@ +// Package backup provides secure backup and restore functionality for CLI Proxy API. +// It handles zip archive creation/extraction with protection against Zip Slip attacks +// and other security vulnerabilities. +package backup + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// BackupContent specifies what to include in a backup +type BackupContent struct { + Env bool `json:"env"` + Config bool `json:"config"` + Auths bool `json:"auths"` +} + +// BackupMetadata contains information about a backup archive +type BackupMetadata struct { + Name string `json:"name"` + Date string `json:"date"` + Size int64 `json:"size"` + Content BackupContent `json:"content"` +} + +// BackupOptions configures backup creation +type BackupOptions struct { + Name string + BackupPath string + Content BackupContent + WorkDir string + AuthDir string +} + +// RestoreOptions configures backup restoration +type RestoreOptions struct { + BackupPath string + BackupName string + AuthsMode string // "overwrite" or "incremental" + WorkDir string + AuthDir string +} + +// SafeJoinPath safely joins a base directory with a relative path from a zip entry. +// It normalizes the path and validates that the result stays within the base directory. +// Returns an error if the path would escape the base directory (Zip Slip attack). +func SafeJoinPath(baseDir, zipEntryName string) (string, error) { + // Normalize zip entry name (zip uses forward slashes) + cleanZipPath := path.Clean(zipEntryName) + + // Reject absolute paths and paths starting with .. + if path.IsAbs(cleanZipPath) || strings.HasPrefix(cleanZipPath, "..") { + return "", fmt.Errorf("invalid zip entry path: %s", zipEntryName) + } + + // Convert to OS-specific path and join with base + relPath := filepath.FromSlash(cleanZipPath) + targetPath := filepath.Join(baseDir, relPath) + + // Clean the target path + targetPath = filepath.Clean(targetPath) + + // Verify the target is still under base directory using filepath.Rel + rel, err := filepath.Rel(baseDir, targetPath) + if err != nil { + return "", fmt.Errorf("failed to compute relative path: %w", err) + } + + // If rel starts with "..", the target is outside base + if strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("zip slip vulnerability detected: path %q escapes base directory", zipEntryName) + } + + return targetPath, nil +} + +// AddFileToZip adds a single file to a zip archive +func AddFileToZip(zipWriter *zip.Writer, filePath, zipPath string) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", filePath, err) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to stat file %s: %w", filePath, err) + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return fmt.Errorf("failed to create zip header for %s: %w", filePath, err) + } + header.Name = zipPath + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return fmt.Errorf("failed to create zip entry for %s: %w", zipPath, err) + } + + if _, err = io.Copy(writer, file); err != nil { + return fmt.Errorf("failed to write file %s to zip: %w", filePath, err) + } + + return nil +} + +// AddDirToZip recursively adds a directory to a zip archive +func AddDirToZip(zipWriter *zip.Writer, dirPath, zipBasePath string) error { + return filepath.Walk(dirPath, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip symlinks for security + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + + relPath, err := filepath.Rel(dirPath, filePath) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Use forward slashes in zip paths + zipPath := path.Join(zipBasePath, filepath.ToSlash(relPath)) + + if info.IsDir() { + if relPath != "." { + _, err := zipWriter.Create(zipPath + "/") + return err + } + return nil + } + + return AddFileToZip(zipWriter, filePath, zipPath) + }) +} + +// WriteMetadata writes backup metadata to the zip archive +func WriteMetadata(zipWriter *zip.Writer, metadata BackupMetadata) error { + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("failed to marshal backup metadata: %w", err) + } + + metaWriter, err := zipWriter.Create("backup_metadata.json") + if err != nil { + return fmt.Errorf("failed to create metadata file in zip: %w", err) + } + + if _, err := metaWriter.Write(metadataBytes); err != nil { + return fmt.Errorf("failed to write metadata to zip: %w", err) + } + + return nil +} + +// ExtractFile safely extracts a single file from a zip entry to the target path. +// It applies safe permissions and validates the target path. +func ExtractFile(f *zip.File, targetPath string, perm os.FileMode) error { + // Ensure parent directory exists + parentDir := filepath.Dir(targetPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } + + // Check if parent is a symlink (security) + if isSymlink, err := isPathSymlink(parentDir); err != nil { + return fmt.Errorf("failed to check parent directory: %w", err) + } else if isSymlink { + return fmt.Errorf("parent directory is a symlink, refusing to write for security") + } + + // Open zip file entry + rc, err := f.Open() + if err != nil { + return fmt.Errorf("failed to open zip entry: %w", err) + } + defer rc.Close() + + // Create target file with safe permissions + outFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer outFile.Close() + + // Limit read size to prevent zip bombs (100MB per file) + const maxFileSize = 100 * 1024 * 1024 + limitedReader := io.LimitReader(rc, maxFileSize+1) + + n, err := io.Copy(outFile, limitedReader) + if err != nil { + return fmt.Errorf("failed to extract file: %w", err) + } + if n > maxFileSize { + os.Remove(targetPath) + return fmt.Errorf("file exceeds maximum allowed size of 100MB") + } + + return nil +} + +// isPathSymlink checks if any component of the path is a symlink +func isPathSymlink(path string) (bool, error) { + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return info.Mode()&os.ModeSymlink != 0, nil +} + +// CreateBackup creates a backup archive with the specified options +func CreateBackup(opts BackupOptions) (string, error) { + // Validate backup directory + backupDir := opts.BackupPath + if backupDir == "" { + backupDir = filepath.Join(opts.WorkDir, "backup") + } + + // Ensure backup directory exists + if err := os.MkdirAll(backupDir, 0755); err != nil { + return "", fmt.Errorf("failed to create backup directory: %w", err) + } + + // Generate backup filename + backupName := opts.Name + if backupName == "" { + backupName = fmt.Sprintf("cliProxyApi_backup_%s.zip", time.Now().Format("20060102_150405")) + } + if !strings.HasSuffix(backupName, ".zip") { + backupName += ".zip" + } + + backupPath := filepath.Join(backupDir, backupName) + + // Create zip file with restrictive permissions + zipFile, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return "", fmt.Errorf("failed to create backup file: %w", err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Backup .env if requested + if opts.Content.Env { + envPath := filepath.Join(opts.WorkDir, ".env") + if _, err := os.Stat(envPath); err == nil { + if err := AddFileToZip(zipWriter, envPath, ".env"); err != nil { + return "", fmt.Errorf("failed to backup .env: %w", err) + } + } + } + + // Backup config.yaml if requested + if opts.Content.Config { + configPath := filepath.Join(opts.WorkDir, "config.yaml") + if _, err := os.Stat(configPath); err == nil { + if err := AddFileToZip(zipWriter, configPath, "config.yaml"); err != nil { + return "", fmt.Errorf("failed to backup config.yaml: %w", err) + } + } + } + + // Backup auths directory if requested + if opts.Content.Auths { + if _, err := os.Stat(opts.AuthDir); err == nil { + if err := AddDirToZip(zipWriter, opts.AuthDir, "auths"); err != nil { + return "", fmt.Errorf("failed to backup auths directory: %w", err) + } + } + } + + // Write metadata + metadata := BackupMetadata{ + Name: backupName, + Date: time.Now().Format(time.RFC3339), + Content: opts.Content, + } + if err := WriteMetadata(zipWriter, metadata); err != nil { + return "", err + } + + return backupPath, nil +} + +// RestoreBackup restores a backup archive with the specified options +func RestoreBackup(opts RestoreOptions) error { + backupPath := filepath.Join(opts.BackupPath, opts.BackupName) + + // Open the zip file + zipReader, err := zip.OpenReader(backupPath) + if err != nil { + return fmt.Errorf("failed to open backup file: %w", err) + } + defer zipReader.Close() + + // If overwrite mode for auths, remove existing directory first + if opts.AuthsMode == "overwrite" { + // Safety check: don't remove if authDir looks suspicious + cleanAuthDir := filepath.Clean(opts.AuthDir) + if cleanAuthDir == "" || cleanAuthDir == "/" || cleanAuthDir == "." || + cleanAuthDir == filepath.VolumeName(cleanAuthDir)+"\\" { + return fmt.Errorf("refusing to remove potentially dangerous auth directory: %s", opts.AuthDir) + } + + if err := os.RemoveAll(opts.AuthDir); err != nil { + return fmt.Errorf("failed to remove existing auth directory: %w", err) + } + if err := os.MkdirAll(opts.AuthDir, 0755); err != nil { + return fmt.Errorf("failed to recreate auth directory: %w", err) + } + } + + // Extract files + for _, f := range zipReader.File { + if f.FileInfo().IsDir() { + continue + } + + var destPath string + var perm os.FileMode = 0644 + + switch { + case f.Name == ".env": + destPath = filepath.Join(opts.WorkDir, ".env") + perm = 0600 // Sensitive file + case f.Name == "config.yaml": + destPath = filepath.Join(opts.WorkDir, "config.yaml") + perm = 0600 // Sensitive file + case strings.HasPrefix(f.Name, "auths/"): + relativePath := strings.TrimPrefix(f.Name, "auths/") + if relativePath == "" { + continue + } + // Use SafeJoinPath to prevent Zip Slip + var err error + destPath, err = SafeJoinPath(opts.AuthDir, relativePath) + if err != nil { + return fmt.Errorf("invalid auth file path in backup: %w", err) + } + perm = 0600 // Auth files are sensitive + case f.Name == "backup_metadata.json": + continue // Skip metadata file + default: + continue // Skip unknown files + } + + if err := ExtractFile(f, destPath, perm); err != nil { + return fmt.Errorf("failed to extract %s: %w", f.Name, err) + } + } + + return nil +} + +// ListBackups returns a list of backup metadata from the backup directory +func ListBackups(backupDir string) ([]BackupMetadata, error) { + var backups []BackupMetadata + + entries, err := os.ReadDir(backupDir) + if err != nil { + if os.IsNotExist(err) { + return backups, nil + } + return nil, fmt.Errorf("failed to read backup directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".zip") { + continue + } + + zipPath := filepath.Join(backupDir, entry.Name()) + metadata, err := ReadBackupMetadata(zipPath) + if err != nil { + // If we can't read metadata, create basic info from file + info, _ := entry.Info() + metadata = BackupMetadata{ + Name: entry.Name(), + Date: info.ModTime().Format(time.RFC3339), + Size: info.Size(), + } + } else { + // Update size from actual file + if info, err := entry.Info(); err == nil { + metadata.Size = info.Size() + } + } + + backups = append(backups, metadata) + } + + return backups, nil +} + +// ReadBackupMetadata reads metadata from a backup archive +func ReadBackupMetadata(zipPath string) (BackupMetadata, error) { + var metadata BackupMetadata + + zipReader, err := zip.OpenReader(zipPath) + if err != nil { + return metadata, err + } + defer zipReader.Close() + + for _, f := range zipReader.File { + if f.Name == "backup_metadata.json" { + rc, err := f.Open() + if err != nil { + return metadata, err + } + defer rc.Close() + + if err := json.NewDecoder(rc).Decode(&metadata); err != nil { + return metadata, err + } + return metadata, nil + } + } + + return metadata, fmt.Errorf("metadata not found in backup") +} + +// DeleteBackup removes a backup file +func DeleteBackup(backupDir, backupName string) error { + // Validate backup name to prevent path traversal + if strings.Contains(backupName, "/") || strings.Contains(backupName, "\\") || + strings.Contains(backupName, "..") { + return fmt.Errorf("invalid backup name") + } + + backupPath := filepath.Join(backupDir, backupName) + + // Verify it's actually under the backup directory + cleanPath := filepath.Clean(backupPath) + cleanDir := filepath.Clean(backupDir) + rel, err := filepath.Rel(cleanDir, cleanPath) + if err != nil || strings.HasPrefix(rel, "..") { + return fmt.Errorf("invalid backup path") + } + + return os.Remove(backupPath) +} + +// ValidateBackupPath validates a backup path for API use (no absolute paths allowed) +func ValidateBackupPath(customPath, workDir string) (string, error) { + if customPath == "" { + return filepath.Join(workDir, "backup"), nil + } + + // API: reject absolute paths for security + if filepath.IsAbs(customPath) { + return "", fmt.Errorf("absolute paths are not allowed for backup directory for security reasons") + } + + // Check for path traversal attempts + if strings.Contains(customPath, "..") { + return "", fmt.Errorf("path traversal not allowed in backup directory") + } + + return filepath.Join(workDir, customPath), nil +} + +// ValidateBackupPathCLI validates a backup path for CLI use (absolute paths allowed) +func ValidateBackupPathCLI(customPath, workDir string) string { + if customPath == "" { + return filepath.Join(workDir, "backup") + } + + if filepath.IsAbs(customPath) { + return filepath.Clean(customPath) + } + + return filepath.Join(workDir, customPath) +} diff --git a/internal/cmd/backup.go b/internal/cmd/backup.go index 6625b3217..51c57d6a7 100644 --- a/internal/cmd/backup.go +++ b/internal/cmd/backup.go @@ -2,33 +2,16 @@ package cmd import ( - "archive/zip" - "encoding/json" "fmt" - "io" "os" "path/filepath" "strings" - "time" + "github.com/router-for-me/CLIProxyAPI/v6/internal/backup" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" log "github.com/sirupsen/logrus" ) -// BackupContent represents the contents that can be backed up -type BackupContent struct { - Env bool `json:"env"` - Config bool `json:"config"` - Auths bool `json:"auths"` -} - -// BackupMetadata represents metadata about a backup -type BackupMetadata struct { - Name string `json:"name"` - Date time.Time `json:"date"` - Content BackupContent `json:"content"` -} - // BackupOptions contains options for backup operations type BackupOptions struct { Name string @@ -66,106 +49,35 @@ func DoBackup(cfg *config.Config, configPath string, opts *BackupOptions) { opts.IncludeAuths = true } - // Determine backup directory - backupDir := opts.BackupPath - if backupDir == "" { - backupDir = filepath.Join(wd, "backup") - } - if !filepath.IsAbs(backupDir) { - backupDir = filepath.Join(wd, backupDir) - } - - // Ensure backup directory exists - if err := os.MkdirAll(backupDir, 0755); err != nil { - log.Errorf("failed to create backup directory: %v", err) - return - } + // Determine backup directory (CLI allows absolute paths) + backupDir := backup.ValidateBackupPathCLI(opts.BackupPath, wd) - // Generate backup name - backupName := opts.Name - if backupName == "" { - backupName = fmt.Sprintf("cliProxyApi_backup_%s", time.Now().Format("20060102_150405")) + // Determine auth directory + authDir := cfg.AuthDir + if authDir == "" { + authDir = filepath.Join(wd, "auths") } - // Ensure unique filename - zipPath := filepath.Join(backupDir, backupName+".zip") - if _, err := os.Stat(zipPath); err == nil { - backupName = fmt.Sprintf("%s_%s", backupName, time.Now().Format("150405")) - zipPath = filepath.Join(backupDir, backupName+".zip") + // Create backup using shared package + backupOpts := backup.BackupOptions{ + Name: opts.Name, + BackupPath: backupDir, + Content: backup.BackupContent{ + Env: opts.IncludeEnv, + Config: opts.IncludeConfig, + Auths: opts.IncludeAuths, + }, + WorkDir: wd, + AuthDir: authDir, } - // Create zip file - zipFile, err := os.Create(zipPath) + backupPath, err := backup.CreateBackup(backupOpts) if err != nil { - log.Errorf("failed to create backup file: %v", err) + log.Errorf("failed to create backup: %v", err) return } - defer zipFile.Close() - zipWriter := zip.NewWriter(zipFile) - defer zipWriter.Close() - - content := BackupContent{ - Env: opts.IncludeEnv, - Config: opts.IncludeConfig, - Auths: opts.IncludeAuths, - } - - // Add .env if selected - if opts.IncludeEnv { - envPath := filepath.Join(wd, ".env") - if _, err := os.Stat(envPath); err == nil { - if err := addFileToZip(zipWriter, envPath, ".env"); err != nil { - log.Errorf("failed to add .env to backup: %v", err) - return - } - log.Info("Added .env to backup") - } - } - - // Add config.yaml if selected - if opts.IncludeConfig { - cfgPath := configPath - if cfgPath == "" { - cfgPath = filepath.Join(wd, "config.yaml") - } - if _, err := os.Stat(cfgPath); err == nil { - if err := addFileToZip(zipWriter, cfgPath, "config.yaml"); err != nil { - log.Errorf("failed to add config.yaml to backup: %v", err) - return - } - log.Info("Added config.yaml to backup") - } - } - - // Add auths folder if selected - if opts.IncludeAuths { - authDir := cfg.AuthDir - if authDir == "" { - authDir = filepath.Join(wd, "auths") - } - if _, err := os.Stat(authDir); err == nil { - if err := addDirToZip(zipWriter, authDir, "auths"); err != nil { - log.Errorf("failed to add auths to backup: %v", err) - return - } - log.Info("Added auths folder to backup") - } - } - - // Write metadata - metadata := BackupMetadata{ - Name: backupName, - Date: time.Now(), - Content: content, - } - metadataBytes, _ := json.Marshal(metadata) - metaWriter, err := zipWriter.Create("backup_metadata.json") - if err == nil { - metaWriter.Write(metadataBytes) - } - - log.Infof("Backup created successfully: %s", zipPath) + log.Infof("Backup created successfully: %s", backupPath) } // DoRestore performs a restore operation @@ -188,13 +100,13 @@ func DoRestore(cfg *config.Config, configPath string, backupFile string, opts *R // Determine backup file path zipPath := backupFile + backupDir := filepath.Join(wd, "backup") + if opts.BackupPath != "" { + backupDir = backup.ValidateBackupPathCLI(opts.BackupPath, wd) + } + if !filepath.IsAbs(zipPath) { // Check if it's in the backup directory - backupDir := filepath.Join(wd, "backup") - if opts.BackupPath != "" { - backupDir = opts.BackupPath - } - possiblePath := filepath.Join(backupDir, zipPath) if _, err := os.Stat(possiblePath); err == nil { zipPath = possiblePath @@ -211,99 +123,24 @@ func DoRestore(cfg *config.Config, configPath string, backupFile string, opts *R return } - reader, err := zip.OpenReader(zipPath) - if err != nil { - log.Errorf("failed to open backup file: %v", err) - return - } - defer reader.Close() - + // Determine auth directory authDir := cfg.AuthDir if authDir == "" { authDir = filepath.Join(wd, "auths") } - cfgPath := configPath - if cfgPath == "" { - cfgPath = filepath.Join(wd, "config.yaml") + // Restore using shared package + restoreOpts := backup.RestoreOptions{ + BackupPath: filepath.Dir(zipPath), + BackupName: filepath.Base(zipPath), + AuthsMode: opts.AuthsMode, + WorkDir: wd, + AuthDir: authDir, } - // If overwrite mode for auths, clear the directory first - if opts.AuthsMode == "overwrite" { - authsCleared := false - for _, f := range reader.File { - if strings.HasPrefix(f.Name, "auths/") && !authsCleared { - log.Info("Clearing auths directory for overwrite...") - os.RemoveAll(authDir) - os.MkdirAll(authDir, 0755) - authsCleared = true - break - } - } - } - - for _, f := range reader.File { - // Skip metadata file - if f.Name == "backup_metadata.json" { - continue - } - - // Determine destination path - var destPath string - switch { - case f.Name == ".env": - destPath = filepath.Join(wd, ".env") - log.Info("Restoring .env (overwrite)") - case f.Name == "config.yaml": - destPath = cfgPath - log.Info("Restoring config.yaml (overwrite)") - case strings.HasPrefix(f.Name, "auths/"): - relativePath := strings.TrimPrefix(f.Name, "auths/") - if relativePath == "" { - continue - } - destPath = filepath.Join(authDir, relativePath) - if opts.AuthsMode == "incremental" { - log.Infof("Restoring auths/%s (incremental)", relativePath) - } - default: - continue - } - - // Create parent directories - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { - log.Errorf("failed to create directory for %s: %v", f.Name, err) - return - } - - // Handle directory entries - if f.FileInfo().IsDir() { - os.MkdirAll(destPath, 0755) - continue - } - - // Extract file - srcFile, err := f.Open() - if err != nil { - log.Errorf("failed to open file in zip %s: %v", f.Name, err) - return - } - - destFile, err := os.Create(destPath) - if err != nil { - srcFile.Close() - log.Errorf("failed to create destination file %s: %v", destPath, err) - return - } - - _, err = io.Copy(destFile, srcFile) - srcFile.Close() - destFile.Close() - - if err != nil { - log.Errorf("failed to extract file %s: %v", f.Name, err) - return - } + if err := backup.RestoreBackup(restoreOpts); err != nil { + log.Errorf("failed to restore backup: %v", err) + return } log.Info("Backup restored successfully!") @@ -312,87 +149,43 @@ func DoRestore(cfg *config.Config, configPath string, backupFile string, opts *R // ListBackups lists all available backups func ListBackups(backupPath string) { wd, _ := os.Getwd() - backupDir := backupPath - if backupDir == "" { - backupDir = filepath.Join(wd, "backup") - } + backupDir := backup.ValidateBackupPathCLI(backupPath, wd) - entries, err := os.ReadDir(backupDir) + backups, err := backup.ListBackups(backupDir) if err != nil { - log.Errorf("failed to read backup directory: %v", err) + log.Errorf("failed to list backups: %v", err) return } fmt.Println("Available backups:") fmt.Println("------------------") - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".zip") { - continue - } - - info, err := entry.Info() - if err != nil { - continue - } - - name := strings.TrimSuffix(entry.Name(), ".zip") - fmt.Printf(" %s (%.2f KB, %s)\n", name, float64(info.Size())/1024, info.ModTime().Format("2006-01-02 15:04:05")) - } -} - -// Helper functions - -func addFileToZip(zipWriter *zip.Writer, filePath, zipPath string) error { - file, err := os.Open(filePath) - if err != nil { - return err - } - defer file.Close() - - info, err := file.Stat() - if err != nil { - return err - } - - header, err := zip.FileInfoHeader(info) - if err != nil { - return err - } - header.Name = zipPath - header.Method = zip.Deflate - - writer, err := zipWriter.CreateHeader(header) - if err != nil { - return err + if len(backups) == 0 { + fmt.Println(" (no backups found)") + return } - _, err = io.Copy(writer, file) - return err -} - -func addDirToZip(zipWriter *zip.Writer, dirPath, zipBasePath string) error { - return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + for _, b := range backups { + contentParts := []string{} + if b.Content.Env { + contentParts = append(contentParts, ".env") } - - relPath, err := filepath.Rel(dirPath, path) - if err != nil { - return err + if b.Content.Config { + contentParts = append(contentParts, "config") } - - zipPath := filepath.Join(zipBasePath, relPath) - zipPath = strings.ReplaceAll(zipPath, "\\", "/") - - if info.IsDir() { - if relPath != "." { - _, err := zipWriter.Create(zipPath + "/") - return err - } - return nil + if b.Content.Auths { + contentParts = append(contentParts, "auths") + } + contentStr := strings.Join(contentParts, ", ") + if contentStr == "" { + contentStr = "unknown" } - return addFileToZip(zipWriter, path, zipPath) - }) + fmt.Printf(" %s (%.2f KB, %s) [%s]\n", + b.Name, + float64(b.Size)/1024, + b.Date, + contentStr, + ) + } } From e624f70512a4625cfbd3610b9d1341865340a737 Mon Sep 17 00:00:00 2001 From: AoaoMH Date: Wed, 7 Jan 2026 16:42:47 +0800 Subject: [PATCH 3/3] fix(backup): improve error handling and API design consistency - Handle entry.Info() errors in ListBackups to prevent nil pointer panic - Handle os.Getwd() errors consistently across all backup handlers - Handle time.Parse() errors in ListBackups API handler - Change DeleteBackup and DownloadBackup routes to use path params (DELETE /backups/:name, GET /backups/download/:name) for RESTful design - Add ResolveAuthDir() helper to centralize auth directory resolution - Remove duplicated authDir logic from CLI and API handlers --- internal/api/handlers/management/backup.go | 39 +++++++++++++++------- internal/api/server.go | 4 +-- internal/backup/backup.go | 16 ++++++++- internal/cmd/backup.go | 20 +++++------ 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/internal/api/handlers/management/backup.go b/internal/api/handlers/management/backup.go index 01048b1fc..13b140a04 100644 --- a/internal/api/handlers/management/backup.go +++ b/internal/api/handlers/management/backup.go @@ -52,14 +52,13 @@ func (h *Handler) getBackupDir() string { return filepath.Join(wd, "backup") } -// getAuthDir returns the auth directory path +// getAuthDir returns the auth directory path using the centralized helper func (h *Handler) getAuthDir() string { - authDir := h.cfg.AuthDir - if authDir == "" { - wd, _ := os.Getwd() - authDir = filepath.Join(wd, "auths") + wd, err := os.Getwd() + if err != nil { + wd = filepath.Dir(h.configFilePath) } - return authDir + return backup.ResolveAuthDir(h.cfg.AuthDir, wd) } // ListBackups returns a list of all available backups @@ -81,7 +80,11 @@ func (h *Handler) ListBackups(c *gin.Context) { // Convert to API response format var response []BackupMetadataResponse for _, b := range backups { - dateTime, _ := time.Parse(time.RFC3339, b.Date) + dateTime, err := time.Parse(time.RFC3339, b.Date) + if err != nil { + // Use zero time if parsing fails, but still include the backup + dateTime = time.Time{} + } response = append(response, BackupMetadataResponse{ Name: b.Name, Date: dateTime, @@ -115,7 +118,11 @@ func (h *Handler) CreateBackup(c *gin.Context) { req.Content.Auths = true } - wd, _ := os.Getwd() + wd, err := os.Getwd() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get working directory"}) + return + } // Validate backup path (no absolute paths allowed for API) backupDir, err := backup.ValidateBackupPath(req.BackupPath, wd) @@ -160,7 +167,7 @@ func (h *Handler) CreateBackup(c *gin.Context) { // DeleteBackup deletes a backup func (h *Handler) DeleteBackup(c *gin.Context) { - name := c.Query("name") + name := c.Param("name") if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "backup name is required"}) return @@ -187,7 +194,7 @@ func (h *Handler) DeleteBackup(c *gin.Context) { // DownloadBackup downloads a backup file func (h *Handler) DownloadBackup(c *gin.Context) { - name := c.Query("name") + name := c.Param("name") if name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "backup name is required"}) return @@ -239,7 +246,11 @@ func (h *Handler) RestoreBackup(c *gin.Context) { } backupDir := h.getBackupDir() - wd, _ := os.Getwd() + wd, err := os.Getwd() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get working directory"}) + return + } // Sanitize name name := filepath.Base(req.Name) @@ -322,7 +333,11 @@ func (h *Handler) UploadAndRestoreBackup(c *gin.Context) { return } - wd, _ := os.Getwd() + wd, err := os.Getwd() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get working directory"}) + return + } // Restore using shared package opts := backup.RestoreOptions{ diff --git a/internal/api/server.go b/internal/api/server.go index 17fa5f44d..d66574a4a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -626,8 +626,8 @@ func (s *Server) registerManagementRoutes() { // Backup management routes mgmt.GET("/backups", s.mgmt.ListBackups) mgmt.POST("/backups", s.mgmt.CreateBackup) - mgmt.DELETE("/backups", s.mgmt.DeleteBackup) - mgmt.GET("/backups/download", s.mgmt.DownloadBackup) + mgmt.DELETE("/backups/:name", s.mgmt.DeleteBackup) + mgmt.GET("/backups/download/:name", s.mgmt.DownloadBackup) mgmt.POST("/backups/restore", s.mgmt.RestoreBackup) mgmt.POST("/backups/upload", s.mgmt.UploadAndRestoreBackup) } diff --git a/internal/backup/backup.go b/internal/backup/backup.go index f87462e05..39561e752 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -389,7 +389,11 @@ func ListBackups(backupDir string) ([]BackupMetadata, error) { metadata, err := ReadBackupMetadata(zipPath) if err != nil { // If we can't read metadata, create basic info from file - info, _ := entry.Info() + info, errInfo := entry.Info() + if errInfo != nil { + // Cannot get file info, skip this backup entry + continue + } metadata = BackupMetadata{ Name: entry.Name(), Date: info.ModTime().Format(time.RFC3339), @@ -488,3 +492,13 @@ func ValidateBackupPathCLI(customPath, workDir string) string { return filepath.Join(workDir, customPath) } + +// ResolveAuthDir returns the auth directory path, using the provided configAuthDir +// if set, otherwise defaulting to "auths" under the working directory. +// This centralizes the auth directory resolution logic used by both CLI and API. +func ResolveAuthDir(configAuthDir, workDir string) string { + if configAuthDir != "" { + return configAuthDir + } + return filepath.Join(workDir, "auths") +} diff --git a/internal/cmd/backup.go b/internal/cmd/backup.go index 51c57d6a7..1bdf8ac43 100644 --- a/internal/cmd/backup.go +++ b/internal/cmd/backup.go @@ -52,11 +52,8 @@ func DoBackup(cfg *config.Config, configPath string, opts *BackupOptions) { // Determine backup directory (CLI allows absolute paths) backupDir := backup.ValidateBackupPathCLI(opts.BackupPath, wd) - // Determine auth directory - authDir := cfg.AuthDir - if authDir == "" { - authDir = filepath.Join(wd, "auths") - } + // Determine auth directory using centralized helper + authDir := backup.ResolveAuthDir(cfg.AuthDir, wd) // Create backup using shared package backupOpts := backup.BackupOptions{ @@ -123,11 +120,8 @@ func DoRestore(cfg *config.Config, configPath string, backupFile string, opts *R return } - // Determine auth directory - authDir := cfg.AuthDir - if authDir == "" { - authDir = filepath.Join(wd, "auths") - } + // Determine auth directory using centralized helper + authDir := backup.ResolveAuthDir(cfg.AuthDir, wd) // Restore using shared package restoreOpts := backup.RestoreOptions{ @@ -148,7 +142,11 @@ func DoRestore(cfg *config.Config, configPath string, backupFile string, opts *R // ListBackups lists all available backups func ListBackups(backupPath string) { - wd, _ := os.Getwd() + wd, err := os.Getwd() + if err != nil { + log.Errorf("failed to get working directory: %v", err) + return + } backupDir := backup.ValidateBackupPathCLI(backupPath, wd) backups, err := backup.ListBackups(backupDir)