Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/binary.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ on:
default: 'amd64'
push:
branches:
- '**'
- main
pull_request:
branches:
- main

permissions:
contents: read
Expand Down
35 changes: 26 additions & 9 deletions server/controller/backup_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package controller

import (
"net/http"
"os"
"path/filepath"
"strconv"

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion server/controller/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
184 changes: 181 additions & 3 deletions server/controller/storage_location.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package controller

import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"

"backapp-server/entity"
"backapp-server/service"
Expand All @@ -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()})
Expand All @@ -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"})
Expand Down Expand Up @@ -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"})
}
3 changes: 2 additions & 1 deletion server/entity/backup_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
13 changes: 11 additions & 2 deletions server/entity/storage_location.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
25 changes: 25 additions & 0 deletions server/entity/storage_location_details.go
Original file line number Diff line number Diff line change
@@ -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"`
}
1 change: 1 addition & 0 deletions server/entity/storage_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading
Loading