Ctrl K
ComunidadGuías…

internal/handler/backup_handler.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}