package handler import ( "encoding/json" "fmt" "net/http" "net/url" "os" "path/filepath" "time" "github.com/go-chi/chi/v5" "github.com/johnzastrow/actalog/internal/domain" "github.com/johnzastrow/actalog/pkg/middleware" ) // BackupHandler handles backup/restore endpoints type BackupHandler struct { backupService domain.BackupService auditLogRepo domain.AuditLogRepository } // NewBackupHandler creates a new backup handler func NewBackupHandler(backupService domain.BackupService, auditLogRepo domain.AuditLogRepository) *BackupHandler { return &BackupHandler{ backupService: backupService, auditLogRepo: auditLogRepo, } } // getFilenameParam extracts and URL-decodes the filename from the URL path func getFilenameParam(r *http.Request) (string, error) { filename := chi.URLParam(r, "filename") if filename == "" { return "", fmt.Errorf("filename is required") } // URL-decode the filename (handles spaces as %20, etc.) decoded, err := url.PathUnescape(filename) if err != nil { return "", fmt.Errorf("invalid filename encoding: %w", err) } return decoded, nil } // CreateBackup creates a new database backup // @Summary Create backup (Admin) // @Description Create a new database backup // @Tags backups // @Accept json // @Produce json // @Security BearerAuth // @Success 201 {object} map[string]interface{} "Backup created with filename and metadata" // @Failure 401 {object} ErrorResponse "Unauthorized" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/backups [post] func (h *BackupHandler) CreateBackup(w http.ResponseWriter, r *http.Request) { // Extract user ID from JWT token in context userID, ok := middleware.GetUserID(r.Context()) if !ok { respondError(w, http.StatusUnauthorized, "Unauthorized") return } // Create backup filename, err := h.backupService.CreateBackup(userID) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create backup: %v", err)) return } // Get metadata for the created backup metadata, err := h.backupService.GetBackupMetadata(filename) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get backup metadata: %v", err)) return } respondJSON(w, http.StatusCreated, map[string]interface{}{ "message": "Backup created successfully", "filename": filename, "metadata": metadata, }) } // ListBackups returns a list of all available backups // @Summary List backups (Admin) // @Description Get a list of all available backup files // @Tags backups // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} map[string]interface{} "List of backups with count" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/backups [get] func (h *BackupHandler) ListBackups(w http.ResponseWriter, r *http.Request) { backups, err := h.backupService.ListBackups() if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list backups: %v", err)) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "backups": backups, "count": len(backups), }) } // GetBackupMetadata returns metadata for a specific backup // @Summary Get backup metadata (Admin) // @Description Get metadata information for a specific backup file // @Tags backups // @Accept json // @Produce json // @Security BearerAuth // @Param filename path string true "Backup filename" // @Success 200 {object} domain.BackupMetadata "Backup metadata" // @Failure 400 {object} ErrorResponse "Invalid filename" // @Failure 404 {object} ErrorResponse "Backup not found" // @Router /admin/backups/{filename}/metadata [get] func (h *BackupHandler) GetBackupMetadata(w http.ResponseWriter, r *http.Request) { filename, err := getFilenameParam(r) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } // Validate filename (prevent directory traversal) if !isValidFilename(filename) { respondError(w, http.StatusBadRequest, "Invalid filename") return } metadata, err := h.backupService.GetBackupMetadata(filename) if err != nil { respondError(w, http.StatusNotFound, fmt.Sprintf("Backup not found: %v", err)) return } respondJSON(w, http.StatusOK, metadata) } // DownloadBackup downloads a backup file // @Summary Download backup (Admin) // @Description Download a backup file // @Tags backups // @Accept json // @Produce application/zip // @Security BearerAuth // @Param filename path string true "Backup filename" // @Success 200 {file} file "Backup ZIP file" // @Failure 400 {object} ErrorResponse "Invalid filename" // @Failure 401 {object} ErrorResponse "Unauthorized" // @Failure 404 {object} ErrorResponse "Backup not found" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/backups/{filename} [get] func (h *BackupHandler) DownloadBackup(w http.ResponseWriter, r *http.Request) { filename, err := getFilenameParam(r) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } // Validate filename (prevent directory traversal) if !isValidFilename(filename) { respondError(w, http.StatusBadRequest, "Invalid filename") return } // Extract user ID from JWT token in context userID, ok := middleware.GetUserID(r.Context()) if !ok { respondError(w, http.StatusUnauthorized, "Unauthorized") return } filePath, err := h.backupService.DownloadBackup(filename) if err != nil { respondError(w, http.StatusNotFound, fmt.Sprintf("Backup not found: %v", err)) return } // Get file info fileInfo, err := os.Stat(filePath) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get file info: %v", err)) return } // Set response headers for file download w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size())) // Open and serve the file file, err := os.Open(filePath) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to open file: %v", err)) return } defer file.Close() // Copy file contents to response http.ServeContent(w, r, filename, fileInfo.ModTime(), file) // Create audit log for download go func() { if err := h.auditLogRepo.Create(&domain.AuditLog{ UserID: &userID, EventType: "backup_downloaded", Details: stringPtr(fmt.Sprintf("Downloaded backup: %s (size: %d bytes)", filename, fileInfo.Size())), CreatedAt: time.Now(), }); err != nil { fmt.Printf("Warning: failed to create audit log for backup download: %v\n", err) } }() } // DeleteBackup deletes a backup file // @Summary Delete backup (Admin) // @Description Delete a backup file // @Tags backups // @Accept json // @Produce json // @Security BearerAuth // @Param filename path string true "Backup filename" // @Success 200 {object} map[string]interface{} "Success message with filename" // @Failure 400 {object} ErrorResponse "Invalid filename" // @Failure 401 {object} ErrorResponse "Unauthorized" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/backups/{filename} [delete] func (h *BackupHandler) DeleteBackup(w http.ResponseWriter, r *http.Request) { filename, err := getFilenameParam(r) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } // Validate filename (prevent directory traversal) if !isValidFilename(filename) { respondError(w, http.StatusBadRequest, "Invalid filename") return } // Extract user ID from JWT token in context userID, ok := middleware.GetUserID(r.Context()) if !ok { respondError(w, http.StatusUnauthorized, "Unauthorized") return } // Delete backup if err := h.backupService.DeleteBackup(filename, userID); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete backup: %v", err)) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "message": "Backup deleted successfully", "filename": filename, }) } // UploadBackup uploads a backup ZIP file from another system // @Summary Upload backup (Admin) // @Description Upload a backup ZIP file from another system // @Tags backups // @Accept multipart/form-data // @Produce json // @Security BearerAuth // @Param file formData file true "Backup ZIP file" // @Success 201 {object} map[string]interface{} "Upload success with filename and metadata" // @Failure 400 {object} ErrorResponse "Invalid file or format" // @Failure 401 {object} ErrorResponse "Unauthorized" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/backups/upload [post] func (h *BackupHandler) UploadBackup(w http.ResponseWriter, r *http.Request) { // Extract user ID from JWT token in context userID, ok := middleware.GetUserID(r.Context()) if !ok { respondError(w, http.StatusUnauthorized, "Unauthorized") return } // Parse multipart form (32MB max) if err := r.ParseMultipartForm(32 << 20); err != nil { respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to parse multipart form: %v", err)) return } // Get uploaded file file, header, err := r.FormFile("file") if err != nil { respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to get uploaded file: %v", err)) return } defer file.Close() // Validate file extension if filepath.Ext(header.Filename) != ".zip" { respondError(w, http.StatusBadRequest, "Only ZIP files are allowed") return } // Upload backup (this will save it to backups/ directory) filename, err := h.backupService.UploadBackup(file, header.Filename, userID) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to upload backup: %v", err)) return } // Get metadata for the uploaded backup metadata, err := h.backupService.GetBackupMetadata(filename) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get backup metadata: %v", err)) return } respondJSON(w, http.StatusCreated, map[string]interface{}{ "message": "Backup uploaded successfully", "filename": filename, "metadata": metadata, }) } // RestoreBackup restores database from a backup file // @Summary Restore backup (Admin) // @Description Restore database from a backup file (requires confirmation) // @Tags backups // @Accept json // @Produce json // @Security BearerAuth // @Param filename path string true "Backup filename" // @Param request body object true "Restore options (confirm, mode)" // @Success 200 {object} map[string]interface{} "Restore result" // @Failure 400 {object} ErrorResponse "Invalid request or filename" // @Failure 401 {object} ErrorResponse "Unauthorized" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/backups/{filename}/restore [post] func (h *BackupHandler) RestoreBackup(w http.ResponseWriter, r *http.Request) { filename, err := getFilenameParam(r) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } // Validate filename (prevent directory traversal) if !isValidFilename(filename) { respondError(w, http.StatusBadRequest, "Invalid filename") return } // Extract user ID from JWT token in context userID, ok := middleware.GetUserID(r.Context()) if !ok { respondError(w, http.StatusUnauthorized, "Unauthorized") return } // Parse request body for confirmation and mode var req struct { Confirm bool `json:"confirm"` Mode string `json:"mode"` // "replace" (default), "merge", or "skip" } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } if !req.Confirm { respondError(w, http.StatusBadRequest, "Restore confirmation required") return } // Validate and set restore mode mode := domain.RestoreModeReplace // Default switch req.Mode { case "", "replace": mode = domain.RestoreModeReplace case "merge": mode = domain.RestoreModeMerge case "skip": mode = domain.RestoreModeSkip default: respondError(w, http.StatusBadRequest, "Invalid restore mode. Use 'replace', 'merge', or 'skip'") return } // Restore backup result, err := h.backupService.RestoreBackup(filename, userID, mode) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restore backup: %v", err)) return } respondJSON(w, http.StatusOK, map[string]interface{}{ "message": "Backup restored successfully", "filename": filename, "result": result, }) } // Helper function to validate filename (prevent directory traversal attacks) func isValidFilename(filename string) bool { // Must end with .zip if filepath.Ext(filename) != ".zip" { return false } // Must not contain path separators if filepath.Base(filename) != filename { return false } // Must not contain ".." if filename == ".." || len(filename) == 0 { return false } return true } // Helper function to create string pointer func stringPtr(s string) *string { return &s }