diff --git a/.github/workflows/binary.test.yml b/.github/workflows/binary.test.yml index e1077d1..527f18b 100644 --- a/.github/workflows/binary.test.yml +++ b/.github/workflows/binary.test.yml @@ -10,7 +10,10 @@ on: default: 'amd64' push: branches: - - '**' + - main + pull_request: + branches: + - main permissions: contents: read diff --git a/server/controller/backup_run.go b/server/controller/backup_run.go index 4fb36c3..bf72bc9 100644 --- a/server/controller/backup_run.go +++ b/server/controller/backup_run.go @@ -2,7 +2,6 @@ package controller import ( "net/http" - "os" "path/filepath" "strconv" @@ -128,20 +127,38 @@ func handleBackupFileDownload(c *gin.Context) { return } - // Check if file is marked as deleted - if file.Deleted { - c.JSON(http.StatusGone, gin.H{"error": "file has been deleted", "deleted": true}) + location, err := service.GetStorageLocationForRun(file.BackupRunID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve storage location"}) return } + backend, err := service.NewStorageBackend(location) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open storage backend"}) + return + } + defer backend.Close() - // Check if file exists on disk - if _, err := os.Stat(file.LocalPath); os.IsNotExist(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "file not found on disk"}) + reader, err := backend.OpenReader(file.LocalPath) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found on storage"}) + return + } + defer reader.Close() + + fileInfo, err := backend.Stat(file.LocalPath) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found on storage"}) return } - // Serve the file for download - c.FileAttachment(file.LocalPath, filepath.Base(file.RemotePath)) + c.DataFromReader( + http.StatusOK, + fileInfo.Size(), + "application/octet-stream", + reader, + map[string]string{"Content-Disposition": "attachment; filename=" + filepath.Base(file.RemotePath)}, + ) } func handleBackupFileDelete(c *gin.Context) { diff --git a/server/controller/router.go b/server/controller/router.go index f7890ce..476b0a9 100644 --- a/server/controller/router.go +++ b/server/controller/router.go @@ -33,6 +33,7 @@ func SetupRouter(r *gin.Engine) { api.DELETE("/storage-locations/:id", handleStorageLocationDelete) api.GET("/storage-locations/:id/move-impact", handleStorageLocationMoveImpact) api.GET("/storage-locations/:id/deletion-impact", handleStorageLocationDeletionImpact) + api.POST("/storage-locations/:id/test-connection", handleStorageLocationTestConnection) api.GET("/local-files", handleLocalFilesList) api.GET("/naming-rules", handleNamingRulesList) @@ -69,7 +70,6 @@ func SetupRouter(r *gin.Engine) { api.DELETE("/backup-runs/:id", handleBackupRunDelete) api.GET("/backup-files/:fileId", handleBackupFileGet) api.GET("/backup-files/:fileId/download", handleBackupFileDownload) - api.DELETE("/backup-files/:fileId", handleBackupFileDelete) // Push notifications api.GET("/notifications/vapid-key", handleGetVAPIDPublicKey) diff --git a/server/controller/storage_location.go b/server/controller/storage_location.go index af433c9..37afac0 100644 --- a/server/controller/storage_location.go +++ b/server/controller/storage_location.go @@ -1,8 +1,11 @@ package controller import ( + "encoding/json" + "io" "net/http" "strconv" + "strings" "backapp-server/entity" "backapp-server/service" @@ -23,15 +26,149 @@ func handleStorageLocationsList(c *gin.Context) { } func handleStorageLocationsCreate(c *gin.Context) { + ct := c.GetHeader("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + if err := c.Request.ParseMultipartForm(10 << 20); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to parse multipart form"}) + return + } + name := c.PostForm("name") + storageType := c.PostForm("type") + basePath := c.PostForm("base_path") + address := c.PostForm("address") + remotePath := c.PostForm("remote_path") + username := c.PostForm("username") + authType := c.PostForm("auth_type") + password := c.PostForm("password") + port := 22 + if portStr := c.PostForm("port"); portStr != "" { + if p, err := strconv.Atoi(portStr); err == nil { + port = p + } + } + + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"}) + return + } + if storageType == "" { + storageType = "local" + } + + if storageType == "local" { + if basePath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing base_path for local storage"}) + return + } + loc := &entity.StorageLocation{ + Name: name, + Type: storageType, + BasePath: basePath, + } + created, err := service.ServiceCreateStorageLocation(loc) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, created) + return + } + + if storageType != "sftp" { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) + return + } + if address == "" || remotePath == "" || username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing required sftp fields"}) + return + } + + location := &entity.StorageLocation{ + Name: name, + Type: storageType, + Address: address, + Port: port, + RemotePath: remotePath, + Username: username, + AuthType: authType, + } + + if authType == "password" { + if password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "password is required for password auth"}) + return + } + location.Password = password + } else { + file, _, err := c.Request.FormFile("keyfile") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing or invalid keyfile"}) + return + } + defer file.Close() + keyContent, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read key file"}) + return + } + if len(keyContent) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "key file is empty"}) + return + } + location.SSHKey = string(keyContent) + location.AuthType = "key" + } + + created, err := service.ServiceCreateStorageLocation(location) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, created) + return + } + var input entity.StorageLocation if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) return } - if input.Name == "" || input.BasePath == "" { + if input.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"}) return } + storageType := service.NormalizeStorageType(&input) + if storageType == "local" { + if input.BasePath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing base_path for local storage"}) + return + } + } else if storageType == "sftp" { + if input.Address == "" || input.RemotePath == "" || input.Username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing required sftp fields"}) + return + } + switch input.AuthType { + case "password": + if input.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "password is required for password auth"}) + return + } + case "key": + if input.SSHKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "ssh key is required for key auth"}) + return + } + default: + if input.Password == "" && input.SSHKey == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing sftp authentication"}) + return + } + } + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported storage type"}) + return + } loc, err := service.ServiceCreateStorageLocation(&input) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -47,12 +184,23 @@ func handleStorageLocationUpdate(c *gin.Context) { return } var input entity.StorageLocation - if err := c.ShouldBindJSON(&input); err != nil { + rawBody, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } + if err := json.Unmarshal(rawBody, &input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } + var rawMap map[string]interface{} + if err := json.Unmarshal(rawBody, &rawMap); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) return } + _, setEnabled := rawMap["enabled"] - loc, err := service.ServiceUpdateStorageLocation(uint(id), &input) + loc, err := service.ServiceUpdateStorageLocation(uint(id), &input, setEnabled) if err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "storage location not found"}) @@ -119,3 +267,33 @@ func handleStorageLocationDelete(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "storage location deleted"}) } + +func handleStorageLocationTestConnection(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + location, err := service.ServiceGetStorageLocation(uint(id)) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "storage location not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if service.NormalizeStorageType(location) != "sftp" { + c.JSON(http.StatusBadRequest, gin.H{"error": "connection test is only supported for sftp storage"}) + return + } + + if err := service.TestSFTPConnection(location); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "connection successful"}) +} diff --git a/server/entity/backup_file.go b/server/entity/backup_file.go index 9595aad..9731243 100644 --- a/server/entity/backup_file.go +++ b/server/entity/backup_file.go @@ -12,7 +12,8 @@ type BackupFile struct { SizeBytes int64 `json:"size_bytes"` FileSize int64 `json:"file_size,omitempty"` Checksum string `json:"checksum,omitempty"` - Deleted bool `gorm:"default:false" json:"deleted"` + Deleted bool `gorm:"default:false" json:"-"` + Available *bool `gorm:"-" json:"available,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"` CreatedAt time.Time `json:"created_at"` } diff --git a/server/entity/storage_location.go b/server/entity/storage_location.go index 9e770dd..71c3209 100644 --- a/server/entity/storage_location.go +++ b/server/entity/storage_location.go @@ -2,10 +2,19 @@ package entity import "time" -// StorageLocation defines where backups are stored locally +// StorageLocation defines where backups are stored type StorageLocation struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"not null" json:"name"` - BasePath string `gorm:"not null" json:"base_path"` + BasePath string `json:"base_path"` + Type string `gorm:"default:local" json:"type"` + Address string `gorm:"->" json:"address,omitempty"` + Port int `gorm:"->" json:"port,omitempty"` + RemotePath string `gorm:"->" json:"remote_path,omitempty"` + Username string `gorm:"->" json:"username,omitempty"` + Password string `gorm:"->" json:"password,omitempty"` + SSHKey string `gorm:"->" json:"ssh_key,omitempty"` + AuthType string `gorm:"->" json:"auth_type,omitempty"` + Enabled bool `gorm:"default:true" json:"enabled"` CreatedAt time.Time `json:"created_at"` } diff --git a/server/entity/storage_location_details.go b/server/entity/storage_location_details.go new file mode 100644 index 0000000..a56c550 --- /dev/null +++ b/server/entity/storage_location_details.go @@ -0,0 +1,25 @@ +package entity + +import "time" + +// LocalStorageLocationDetails stores local storage configuration. +type LocalStorageLocationDetails struct { + ID uint `gorm:"primaryKey" json:"id"` + StorageLocationID uint `gorm:"not null;uniqueIndex" json:"storage_location_id"` + BasePath string `gorm:"not null" json:"base_path"` + CreatedAt time.Time `json:"created_at"` +} + +// SftpStorageLocationDetails stores SFTP storage configuration. +type SftpStorageLocationDetails struct { + ID uint `gorm:"primaryKey" json:"id"` + StorageLocationID uint `gorm:"not null;uniqueIndex" json:"storage_location_id"` + Address string `json:"address,omitempty"` + Port int `json:"port,omitempty"` + RemotePath string `json:"remote_path,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + SSHKey string `json:"ssh_key,omitempty"` + AuthType string `json:"auth_type,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/server/entity/storage_usage.go b/server/entity/storage_usage.go index f0a515e..0642699 100644 --- a/server/entity/storage_usage.go +++ b/server/entity/storage_usage.go @@ -5,6 +5,7 @@ type StorageUsage struct { StorageLocationID uint `json:"storage_location_id"` Name string `json:"name"` BasePath string `json:"base_path"` + Enabled bool `json:"enabled"` TotalBytes int64 `json:"total_bytes"` UsedBytes int64 `json:"used_bytes"` FreeBytes int64 `json:"free_bytes"` diff --git a/server/go.mod b/server/go.mod index 530f7c6..bd548c5 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,15 +3,17 @@ module backapp-server go 1.24.0 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/gin-gonic/gin v1.10.0 + github.com/pkg/sftp v1.13.9 github.com/robfig/cron/v3 v3.0.1 golang.org/x/crypto v0.46.0 + golang.org/x/sys v0.39.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.30.0 ) require ( - github.com/SherClockHolmes/webpush-go v1.4.0 // indirect github.com/bytedance/sonic v1.12.3 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect @@ -27,6 +29,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect @@ -37,7 +40,6 @@ require ( github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.11.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/server/go.sum b/server/go.sum index c18b720..c0e45b9 100644 --- a/server/go.sum +++ b/server/go.sum @@ -30,8 +30,6 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -45,6 +43,8 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -58,6 +58,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -152,8 +154,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/server/service/backup_executor.go b/server/service/backup_executor.go index 7235944..becda33 100644 --- a/server/service/backup_executor.go +++ b/server/service/backup_executor.go @@ -3,7 +3,6 @@ package service import ( "fmt" "log" - "os" "path/filepath" "sort" "time" @@ -51,6 +50,9 @@ func (e *BackupExecutor) ExecuteBackup(profileID uint, allowDisabled bool) error if !profile.Enabled && !allowDisabled { return fmt.Errorf("backup profile is disabled") } + if profile.StorageLocation != nil && !profile.StorageLocation.Enabled { + return fmt.Errorf("storage location is disabled") + } // Create backup run record run := &entity.BackupRun{ @@ -135,25 +137,36 @@ func (e *BackupExecutor) executeBackupInternal(profile *entity.BackupProfile, ru // Generate backup directory name using naming rule backupDirName := e.generateBackupName(profile) - backupDir := filepath.Join(profile.StorageLocation.BasePath, backupDirName) + backupBasePath := StorageBasePath(profile.StorageLocation) + backupDir := JoinStoragePath(profile.StorageLocation, backupBasePath, backupDirName) e.logToDatabase(run.ID, "INFO", fmt.Sprintf("Backup directory: %s", backupDir)) + storageBackend, err := NewStorageBackend(profile.StorageLocation) + if err != nil { + e.logToDatabase(run.ID, "ERROR", fmt.Sprintf("Failed to initialize storage backend: %v", err)) + return fmt.Errorf("failed to initialize storage backend: %v", err) + } + defer storageBackend.Close() + // Create backup directory - if err := os.MkdirAll(backupDir, 0755); err != nil { + if err := storageBackend.EnsureDir(backupDir); err != nil { e.logToDatabase(run.ID, "ERROR", fmt.Sprintf("Failed to create backup directory: %v", err)) return fmt.Errorf("failed to create backup directory: %v", err) } - absBackupDir, absErr := filepath.Abs(backupDir) - if absErr != nil { - e.logToDatabase(run.ID, "ERROR", fmt.Sprintf("Failed to get absolute path of backup directory: %v", absErr)) - } else { - e.logToDatabase(run.ID, "INFO", fmt.Sprintf("Absolute backup directory path: %s", absBackupDir)) + if storageBackend.IsLocal() { + absBackupDir, absErr := filepath.Abs(backupDir) + if absErr != nil { + e.logToDatabase(run.ID, "ERROR", fmt.Sprintf("Failed to get absolute path of backup directory: %v", absErr)) + } else { + e.logToDatabase(run.ID, "INFO", fmt.Sprintf("Absolute backup directory path: %s", absBackupDir)) + } } e.logToDatabase(run.ID, "INFO", "Backup directory created") + run.LocalBackupPath = backupDir // Transfer files e.logToDatabase(run.ID, "INFO", fmt.Sprintf("Starting file transfer (%d rules)", len(profile.FileRules))) - transferService := NewFileTransferService(sshClient, backupDir, run.ID) + transferService := NewFileTransferService(sshClient, storageBackend, backupDir, run.ID) backupFiles, err := transferService.TransferFiles(profile.FileRules) if err != nil { e.logToDatabase(run.ID, "ERROR", fmt.Sprintf("File transfer failed: %v", err)) diff --git a/server/service/backup_profile.go b/server/service/backup_profile.go index fcd37cc..d31dda0 100644 --- a/server/service/backup_profile.go +++ b/server/service/backup_profile.go @@ -20,6 +20,13 @@ func ServiceListBackupProfiles() ([]entity.BackupProfile, error) { Find(&profiles).Error; err != nil { return nil, err } + for i := range profiles { + if profiles[i].StorageLocation != nil { + if err := hydrateStorageLocation(profiles[i].StorageLocation); err != nil { + return nil, err + } + } + } return profiles, nil } @@ -138,6 +145,11 @@ func ServiceGetBackupProfileFull(id uint) (*entity.BackupProfile, error) { First(&profile, id).Error; err != nil { return nil, err } + if profile.StorageLocation != nil { + if err := hydrateStorageLocation(profile.StorageLocation); err != nil { + return nil, err + } + } if profile.Server != nil { profile.Server = sanitizeServer(profile.Server) } diff --git a/server/service/backup_run.go b/server/service/backup_run.go index 76b6a24..70edce7 100644 --- a/server/service/backup_run.go +++ b/server/service/backup_run.go @@ -2,7 +2,6 @@ package service import ( "os" - "path/filepath" "time" "backapp-server/entity" @@ -47,6 +46,29 @@ func ServiceListBackupFilesForRun(runID uint) ([]entity.BackupFile, error) { if err := DB.Where("backup_run_id = ?", runID).Find(&files).Error; err != nil { return nil, err } + if len(files) == 0 { + return files, nil + } + + location, err := GetStorageLocationForRun(runID) + if err != nil { + return files, nil + } + backend, err := NewStorageBackend(location) + if err != nil { + return files, nil + } + defer backend.Close() + + for i := range files { + available := files[i].LocalPath != "" + if available { + if _, err := backend.Stat(files[i].LocalPath); err != nil { + available = false + } + } + files[i].Available = &available + } return files, nil } @@ -65,15 +87,19 @@ func ServiceDeleteBackupFile(fileID uint) error { return err } - // Delete the file from disk if it exists + location, err := GetStorageLocationForRun(file.BackupRunID) + if err != nil { + return err + } + backend, err := NewStorageBackend(location) + if err != nil { + return err + } + defer backend.Close() + + // Delete the file from storage if it exists if file.LocalPath != "" { - parentDir := filepath.Dir(file.LocalPath) - if err := os.Remove(file.LocalPath); err != nil && !os.IsNotExist(err) { - // Log but continue - we still want to mark it as deleted - } else { - // Clean up empty parent directories - removeEmptyDirs(parentDir) - } + _ = backend.RemoveWithCleanup(file.LocalPath) } // Mark as deleted @@ -91,28 +117,29 @@ func ServiceDeleteBackupRun(runID uint) error { return err } + location, err := GetStorageLocationForRun(runID) + if err != nil { + return err + } + backend, err := NewStorageBackend(location) + if err != nil { + return err + } + defer backend.Close() + // Get all backup files for this run to delete from disk var files []entity.BackupFile if err := DB.Where("backup_run_id = ?", runID).Find(&files).Error; err != nil { return err } - // Track directories for cleanup - dirsToCleanup := make(map[string]bool) - // Delete files from disk for _, file := range files { if file.LocalPath != "" { - dirsToCleanup[filepath.Dir(file.LocalPath)] = true - os.Remove(file.LocalPath) // Ignore errors, best effort cleanup + _ = backend.RemoveWithCleanup(file.LocalPath) } } - // Clean up empty directories - for dir := range dirsToCleanup { - removeEmptyDirs(dir) - } - // Delete dependent records: logs and files if err := DB.Where("backup_run_id = ?", runID).Delete(&entity.BackupRunLog{}).Error; err != nil { return err @@ -164,3 +191,22 @@ func ServiceGetBackupRunDeletionImpact(runID uint) (*DeletionImpact, error) { return impact, nil } + +func GetStorageLocationForRun(runID uint) (*entity.StorageLocation, error) { + var run entity.BackupRun + if err := DB.First(&run, runID).Error; err != nil { + return nil, err + } + + var profile entity.BackupProfile + if err := DB.Preload("StorageLocation").First(&profile, run.BackupProfileID).Error; err != nil { + return nil, err + } + if profile.StorageLocation == nil { + return nil, os.ErrNotExist + } + if err := hydrateStorageLocation(profile.StorageLocation); err != nil { + return nil, err + } + return profile.StorageLocation, nil +} diff --git a/server/service/db.go b/server/service/db.go index 611a0c9..c879561 100644 --- a/server/service/db.go +++ b/server/service/db.go @@ -32,6 +32,8 @@ func InitDB(dataSourceName string) { err = DB.AutoMigrate( &entity.Server{}, &entity.StorageLocation{}, + &entity.LocalStorageLocationDetails{}, + &entity.SftpStorageLocationDetails{}, &entity.NamingRule{}, &entity.BackupProfile{}, &entity.Command{}, @@ -65,14 +67,16 @@ func initializeDefaults() { { Name: "Local Backups", BasePath: "/var/backups/app", + Type: storageTypeLocal, }, { Name: "Archive", BasePath: "/var/backups/archive", + Type: storageTypeLocal, }, } for _, loc := range defaultStorageLocations { - if err := DB.Create(&loc).Error; err != nil { + if _, err := ServiceCreateStorageLocation(&loc); err != nil { log.Printf("Error creating default storage location '%s': %v", loc.Name, err) } else { log.Printf("Created default storage location: %s", loc.Name) diff --git a/server/service/disk_usage_unix.go b/server/service/disk_usage_unix.go new file mode 100644 index 0000000..316f70f --- /dev/null +++ b/server/service/disk_usage_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package service + +import "syscall" + +func getDiskUsage(path string) (diskUsage, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return diskUsage{}, err + } + + total := int64(stat.Blocks) * int64(stat.Bsize) + free := int64(stat.Bavail) * int64(stat.Bsize) + used := total - free + return diskUsage{ + Total: total, + Free: free, + Used: used, + Ok: true, + }, nil +} diff --git a/server/service/disk_usage_windows.go b/server/service/disk_usage_windows.go new file mode 100644 index 0000000..a60b3d7 --- /dev/null +++ b/server/service/disk_usage_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package service + +import "golang.org/x/sys/windows" + +func getDiskUsage(path string) (diskUsage, error) { + if path == "" { + return diskUsage{}, windows.ERROR_PATH_NOT_FOUND + } + + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return diskUsage{}, err + } + + var freeAvailable uint64 + var total uint64 + var totalFree uint64 + if err := windows.GetDiskFreeSpaceEx(pathPtr, &freeAvailable, &total, &totalFree); err != nil { + return diskUsage{}, err + } + + used := int64(total - freeAvailable) + return diskUsage{ + Total: int64(total), + Free: int64(freeAvailable), + Used: used, + Ok: true, + }, nil +} diff --git a/server/service/file_transfer.go b/server/service/file_transfer.go index fc935c9..cfe7cbd 100644 --- a/server/service/file_transfer.go +++ b/server/service/file_transfer.go @@ -3,7 +3,7 @@ package service import ( "fmt" "log" - "os" + "path" "path/filepath" "strings" "time" @@ -13,17 +13,19 @@ import ( // FileTransferService handles file transfers with include/exclude rules type FileTransferService struct { - sshClient *SSHClient - destDir string - runID uint + sshClient *SSHClient + storageBackend StorageBackend + destDir string + runID uint } // NewFileTransferService creates a new file transfer service -func NewFileTransferService(sshClient *SSHClient, destDir string, runID uint) *FileTransferService { +func NewFileTransferService(sshClient *SSHClient, storageBackend StorageBackend, destDir string, runID uint) *FileTransferService { return &FileTransferService{ - sshClient: sshClient, - destDir: destDir, - runID: runID, + sshClient: sshClient, + storageBackend: storageBackend, + destDir: destDir, + runID: runID, } } @@ -45,7 +47,7 @@ func (s *FileTransferService) TransferFiles(fileRules []entity.FileRule) ([]enti var backupFiles []entity.BackupFile // Ensure destination directory exists - if err := os.MkdirAll(s.destDir, 0755); err != nil { + if err := s.storageBackend.EnsureDir(s.destDir); err != nil { s.logToDatabase("ERROR", fmt.Sprintf("Failed to create destination directory: %v", err)) return nil, fmt.Errorf("failed to create destination directory: %v", err) } @@ -85,7 +87,7 @@ func (s *FileTransferService) transferFileRule(rule entity.FileRule) ([]entity.B isDir := strings.TrimSpace(isDirOutput) == "yes" if isDir { - if rule.Recursive { + if rule.Recursive { return s.transferDirectory(rule) } // Non-recursive directory transfer @@ -99,7 +101,7 @@ func (s *FileTransferService) transferFileRule(rule entity.FileRule) ([]entity.B // transferSingleFile transfers a single file func (s *FileTransferService) transferSingleFile(rule entity.FileRule) ([]entity.BackupFile, error) { fileName := filepath.Base(rule.RemotePath) - localPath := filepath.Join(s.destDir, fileName) + localPath := s.joinDestPath(fileName) s.logToDatabase("DEBUG", fmt.Sprintf("Transferring file: %s", rule.RemotePath)) // Get file size @@ -114,7 +116,7 @@ func (s *FileTransferService) transferSingleFile(rule entity.FileRule) ([]entity fmt.Sscanf(strings.TrimSpace(sizeOutput), "%d", &fileSize) // Download file - if err := s.sshClient.CopyFileFromRemote(rule.RemotePath, localPath); err != nil { + if err := s.copyRemoteFile(rule.RemotePath, localPath); err != nil { s.logToDatabase("ERROR", fmt.Sprintf("Failed to copy file %s: %v", rule.RemotePath, err)) return nil, fmt.Errorf("failed to copy file: %v", err) } @@ -199,10 +201,10 @@ func (s *FileTransferService) transferDirectory(rule entity.FileRule) ([]entity. // Preserve directory structure relPath := strings.TrimPrefix(file, rule.RemotePath) relPath = strings.TrimPrefix(relPath, "/") - localPath := filepath.Join(s.destDir, relPath) + localPath := s.joinDestPath(relPath) // Create parent directory - if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { + if err := s.storageBackend.EnsureDir(s.destDirForPath(localPath)); err != nil { return nil, fmt.Errorf("failed to create directory: %v", err) } @@ -217,7 +219,7 @@ func (s *FileTransferService) transferDirectory(rule entity.FileRule) ([]entity. fmt.Sscanf(strings.TrimSpace(sizeOutput), "%d", &fileSize) // Download file - if err := s.sshClient.CopyFileFromRemote(file, localPath); err != nil { + if err := s.copyRemoteFile(file, localPath); err != nil { return nil, fmt.Errorf("failed to copy file %s: %v", file, err) } @@ -263,3 +265,91 @@ func (s *FileTransferService) shouldExclude(filePath, excludePattern string) boo return false } + +func (s *FileTransferService) joinDestPath(relPath string) string { + if s.storageBackend.IsLocal() { + return filepath.Join(s.destDir, relPath) + } + return path.Join(s.destDir, relPath) +} + +func (s *FileTransferService) destDirForPath(filePath string) string { + if s.storageBackend.IsLocal() { + return filepath.Dir(filePath) + } + return path.Dir(filePath) +} + +func (s *FileTransferService) copyRemoteFile(remotePath, destPath string) error { + if s.storageBackend.IsLocal() { + return s.sshClient.CopyFileFromRemote(remotePath, destPath) + } + writer, err := s.storageBackend.OpenWriter(destPath) + if err != nil { + return err + } + defer writer.Close() + return s.sshClient.CopyFileFromRemoteToWriter(remotePath, writer) +} + +func (s *FileTransferService) getRemoteFileSize(remotePath string) (int64, error) { + sizeCmd := fmt.Sprintf("stat -c%%s '%s' 2>/dev/null || stat -f%%z '%s'", remotePath, remotePath) + sizeOutput, err := s.sshClient.RunCommand(sizeCmd) + if err != nil { + return 0, err + } + + var fileSize int64 + fmt.Sscanf(strings.TrimSpace(sizeOutput), "%d", &fileSize) + return fileSize, nil +} + +func (s *FileTransferService) buildFileListCommand(parentDir, baseName, listFile string) string { + cmd := fmt.Sprintf( + "cd %s && find %s -xdev -print > %s 2> %s.err; cat %s.err; rm -f %s.err; if [ ! -s %s ]; then exit 1; fi; exit 0", + shellQuote(parentDir), + shellQuote(baseName), + shellQuote(listFile), + shellQuote(listFile), + shellQuote(listFile), + shellQuote(listFile), + shellQuote(listFile), + ) + return cmd +} + +func formatCommandFailure(err error, output string) string { + if output != "" { + return fmt.Sprintf("%v (%s)", err, output) + } + return err.Error() +} + +func (s *FileTransferService) logPermissionIssues(tool, output string) { + if output == "" { + return + } + lines := strings.Split(output, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, "permission denied") || + strings.Contains(lower, "access is denied") || + strings.Contains(lower, "zugriff verweigert") || + strings.Contains(lower, "operation not permitted") || + strings.Contains(lower, "errno=13") || + strings.Contains(lower, "eacces") { + s.logToDatabase("ERROR", fmt.Sprintf("%s permission error: %s", tool, line)) + } + } +} + +func shellQuote(value string) string { + if value == "" { + return "''" + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} diff --git a/server/service/sftp_helpers.go b/server/service/sftp_helpers.go new file mode 100644 index 0000000..261611f --- /dev/null +++ b/server/service/sftp_helpers.go @@ -0,0 +1,90 @@ +package service + +import ( + "fmt" + "net" + "strings" + "time" + + "backapp-server/entity" + + "golang.org/x/crypto/ssh" +) + +func resolveSFTPAddress(location *entity.StorageLocation) (string, error) { + _ = hydrateStorageLocation(location) + if location == nil { + return "", fmt.Errorf("storage location is required") + } + address := strings.TrimSpace(location.Address) + if address == "" { + return "", fmt.Errorf("sftp address is required") + } + port := location.Port + if port == 0 { + port = 22 + } + if _, _, err := net.SplitHostPort(address); err == nil { + return address, nil + } + return net.JoinHostPort(address, fmt.Sprintf("%d", port)), nil +} + +func buildSFTPAuthMethods(location *entity.StorageLocation) ([]ssh.AuthMethod, error) { + _ = hydrateStorageLocation(location) + authType := strings.ToLower(strings.TrimSpace(location.AuthType)) + authMethods := []ssh.AuthMethod{} + + if authType == "" { + if location.SSHKey != "" { + authType = "key" + } else if location.Password != "" { + authType = "password" + } + } + + switch authType { + case "password": + if location.Password == "" { + return nil, fmt.Errorf("password is required for sftp auth") + } + authMethods = append(authMethods, ssh.Password(location.Password)) + case "key", "": + if location.SSHKey == "" { + return nil, fmt.Errorf("ssh key is required for sftp key auth") + } + keyData, err := readPrivateKeyData(location.SSHKey) + if err != nil { + return nil, err + } + signer, err := ssh.ParsePrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("failed to parse ssh key: %v", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + default: + return nil, fmt.Errorf("unsupported auth_type: %s", authType) + } + + return authMethods, nil +} + +func buildSFTPSSHConfig(location *entity.StorageLocation) (*ssh.ClientConfig, error) { + _ = hydrateStorageLocation(location) + if location == nil { + return nil, fmt.Errorf("storage location is required") + } + if strings.TrimSpace(location.Username) == "" { + return nil, fmt.Errorf("sftp username is required") + } + authMethods, err := buildSFTPAuthMethods(location) + if err != nil { + return nil, err + } + return &ssh.ClientConfig{ + User: location.Username, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 30 * time.Second, + }, nil +} diff --git a/server/service/ssh_client.go b/server/service/ssh_client.go index e0940f7..0335771 100644 --- a/server/service/ssh_client.go +++ b/server/service/ssh_client.go @@ -26,18 +26,9 @@ func NewSSHClient(server *entity.Server) (*SSHClient, error) { switch server.AuthType { case "key": - var keyData []byte - var err error - - // Try reading as file first - if stat, statErr := os.Stat(server.PrivateKeyPath); statErr == nil && !stat.IsDir() { - keyData, err = os.ReadFile(server.PrivateKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to read private key file: %v", err) - } - } else { - // Treat as key content - keyData = []byte(server.PrivateKeyPath) + keyData, err := readPrivateKeyData(server.PrivateKeyPath) + if err != nil { + return nil, err } signer, err := ssh.ParsePrivateKey(keyData) @@ -133,6 +124,34 @@ func (c *SSHClient) CopyFileFromRemote(remotePath, localPath string) error { return c.copyFileUsingSCP(remotePath, localPath) } +// CopyFileFromRemoteToWriter streams a remote file into a writer. +func (c *SSHClient) CopyFileFromRemoteToWriter(remotePath string, writer io.Writer) error { + session, err := c.client.NewSession() + if err != nil { + return fmt.Errorf("failed to create session: %v", err) + } + defer session.Close() + + stdout, err := session.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to get stdout pipe: %v", err) + } + + if err := session.Start(fmt.Sprintf("cat '%s'", remotePath)); err != nil { + return fmt.Errorf("failed to start cat: %v", err) + } + + if _, err := io.Copy(writer, stdout); err != nil { + return fmt.Errorf("failed to copy file content: %v", err) + } + + if err := session.Wait(); err != nil { + return fmt.Errorf("cat command failed: %v", err) + } + + return nil +} + // copyFileUsingCat downloads a file using cat (simpler and more reliable) func (c *SSHClient) copyFileUsingCat(remotePath, localPath string) error { session, err := c.client.NewSession() diff --git a/server/service/ssh_key.go b/server/service/ssh_key.go new file mode 100644 index 0000000..ceabf34 --- /dev/null +++ b/server/service/ssh_key.go @@ -0,0 +1,21 @@ +package service + +import ( + "fmt" + "os" +) + +// readPrivateKeyData reads a private key from a file path or returns inline data. +func readPrivateKeyData(value string) ([]byte, error) { + if value == "" { + return nil, fmt.Errorf("private key is empty") + } + if stat, statErr := os.Stat(value); statErr == nil && !stat.IsDir() { + keyData, err := os.ReadFile(value) + if err != nil { + return nil, fmt.Errorf("failed to read private key file: %v", err) + } + return keyData, nil + } + return []byte(value), nil +} diff --git a/server/service/storage_backend.go b/server/service/storage_backend.go new file mode 100644 index 0000000..3eaebc0 --- /dev/null +++ b/server/service/storage_backend.go @@ -0,0 +1,108 @@ +package service + +import ( + "io" + "os" + "path" + "path/filepath" + "strings" + + "backapp-server/entity" +) + +const ( + storageTypeLocal = "local" + storageTypeSFTP = "sftp" +) + +// StorageBackend abstracts where backups are written/read. +type StorageBackend interface { + EnsureDir(path string) error + OpenWriter(path string) (io.WriteCloser, error) + OpenReader(path string) (io.ReadCloser, error) + Remove(path string) error + RemoveWithCleanup(path string) error + Stat(path string) (os.FileInfo, error) + IsLocal() bool + Close() error +} + +type localStorageBackend struct{} + +func (b *localStorageBackend) EnsureDir(dirPath string) error { + return os.MkdirAll(dirPath, 0755) +} + +func (b *localStorageBackend) OpenWriter(filePath string) (io.WriteCloser, error) { + return os.Create(filePath) +} + +func (b *localStorageBackend) OpenReader(filePath string) (io.ReadCloser, error) { + return os.Open(filePath) +} + +func (b *localStorageBackend) Remove(filePath string) error { + return os.Remove(filePath) +} + +func (b *localStorageBackend) RemoveWithCleanup(filePath string) error { + if filePath == "" { + return nil + } + parentDir := filepath.Dir(filePath) + if err := os.Remove(filePath); err != nil { + return err + } + removeEmptyDirs(parentDir) + return nil +} + +func (b *localStorageBackend) Stat(filePath string) (os.FileInfo, error) { + return os.Stat(filePath) +} + +func (b *localStorageBackend) IsLocal() bool { + return true +} + +func (b *localStorageBackend) Close() error { + return nil +} + +// NormalizeStorageType returns the effective storage type. +func NormalizeStorageType(location *entity.StorageLocation) string { + if location == nil { + return storageTypeLocal + } + storageType := strings.ToLower(strings.TrimSpace(location.Type)) + if storageType == "" { + return storageTypeLocal + } + return storageType +} + +// StorageBasePath returns the base path/prefix for a storage location. +func StorageBasePath(location *entity.StorageLocation) string { + _ = hydrateStorageLocation(location) + if NormalizeStorageType(location) == storageTypeSFTP { + return location.RemotePath + } + return location.BasePath +} + +// JoinStoragePath joins path elements based on storage type. +func JoinStoragePath(location *entity.StorageLocation, elements ...string) string { + _ = hydrateStorageLocation(location) + if NormalizeStorageType(location) == storageTypeSFTP { + return path.Join(elements...) + } + return filepath.Join(elements...) +} + +// NewStorageBackend creates a storage backend for the location. +func NewStorageBackend(location *entity.StorageLocation) (StorageBackend, error) { + if NormalizeStorageType(location) == storageTypeSFTP { + return NewSFTPStorageBackend(location) + } + return &localStorageBackend{}, nil +} diff --git a/server/service/storage_backend_sftp.go b/server/service/storage_backend_sftp.go new file mode 100644 index 0000000..0070943 --- /dev/null +++ b/server/service/storage_backend_sftp.go @@ -0,0 +1,118 @@ +package service + +import ( + "fmt" + "io" + "os" + + "backapp-server/entity" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type sftpStorageBackend struct { + sshClient *ssh.Client + sftpClient *sftp.Client +} + +// NewSFTPStorageBackend connects to an SFTP storage location. +func NewSFTPStorageBackend(location *entity.StorageLocation) (StorageBackend, error) { + if location == nil { + return nil, fmt.Errorf("storage location is required") + } + + config, err := buildSFTPSSHConfig(location) + if err != nil { + return nil, err + } + addr, err := resolveSFTPAddress(location) + if err != nil { + return nil, err + } + + sshClient, err := ssh.Dial("tcp", addr, config) + if err != nil { + return nil, fmt.Errorf("sftp ssh connection failed: %v", err) + } + + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + sshClient.Close() + return nil, fmt.Errorf("failed to create sftp client: %v", err) + } + + return &sftpStorageBackend{ + sshClient: sshClient, + sftpClient: sftpClient, + }, nil +} + +// TestSFTPConnection verifies that the SFTP storage can be reached. +func TestSFTPConnection(location *entity.StorageLocation) error { + config, err := buildSFTPSSHConfig(location) + if err != nil { + return err + } + addr, err := resolveSFTPAddress(location) + if err != nil { + return err + } + + sshClient, err := ssh.Dial("tcp", addr, config) + if err != nil { + return fmt.Errorf("sftp ssh connection failed: %v", err) + } + defer sshClient.Close() + + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + return fmt.Errorf("failed to create sftp client: %v", err) + } + defer sftpClient.Close() + + if location.RemotePath != "" { + if _, err := sftpClient.Stat(location.RemotePath); err != nil { + return fmt.Errorf("remote path not accessible: %v", err) + } + } + return nil +} + +func (b *sftpStorageBackend) EnsureDir(dirPath string) error { + return b.sftpClient.MkdirAll(dirPath) +} + +func (b *sftpStorageBackend) OpenWriter(filePath string) (io.WriteCloser, error) { + return b.sftpClient.Create(filePath) +} + +func (b *sftpStorageBackend) OpenReader(filePath string) (io.ReadCloser, error) { + return b.sftpClient.Open(filePath) +} + +func (b *sftpStorageBackend) Remove(filePath string) error { + return b.sftpClient.Remove(filePath) +} + +func (b *sftpStorageBackend) RemoveWithCleanup(filePath string) error { + return b.sftpClient.Remove(filePath) +} + +func (b *sftpStorageBackend) Stat(filePath string) (os.FileInfo, error) { + return b.sftpClient.Stat(filePath) +} + +func (b *sftpStorageBackend) IsLocal() bool { + return false +} + +func (b *sftpStorageBackend) Close() error { + if b.sftpClient != nil { + b.sftpClient.Close() + } + if b.sshClient != nil { + return b.sshClient.Close() + } + return nil +} diff --git a/server/service/storage_location.go b/server/service/storage_location.go index b101e0e..b96c998 100644 --- a/server/service/storage_location.go +++ b/server/service/storage_location.go @@ -14,6 +14,9 @@ func ServiceListStorageLocations() ([]entity.StorageLocation, error) { if err := DB.Find(&locs).Error; err != nil { return nil, err } + if err := hydrateStorageLocations(locs); err != nil { + return nil, err + } return locs, nil } @@ -22,14 +25,72 @@ func ServiceGetStorageLocation(id uint) (*entity.StorageLocation, error) { if err := DB.First(&loc, id).Error; err != nil { return nil, err } + if err := hydrateStorageLocation(&loc); err != nil { + return nil, err + } return &loc, nil } func ServiceCreateStorageLocation(input *entity.StorageLocation) (*entity.StorageLocation, error) { - if err := DB.Create(input).Error; err != nil { + storageType := NormalizeStorageType(input) + if storageType == "" { + storageType = storageTypeLocal + } + + location := &entity.StorageLocation{ + Name: input.Name, + Type: storageType, + Enabled: true, + BasePath: "", + } + if err := DB.Create(location).Error; err != nil { return nil, err } - return input, nil + + if storageType == storageTypeLocal { + details := &entity.LocalStorageLocationDetails{ + StorageLocationID: location.ID, + BasePath: input.BasePath, + } + if err := DB.Create(details).Error; err != nil { + return nil, err + } + location.BasePath = details.BasePath + return location, nil + } + + if input.AuthType == "" { + if input.SSHKey != "" { + input.AuthType = "key" + } else if input.Password != "" { + input.AuthType = "password" + } + } + port := input.Port + if port == 0 { + port = 22 + } + details := &entity.SftpStorageLocationDetails{ + StorageLocationID: location.ID, + Address: input.Address, + Port: port, + RemotePath: input.RemotePath, + Username: input.Username, + Password: input.Password, + SSHKey: input.SSHKey, + AuthType: input.AuthType, + } + if err := DB.Create(details).Error; err != nil { + return nil, err + } + location.Address = details.Address + location.Port = details.Port + location.RemotePath = details.RemotePath + location.Username = details.Username + location.Password = details.Password + location.SSHKey = details.SSHKey + location.AuthType = details.AuthType + return location, nil } // StorageLocationMoveImpact represents what will be affected by moving a storage location @@ -51,7 +112,7 @@ func ServiceGetStorageLocationMoveImpact(id uint, newPath string) (*StorageLocat } impact := &StorageLocationMoveImpact{ - OldPath: location.BasePath, + OldPath: StorageBasePath(&location), NewPath: newPath, } @@ -78,7 +139,7 @@ func ServiceGetStorageLocationMoveImpact(id uint, newPath string) (*StorageLocat impact.BackupFiles += len(files) for _, file := range files { impact.TotalSizeBytes += file.SizeBytes - if file.LocalPath != "" && strings.HasPrefix(file.LocalPath, location.BasePath) { + if file.LocalPath != "" && strings.HasPrefix(file.LocalPath, impact.OldPath) { impact.FilesToMove = append(impact.FilesToMove, file.LocalPath) } } @@ -134,21 +195,116 @@ func ServiceGetStorageLocationDeletionImpact(id uint) (*DeletionImpact, error) { } // ServiceUpdateStorageLocation updates a storage location and moves files if path changed -func ServiceUpdateStorageLocation(id uint, input *entity.StorageLocation) (*entity.StorageLocation, error) { +func ServiceUpdateStorageLocation(id uint, input *entity.StorageLocation, setEnabled bool) (*entity.StorageLocation, error) { var location entity.StorageLocation if err := DB.First(&location, id).Error; err != nil { return nil, err } + if err := hydrateStorageLocation(&location); err != nil { + return nil, err + } - oldBasePath := location.BasePath - newBasePath := input.BasePath + oldStorageType := NormalizeStorageType(&location) + oldBasePath := StorageBasePath(&location) if input.Name != "" { location.Name = input.Name } + if input.Type != "" { + location.Type = input.Type + } + if setEnabled { + location.Enabled = input.Enabled + } + shouldDisableProfiles := setEnabled && location.Enabled == false + + newStorageType := NormalizeStorageType(&location) + + var localDetails entity.LocalStorageLocationDetails + hasLocalDetails := false + if oldStorageType == storageTypeLocal || newStorageType == storageTypeLocal { + if err := DB.Where("storage_location_id = ?", id).First(&localDetails).Error; err == nil { + hasLocalDetails = true + } + } + + var sftpDetails entity.SftpStorageLocationDetails + hasSftpDetails := false + if oldStorageType == storageTypeSFTP || newStorageType == storageTypeSFTP { + if err := DB.Where("storage_location_id = ?", id).First(&sftpDetails).Error; err == nil { + hasSftpDetails = true + } + } + + if newStorageType == storageTypeLocal { + if !hasLocalDetails { + localDetails = entity.LocalStorageLocationDetails{StorageLocationID: id} + } + if input.BasePath != "" { + localDetails.BasePath = input.BasePath + } + location.BasePath = localDetails.BasePath + if oldStorageType == storageTypeSFTP { + DB.Where("storage_location_id = ?", id).Delete(&entity.SftpStorageLocationDetails{}) + location.Address = "" + location.Port = 0 + location.RemotePath = "" + location.Username = "" + location.Password = "" + location.SSHKey = "" + location.AuthType = "" + } + } else if newStorageType == storageTypeSFTP { + if !hasSftpDetails { + sftpDetails = entity.SftpStorageLocationDetails{StorageLocationID: id} + } + if input.Address != "" { + sftpDetails.Address = input.Address + } + if input.Port != 0 { + sftpDetails.Port = input.Port + } else if sftpDetails.Port == 0 { + sftpDetails.Port = 22 + } + if input.RemotePath != "" { + sftpDetails.RemotePath = input.RemotePath + } + if input.Username != "" { + sftpDetails.Username = input.Username + } + if input.Password != "" { + sftpDetails.Password = input.Password + } + if input.SSHKey != "" { + sftpDetails.SSHKey = input.SSHKey + } + if input.AuthType != "" { + sftpDetails.AuthType = input.AuthType + } + if sftpDetails.AuthType == "" { + if sftpDetails.SSHKey != "" { + sftpDetails.AuthType = "key" + } else if sftpDetails.Password != "" { + sftpDetails.AuthType = "password" + } + } + location.Address = sftpDetails.Address + location.Port = sftpDetails.Port + location.RemotePath = sftpDetails.RemotePath + location.Username = sftpDetails.Username + location.Password = sftpDetails.Password + location.SSHKey = sftpDetails.SSHKey + location.AuthType = sftpDetails.AuthType + if oldStorageType == storageTypeLocal { + DB.Where("storage_location_id = ?", id).Delete(&entity.LocalStorageLocationDetails{}) + location.BasePath = "" + } + } + + newBasePath := StorageBasePath(&location) - // If path changed, move files to the new location - if newBasePath != "" && newBasePath != oldBasePath { + // If path changed, move files to the new location (local only) + if oldStorageType == storageTypeLocal && newStorageType == storageTypeLocal && newBasePath != "" && newBasePath != oldBasePath { // Track directories that may become empty after moving files dirsToCleanup := make(map[string]bool) @@ -171,11 +327,6 @@ func ServiceUpdateStorageLocation(id uint, input *entity.StorageLocation) (*enti } for _, file := range files { - // Skip files that have been deleted - they don't exist on disk anymore - if file.Deleted { - continue - } - if file.LocalPath != "" && strings.HasPrefix(file.LocalPath, oldBasePath) { // Check if file actually exists on disk before trying to move it if _, err := os.Stat(file.LocalPath); os.IsNotExist(err) { @@ -239,12 +390,44 @@ func ServiceUpdateStorageLocation(id uint, input *entity.StorageLocation) (*enti // Also try to remove the old base path itself and its empty parents removeEmptyDirs(oldBasePath) - location.BasePath = newBasePath } - if err := DB.Save(&location).Error; err != nil { + if err := DB.Model(&location).Select("name", "type", "enabled").Updates(&location).Error; err != nil { return nil, err } + if newStorageType == storageTypeLocal && localDetails.StorageLocationID != 0 { + if localDetails.ID == 0 { + if err := DB.Create(&localDetails).Error; err != nil { + return nil, err + } + } else if err := DB.Save(&localDetails).Error; err != nil { + return nil, err + } + location.BasePath = localDetails.BasePath + } + if newStorageType == storageTypeSFTP && sftpDetails.StorageLocationID != 0 { + if sftpDetails.ID == 0 { + if err := DB.Create(&sftpDetails).Error; err != nil { + return nil, err + } + } else if err := DB.Save(&sftpDetails).Error; err != nil { + return nil, err + } + location.Address = sftpDetails.Address + location.Port = sftpDetails.Port + location.RemotePath = sftpDetails.RemotePath + location.Username = sftpDetails.Username + location.Password = sftpDetails.Password + location.SSHKey = sftpDetails.SSHKey + location.AuthType = sftpDetails.AuthType + } + if shouldDisableProfiles { + if err := DB.Model(&entity.BackupProfile{}). + Where("storage_location_id = ?", id). + Update("enabled", false).Error; err != nil { + return nil, err + } + } return &location, nil } @@ -311,5 +494,7 @@ func ServiceDeleteStorageLocation(id string) error { return fmt.Errorf("cannot delete storage location: %d backup profile(s) still reference it", count) } + DB.Where("storage_location_id = ?", id).Delete(&entity.LocalStorageLocationDetails{}) + DB.Where("storage_location_id = ?", id).Delete(&entity.SftpStorageLocationDetails{}) return DB.Delete(&entity.StorageLocation{}, "id = ?", id).Error } diff --git a/server/service/storage_location_details.go b/server/service/storage_location_details.go new file mode 100644 index 0000000..9f1125b --- /dev/null +++ b/server/service/storage_location_details.go @@ -0,0 +1,82 @@ +package service + +import ( + "errors" + + "backapp-server/entity" + + "gorm.io/gorm" +) + +func hydrateStorageLocation(location *entity.StorageLocation) error { + if location == nil { + return nil + } + + storageType := NormalizeStorageType(location) + if storageType == storageTypeLocal { + var details entity.LocalStorageLocationDetails + err := DB.Where("storage_location_id = ?", location.ID).First(&details).Error + if err == nil { + location.BasePath = details.BasePath + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if location.BasePath != "" { + details = entity.LocalStorageLocationDetails{ + StorageLocationID: location.ID, + BasePath: location.BasePath, + } + if err := DB.Create(&details).Error; err != nil { + return err + } + } + return nil + } + + if storageType == storageTypeSFTP { + var details entity.SftpStorageLocationDetails + err := DB.Where("storage_location_id = ?", location.ID).First(&details).Error + if err == nil { + location.Address = details.Address + location.Port = details.Port + location.RemotePath = details.RemotePath + location.Username = details.Username + location.Password = details.Password + location.SSHKey = details.SSHKey + location.AuthType = details.AuthType + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if location.Address != "" || location.RemotePath != "" || location.Username != "" || location.Password != "" || location.SSHKey != "" { + details = entity.SftpStorageLocationDetails{ + StorageLocationID: location.ID, + Address: location.Address, + Port: location.Port, + RemotePath: location.RemotePath, + Username: location.Username, + Password: location.Password, + SSHKey: location.SSHKey, + AuthType: location.AuthType, + } + if err := DB.Create(&details).Error; err != nil { + return err + } + } + } + + return nil +} + +func hydrateStorageLocations(locations []entity.StorageLocation) error { + for i := range locations { + if err := hydrateStorageLocation(&locations[i]); err != nil { + return err + } + } + return nil +} diff --git a/server/service/storage_usage.go b/server/service/storage_usage.go index 3c82d30..a9f1116 100644 --- a/server/service/storage_usage.go +++ b/server/service/storage_usage.go @@ -3,11 +3,17 @@ package service import ( "os" "path/filepath" - "syscall" "backapp-server/entity" ) +type diskUsage struct { + Total int64 + Free int64 + Used int64 + Ok bool +} + // GetStorageUsage returns storage usage information for all storage locations func GetStorageUsage() (*entity.TotalStorageUsage, error) { var locations []entity.StorageLocation @@ -23,32 +29,62 @@ func GetStorageUsage() (*entity.TotalStorageUsage, error) { usage := entity.StorageUsage{ StorageLocationID: loc.ID, Name: loc.Name, - BasePath: loc.BasePath, + BasePath: StorageBasePath(&loc), + Enabled: loc.Enabled, } - // Get filesystem stats - var stat syscall.Statfs_t - if err := syscall.Statfs(loc.BasePath, &stat); err == nil { - usage.TotalBytes = int64(stat.Blocks) * int64(stat.Bsize) - usage.FreeBytes = int64(stat.Bavail) * int64(stat.Bsize) - usage.UsedBytes = usage.TotalBytes - usage.FreeBytes + if !loc.Enabled { + result.Locations = append(result.Locations, usage) + continue + } - if usage.TotalBytes > 0 { - usage.UsedPercent = float64(usage.UsedBytes) / float64(usage.TotalBytes) * 100 - usage.FreePercent = float64(usage.FreeBytes) / float64(usage.TotalBytes) * 100 + storageType := NormalizeStorageType(&loc) + if storageType == storageTypeLocal { + if disk, err := getDiskUsage(loc.BasePath); err == nil && disk.Ok { + usage.TotalBytes = disk.Total + usage.FreeBytes = disk.Free + usage.UsedBytes = disk.Used + + if usage.TotalBytes > 0 { + usage.UsedPercent = float64(usage.UsedBytes) / float64(usage.TotalBytes) * 100 + usage.FreePercent = float64(usage.FreeBytes) / float64(usage.TotalBytes) * 100 + } + + result.TotalBytes += usage.TotalBytes + result.FreeBytes += usage.FreeBytes + result.UsedBytes += usage.UsedBytes } - - result.TotalBytes += usage.TotalBytes - result.FreeBytes += usage.FreeBytes - result.UsedBytes += usage.UsedBytes } - // Calculate backup size for this location - backupSize, backupCount := calculateBackupSize(loc.BasePath) - usage.BackupSizeBytes = backupSize - usage.BackupCount = backupCount - result.TotalBackupSize += backupSize - result.TotalBackups += backupCount + if storageType == storageTypeLocal { + // Calculate backup size for this location + backupSize, backupCount := calculateBackupSize(loc.BasePath) + usage.BackupSizeBytes = backupSize + usage.BackupCount = backupCount + result.TotalBackupSize += backupSize + result.TotalBackups += backupCount + } else if storageType == storageTypeSFTP { + if backupSize, backupCount, err := getSFTPBackupSize(&loc); err == nil { + usage.BackupSizeBytes = backupSize + usage.BackupCount = backupCount + result.TotalBackupSize += backupSize + result.TotalBackups += backupCount + } + if disk, err := getSFTPDiskUsage(&loc); err == nil && disk.Ok { + usage.TotalBytes = disk.Total + usage.FreeBytes = disk.Free + usage.UsedBytes = disk.Used + + if usage.TotalBytes > 0 { + usage.UsedPercent = float64(usage.UsedBytes) / float64(usage.TotalBytes) * 100 + usage.FreePercent = float64(usage.FreeBytes) / float64(usage.TotalBytes) * 100 + } + + result.TotalBytes += usage.TotalBytes + result.FreeBytes += usage.FreeBytes + result.UsedBytes += usage.UsedBytes + } + } result.Locations = append(result.Locations, usage) } @@ -72,24 +108,47 @@ func GetStorageLocationUsage(locationID uint) (*entity.StorageUsage, error) { usage := &entity.StorageUsage{ StorageLocationID: loc.ID, Name: loc.Name, - BasePath: loc.BasePath, + BasePath: StorageBasePath(&loc), + Enabled: loc.Enabled, + } + + if !loc.Enabled { + return usage, nil } - // Get filesystem stats - var stat syscall.Statfs_t - if err := syscall.Statfs(loc.BasePath, &stat); err == nil { - usage.TotalBytes = int64(stat.Blocks) * int64(stat.Bsize) - usage.FreeBytes = int64(stat.Bavail) * int64(stat.Bsize) - usage.UsedBytes = usage.TotalBytes - usage.FreeBytes + storageType := NormalizeStorageType(&loc) + if storageType == storageTypeLocal { + if disk, err := getDiskUsage(loc.BasePath); err == nil && disk.Ok { + usage.TotalBytes = disk.Total + usage.FreeBytes = disk.Free + usage.UsedBytes = disk.Used - if usage.TotalBytes > 0 { - usage.UsedPercent = float64(usage.UsedBytes) / float64(usage.TotalBytes) * 100 - usage.FreePercent = float64(usage.FreeBytes) / float64(usage.TotalBytes) * 100 + if usage.TotalBytes > 0 { + usage.UsedPercent = float64(usage.UsedBytes) / float64(usage.TotalBytes) * 100 + usage.FreePercent = float64(usage.FreeBytes) / float64(usage.TotalBytes) * 100 + } } } - // Calculate backup size - usage.BackupSizeBytes, usage.BackupCount = calculateBackupSize(loc.BasePath) + if storageType == storageTypeLocal { + // Calculate backup size + usage.BackupSizeBytes, usage.BackupCount = calculateBackupSize(loc.BasePath) + } else if storageType == storageTypeSFTP { + if backupSize, backupCount, err := getSFTPBackupSize(&loc); err == nil { + usage.BackupSizeBytes = backupSize + usage.BackupCount = backupCount + } + if disk, err := getSFTPDiskUsage(&loc); err == nil && disk.Ok { + usage.TotalBytes = disk.Total + usage.FreeBytes = disk.Free + usage.UsedBytes = disk.Used + + if usage.TotalBytes > 0 { + usage.UsedPercent = float64(usage.UsedBytes) / float64(usage.TotalBytes) * 100 + usage.FreePercent = float64(usage.FreeBytes) / float64(usage.TotalBytes) * 100 + } + } + } return usage, nil } diff --git a/server/service/storage_usage_sftp.go b/server/service/storage_usage_sftp.go new file mode 100644 index 0000000..59a72fa --- /dev/null +++ b/server/service/storage_usage_sftp.go @@ -0,0 +1,155 @@ +package service + +import ( + "fmt" + "strconv" + "strings" + + "backapp-server/entity" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +func getSFTPDiskUsage(location *entity.StorageLocation) (diskUsage, error) { + _ = hydrateStorageLocation(location) + if location == nil || strings.TrimSpace(location.RemotePath) == "" { + return diskUsage{}, fmt.Errorf("remote_path is required for sftp usage") + } + + config, err := buildSFTPSSHConfig(location) + if err != nil { + return diskUsage{}, err + } + addr, err := resolveSFTPAddress(location) + if err != nil { + return diskUsage{}, err + } + + sshClient, err := ssh.Dial("tcp", addr, config) + if err != nil { + return diskUsage{}, fmt.Errorf("sftp ssh connection failed: %v", err) + } + defer sshClient.Close() + + if usage, err := getSFTPStatVFS(sshClient, location.RemotePath); err == nil { + return usage, nil + } + + return getSFTPDiskUsageViaDF(sshClient, location.RemotePath) +} + +func getSFTPBackupSize(location *entity.StorageLocation) (int64, int64, error) { + _ = hydrateStorageLocation(location) + if location == nil || strings.TrimSpace(location.RemotePath) == "" { + return 0, 0, fmt.Errorf("remote_path is required for sftp usage") + } + + config, err := buildSFTPSSHConfig(location) + if err != nil { + return 0, 0, err + } + addr, err := resolveSFTPAddress(location) + if err != nil { + return 0, 0, err + } + + sshClient, err := ssh.Dial("tcp", addr, config) + if err != nil { + return 0, 0, fmt.Errorf("sftp ssh connection failed: %v", err) + } + defer sshClient.Close() + + client, err := sftp.NewClient(sshClient) + if err != nil { + return 0, 0, fmt.Errorf("failed to create sftp client: %v", err) + } + defer client.Close() + + var totalSize int64 + var fileCount int64 + walker := client.Walk(location.RemotePath) + for walker.Step() { + if err := walker.Err(); err != nil { + continue + } + info := walker.Stat() + if info == nil || info.IsDir() { + continue + } + totalSize += info.Size() + fileCount++ + } + + return totalSize, fileCount, nil +} + +func getSFTPStatVFS(sshClient *ssh.Client, remotePath string) (diskUsage, error) { + client, err := sftp.NewClient(sshClient) + if err != nil { + return diskUsage{}, fmt.Errorf("failed to create sftp client: %v", err) + } + defer client.Close() + + stats, err := client.StatVFS(remotePath) + if err != nil { + return diskUsage{}, err + } + + blockSize := int64(stats.Frsize) + if blockSize == 0 { + blockSize = int64(stats.Bsize) + } + if blockSize == 0 { + blockSize = 1 + } + + total := int64(stats.Blocks) * blockSize + free := int64(stats.Bfree) * blockSize + used := total - free + return diskUsage{ + Total: total, + Free: free, + Used: used, + Ok: true, + }, nil +} + +func getSFTPDiskUsageViaDF(sshClient *ssh.Client, remotePath string) (diskUsage, error) { + session, err := sshClient.NewSession() + if err != nil { + return diskUsage{}, fmt.Errorf("failed to create ssh session: %v", err) + } + defer session.Close() + + cmd := fmt.Sprintf("df -k '%s' | tail -1", remotePath) + output, err := session.CombinedOutput(cmd) + if err != nil { + return diskUsage{}, fmt.Errorf("df command failed: %v", err) + } + + fields := strings.Fields(strings.TrimSpace(string(output))) + if len(fields) < 5 { + return diskUsage{}, fmt.Errorf("unexpected df output: %s", strings.TrimSpace(string(output))) + } + + sizeKB, err := strconv.ParseInt(fields[1], 10, 64) + if err != nil { + return diskUsage{}, fmt.Errorf("failed to parse size: %v", err) + } + usedKB, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil { + return diskUsage{}, fmt.Errorf("failed to parse used: %v", err) + } + freeKB, err := strconv.ParseInt(fields[3], 10, 64) + if err != nil { + return diskUsage{}, fmt.Errorf("failed to parse free: %v", err) + } + + return diskUsage{ + Total: sizeKB * 1024, + Free: freeKB * 1024, + Used: usedKB * 1024, + Ok: true, + }, nil +} diff --git a/web/package-lock.json b/web/package-lock.json index 611d8a4..2139512 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -67,7 +67,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -336,7 +335,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -437,7 +435,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -481,7 +478,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1265,7 +1261,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.6", @@ -1864,7 +1859,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1886,7 +1880,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1964,7 +1957,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2105,7 +2097,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2426,7 +2417,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3253,7 +3243,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3379,7 +3368,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3389,7 +3377,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3782,7 +3769,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3904,7 +3890,6 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/api/notifications.ts b/web/src/api/notifications.ts index bc6f3bb..311f1bd 100644 --- a/web/src/api/notifications.ts +++ b/web/src/api/notifications.ts @@ -47,6 +47,7 @@ export interface StorageUsage { storage_location_id: number; name: string; base_path: string; + enabled: boolean; total_bytes: number; used_bytes: number; free_bytes: number; diff --git a/web/src/api/storage-locations.ts b/web/src/api/storage-locations.ts index b7532b2..1bf3681 100644 --- a/web/src/api/storage-locations.ts +++ b/web/src/api/storage-locations.ts @@ -7,13 +7,10 @@ export const storageLocationApi = { return fetchJSON('/storage-locations'); }, - async create(data: StorageLocationCreateInput): Promise { + async create(formData: FormData): Promise { return fetchJSON('/storage-locations', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), + body: formData, }); }, @@ -40,4 +37,10 @@ export const storageLocationApi = { method: 'DELETE', }); }, + + async testConnection(id: number): Promise<{ success: boolean; message: string }> { + return fetchJSON<{ success: boolean; message: string }>(`/storage-locations/${id}/test-connection`, { + method: 'POST', + }); + }, }; diff --git a/web/src/components/backup-profiles/BackupRunFilesCard.tsx b/web/src/components/backup-profiles/BackupRunFilesCard.tsx index 9884c12..3f4d8f1 100644 --- a/web/src/components/backup-profiles/BackupRunFilesCard.tsx +++ b/web/src/components/backup-profiles/BackupRunFilesCard.tsx @@ -1,4 +1,3 @@ -import DeleteIcon from '@mui/icons-material/Delete'; import DownloadIcon from '@mui/icons-material/Download'; import FolderIcon from '@mui/icons-material/Folder'; import { @@ -24,10 +23,9 @@ interface BackupRunFilesCardProps { files: BackupFile[]; formatSize: (bytes: number) => string; onDownload: (fileId: number, filePath: string) => void; - onDeleteFile?: (fileId: number) => void; } -function BackupRunFilesCard({ files, formatSize, onDownload, onDeleteFile }: BackupRunFilesCardProps) { +function BackupRunFilesCard({ files, formatSize, onDownload }: BackupRunFilesCardProps) { return ( @@ -57,9 +55,11 @@ function BackupRunFilesCard({ files, formatSize, onDownload, onDeleteFile }: Bac - {files.map((file, index) => ( - - + {files.map((file, index) => { + const isAvailable = file.available !== false; + return ( + + + ) : !isAvailable ? ( + ) : ( - {file.deleted ? ( - - - - - - - - ) : ( + {file.deleted || !isAvailable ? null : ( )} - {onDeleteFile && !file.deleted && ( - - onDeleteFile(file.id)} - aria-label={`Delete ${file.remote_path || ''}`} - > - - - - )} - - ))} + + ); + })} diff --git a/web/src/components/backup-profiles/ConfigureProfileDialog.tsx b/web/src/components/backup-profiles/ConfigureProfileDialog.tsx index b6bf433..d5941c3 100644 --- a/web/src/components/backup-profiles/ConfigureProfileDialog.tsx +++ b/web/src/components/backup-profiles/ConfigureProfileDialog.tsx @@ -4,12 +4,10 @@ import { Button, Card, CardContent, - Checkbox, Chip, Dialog, DialogContent, DialogTitle, - FormControlLabel, IconButton, MenuItem, Stack, @@ -22,6 +20,7 @@ import { useEffect, useState } from 'react'; import { commandApi, fileRuleApi } from '../../api'; import type { Command, FileRule } from '../../types'; import { TabPanel } from '../common'; +import AddFileRuleForm from '../forms/AddFileRuleForm'; interface ConfigureProfileDialogProps { open: boolean; @@ -35,6 +34,11 @@ function ConfigureProfileDialog({ open, profileId, onClose }: ConfigureProfileDi const [fileRules, setFileRules] = useState([]); const [showCommandForm, setShowCommandForm] = useState(false); const [showFileRuleForm, setShowFileRuleForm] = useState(false); + const [newFileRule, setNewFileRule] = useState({ + remote_path: '', + recursive: true, + exclude_pattern: '', + }); useEffect(() => { if (open) { @@ -90,20 +94,24 @@ function ConfigureProfileDialog({ open, profileId, onClose }: ConfigureProfileDi } }; - const handleAddFileRule = async (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); + const handleAddFileRule = async () => { + const trimmedPath = newFileRule.remote_path.trim(); + if (!trimmedPath) return; const data = { - remote_path: formData.get('remote_path') as string, - recursive: formData.get('recursive') === 'on', - exclude_pattern: formData.get('exclude_pattern') as string || undefined, + remote_path: trimmedPath, + recursive: newFileRule.recursive, + exclude_pattern: newFileRule.exclude_pattern.trim() || undefined, }; try { await fileRuleApi.create(profileId, data); setShowFileRuleForm(false); loadFileRules(); - e.currentTarget.reset(); + setNewFileRule({ + remote_path: '', + recursive: true, + exclude_pattern: '', + }); } catch (error) { console.error('Error creating file rule:', error); } @@ -246,40 +254,12 @@ function ConfigureProfileDialog({ open, profileId, onClose }: ConfigureProfileDi {showFileRuleForm && ( - - -
- - - } - label="Recursive (include subdirectories)" - /> - - - - - - -
-
-
+ setShowFileRuleForm(false)} + /> )} @@ -302,7 +282,7 @@ function ConfigureProfileDialog({ open, profileId, onClose }: ConfigureProfileDi diff --git a/web/src/components/backup-profiles/FileRuleItem.tsx b/web/src/components/backup-profiles/FileRuleItem.tsx index b5f6604..659998a 100644 --- a/web/src/components/backup-profiles/FileRuleItem.tsx +++ b/web/src/components/backup-profiles/FileRuleItem.tsx @@ -26,10 +26,11 @@ function FileRuleItem({ fileRule, profileId, onFileRuleChanged, serverId }: File }; const handleSave = async () => { - if (!editedPath.trim()) return; + const trimmedPath = editedPath.trim(); + if (!trimmedPath) return; try { await fileRuleApi.update(fileRule.id, { - remote_path: editedPath.trim(), + remote_path: trimmedPath, recursive: editedRecursive, exclude_pattern: editedPattern.trim() || undefined, }); @@ -75,14 +76,14 @@ function FileRuleItem({ fileRule, profileId, onFileRuleChanged, serverId }: File {isEditing ? ( - + + {fileRule.exclude_pattern && ( void; + showStatus?: boolean; }) { const navigate = useNavigate(); const [expanded, setExpanded] = useState(group.files.length === 1); const hasMultipleFiles = group.files.length > 1; + const singleFile = group.files[0]; + const isSingleAvailable = singleFile?.available !== false; + const isSingleDeleted = singleFile?.deleted; return ( <> @@ -140,25 +147,42 @@ function RunGroupRow({ {formatFileSize(group.totalSize)} {formatDate(group.runStartedAt)} + {showStatus && ( + + {!hasMultipleFiles && ( + isSingleDeleted ? ( + + ) : !isSingleAvailable ? ( + + ) : ( + + ) + )} + + )} {!hasMultipleFiles && ( - - { - e.stopPropagation(); - onDownload(group.files[0].id, group.files[0].remote_path || group.files[0].local_path); - }} - > - - - + + {isSingleDeleted || !isSingleAvailable ? null : ( + + { + e.stopPropagation(); + onDownload(singleFile.id, singleFile.remote_path || singleFile.local_path); + }} + > + + + + )} + )} {hasMultipleFiles && ( - + @@ -173,15 +197,30 @@ function RunGroupRow({ {formatFileSize(file.size_bytes || file.file_size)} + {showStatus && ( + + {file.deleted ? ( + + ) : file.available === false ? ( + + ) : ( + + )} + + )} - - onDownload(file.id, file.remote_path || file.local_path)} - > - - - + + {file.deleted || file.available === false ? null : ( + + onDownload(file.id, file.remote_path || file.local_path)} + > + + + + )} + ))} @@ -201,6 +240,7 @@ function BackupFilesTable({ title = 'Backup Files', showRunColumn = true, emptyMessage = 'No backup files found.', + showStatus = false, groupByRun = false, initialLimit = 0, }: BackupFilesTableProps) { @@ -252,25 +292,27 @@ function BackupFilesTable({ <>
- - - - Run - Path - Size - Date - Actions - - - - {displayedGroups.map((group) => ( - - ))} - + + + + Run + Path + Size + Date + {showStatus && Status} + Actions + + + + {displayedGroups.map((group) => ( + + ))} +
{hiddenCount > 0 && ( @@ -321,6 +363,7 @@ function BackupFilesTable({ Local Path Size Created + {showStatus && Status} Actions
@@ -349,12 +392,27 @@ function BackupFilesTable({ {formatFileSize(file.size_bytes || file.file_size)} {formatDate(file.created_at)} + {showStatus && ( + + {file.deleted ? ( + + ) : file.available === false ? ( + + ) : ( + + )} + + )} - - handleDownloadFile(file.id, file.remote_path || file.local_path)}> - - - + + {file.deleted || file.available === false ? null : ( + + handleDownloadFile(file.id, file.remote_path || file.local_path)}> + + + + )} + ))} diff --git a/web/src/components/common/PathPickerField.tsx b/web/src/components/common/PathPickerField.tsx index 13dbe7b..425c5ab 100644 --- a/web/src/components/common/PathPickerField.tsx +++ b/web/src/components/common/PathPickerField.tsx @@ -9,6 +9,7 @@ interface PathPickerFieldProps { onChange: (value: string) => void; placeholder?: string; helperText?: string; + error?: boolean; size?: 'small' | 'medium'; fullWidth?: boolean; disabled?: boolean; @@ -24,6 +25,7 @@ function PathPickerField({ onChange, placeholder, helperText, + error = false, size = 'small', fullWidth = true, disabled = false, @@ -48,6 +50,7 @@ function PathPickerField({ onChange={(e) => onChange(e.target.value)} placeholder={placeholder} helperText={helperText} + error={error} data-testid="input-base-path" /> diff --git a/web/src/components/common/StorageWidget.tsx b/web/src/components/common/StorageWidget.tsx index ec8cba4..ad4609b 100644 --- a/web/src/components/common/StorageWidget.tsx +++ b/web/src/components/common/StorageWidget.tsx @@ -23,6 +23,8 @@ export function StorageWidget({ compact = false }: StorageWidgetProps) { useEffect(() => { loadUsage(); + const intervalId = window.setInterval(loadUsage, 30000); + return () => window.clearInterval(intervalId); }, []); const loadUsage = async () => { @@ -178,12 +180,12 @@ export function StorageWidget({ compact = false }: StorageWidgetProps) { {loc.name} - {formatBytes(loc.backup_size_bytes)} + {loc.enabled ? formatBytes(loc.backup_size_bytes) : 'Disabled'}
0 ? loc.used_percent : 0} + value={loc.total_bytes > 0 && loc.enabled ? loc.used_percent : 0} color={getProgressColor(loc.used_percent)} sx={{ height: 4, borderRadius: 2 }} /> diff --git a/web/src/components/forms/AddFileRuleForm.tsx b/web/src/components/forms/AddFileRuleForm.tsx index 5e4f970..359e37b 100644 --- a/web/src/components/forms/AddFileRuleForm.tsx +++ b/web/src/components/forms/AddFileRuleForm.tsx @@ -18,6 +18,8 @@ interface AddFileRuleFormProps { } function AddFileRuleForm({ formData, onFormDataChange, onAdd, serverId, onCancel }: AddFileRuleFormProps) { + const trimmedPath = formData.remote_path.trim(); + return ( <> @@ -62,7 +64,7 @@ function AddFileRuleForm({ formData, onFormDataChange, onAdd, serverId, onCancel + {selectedFile && ( + + setSelectedFile(null)} + color="primary" + variant="outlined" + /> + + )} + + {isEditMode ? 'Leave empty to keep the existing SSH key' : 'Upload your SSH private key file'} + +
+ )} + + {authType === 'password' && ( + + + {isEditMode && ( + + Leave empty to keep the existing password + + )} + + )} +
+ )}
diff --git a/web/src/components/storage-locations/StorageLocationList.tsx b/web/src/components/storage-locations/StorageLocationList.tsx index dec1281..00b2cf2 100644 --- a/web/src/components/storage-locations/StorageLocationList.tsx +++ b/web/src/components/storage-locations/StorageLocationList.tsx @@ -1,9 +1,12 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import EditIcon from '@mui/icons-material/Edit'; import { Box, Button, + CircularProgress, Paper, Stack, + Switch, Table, TableBody, TableCell, @@ -18,9 +21,21 @@ interface StorageLocationListProps { locations: StorageLocation[]; onDelete: (id: number) => void; onEdit: (location: StorageLocation) => void; + testingConnection: number | null; + onTestConnection: (locationId: number) => void; + onToggleEnabled: (location: StorageLocation) => void; + togglingLocation: number | null; } -function StorageLocationList({ locations, onDelete, onEdit }: StorageLocationListProps) { +function StorageLocationList({ + locations, + onDelete, + onEdit, + testingConnection, + onTestConnection, + onToggleEnabled, + togglingLocation, +}: StorageLocationListProps) { if (locations.length === 0) { return ( @@ -43,7 +58,9 @@ function StorageLocationList({ locations, onDelete, onEdit }: StorageLocationLis Name - Base Path + Type + Path + Enabled Actions @@ -53,6 +70,11 @@ function StorageLocationList({ locations, onDelete, onEdit }: StorageLocationLis {location.name} + + + {location.type || 'local'} + + - {location.base_path} + {location.type === 'sftp' ? location.remote_path || '' : location.base_path} + + onToggleEnabled(location)} + disabled={togglingLocation === location.id} + inputProps={{ 'aria-label': 'toggle storage location' }} + /> + + {location.type === 'sftp' && ( + + )}