Modify the authentication handler to return an unauthorized error when an invalid or disabled bearer token is provided, rather than silently falling back to an anonymous request. This ensures that clients attempting to authenticate but failing (due to expired, malformed, or disabled tokens) are explicitly notified of the auth failure instead of proceeding anonymously. True anonymous requests without any Authorization header remain supported.
442 lines
13 KiB
Go
442 lines
13 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.etcd.io/bbolt"
|
|
)
|
|
|
|
var storageBackendsBucket = []byte("storage_backends")
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
switch input.Provider {
|
|
case StorageProviderSFTP:
|
|
input.Type = StorageBackendSFTP
|
|
case StorageProviderSMB:
|
|
input.Type = StorageBackendSMB
|
|
case StorageProviderWebDAV:
|
|
input.Type = StorageBackendWebDAV
|
|
default:
|
|
input.Type = StorageBackendS3
|
|
}
|
|
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
|
|
}
|
|
if current.ID == StorageBackendLocal {
|
|
return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited")
|
|
}
|
|
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
|
|
}
|
|
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) 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)
|
|
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 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, "/")
|
|
}
|