backea/internal/web/handlers/backup_actions_handler.go

378 lines
12 KiB
Go

package handlers
import (
"backea/internal/backup/strategy"
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/labstack/echo/v4"
)
// BackupActionsHandler handles backup-related actions like restore and download
type BackupActionsHandler struct {
backupFactory strategy.Factory
}
// NewBackupActionsHandler creates a new backup actions handler
func NewBackupActionsHandler(factory strategy.Factory) *BackupActionsHandler {
return &BackupActionsHandler{
backupFactory: factory,
}
}
// RestoreRequest represents the request data for a backup restore
type RestoreRequest struct {
BackupID string `json:"backupID" form:"backupID"`
GroupName string `json:"groupName" form:"groupName"`
ServiceIndex string `json:"serviceIndex" form:"serviceIndex"`
DestinationPath string `json:"destinationPath" form:"destinationPath"`
}
// RestoreBackup handles the request to restore a backup with destination path support
func (h *BackupActionsHandler) RestoreBackup(c echo.Context) error {
backupID := c.Param("backupID")
if backupID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "backupID is required")
}
// Try to parse the group and service from query parameters
groupName := c.QueryParam("groupName")
serviceIndex := c.QueryParam("serviceIndex")
destinationPath := c.QueryParam("destinationPath")
// If not in query parameters, check form data
if groupName == "" || serviceIndex == "" {
var req RestoreRequest
if err := c.Bind(&req); err == nil {
groupName = req.GroupName
serviceIndex = req.ServiceIndex
destinationPath = req.DestinationPath
}
}
if groupName == "" || serviceIndex == "" {
return echo.NewHTTPError(http.StatusBadRequest,
"groupName and serviceIndex are required either as query parameters or in the request body")
}
// Construct full service name
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
log.Printf("Restoring backup ID=%s to service %s", backupID, serviceName)
if destinationPath != "" {
log.Printf("Restoring to custom destination: %s", destinationPath)
}
// Get the appropriate strategy for this service
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to get backup strategy: %v", err))
}
// If destination path is provided, use our custom restore function
if destinationPath != "" {
err = h.restoreBackupToCustomDestination(c.Request().Context(), strategy, backupID, serviceName, destinationPath)
} else {
// Otherwise use the regular restore
err = strategy.RestoreBackup(c.Request().Context(), backupID, serviceName)
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to restore backup: %v", err))
}
return c.JSON(http.StatusOK, map[string]string{
"status": "success",
"message": fmt.Sprintf("Backup %s restored successfully", backupID),
})
}
// RestoreBackupForm shows the restore form with directory input
func (h *BackupActionsHandler) RestoreBackupForm(c echo.Context) error {
backupID := c.QueryParam("backupID")
groupName := c.QueryParam("groupName")
serviceIndex := c.QueryParam("serviceIndex")
if backupID == "" || groupName == "" || serviceIndex == "" {
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
}
// Build service name for lookup
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
// Get backup info if available
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to get backup strategy: %v", err))
}
// Try to get backup info
backupInfo, err := strategy.GetBackupInfo(c.Request().Context(), backupID, serviceName)
var backupTimeStr string
var backupSizeStr string
var defaultPath string
if err == nil && backupInfo != nil {
backupTimeStr = backupInfo.CreationTime.Format("Jan 02, 2006 15:04:05")
backupSizeStr = formatSize(backupInfo.Size)
defaultPath = backupInfo.Source // Use original source as default path
} else {
backupTimeStr = "Unknown"
backupSizeStr = "Unknown"
defaultPath = ""
}
// TODO : create a real template
return c.HTML(http.StatusOK, fmt.Sprintf(`
<div class="restore-modal gruvbox-bg1 round padding">
<h3 class="gruvbox-yellow">Restore Backup</h3>
<div class="backup-details">
<p><span class="gruvbox-fg-dim">Backup Date:</span> <span class="gruvbox-yellow">%s</span></p>
<p><span class="gruvbox-fg-dim">Size:</span> <span class="gruvbox-yellow">%s</span></p>
</div>
<form
id="restore-form"
hx-post="/api/backups/%s/restore"
hx-swap="outerHTML"
hx-indicator="#restore-indicator"
>
<input type="hidden" name="backupID" value="%s">
<input type="hidden" name="groupName" value="%s">
<input type="hidden" name="serviceIndex" value="%s">
<div class="form-group">
<label for="destinationPath" class="gruvbox-fg-dim">Destination Path:</label>
<input
type="text"
id="destinationPath"
name="destinationPath"
class="gruvbox-input"
value="%s"
placeholder="Leave empty to restore to original location"
>
</div>
<div class="form-group form-buttons">
<button type="submit" class="gruvbox-button gruvbox-bg2">
Restore
<span id="restore-indicator" class="htmx-indicator">
<span class="loading-spinner inline-spinner"></span>
</span>
</button>
<button
type="button"
class="gruvbox-button gruvbox-bg3"
onclick="document.querySelector('.restore-modal').remove()"
>
Cancel
</button>
</div>
</form>
</div>
`, backupTimeStr, backupSizeStr, backupID, backupID, groupName, serviceIndex, defaultPath))
}
// restoreBackupToCustomDestination handles restoring a backup to a custom directory
func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy strategy.Strategy, backupID, serviceName, destinationPath string) error {
// Validate destination path
destinationPath = filepath.Clean(destinationPath)
// Check if destination exists
stat, err := os.Stat(destinationPath)
if err != nil {
if os.IsNotExist(err) {
// Create directory if it doesn't exist
if err := os.MkdirAll(destinationPath, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
} else {
return fmt.Errorf("failed to check destination directory: %w", err)
}
} else if !stat.IsDir() {
return fmt.Errorf("destination path is not a directory")
}
// Create a temporary directory to store the downloaded backup
tempDir, err := os.MkdirTemp("", "backea-restore-download-*")
if err != nil {
return fmt.Errorf("failed to create temporary download directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Create a temporary directory for extraction
extractDir, err := os.MkdirTemp("", "backea-restore-extract-*")
if err != nil {
return fmt.Errorf("failed to create temporary extraction directory: %w", err)
}
defer os.RemoveAll(extractDir)
// Download the backup to a temporary file
log.Printf("Downloading backup %s to temporary location for service %s", backupID, serviceName)
reader, err := strategy.DownloadBackup(ctx, backupID, serviceName)
if err != nil {
return fmt.Errorf("failed to download backup: %w", err)
}
defer reader.Close()
// Create a temporary file to store the zip
zipFile, err := os.CreateTemp(tempDir, "backup-*.zip")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
zipPath := zipFile.Name()
defer os.Remove(zipPath)
// Copy the zip content to the temp file
log.Printf("Writing backup data to temporary file: %s", zipPath)
_, err = io.Copy(zipFile, reader)
if err != nil {
zipFile.Close()
return fmt.Errorf("failed to write backup to disk: %w", err)
}
// Flush to disk and close the file
zipFile.Sync()
zipFile.Close()
// Extract the zip to the extraction directory
log.Printf("Extracting backup to %s", extractDir)
if err := extractZip(zipPath, extractDir); err != nil {
return fmt.Errorf("failed to extract backup: %w", err)
}
// Sync extracted files to the destination using rsync
log.Printf("Syncing files from %s to %s", extractDir, destinationPath)
if err := syncDirectories(extractDir, destinationPath); err != nil {
return fmt.Errorf("failed to sync files to destination: %w", err)
}
log.Printf("Backup successfully restored to %s", destinationPath)
return nil
}
// extractZip extracts a zip file to a destination directory
func extractZip(zipFile, destDir string) error {
// Use unzip command for extraction
cmd := exec.Command("unzip", "-o", "-q", zipFile, "-d", destDir)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to extract zip: %w, output: %s", err, string(output))
}
return nil
}
// syncDirectories syncs files from source to destination using rsync
func syncDirectories(src, dest string) error {
// Ensure source path ends with a slash to copy contents, not the directory itself
if !strings.HasSuffix(src, "/") {
src = src + "/"
}
cmd := exec.Command("rsync", "-av", "--delete", src, dest)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to sync directories: %w, output: %s", err, string(output))
}
return nil
}
// Helper function to format file size
func formatSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
}
// DownloadBackup handles the backup download request
func (h *BackupActionsHandler) DownloadBackup(c echo.Context) error {
// Get query parameters
backupID := c.QueryParam("backupID")
groupName := c.QueryParam("groupName")
serviceIndex := c.QueryParam("serviceIndex")
// Validate required parameters
if backupID == "" || groupName == "" || serviceIndex == "" {
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
}
// Create service name for logging
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
log.Printf("Downloading backup: ID=%s, Service=%s", backupID, serviceName)
// Get the appropriate strategy for this service
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to get backup strategy: %v", err))
}
// Get all backups for this service
backups, err := strategy.ListBackups(c.Request().Context(), serviceName)
if err != nil {
log.Printf("Warning: Failed to list backups: %v. Will try with provided ID directly.", err)
} else {
// Look for a matching backup with the proper snapshot ID
found := false
for _, backup := range backups {
// If we find an exact match, or if the backup ID contains our ID (partial match)
if backup.ID == backupID {
found = true
break
}
}
if !found {
log.Printf("Warning: Backup ID %s not found in service backups. Will try direct download anyway.", backupID)
}
}
// Attempt to download the backup using the provided ID
backupReader, err := strategy.DownloadBackup(c.Request().Context(), backupID, serviceName)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Failed to download backup: %v", err))
}
defer backupReader.Close()
// Set filename for download
filename := fmt.Sprintf("%s_%s_%s.zip",
groupName,
serviceIndex,
time.Now().Format("20060102_150405"),
)
// Set response headers for zip download
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
c.Response().Header().Set("Content-Type", "application/zip")
// Stream the file to the client
log.Printf("Streaming backup as %s", filename)
_, err = io.Copy(c.Response().Writer, backupReader)
if err != nil {
log.Printf("Error streaming backup: %v", err)
return err
}
log.Printf("Backup download for %s completed successfully", backupID)
return nil
}