378 lines
12 KiB
Go
378 lines
12 KiB
Go
package handlers
|
|
|
|
import (
|
|
"backea/internal/backup"
|
|
"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 *backup.BackupFactory
|
|
}
|
|
|
|
// NewBackupActionsHandler creates a new backup actions handler
|
|
func NewBackupActionsHandler(factory *backup.BackupFactory) *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 backup.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
|
|
}
|