feat(admin): implement provider-specific storage configuration pages
Some checks failed
Build and Publish Docker Image / deploy (push) Has been cancelled

Refactor the admin storage backend creation and editing flows to use
provider-specific pages (e.g., `/admin/storage/new/sftp`) instead of a
single generic form. This ensures only relevant fields are rendered for
each storage provider (such as SFTP, S3, or WebDAV).

Additionally:
- Prevent mutation of the storage provider type during backend edits.
- Add comprehensive unit tests for provider-specific rendering, edit
  validation, and CSRF/admin route protection.
This commit is contained in:
2026-05-31 19:52:46 +03:00
parent ac9b8232f3
commit 1513030c2a
14 changed files with 2031 additions and 355 deletions

View File

@@ -16,6 +16,7 @@ import (
)
var storageBackendsBucket = []byte("storage_backends")
var storageBackendTestStatusBucket = []byte("storage_backend_test_status")
const (
StorageBackendLocal = "local"
@@ -81,10 +82,12 @@ type StorageBackendConfig struct {
}
type StorageBackendView struct {
Config StorageBackendConfig
UsageBytes int64
UsageLabel string
InUse bool
Config StorageBackendConfig
UsageBytes int64
UsageLabel string
InUse bool
SpeedTests []StorageSpeedTest
CanSpeedTest bool
}
type StorageService struct {
@@ -99,7 +102,13 @@ func NewStorageService(db *bbolt.DB, dataDir string) (*StorageService, error) {
}
service := &StorageService{db: db, localFilesDir: filesDir}
err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(storageBackendsBucket)
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 {
@@ -126,7 +135,9 @@ func (s *StorageService) Backend(id string) (StorageBackend, error) {
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
id = strings.TrimSpace(id)
if id == "" || id == StorageBackendLocal {
return s.localConfig(), nil
cfg := s.localConfig()
s.applyStoredTestStatus(&cfg)
return cfg, nil
}
var cfg StorageBackendConfig
err := s.db.View(func(tx *bbolt.Tx) error {
@@ -167,18 +178,13 @@ func (s *StorageService) ListBackendConfigs() ([]StorageBackendConfig, error) {
}
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)
switch input.Provider {
case StorageProviderSFTP:
input.Type = StorageBackendSFTP
case StorageProviderSMB:
input.Type = StorageBackendSMB
case StorageProviderWebDAV:
input.Type = StorageBackendWebDAV
default:
input.Type = StorageBackendS3
}
input.Type = storageTypeForProvider(input.Provider)
if err := normalizeStorageBackendConfig(&input, true); err != nil {
return StorageBackendConfig{}, err
}
@@ -193,6 +199,10 @@ func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBac
}
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
@@ -200,18 +210,19 @@ func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig)
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
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
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
@@ -374,10 +385,56 @@ func (s *StorageService) TestBackend(id string) (StorageBackendConfig, 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:
@@ -424,6 +481,35 @@ func normalizeStorageProvider(provider string) string {
}
}
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, "/"))), "./")
}