Files
warpbox-dev/backend/libs/services/storage.go

442 lines
13 KiB
Go
Raw Normal View History

package services
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
2026-05-31 04:02:28 +03:00
"path"
"path/filepath"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
)
var storageBackendsBucket = []byte("storage_backends")
const (
2026-05-31 04:02:28 +03:00
StorageBackendLocal = "local"
StorageBackendS3 = "s3"
StorageBackendSFTP = "sftp"
StorageBackendSMB = "smb"
StorageBackendWebDAV = "webdav"
StorageProviderS3 = "s3"
StorageProviderContabo = "contabo"
2026-05-31 04:02:28 +03:00
StorageProviderSFTP = "sftp"
StorageProviderSMB = "smb"
StorageProviderWebDAV = "webdav"
)
type StorageObject struct {
Key string
Size int64
ContentType string
ModTime time.Time
Body io.ReadCloser
}
type StorageBackend interface {
ID() string
Type() string
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
Get(ctx context.Context, key string) (StorageObject, error)
Delete(ctx context.Context, key string) error
DeletePrefix(ctx context.Context, prefix string) error
Usage(ctx context.Context) (int64, error)
Test(ctx context.Context) error
}
type StorageBackendConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Provider string `json:"provider,omitempty"`
Enabled bool `json:"enabled"`
LocalPath string `json:"localPath,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Region string `json:"region,omitempty"`
Bucket string `json:"bucket,omitempty"`
AccessKey string `json:"accessKey,omitempty"`
SecretKey string `json:"secretKey,omitempty"`
UseSSL bool `json:"useSsl,omitempty"`
PathStyle bool `json:"pathStyle,omitempty"`
2026-05-31 04:02:28 +03:00
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
PrivateKey string `json:"privateKey,omitempty"`
HostKey string `json:"hostKey,omitempty"`
RemotePath string `json:"remotePath,omitempty"`
Share string `json:"share,omitempty"`
Domain string `json:"domain,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastTestedAt time.Time `json:"lastTestedAt,omitempty"`
LastTestError string `json:"lastTestError,omitempty"`
LastTestSuccess bool `json:"lastTestSuccess,omitempty"`
}
type StorageBackendView struct {
Config StorageBackendConfig
UsageBytes int64
UsageLabel string
InUse bool
}
type StorageService struct {
db *bbolt.DB
localFilesDir string
}
func NewStorageService(db *bbolt.DB, dataDir string) (*StorageService, error) {
filesDir := filepath.Join(dataDir, "files")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return nil, err
}
service := &StorageService{db: db, localFilesDir: filesDir}
err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(storageBackendsBucket)
return err
})
if err != nil {
return nil, err
}
return service, nil
}
func (s *StorageService) LocalFilesDir() string {
return s.localFilesDir
}
func (s *StorageService) Backend(id string) (StorageBackend, error) {
cfg, err := s.BackendConfig(id)
if err != nil {
return nil, err
}
if !cfg.Enabled {
return nil, fmt.Errorf("storage backend is disabled")
}
return s.backendFromConfig(cfg)
}
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
id = strings.TrimSpace(id)
if id == "" || id == StorageBackendLocal {
return s.localConfig(), nil
}
var cfg StorageBackendConfig
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(storageBackendsBucket).Get([]byte(id))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &cfg)
})
if err != nil {
return StorageBackendConfig{}, err
}
return cfg, nil
}
func (s *StorageService) ListBackendConfigs() ([]StorageBackendConfig, error) {
configs := []StorageBackendConfig{s.localConfig()}
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(storageBackendsBucket).ForEach(func(_, value []byte) error {
var cfg StorageBackendConfig
if err := json.Unmarshal(value, &cfg); err != nil {
return err
}
configs = append(configs, cfg)
return nil
})
})
sort.Slice(configs, func(i, j int) bool {
if configs[i].ID == StorageBackendLocal {
return true
}
if configs[j].ID == StorageBackendLocal {
return false
}
return strings.ToLower(configs[i].Name) < strings.ToLower(configs[j].Name)
})
return configs, err
}
func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBackendConfig, error) {
input.ID = randomID(10)
input.Provider = normalizeStorageProvider(input.Provider)
2026-05-31 04:02:28 +03:00
switch input.Provider {
case StorageProviderSFTP:
input.Type = StorageBackendSFTP
case StorageProviderSMB:
input.Type = StorageBackendSMB
case StorageProviderWebDAV:
input.Type = StorageBackendWebDAV
default:
input.Type = StorageBackendS3
}
2026-05-31 04:02:28 +03:00
if err := normalizeStorageBackendConfig(&input, true); err != nil {
return StorageBackendConfig{}, err
}
now := time.Now().UTC()
input.Enabled = true
input.CreatedAt = now
input.UpdatedAt = now
if err := s.SaveBackendConfig(input); err != nil {
return StorageBackendConfig{}, err
}
return input, nil
}
func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig) (StorageBackendConfig, error) {
current, err := s.BackendConfig(id)
if err != nil {
return StorageBackendConfig{}, err
}
2026-05-31 04:02:28 +03:00
if current.ID == StorageBackendLocal {
return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited")
}
2026-05-31 04:02:28 +03:00
input.ID = current.ID
input.Type = current.Type
input.Provider = normalizeStorageProvider(input.Provider)
switch input.Provider {
case StorageProviderSFTP:
input.Type = StorageBackendSFTP
case StorageProviderSMB:
input.Type = StorageBackendSMB
case StorageProviderWebDAV:
input.Type = StorageBackendWebDAV
default:
input.Type = StorageBackendS3
}
if strings.TrimSpace(input.SecretKey) == "" {
input.SecretKey = current.SecretKey
}
2026-05-31 04:02:28 +03:00
if strings.TrimSpace(input.Password) == "" {
input.Password = current.Password
}
2026-05-31 04:02:28 +03:00
if strings.TrimSpace(input.PrivateKey) == "" {
input.PrivateKey = current.PrivateKey
}
2026-05-31 04:02:28 +03:00
if strings.TrimSpace(input.HostKey) == "" {
input.HostKey = current.HostKey
}
2026-05-31 04:02:28 +03:00
input.Enabled = current.Enabled
input.CreatedAt = current.CreatedAt
input.LastTestedAt = current.LastTestedAt
input.LastTestError = current.LastTestError
input.LastTestSuccess = current.LastTestSuccess
if err := normalizeStorageBackendConfig(&input, false); err != nil {
return StorageBackendConfig{}, err
}
2026-05-31 04:02:28 +03:00
if err := s.SaveBackendConfig(input); err != nil {
return StorageBackendConfig{}, err
}
return input, nil
}
func normalizeStorageBackendConfig(input *StorageBackendConfig, creating bool) error {
input.Name = strings.TrimSpace(input.Name)
input.Provider = normalizeStorageProvider(input.Provider)
if input.Provider == StorageProviderSFTP {
input.Type = StorageBackendSFTP
input.Host = strings.TrimSpace(input.Host)
input.Username = strings.TrimSpace(input.Username)
input.Password = strings.TrimSpace(input.Password)
input.PrivateKey = strings.TrimSpace(input.PrivateKey)
input.HostKey = strings.TrimSpace(input.HostKey)
input.RemotePath = cleanRemoteRoot(input.RemotePath)
if input.Port <= 0 {
input.Port = 22
}
if input.Name == "" {
input.Name = input.Host
}
if input.Name == "" || input.Host == "" || input.Username == "" || (input.Password == "" && input.PrivateKey == "") {
return fmt.Errorf("name, host, username, and password or private key are required")
}
return nil
}
if input.Provider == StorageProviderSMB {
input.Type = StorageBackendSMB
input.Host = strings.TrimSpace(input.Host)
input.Username = strings.TrimSpace(input.Username)
input.Password = strings.TrimSpace(input.Password)
input.Share = strings.Trim(strings.TrimSpace(input.Share), `/\`)
input.Domain = strings.TrimSpace(input.Domain)
input.RemotePath = cleanRemoteRoot(input.RemotePath)
if input.Port <= 0 {
input.Port = 445
}
if input.Name == "" {
input.Name = input.Share
}
if input.Name == "" || input.Host == "" || input.Share == "" || input.Username == "" || input.Password == "" {
return fmt.Errorf("name, host, share, username, and password are required")
}
return nil
}
if input.Provider == StorageProviderWebDAV {
input.Type = StorageBackendWebDAV
input.Endpoint = strings.TrimSpace(input.Endpoint)
input.Username = strings.TrimSpace(input.Username)
input.Password = strings.TrimSpace(input.Password)
input.RemotePath = cleanRemoteRoot(input.RemotePath)
if input.Name == "" {
input.Name = input.Endpoint
}
if input.Name == "" || input.Endpoint == "" {
return fmt.Errorf("name and WebDAV URL are required")
}
return nil
}
input.Type = StorageBackendS3
if input.Provider == StorageProviderContabo {
input.UseSSL = true
input.PathStyle = true
}
input.Name = strings.TrimSpace(input.Name)
input.Endpoint = strings.TrimSpace(input.Endpoint)
input.Region = strings.TrimSpace(input.Region)
input.Bucket = strings.TrimSpace(input.Bucket)
input.AccessKey = strings.TrimSpace(input.AccessKey)
input.SecretKey = strings.TrimSpace(input.SecretKey)
if input.Name == "" {
input.Name = input.Bucket
}
if input.Name == "" || input.Endpoint == "" || input.Bucket == "" || input.AccessKey == "" || input.SecretKey == "" {
return fmt.Errorf("name, endpoint, bucket, access key, and secret key are required")
}
return nil
}
func (s *StorageService) SaveBackendConfig(cfg StorageBackendConfig) error {
if cfg.ID == "" || cfg.ID == StorageBackendLocal {
return fmt.Errorf("invalid storage backend id")
}
cfg.UpdatedAt = time.Now().UTC()
data, err := json.Marshal(cfg)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(storageBackendsBucket).Put([]byte(cfg.ID), data)
})
}
func (s *StorageService) DisableBackend(id string, inUse bool) error {
if id == "" || id == StorageBackendLocal {
return fmt.Errorf("local storage cannot be disabled")
}
if inUse {
return fmt.Errorf("storage backend is in use")
}
cfg, err := s.BackendConfig(id)
if err != nil {
return err
}
cfg.Enabled = false
return s.SaveBackendConfig(cfg)
}
func (s *StorageService) DeleteBackend(id string, inUse bool) error {
if id == "" || id == StorageBackendLocal {
return fmt.Errorf("local storage cannot be deleted")
}
if inUse {
return fmt.Errorf("storage backend is in use")
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(storageBackendsBucket).Delete([]byte(id))
})
}
func (s *StorageService) TestBackend(id string) (StorageBackendConfig, error) {
cfg, err := s.BackendConfig(id)
if err != nil {
return StorageBackendConfig{}, err
}
backend, err := s.backendFromConfig(cfg)
if err != nil {
return StorageBackendConfig{}, err
}
err = backend.Test(context.Background())
cfg.LastTestedAt = time.Now().UTC()
cfg.LastTestError = ""
cfg.LastTestSuccess = err == nil
if err != nil {
cfg.LastTestError = err.Error()
}
if cfg.ID != StorageBackendLocal {
_ = s.SaveBackendConfig(cfg)
}
return cfg, err
}
func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBackend, error) {
switch cfg.Type {
case StorageBackendLocal:
return localStorageBackend{id: cfg.ID, root: cfg.LocalPath}, nil
case StorageBackendS3:
return newS3StorageBackend(cfg)
2026-05-31 04:02:28 +03:00
case StorageBackendSFTP:
return sftpStorageBackend{cfg: cfg}, nil
case StorageBackendSMB:
return smbStorageBackend{cfg: cfg}, nil
case StorageBackendWebDAV:
return newWebDAVStorageBackend(cfg), nil
default:
return nil, fmt.Errorf("unsupported storage backend type %q", cfg.Type)
}
}
func (s *StorageService) localConfig() StorageBackendConfig {
now := time.Now().UTC()
return StorageBackendConfig{
ID: StorageBackendLocal,
Name: "Local files",
Type: StorageBackendLocal,
Provider: StorageBackendLocal,
Enabled: true,
LocalPath: s.localFilesDir,
CreatedAt: now,
UpdatedAt: now,
}
}
func normalizeStorageProvider(provider string) string {
switch strings.TrimSpace(provider) {
case StorageProviderContabo:
return StorageProviderContabo
2026-05-31 04:02:28 +03:00
case StorageProviderSFTP:
return StorageProviderSFTP
case StorageProviderSMB:
return StorageProviderSMB
case StorageProviderWebDAV:
return StorageProviderWebDAV
default:
return StorageProviderS3
}
}
func cleanObjectKey(key string) string {
return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./")
}
2026-05-31 04:02:28 +03:00
func cleanRemoteRoot(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "."
}
cleaned := path.Clean(strings.ReplaceAll(value, "\\", "/"))
if cleaned == "/" {
return "/"
}
return strings.TrimSuffix(cleaned, "/")
}