Storage backend: - Add internal/storage package with Storer interface - Implement FileStorer for local filesystem storage (file:// URLs) - Implement S3Storer wrapping existing s3.Client - Support storage_url config field (s3:// or file://) - Migrate all consumers to use storage.Storer interface PID locking: - Add internal/pidlock package to prevent concurrent instances - Acquire lock before app start, release on exit - Detect stale locks from crashed processes Scan progress improvements: - Add fast file enumeration pass before stat() phase - Use enumerated set for deletion detection (no extra filesystem access) - Show progress with percentage, files/sec, elapsed time, and ETA - Change "changed" to "changed/new" for clarity Config improvements: - Add tilde expansion for paths (~/) - Use xdg library for platform-specific default index path
91 lines
2.1 KiB
Go
91 lines
2.1 KiB
Go
package storage
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// StorageURL represents a parsed storage URL.
|
|
type StorageURL struct {
|
|
Scheme string // "s3" or "file"
|
|
Bucket string // S3 bucket name (empty for file)
|
|
Prefix string // Path within bucket or filesystem base path
|
|
Endpoint string // S3 endpoint (optional, default AWS)
|
|
Region string // S3 region (optional)
|
|
UseSSL bool // Use HTTPS for S3 (default true)
|
|
}
|
|
|
|
// ParseStorageURL parses a storage URL string.
|
|
// Supported formats:
|
|
// - s3://bucket/prefix?endpoint=host®ion=us-east-1&ssl=true
|
|
// - file:///absolute/path/to/backup
|
|
func ParseStorageURL(rawURL string) (*StorageURL, error) {
|
|
if rawURL == "" {
|
|
return nil, fmt.Errorf("storage URL is empty")
|
|
}
|
|
|
|
// Handle file:// URLs
|
|
if strings.HasPrefix(rawURL, "file://") {
|
|
path := strings.TrimPrefix(rawURL, "file://")
|
|
if path == "" {
|
|
return nil, fmt.Errorf("file URL path is empty")
|
|
}
|
|
return &StorageURL{
|
|
Scheme: "file",
|
|
Prefix: path,
|
|
}, nil
|
|
}
|
|
|
|
// Handle s3:// URLs
|
|
if strings.HasPrefix(rawURL, "s3://") {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
bucket := u.Host
|
|
if bucket == "" {
|
|
return nil, fmt.Errorf("s3 URL missing bucket name")
|
|
}
|
|
|
|
prefix := strings.TrimPrefix(u.Path, "/")
|
|
|
|
query := u.Query()
|
|
useSSL := true
|
|
if query.Get("ssl") == "false" {
|
|
useSSL = false
|
|
}
|
|
|
|
return &StorageURL{
|
|
Scheme: "s3",
|
|
Bucket: bucket,
|
|
Prefix: prefix,
|
|
Endpoint: query.Get("endpoint"),
|
|
Region: query.Get("region"),
|
|
UseSSL: useSSL,
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("unsupported URL scheme: must start with s3:// or file://")
|
|
}
|
|
|
|
// String returns a human-readable representation of the storage URL.
|
|
func (u *StorageURL) String() string {
|
|
switch u.Scheme {
|
|
case "file":
|
|
return fmt.Sprintf("file://%s", u.Prefix)
|
|
case "s3":
|
|
endpoint := u.Endpoint
|
|
if endpoint == "" {
|
|
endpoint = "s3.amazonaws.com"
|
|
}
|
|
if u.Prefix != "" {
|
|
return fmt.Sprintf("s3://%s/%s (endpoint: %s)", u.Bucket, u.Prefix, endpoint)
|
|
}
|
|
return fmt.Sprintf("s3://%s (endpoint: %s)", u.Bucket, endpoint)
|
|
default:
|
|
return fmt.Sprintf("%s://?", u.Scheme)
|
|
}
|
|
}
|