2026-05-31 02:14:10 +03:00
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
2026-05-31 04:02:28 +03:00
|
|
|
"path"
|
2026-05-31 02:14:10 +03:00
|
|
|
"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"
|
2026-05-31 02:14:10 +03:00
|
|
|
|
|
|
|
|
StorageProviderS3 = "s3"
|
|
|
|
|
StorageProviderContabo = "contabo"
|
2026-05-31 04:02:28 +03:00
|
|
|
StorageProviderSFTP = "sftp"
|
|
|
|
|
StorageProviderSMB = "smb"
|
|
|
|
|
StorageProviderWebDAV = "webdav"
|
2026-05-31 02:14:10 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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"`
|
2026-05-31 02:14:10 +03:00
|
|
|
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 02:14:10 +03:00
|
|
|
}
|
2026-05-31 04:02:28 +03:00
|
|
|
if err := normalizeStorageBackendConfig(&input, true); err != nil {
|
|
|
|
|
return StorageBackendConfig{}, err
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
|
|
|
|
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 02:14:10 +03:00
|
|
|
}
|
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 02:14:10 +03:00
|
|
|
}
|
2026-05-31 04:02:28 +03:00
|
|
|
if strings.TrimSpace(input.Password) == "" {
|
|
|
|
|
input.Password = current.Password
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 04:02:28 +03:00
|
|
|
if strings.TrimSpace(input.PrivateKey) == "" {
|
|
|
|
|
input.PrivateKey = current.PrivateKey
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
2026-05-31 04:02:28 +03:00
|
|
|
if strings.TrimSpace(input.HostKey) == "" {
|
|
|
|
|
input.HostKey = current.HostKey
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
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 {
|
2026-05-31 02:14:10 +03:00
|
|
|
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
|
2026-05-31 02:14:10 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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:
|
2026-05-31 13:02:58 +03:00
|
|
|
return newWebDAVStorageBackend(cfg), nil
|
2026-05-31 02:14:10 +03:00
|
|
|
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
|
2026-05-31 02:14:10 +03:00
|
|
|
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, "/")
|
|
|
|
|
}
|