package services import ( "context" "encoding/json" "fmt" "io" "os" "path" "path/filepath" "sort" "strings" "time" "go.etcd.io/bbolt" ) var storageBackendsBucket = []byte("storage_backends") var storageBackendTestStatusBucket = []byte("storage_backend_test_status") const ( StorageBackendLocal = "local" StorageBackendS3 = "s3" StorageBackendSFTP = "sftp" StorageBackendSMB = "smb" StorageBackendWebDAV = "webdav" StorageProviderS3 = "s3" StorageProviderContabo = "contabo" 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"` 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 InUseReason string SpeedTests []StorageSpeedTest CanSpeedTest 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 { if _, err := tx.CreateBucketIfNotExists(storageBackendsBucket); err != nil { return err } if _, err := tx.CreateBucketIfNotExists(storageBackendTestStatusBucket); err != nil { return err } _, err := tx.CreateBucketIfNotExists(storageSpeedTestsBucket) 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) BackendForMaintenance(id string) (StorageBackend, error) { cfg, err := s.BackendConfig(id) if err != nil { return nil, err } return s.backendFromConfig(cfg) } func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) { id = strings.TrimSpace(id) if id == "" || id == StorageBackendLocal { cfg := s.localConfig() s.applyStoredTestStatus(&cfg) return cfg, 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) { return s.CreateBackend(input) } func (s *StorageService) CreateBackend(input StorageBackendConfig) (StorageBackendConfig, error) { input.ID = randomID(10) input.Provider = normalizeStorageProvider(input.Provider) input.Type = storageTypeForProvider(input.Provider) 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) { return s.UpdateBackend(id, input) } func (s *StorageService) UpdateBackend(id string, input StorageBackendConfig) (StorageBackendConfig, error) { current, err := s.BackendConfig(id) if err != nil { return StorageBackendConfig{}, err } if current.ID == StorageBackendLocal { return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited") } current.Provider = canonicalStorageProvider(current) current.Type = storageTypeForProvider(current.Provider) input.ID = current.ID requestedProvider := normalizeStorageProvider(input.Provider) requestedType := storageTypeForProvider(requestedProvider) if input.Type != "" && input.Type != requestedType { return StorageBackendConfig{}, fmt.Errorf("storage type cannot be changed after creation") } input.Provider = requestedProvider input.Type = requestedType if input.Provider != current.Provider || input.Type != current.Type { return StorageBackendConfig{}, fmt.Errorf("storage provider cannot be changed after creation") } if strings.TrimSpace(input.SecretKey) == "" { input.SecretKey = current.SecretKey } if strings.TrimSpace(input.Password) == "" { input.Password = current.Password } if strings.TrimSpace(input.PrivateKey) == "" { input.PrivateKey = current.PrivateKey } if strings.TrimSpace(input.HostKey) == "" { input.HostKey = current.HostKey } 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 } 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) 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) } else { _ = s.saveBackendTestStatus(cfg) } return cfg, err } func (s *StorageService) applyStoredTestStatus(cfg *StorageBackendConfig) { _ = s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(storageBackendTestStatusBucket) if bucket == nil { return nil } data := bucket.Get([]byte(cfg.ID)) if data == nil { return nil } var status struct { LastTestedAt time.Time `json:"lastTestedAt,omitempty"` LastTestError string `json:"lastTestError,omitempty"` LastTestSuccess bool `json:"lastTestSuccess,omitempty"` } if err := json.Unmarshal(data, &status); err != nil { return nil } cfg.LastTestedAt = status.LastTestedAt cfg.LastTestError = status.LastTestError cfg.LastTestSuccess = status.LastTestSuccess return nil }) } func (s *StorageService) saveBackendTestStatus(cfg StorageBackendConfig) error { status := struct { LastTestedAt time.Time `json:"lastTestedAt,omitempty"` LastTestError string `json:"lastTestError,omitempty"` LastTestSuccess bool `json:"lastTestSuccess,omitempty"` }{ LastTestedAt: cfg.LastTestedAt, LastTestError: cfg.LastTestError, LastTestSuccess: cfg.LastTestSuccess, } data, err := json.Marshal(status) if err != nil { return err } return s.db.Update(func(tx *bbolt.Tx) error { return tx.Bucket(storageBackendTestStatusBucket).Put([]byte(cfg.ID), data) }) } 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) 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 case StorageProviderSFTP: return StorageProviderSFTP case StorageProviderSMB: return StorageProviderSMB case StorageProviderWebDAV: return StorageProviderWebDAV default: return StorageProviderS3 } } func canonicalStorageProvider(cfg StorageBackendConfig) string { if cfg.Provider != "" && cfg.Provider != StorageBackendLocal { return normalizeStorageProvider(cfg.Provider) } switch cfg.Type { case StorageBackendSFTP: return StorageProviderSFTP case StorageBackendSMB: return StorageProviderSMB case StorageBackendWebDAV: return StorageProviderWebDAV default: return StorageProviderS3 } } func storageTypeForProvider(provider string) string { switch normalizeStorageProvider(provider) { case StorageProviderSFTP: return StorageBackendSFTP case StorageProviderSMB: return StorageBackendSMB case StorageProviderWebDAV: return StorageBackendWebDAV default: return StorageBackendS3 } } func cleanObjectKey(key string) string { return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./") } 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, "/") }