mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-07-08 22:11:54 +08:00
first commit
This commit is contained in:
66
server/internal/storage/aliyun/factory.go
Normal file
66
server/internal/storage/aliyun/factory.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Package aliyun provides an Aliyun OSS storage factory that delegates to the S3-compatible engine.
|
||||
// Aliyun OSS is fully S3-compatible; we auto-assemble the endpoint from the user-provided region.
|
||||
package aliyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/s3"
|
||||
)
|
||||
|
||||
// Config is the user-facing configuration for Aliyun OSS.
|
||||
type Config struct {
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"` // optional override
|
||||
InternalNetwork bool `json:"internalNetwork"` // use -internal endpoint
|
||||
}
|
||||
|
||||
// Factory creates Aliyun OSS providers by composing the S3 engine.
|
||||
type Factory struct {
|
||||
s3Factory s3.Factory
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{s3Factory: s3.NewFactory()}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeAliyunOSS }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("aliyun oss region is required")
|
||||
}
|
||||
suffix := "aliyuncs.com"
|
||||
if cfg.InternalNetwork {
|
||||
endpoint = fmt.Sprintf("https://oss-%s-internal.%s", region, suffix)
|
||||
} else {
|
||||
endpoint = fmt.Sprintf("https://oss-%s.%s", region, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Delegate to S3 engine with assembled endpoint.
|
||||
s3Config := map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"region": cfg.Region,
|
||||
"bucket": cfg.Bucket,
|
||||
"accessKeyId": cfg.AccessKeyID,
|
||||
"secretAccessKey": cfg.SecretAccessKey,
|
||||
"forcePathStyle": false, // Aliyun OSS uses virtual-hosted style
|
||||
}
|
||||
return f.s3Factory.New(ctx, s3Config)
|
||||
}
|
||||
155
server/internal/storage/codec/cipher.go
Normal file
155
server/internal/storage/codec/cipher.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const maskedValue = "********"
|
||||
|
||||
type ConfigCipher struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
type Cipher = ConfigCipher
|
||||
|
||||
func New(secret string) *ConfigCipher {
|
||||
hash := sha256.Sum256([]byte(secret))
|
||||
return &ConfigCipher{key: hash[:]}
|
||||
}
|
||||
|
||||
func NewConfigCipher(secret string) *ConfigCipher {
|
||||
return New(secret)
|
||||
}
|
||||
|
||||
func (c *ConfigCipher) Key() []byte {
|
||||
copyKey := make([]byte, len(c.key))
|
||||
copy(copyKey, c.key)
|
||||
return copyKey
|
||||
}
|
||||
|
||||
func (c *ConfigCipher) Encrypt(raw []byte) (string, error) {
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create gcm: %w", err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, raw, nil)
|
||||
return base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func (c *ConfigCipher) Decrypt(encoded string) ([]byte, error) {
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create cipher: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create gcm: %w", err)
|
||||
}
|
||||
if len(payload) < gcm.NonceSize() {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := payload[:gcm.NonceSize()], payload[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt ciphertext: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func (c *ConfigCipher) EncryptJSON(value any) (string, error) {
|
||||
plain, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal plaintext: %w", err)
|
||||
}
|
||||
return c.Encrypt(plain)
|
||||
}
|
||||
|
||||
func (c *ConfigCipher) DecryptJSON(encoded string, out any) error {
|
||||
plain, err := c.Decrypt(encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(plain, out); err != nil {
|
||||
return fmt.Errorf("unmarshal plaintext: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ConfigCipher) EncryptValue(value any) (string, error) {
|
||||
return c.EncryptJSON(value)
|
||||
}
|
||||
|
||||
func (c *ConfigCipher) DecryptValue(encoded string, out any) error {
|
||||
return c.DecryptJSON(encoded, out)
|
||||
}
|
||||
|
||||
func MaskConfig(raw map[string]any, sensitiveFields []string) map[string]any {
|
||||
masked := cloneMap(raw)
|
||||
for _, field := range sensitiveFields {
|
||||
value, ok := masked[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch actual := value.(type) {
|
||||
case string:
|
||||
if actual != "" {
|
||||
masked[field] = maskedValue
|
||||
}
|
||||
default:
|
||||
masked[field] = maskedValue
|
||||
}
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
func MergeMaskedConfig(next map[string]any, existing map[string]any, sensitiveFields []string) map[string]any {
|
||||
merged := cloneMap(existing)
|
||||
for key, value := range next {
|
||||
merged[key] = value
|
||||
}
|
||||
for _, field := range sensitiveFields {
|
||||
value, ok := merged[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch actual := value.(type) {
|
||||
case string:
|
||||
if actual == "" || actual == maskedValue {
|
||||
merged[field] = existing[field]
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func IsMaskedString(value string) bool {
|
||||
return bytes.Equal([]byte(value), []byte(maskedValue))
|
||||
}
|
||||
|
||||
func cloneMap(source map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(source))
|
||||
for key, value := range source {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
64
server/internal/storage/codec/cipher_test.go
Normal file
64
server/internal/storage/codec/cipher_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCipherEncryptAndDecrypt(t *testing.T) {
|
||||
cipher := New("encryption-secret")
|
||||
input := map[string]any{
|
||||
"endpoint": "https://example.com",
|
||||
"secret": "top-secret",
|
||||
}
|
||||
encoded, err := cipher.EncryptValue(input)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptValue returned error: %v", err)
|
||||
}
|
||||
var output map[string]any
|
||||
if err := cipher.DecryptValue(encoded, &output); err != nil {
|
||||
t.Fatalf("DecryptValue returned error: %v", err)
|
||||
}
|
||||
if output["secret"] != "top-secret" {
|
||||
t.Fatalf("expected decrypted secret, got %#v", output["secret"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCipherEncryptAndDecryptBytes(t *testing.T) {
|
||||
cipher := NewConfigCipher("encryption-secret")
|
||||
encoded, err := cipher.Encrypt([]byte(`{"bucket":"demo"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt returned error: %v", err)
|
||||
}
|
||||
decoded, err := cipher.Decrypt(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt returned error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(decoded, []byte(`{"bucket":"demo"}`)) {
|
||||
t.Fatalf("expected decrypted payload to match, got %s", string(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskConfig(t *testing.T) {
|
||||
masked := MaskConfig(map[string]any{"secret": "abc", "bucket": "demo"}, []string{"secret"})
|
||||
if masked["secret"] != "********" {
|
||||
t.Fatalf("expected masked secret, got %#v", masked["secret"])
|
||||
}
|
||||
if masked["bucket"] != "demo" {
|
||||
t.Fatalf("expected bucket to remain unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMaskedConfig(t *testing.T) {
|
||||
merged := MergeMaskedConfig(
|
||||
map[string]any{"bucket": "changed", "secret": "********"},
|
||||
map[string]any{"bucket": "demo", "secret": "top-secret"},
|
||||
[]string{"secret"},
|
||||
)
|
||||
if merged["bucket"] != "changed" {
|
||||
t.Fatalf("expected bucket to use new value, got %#v", merged["bucket"])
|
||||
}
|
||||
if merged["secret"] != "top-secret" {
|
||||
t.Fatalf("expected masked secret to reuse stored value, got %#v", merged["secret"])
|
||||
}
|
||||
}
|
||||
299
server/internal/storage/googledrive/provider.go
Normal file
299
server/internal/storage/googledrive/provider.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package googledrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"golang.org/x/oauth2"
|
||||
googleoauth "golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/drive/v3"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
|
||||
type fileInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Size int64
|
||||
ModifiedTime time.Time
|
||||
}
|
||||
|
||||
type client interface {
|
||||
TestConnection(context.Context, string) error
|
||||
Upload(context.Context, string, string, io.Reader) error
|
||||
Download(context.Context, string, string) (io.ReadCloser, error)
|
||||
Delete(context.Context, string, string) error
|
||||
List(context.Context, string, string) ([]storage.ObjectInfo, error)
|
||||
EnsureFolder(ctx context.Context, parentID, name string) (string, error)
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client client
|
||||
rootFolder string // user-configured folderId, empty means Drive root
|
||||
folderCache map[string]string // cache: path -> folderID
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
newClient func(context.Context, storage.GoogleDriveConfig) (client, error)
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{newClient: newDriveClient}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeGoogleDrive }
|
||||
func (Factory) SensitiveFields() []string {
|
||||
return []string{"clientId", "clientSecret", "refreshToken"}
|
||||
}
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.GoogleDriveConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg = cfg.Normalize()
|
||||
if strings.TrimSpace(cfg.ClientID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
return nil, fmt.Errorf("google drive client credentials are required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.RefreshToken) == "" {
|
||||
return nil, fmt.Errorf("google drive refresh token is required")
|
||||
}
|
||||
newClient := f.newClient
|
||||
if newClient == nil {
|
||||
newClient = NewFactory().newClient
|
||||
}
|
||||
client, err := newClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Provider{
|
||||
client: client,
|
||||
rootFolder: strings.TrimSpace(cfg.FolderID),
|
||||
folderCache: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeGoogleDrive }
|
||||
|
||||
// ensureFolderPath creates nested folders for a path like "BackupX/file/260308"
|
||||
// and returns the deepest folder's ID.
|
||||
func (p *Provider) ensureFolderPath(ctx context.Context, folderPath string) (string, error) {
|
||||
if folderPath == "" || folderPath == "." {
|
||||
return p.rootFolder, nil
|
||||
}
|
||||
if cached, ok := p.folderCache[folderPath]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
parts := strings.Split(path.Clean(folderPath), "/")
|
||||
parentID := p.rootFolder
|
||||
builtPath := ""
|
||||
for _, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
if builtPath == "" {
|
||||
builtPath = part
|
||||
} else {
|
||||
builtPath = builtPath + "/" + part
|
||||
}
|
||||
if cached, ok := p.folderCache[builtPath]; ok {
|
||||
parentID = cached
|
||||
continue
|
||||
}
|
||||
folderID, err := p.client.EnsureFolder(ctx, parentID, part)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ensure folder %s: %w", builtPath, err)
|
||||
}
|
||||
p.folderCache[builtPath] = folderID
|
||||
parentID = folderID
|
||||
}
|
||||
return parentID, nil
|
||||
}
|
||||
|
||||
func (p *Provider) TestConnection(ctx context.Context) error {
|
||||
return p.client.TestConnection(ctx, p.rootFolder)
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(ctx context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
dir := path.Dir(objectKey)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.client.Upload(ctx, folderID, objectKey, reader)
|
||||
}
|
||||
|
||||
func (p *Provider) Download(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
dir := path.Dir(objectKey)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.client.Download(ctx, folderID, objectKey)
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
|
||||
dir := path.Dir(objectKey)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.client.Delete(ctx, folderID, objectKey)
|
||||
}
|
||||
|
||||
func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
dir := path.Dir(prefix)
|
||||
folderID, err := p.ensureFolderPath(ctx, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.client.List(ctx, folderID, prefix)
|
||||
}
|
||||
|
||||
type driveClient struct {
|
||||
service *drive.Service
|
||||
}
|
||||
|
||||
func newDriveClient(ctx context.Context, cfg storage.GoogleDriveConfig) (client, error) {
|
||||
cfg = cfg.Normalize()
|
||||
oauthCfg := &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: googleoauth.Endpoint,
|
||||
Scopes: []string{drive.DriveScope},
|
||||
}
|
||||
httpClient := oauthCfg.Client(ctx, &oauth2.Token{RefreshToken: cfg.RefreshToken})
|
||||
service, err := drive.NewService(ctx, option.WithHTTPClient(httpClient))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create google drive service: %w", err)
|
||||
}
|
||||
return &driveClient{service: service}, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) TestConnection(ctx context.Context, folderID string) error {
|
||||
if strings.TrimSpace(folderID) == "" {
|
||||
_, err := c.service.About.Get().Fields("user").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("test google drive connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := c.service.Files.Get(folderID).Fields("id").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("test google drive folder: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *driveClient) EnsureFolder(ctx context.Context, parentID, name string) (string, error) {
|
||||
// Search for existing folder
|
||||
query := fmt.Sprintf("name = '%s' and mimeType = 'application/vnd.google-apps.folder' and trashed = false", escapeQuery(name))
|
||||
if strings.TrimSpace(parentID) != "" {
|
||||
query += fmt.Sprintf(" and '%s' in parents", escapeQuery(parentID))
|
||||
} else {
|
||||
query += " and 'root' in parents"
|
||||
}
|
||||
result, err := c.service.Files.List().Q(query).PageSize(1).Fields("files(id)").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search for folder %s: %w", name, err)
|
||||
}
|
||||
if len(result.Files) > 0 {
|
||||
return result.Files[0].Id, nil
|
||||
}
|
||||
// Create the folder
|
||||
folder := &drive.File{
|
||||
Name: name,
|
||||
MimeType: "application/vnd.google-apps.folder",
|
||||
}
|
||||
if strings.TrimSpace(parentID) != "" {
|
||||
folder.Parents = []string{parentID}
|
||||
}
|
||||
created, err := c.service.Files.Create(folder).Fields("id").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create folder %s: %w", name, err)
|
||||
}
|
||||
return created.Id, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) Upload(ctx context.Context, folderID, objectKey string, reader io.Reader) error {
|
||||
file := &drive.File{Name: path.Base(objectKey)}
|
||||
if strings.TrimSpace(folderID) != "" {
|
||||
file.Parents = []string{folderID}
|
||||
}
|
||||
_, err := c.service.Files.Create(file).Media(reader).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload google drive object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *driveClient) Download(ctx context.Context, folderID, objectKey string) (io.ReadCloser, error) {
|
||||
file, err := c.findFile(ctx, folderID, objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := c.service.Files.Get(file.ID).Context(ctx).Download()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download google drive object: %w", err)
|
||||
}
|
||||
return response.Body, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) Delete(ctx context.Context, folderID, objectKey string) error {
|
||||
file, err := c.findFile(ctx, folderID, objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.service.Files.Delete(file.ID).Context(ctx).Do(); err != nil {
|
||||
return fmt.Errorf("delete google drive object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *driveClient) List(ctx context.Context, folderID, prefix string) ([]storage.ObjectInfo, error) {
|
||||
query := "trashed = false"
|
||||
if strings.TrimSpace(folderID) != "" {
|
||||
query += fmt.Sprintf(" and '%s' in parents", escapeQuery(folderID))
|
||||
}
|
||||
if strings.TrimSpace(prefix) != "" {
|
||||
query += fmt.Sprintf(" and name contains '%s'", escapeQuery(prefix))
|
||||
}
|
||||
result, err := c.service.Files.List().Q(query).Fields("files(id,name,size,modifiedTime)").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list google drive objects: %w", err)
|
||||
}
|
||||
items := make([]storage.ObjectInfo, 0, len(result.Files))
|
||||
for _, file := range result.Files {
|
||||
modifiedAt, _ := time.Parse(time.RFC3339, file.ModifiedTime)
|
||||
items = append(items, storage.ObjectInfo{Key: file.Name, Size: file.Size, UpdatedAt: modifiedAt.UTC()})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (c *driveClient) findFile(ctx context.Context, folderID, objectKey string) (*fileInfo, error) {
|
||||
query := fmt.Sprintf("name = '%s' and trashed = false", escapeQuery(path.Base(objectKey)))
|
||||
if strings.TrimSpace(folderID) != "" {
|
||||
query += fmt.Sprintf(" and '%s' in parents", escapeQuery(folderID))
|
||||
}
|
||||
result, err := c.service.Files.List().Q(query).PageSize(1).Fields("files(id,name,size,modifiedTime)").Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query google drive object: %w", err)
|
||||
}
|
||||
if len(result.Files) == 0 {
|
||||
return nil, fmt.Errorf("google drive object not found: %s", objectKey)
|
||||
}
|
||||
file := result.Files[0]
|
||||
modifiedAt, _ := time.Parse(time.RFC3339, file.ModifiedTime)
|
||||
return &fileInfo{ID: file.Id, Name: file.Name, Size: file.Size, ModifiedTime: modifiedAt.UTC()}, nil
|
||||
}
|
||||
|
||||
func escapeQuery(value string) string {
|
||||
return strings.ReplaceAll(value, "'", "\\'")
|
||||
}
|
||||
|
||||
75
server/internal/storage/googledrive/provider_test.go
Normal file
75
server/internal/storage/googledrive/provider_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package googledrive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
type fakeClient struct{ data map[string]string }
|
||||
|
||||
func (c *fakeClient) TestConnection(context.Context, string) error { return nil }
|
||||
func (c *fakeClient) Upload(_ context.Context, _ string, objectKey string, reader io.Reader) error {
|
||||
content, _ := io.ReadAll(reader)
|
||||
c.data[objectKey] = string(content)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeClient) Download(_ context.Context, _ string, objectKey string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader(c.data[objectKey])), nil
|
||||
}
|
||||
func (c *fakeClient) Delete(_ context.Context, _ string, objectKey string) error {
|
||||
delete(c.data, objectKey)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeClient) List(_ context.Context, _ string, prefix string) ([]storage.ObjectInfo, error) {
|
||||
items := make([]storage.ObjectInfo, 0)
|
||||
for key, value := range c.data {
|
||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||
items = append(items, storage.ObjectInfo{Key: key, Size: int64(len(value)), UpdatedAt: time.Now().UTC()})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (c *fakeClient) EnsureFolder(_ context.Context, _, name string) (string, error) {
|
||||
return "fake-folder-" + name, nil
|
||||
}
|
||||
|
||||
func TestGoogleDriveProviderCRUD(t *testing.T) {
|
||||
factory := Factory{newClient: func(context.Context, storage.GoogleDriveConfig) (client, error) {
|
||||
return &fakeClient{data: make(map[string]string)}, nil
|
||||
}}
|
||||
providerAny, err := factory.New(context.Background(), map[string]any{"clientId": "id", "clientSecret": "secret", "refreshToken": "refresh"})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "backup.tar.gz", strings.NewReader("payload"), 7, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "backup.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "payload" {
|
||||
t.Fatalf("unexpected content: %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "backup")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "backup.tar.gz" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "backup.tar.gz"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
137
server/internal/storage/localdisk/provider.go
Normal file
137
server/internal/storage/localdisk/provider.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package localdisk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
type Factory struct{}
|
||||
|
||||
func NewFactory() Factory { return Factory{} }
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
|
||||
func (Factory) SensitiveFields() []string { return nil }
|
||||
|
||||
func (Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.LocalDiskConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.BasePath) == "" {
|
||||
return nil, fmt.Errorf("local disk basePath is required")
|
||||
}
|
||||
return &Provider{basePath: filepath.Clean(cfg.BasePath)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
|
||||
|
||||
func (p *Provider) TestConnection(_ context.Context) error {
|
||||
if err := os.MkdirAll(p.basePath, 0o755); err != nil {
|
||||
return fmt.Errorf("ensure local disk base path: %w", err)
|
||||
}
|
||||
tempFile, err := os.CreateTemp(p.basePath, ".backupx-connection-test-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write access check failed: %w", err)
|
||||
}
|
||||
name := tempFile.Name()
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
targetPath, err := p.resolvePath(objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create local disk directories: %w", err)
|
||||
}
|
||||
file, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create local disk object: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(file, reader); err != nil {
|
||||
return fmt.Errorf("write local disk object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
targetPath, err := p.resolvePath(objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Open(targetPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open local disk object: %w", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(_ context.Context, objectKey string) error {
|
||||
targetPath, err := p.resolvePath(objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(targetPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete local disk object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
items := make([]storage.ObjectInfo, 0)
|
||||
err := filepath.WalkDir(p.basePath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(p.basePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := filepath.ToSlash(rel)
|
||||
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{Key: key, Size: info.Size(), UpdatedAt: info.ModTime().UTC()})
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("list local disk objects: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (p *Provider) resolvePath(objectKey string) (string, error) {
|
||||
cleanBase := filepath.Clean(p.basePath)
|
||||
cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimSpace(objectKey)))
|
||||
if cleanKey == "." || cleanKey == string(filepath.Separator) || cleanKey == "" {
|
||||
return "", fmt.Errorf("object key is required")
|
||||
}
|
||||
fullPath := filepath.Clean(filepath.Join(cleanBase, cleanKey))
|
||||
baseWithSep := cleanBase + string(filepath.Separator)
|
||||
if fullPath != cleanBase && !strings.HasPrefix(fullPath, baseWithSep) {
|
||||
return "", fmt.Errorf("object key escapes base path")
|
||||
}
|
||||
return fullPath, nil
|
||||
}
|
||||
52
server/internal/storage/localdisk/provider_test.go
Normal file
52
server/internal/storage/localdisk/provider_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package localdisk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalDiskProviderCRUD(t *testing.T) {
|
||||
providerAny, err := (Factory{}).New(context.Background(), map[string]any{"basePath": t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "daily/backup.txt", strings.NewReader("hello"), 5, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "daily/backup.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "hello" {
|
||||
t.Fatalf("expected downloaded content to match, got %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "daily")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "daily/backup.txt" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "daily/backup.txt"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiskProviderRejectsTraversal(t *testing.T) {
|
||||
providerAny, err := (Factory{}).New(context.Background(), map[string]any{"basePath": t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if _, err := provider.resolvePath("../escape.txt"); err == nil {
|
||||
t.Fatalf("expected traversal to be rejected")
|
||||
}
|
||||
}
|
||||
73
server/internal/storage/qiniu/factory.go
Normal file
73
server/internal/storage/qiniu/factory.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package qiniu provides a Qiniu Cloud Kodo storage factory that delegates to the S3-compatible engine.
|
||||
// Qiniu Kodo is S3-compatible; we auto-assemble the endpoint from the user-provided region.
|
||||
package qiniu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/s3"
|
||||
)
|
||||
|
||||
// Config is the user-facing configuration for Qiniu Kodo.
|
||||
type Config struct {
|
||||
Region string `json:"region"` // e.g. z0, z1, z2, na0, as0
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKey string `json:"accessKeyId"`
|
||||
SecretKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"` // optional override
|
||||
}
|
||||
|
||||
// regionEndpoints maps Qiniu storage region codes to their S3-compatible endpoints.
|
||||
var regionEndpoints = map[string]string{
|
||||
"z0": "https://s3-cn-east-1.qiniucs.com",
|
||||
"cn-east-2": "https://s3-cn-east-2.qiniucs.com",
|
||||
"z1": "https://s3-cn-north-1.qiniucs.com",
|
||||
"z2": "https://s3-cn-south-1.qiniucs.com",
|
||||
"na0": "https://s3-us-north-1.qiniucs.com",
|
||||
"as0": "https://s3-ap-southeast-1.qiniucs.com",
|
||||
}
|
||||
|
||||
// Factory creates Qiniu Kodo providers by composing the S3 engine.
|
||||
type Factory struct {
|
||||
s3Factory s3.Factory
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{s3Factory: s3.NewFactory()}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeQiniuKodo }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("qiniu kodo region is required")
|
||||
}
|
||||
var ok bool
|
||||
endpoint, ok = regionEndpoints[region]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported qiniu region: %s (supported: z0, cn-east-2, z1, z2, na0, as0)", region)
|
||||
}
|
||||
}
|
||||
|
||||
s3Config := map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"region": cfg.Region,
|
||||
"bucket": cfg.Bucket,
|
||||
"accessKeyId": cfg.AccessKey,
|
||||
"secretAccessKey": cfg.SecretKey,
|
||||
"forcePathStyle": true, // Qiniu S3-compatible uses path-style
|
||||
}
|
||||
return f.s3Factory.New(ctx, s3Config)
|
||||
}
|
||||
192
server/internal/storage/registry.go
Normal file
192
server/internal/storage/registry.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
)
|
||||
|
||||
type providerFactoryWithNew interface {
|
||||
New(context.Context, map[string]any) (StorageProvider, error)
|
||||
}
|
||||
|
||||
type providerFactoryWithCreate interface {
|
||||
Create(context.Context, json.RawMessage) (StorageProvider, error)
|
||||
}
|
||||
|
||||
type providerFactoryWithSensitiveFields interface {
|
||||
SensitiveFields() []string
|
||||
}
|
||||
|
||||
type providerFactoryWithSensitiveKeys interface {
|
||||
SensitiveKeys() []string
|
||||
}
|
||||
|
||||
type providerFactoryWithValidate interface {
|
||||
Validate(json.RawMessage) error
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
factories map[ProviderType]ProviderFactory
|
||||
}
|
||||
|
||||
func NewRegistry(factories ...ProviderFactory) *Registry {
|
||||
registry := &Registry{factories: make(map[ProviderType]ProviderFactory)}
|
||||
for _, factory := range factories {
|
||||
registry.Register(factory)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func (r *Registry) Register(factory ProviderFactory) {
|
||||
if factory == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.factories == nil {
|
||||
r.factories = make(map[ProviderType]ProviderFactory)
|
||||
}
|
||||
r.factories[factory.Type()] = factory
|
||||
}
|
||||
|
||||
func (r *Registry) Factory(providerType string) (ProviderFactory, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
factory, ok := r.factories[providerType]
|
||||
return factory, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Types() []ProviderType {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
items := make([]ProviderType, 0, len(r.factories))
|
||||
for providerType := range r.factories {
|
||||
items = append(items, providerType)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i] < items[j] })
|
||||
return items
|
||||
}
|
||||
|
||||
func (r *Registry) SensitiveFields(providerType string) []string {
|
||||
factory, ok := r.Factory(providerType)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if typed, ok := factory.(providerFactoryWithSensitiveFields); ok {
|
||||
return typed.SensitiveFields()
|
||||
}
|
||||
if typed, ok := factory.(providerFactoryWithSensitiveKeys); ok {
|
||||
return typed.SensitiveKeys()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) SensitiveKeys(providerType string) []string {
|
||||
return r.SensitiveFields(providerType)
|
||||
}
|
||||
|
||||
func (r *Registry) Validate(providerType string, raw json.RawMessage) error {
|
||||
factory, ok := r.Factory(providerType)
|
||||
if !ok {
|
||||
return apperror.BadRequest("STORAGE_PROVIDER_UNSUPPORTED", "不支持的存储类型", fmt.Errorf("unsupported storage provider type: %s", providerType))
|
||||
}
|
||||
if typed, ok := factory.(providerFactoryWithValidate); ok {
|
||||
if err := typed.Validate(raw); err != nil {
|
||||
return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
configMap, err := decodeConfigMap(raw)
|
||||
if err != nil {
|
||||
return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err)
|
||||
}
|
||||
if typed, ok := factory.(providerFactoryWithNew); ok {
|
||||
if _, err := typed.New(context.Background(), configMap); err != nil {
|
||||
return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", fmt.Errorf("provider %s has no validator", providerType))
|
||||
}
|
||||
|
||||
func (r *Registry) Create(ctx context.Context, providerType string, rawConfig any) (StorageProvider, error) {
|
||||
factory, ok := r.Factory(providerType)
|
||||
if !ok {
|
||||
return nil, apperror.BadRequest("STORAGE_PROVIDER_UNSUPPORTED", "不支持的存储类型", fmt.Errorf("unsupported storage provider type: %s", providerType))
|
||||
}
|
||||
raw, configMap, err := normalizeConfig(rawConfig)
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err)
|
||||
}
|
||||
if typed, ok := factory.(providerFactoryWithNew); ok {
|
||||
provider, err := typed.New(ctx, configMap)
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "无法创建存储客户端", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
if typed, ok := factory.(providerFactoryWithCreate); ok {
|
||||
provider, err := typed.Create(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "无法创建存储客户端", err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "无法创建存储客户端", fmt.Errorf("provider %s has no constructor", providerType))
|
||||
}
|
||||
|
||||
func normalizeConfig(rawConfig any) (json.RawMessage, map[string]any, error) {
|
||||
switch value := rawConfig.(type) {
|
||||
case nil:
|
||||
return json.RawMessage("{}"), map[string]any{}, nil
|
||||
case map[string]any:
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
return raw, value, nil
|
||||
case json.RawMessage:
|
||||
configMap, err := decodeConfigMap(value)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return value, configMap, nil
|
||||
case []byte:
|
||||
raw := json.RawMessage(value)
|
||||
configMap, err := decodeConfigMap(raw)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return raw, configMap, nil
|
||||
default:
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
configMap, err := decodeConfigMap(raw)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return raw, configMap, nil
|
||||
}
|
||||
}
|
||||
|
||||
func decodeConfigMap(raw json.RawMessage) (map[string]any, error) {
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var configMap map[string]any
|
||||
if err := json.Unmarshal(raw, &configMap); err != nil {
|
||||
return nil, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
if configMap == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return configMap, nil
|
||||
}
|
||||
58
server/internal/storage/registry_test.go
Normal file
58
server/internal/storage/registry_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeProvider struct{}
|
||||
|
||||
func (fakeProvider) Type() ProviderType { return ProviderTypeLocalDisk }
|
||||
func (fakeProvider) TestConnection(context.Context) error { return nil }
|
||||
func (fakeProvider) Upload(context.Context, string, io.Reader, int64, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (fakeProvider) Download(context.Context, string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader("ok")), nil
|
||||
}
|
||||
func (fakeProvider) Delete(context.Context, string) error { return nil }
|
||||
func (fakeProvider) List(context.Context, string) ([]ObjectInfo, error) { return nil, nil }
|
||||
|
||||
type fakeFactory struct{}
|
||||
|
||||
func (fakeFactory) Type() ProviderType { return ProviderTypeLocalDisk }
|
||||
func (fakeFactory) SensitiveFields() []string { return []string{"secret"} }
|
||||
func (fakeFactory) New(context.Context, map[string]any) (StorageProvider, error) {
|
||||
return fakeProvider{}, nil
|
||||
}
|
||||
|
||||
func TestRegistryCreate(t *testing.T) {
|
||||
registry := NewRegistry(fakeFactory{})
|
||||
provider, err := registry.Create(context.Background(), ProviderTypeLocalDisk, map[string]any{"basePath": "/tmp"})
|
||||
if err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if provider.Type() != ProviderTypeLocalDisk {
|
||||
t.Fatalf("expected local disk provider, got %s", provider.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCreateReturnsErrorForUnknownType(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
_, err := registry.Create(context.Background(), ProviderTypeS3, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("expected unsupported type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeConfig(t *testing.T) {
|
||||
cfg, err := DecodeConfig[LocalDiskConfig](map[string]any{"basePath": "/tmp/storage"})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeConfig returned error: %v", err)
|
||||
}
|
||||
if cfg.BasePath != "/tmp/storage" {
|
||||
t.Fatalf("expected base path to decode")
|
||||
}
|
||||
}
|
||||
126
server/internal/storage/s3/provider.go
Normal file
126
server/internal/storage/s3/provider.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
awscore "github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
type client interface {
|
||||
HeadBucket(context.Context, *awss3.HeadBucketInput, ...func(*awss3.Options)) (*awss3.HeadBucketOutput, error)
|
||||
PutObject(context.Context, *awss3.PutObjectInput, ...func(*awss3.Options)) (*awss3.PutObjectOutput, error)
|
||||
GetObject(context.Context, *awss3.GetObjectInput, ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
|
||||
DeleteObject(context.Context, *awss3.DeleteObjectInput, ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error)
|
||||
ListObjectsV2(context.Context, *awss3.ListObjectsV2Input, ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error)
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client client
|
||||
bucket string
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
newClient func(cfg storage.S3Config) client
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{newClient: func(cfg storage.S3Config) client {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
awsConfig := awscore.Config{
|
||||
Region: region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
|
||||
}
|
||||
return awss3.NewFromConfig(awsConfig, func(options *awss3.Options) {
|
||||
options.UsePathStyle = cfg.ForcePathStyle
|
||||
if strings.TrimSpace(cfg.Endpoint) != "" {
|
||||
options.BaseEndpoint = awscore.String(strings.TrimRight(cfg.Endpoint, "/"))
|
||||
}
|
||||
})
|
||||
}}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeS3 }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.S3Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Bucket) == "" {
|
||||
return nil, fmt.Errorf("s3 bucket is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(cfg.SecretAccessKey) == "" {
|
||||
return nil, fmt.Errorf("s3 credentials are required")
|
||||
}
|
||||
newClient := f.newClient
|
||||
if newClient == nil {
|
||||
factory := NewFactory()
|
||||
newClient = factory.newClient
|
||||
}
|
||||
return &Provider{client: newClient(cfg), bucket: cfg.Bucket}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeS3 }
|
||||
|
||||
func (p *Provider) TestConnection(ctx context.Context) error {
|
||||
_, err := p.client.HeadBucket(ctx, &awss3.HeadBucketInput{Bucket: awscore.String(p.bucket)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("test s3 connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(ctx context.Context, objectKey string, reader io.Reader, _ int64, metadata map[string]string) error {
|
||||
_, err := p.client.PutObject(ctx, &awss3.PutObjectInput{Bucket: awscore.String(p.bucket), Key: awscore.String(objectKey), Body: reader, Metadata: metadata})
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload s3 object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(ctx context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
result, err := p.client.GetObject(ctx, &awss3.GetObjectInput{Bucket: awscore.String(p.bucket), Key: awscore.String(objectKey)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download s3 object: %w", err)
|
||||
}
|
||||
return result.Body, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
|
||||
_, err := p.client.DeleteObject(ctx, &awss3.DeleteObjectInput{Bucket: awscore.String(p.bucket), Key: awscore.String(objectKey)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete s3 object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
result, err := p.client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{Bucket: awscore.String(p.bucket), Prefix: awscore.String(prefix)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list s3 objects: %w", err)
|
||||
}
|
||||
items := make([]storage.ObjectInfo, 0, len(result.Contents))
|
||||
for _, object := range result.Contents {
|
||||
updatedAt := time.Time{}
|
||||
if object.LastModified != nil {
|
||||
updatedAt = object.LastModified.UTC()
|
||||
}
|
||||
size := int64(0)
|
||||
if object.Size != nil {
|
||||
size = *object.Size
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{Key: awscore.ToString(object.Key), Size: size, UpdatedAt: updatedAt})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
78
server/internal/storage/s3/provider_test.go
Normal file
78
server/internal/storage/s3/provider_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package s3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
|
||||
awscore "github.com/aws/aws-sdk-go-v2/aws"
|
||||
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
awss3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
type fakeClient struct{ data map[string]string }
|
||||
|
||||
func (c *fakeClient) HeadBucket(context.Context, *awss3.HeadBucketInput, ...func(*awss3.Options)) (*awss3.HeadBucketOutput, error) {
|
||||
return &awss3.HeadBucketOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) PutObject(_ context.Context, input *awss3.PutObjectInput, _ ...func(*awss3.Options)) (*awss3.PutObjectOutput, error) {
|
||||
body, _ := io.ReadAll(input.Body)
|
||||
c.data[awscore.ToString(input.Key)] = string(body)
|
||||
return &awss3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) GetObject(_ context.Context, input *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
|
||||
return &awss3.GetObjectOutput{Body: io.NopCloser(strings.NewReader(c.data[awscore.ToString(input.Key)]))}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) DeleteObject(_ context.Context, input *awss3.DeleteObjectInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
|
||||
delete(c.data, awscore.ToString(input.Key))
|
||||
return &awss3.DeleteObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) ListObjectsV2(_ context.Context, _ *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
|
||||
now := time.Now().UTC()
|
||||
return &awss3.ListObjectsV2Output{Contents: []awss3types.Object{{Key: awscore.String("backup.tar.gz"), Size: awscore.Int64(10), LastModified: &now}}}, nil
|
||||
}
|
||||
|
||||
func TestS3ProviderCRUD(t *testing.T) {
|
||||
factory := Factory{newClient: func(cfg storage.S3Config) client {
|
||||
return &fakeClient{data: make(map[string]string)}
|
||||
}}
|
||||
providerAny, err := factory.New(context.Background(), map[string]any{"bucket": "demo", "accessKeyId": "a", "secretAccessKey": "b"})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "backup.tar.gz", bytes.NewBufferString("payload"), 7, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "backup.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "payload" {
|
||||
t.Fatalf("unexpected content: %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "backup")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "backup.tar.gz" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "backup.tar.gz"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
9
server/internal/storage/s3provider/provider.go
Normal file
9
server/internal/storage/s3provider/provider.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package s3provider
|
||||
|
||||
import "backupx/server/internal/storage/s3"
|
||||
|
||||
type Factory = s3.Factory
|
||||
|
||||
func NewFactory() Factory {
|
||||
return s3.NewFactory()
|
||||
}
|
||||
60
server/internal/storage/tencent/factory.go
Normal file
60
server/internal/storage/tencent/factory.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Package tencent provides a Tencent Cloud COS storage factory that delegates to the S3-compatible engine.
|
||||
// Tencent COS is fully S3-compatible; we auto-assemble the endpoint from region and appId.
|
||||
package tencent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/s3"
|
||||
)
|
||||
|
||||
// Config is the user-facing configuration for Tencent COS.
|
||||
type Config struct {
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"` // format: bucketname-appid
|
||||
SecretID string `json:"accessKeyId"`
|
||||
SecretKey string `json:"secretAccessKey"`
|
||||
Endpoint string `json:"endpoint"` // optional override
|
||||
}
|
||||
|
||||
// Factory creates Tencent COS providers by composing the S3 engine.
|
||||
type Factory struct {
|
||||
s3Factory s3.Factory
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{s3Factory: s3.NewFactory()}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeTencentCOS }
|
||||
func (Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
|
||||
|
||||
func (f Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[Config](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := strings.TrimSpace(cfg.Endpoint)
|
||||
if endpoint == "" {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region == "" {
|
||||
return nil, fmt.Errorf("tencent cos region is required")
|
||||
}
|
||||
// Tencent COS S3-compatible endpoint format
|
||||
endpoint = fmt.Sprintf("https://cos.%s.myqcloud.com", region)
|
||||
}
|
||||
|
||||
s3Config := map[string]any{
|
||||
"endpoint": endpoint,
|
||||
"region": cfg.Region,
|
||||
"bucket": cfg.Bucket,
|
||||
"accessKeyId": cfg.SecretID,
|
||||
"secretAccessKey": cfg.SecretKey,
|
||||
"forcePathStyle": false, // COS uses virtual-hosted style
|
||||
}
|
||||
return f.s3Factory.New(ctx, s3Config)
|
||||
}
|
||||
120
server/internal/storage/types.go
Normal file
120
server/internal/storage/types.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProviderType = string
|
||||
|
||||
const (
|
||||
ProviderTypeLocalDisk ProviderType = "local_disk"
|
||||
ProviderTypeGoogleDrive ProviderType = "google_drive"
|
||||
ProviderTypeS3 ProviderType = "s3"
|
||||
ProviderTypeWebDAV ProviderType = "webdav"
|
||||
ProviderTypeAliyunOSS ProviderType = "aliyun_oss"
|
||||
ProviderTypeTencentCOS ProviderType = "tencent_cos"
|
||||
ProviderTypeQiniuKodo ProviderType = "qiniu_kodo"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeLocalDisk = string(ProviderTypeLocalDisk)
|
||||
TypeGoogleDrive = string(ProviderTypeGoogleDrive)
|
||||
TypeS3 = string(ProviderTypeS3)
|
||||
TypeWebDAV = string(ProviderTypeWebDAV)
|
||||
TypeAliyunOSS = string(ProviderTypeAliyunOSS)
|
||||
TypeTencentCOS = string(ProviderTypeTencentCOS)
|
||||
TypeQiniuKodo = string(ProviderTypeQiniuKodo)
|
||||
)
|
||||
|
||||
type ObjectInfo struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type StorageProvider interface {
|
||||
Type() ProviderType
|
||||
TestConnection(context.Context) error
|
||||
Upload(ctx context.Context, objectKey string, reader io.Reader, size int64, metadata map[string]string) error
|
||||
Download(ctx context.Context, objectKey string) (io.ReadCloser, error)
|
||||
Delete(ctx context.Context, objectKey string) error
|
||||
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
|
||||
}
|
||||
|
||||
type ProviderFactory interface {
|
||||
Type() ProviderType
|
||||
}
|
||||
|
||||
func DecodeConfig[T any](raw map[string]any) (T, error) {
|
||||
var cfg T
|
||||
encoded, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func DecodeRawConfig[T any](raw json.RawMessage) (T, error) {
|
||||
var cfg T
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func ParseProviderType(value string) ProviderType {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
type LocalDiskConfig struct {
|
||||
BasePath string `json:"basePath"`
|
||||
}
|
||||
|
||||
type S3Config struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SecretAccessKey string `json:"secretAccessKey"`
|
||||
ForcePathStyle bool `json:"forcePathStyle"`
|
||||
}
|
||||
|
||||
type WebDAVConfig struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
BasePath string `json:"basePath"`
|
||||
}
|
||||
|
||||
type GoogleDriveConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
RedirectURI string `json:"redirectUri"`
|
||||
RedirectURL string `json:"redirectUrl"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
FolderID string `json:"folderId"`
|
||||
}
|
||||
|
||||
func (cfg GoogleDriveConfig) Normalize() GoogleDriveConfig {
|
||||
cfg.ClientID = strings.TrimSpace(cfg.ClientID)
|
||||
cfg.ClientSecret = strings.TrimSpace(cfg.ClientSecret)
|
||||
cfg.RedirectURI = strings.TrimSpace(cfg.RedirectURI)
|
||||
cfg.RedirectURL = strings.TrimSpace(cfg.RedirectURL)
|
||||
cfg.RefreshToken = strings.TrimSpace(cfg.RefreshToken)
|
||||
cfg.FolderID = strings.TrimSpace(cfg.FolderID)
|
||||
if cfg.RedirectURI == "" {
|
||||
cfg.RedirectURI = cfg.RedirectURL
|
||||
}
|
||||
if cfg.RedirectURL == "" {
|
||||
cfg.RedirectURL = cfg.RedirectURI
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
126
server/internal/storage/webdav/provider.go
Normal file
126
server/internal/storage/webdav/provider.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
gowebdav "github.com/studio-b12/gowebdav"
|
||||
)
|
||||
|
||||
type client interface {
|
||||
ReadDir(path string) ([]os.FileInfo, error)
|
||||
WriteStream(path string, stream io.Reader, perm os.FileMode) error
|
||||
ReadStream(path string) (io.ReadCloser, error)
|
||||
Remove(path string) error
|
||||
MkdirAll(path string, perm os.FileMode) error
|
||||
Stat(path string) (os.FileInfo, error)
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
client client
|
||||
basePath string
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
newClient func(cfg storage.WebDAVConfig) client
|
||||
}
|
||||
|
||||
func NewFactory() Factory {
|
||||
return Factory{newClient: func(cfg storage.WebDAVConfig) client {
|
||||
return gowebdav.NewClient(strings.TrimRight(cfg.Endpoint, "/"), cfg.Username, cfg.Password)
|
||||
}}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
|
||||
func (Factory) SensitiveFields() []string { return []string{"username", "password"} }
|
||||
|
||||
func (f Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.WebDAVConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Endpoint) == "" {
|
||||
return nil, fmt.Errorf("webdav endpoint is required")
|
||||
}
|
||||
newClient := f.newClient
|
||||
if newClient == nil {
|
||||
factory := NewFactory()
|
||||
newClient = factory.newClient
|
||||
}
|
||||
return &Provider{client: newClient(cfg), basePath: normalizeBasePath(cfg.BasePath)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
|
||||
|
||||
func (p *Provider) TestConnection(_ context.Context) error {
|
||||
if err := p.client.MkdirAll(p.basePath, 0o755); err != nil {
|
||||
return fmt.Errorf("ensure webdav base path: %w", err)
|
||||
}
|
||||
if _, err := p.client.Stat(p.basePath); err != nil {
|
||||
return fmt.Errorf("stat webdav base path: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
if err := p.client.MkdirAll(path.Dir(objectPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create webdav directories: %w", err)
|
||||
}
|
||||
if err := p.client.WriteStream(objectPath, reader, 0o644); err != nil {
|
||||
return fmt.Errorf("write webdav object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
reader, err := p.client.ReadStream(p.resolvePath(objectKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read webdav object: %w", err)
|
||||
}
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(_ context.Context, objectKey string) error {
|
||||
if err := p.client.Remove(p.resolvePath(objectKey)); err != nil {
|
||||
return fmt.Errorf("delete webdav object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
entries, err := p.client.ReadDir(p.basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list webdav directory: %w", err)
|
||||
}
|
||||
items := make([]storage.ObjectInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimPrefix(path.Join(strings.TrimPrefix(p.basePath, "/"), entry.Name()), "/")
|
||||
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{Key: key, Size: entry.Size(), UpdatedAt: entry.ModTime().UTC()})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func normalizeBasePath(value string) string {
|
||||
clean := path.Clean("/" + strings.TrimSpace(value))
|
||||
if clean == "." {
|
||||
return "/"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func (p *Provider) resolvePath(objectKey string) string {
|
||||
cleanKey := path.Clean("/" + strings.TrimSpace(objectKey))
|
||||
return path.Clean(path.Join(p.basePath, cleanKey))
|
||||
}
|
||||
79
server/internal/storage/webdav/provider_test.go
Normal file
79
server/internal/storage/webdav/provider_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
)
|
||||
|
||||
type fakeFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mod time.Time
|
||||
dir bool
|
||||
}
|
||||
|
||||
func (f fakeFileInfo) Name() string { return f.name }
|
||||
func (f fakeFileInfo) Size() int64 { return f.size }
|
||||
func (f fakeFileInfo) Mode() os.FileMode { return 0 }
|
||||
func (f fakeFileInfo) ModTime() time.Time { return f.mod }
|
||||
func (f fakeFileInfo) IsDir() bool { return f.dir }
|
||||
func (f fakeFileInfo) Sys() any { return nil }
|
||||
|
||||
type fakeClient struct{ data map[string]string }
|
||||
|
||||
func (c *fakeClient) ReadDir(_ string) ([]os.FileInfo, error) {
|
||||
return []os.FileInfo{fakeFileInfo{name: "backup.tar.gz", size: int64(len(c.data["/storage/backup.tar.gz"])), mod: time.Now().UTC()}}, nil
|
||||
}
|
||||
func (c *fakeClient) WriteStream(path string, stream io.Reader, _ os.FileMode) error {
|
||||
content, _ := io.ReadAll(stream)
|
||||
c.data[path] = string(content)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeClient) ReadStream(path string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader(c.data[path])), nil
|
||||
}
|
||||
func (c *fakeClient) Remove(path string) error { delete(c.data, path); return nil }
|
||||
func (c *fakeClient) MkdirAll(_ string, _ os.FileMode) error { return nil }
|
||||
func (c *fakeClient) Stat(path string) (os.FileInfo, error) {
|
||||
return fakeFileInfo{name: path, dir: true}, nil
|
||||
}
|
||||
|
||||
func TestWebDAVProviderCRUD(t *testing.T) {
|
||||
factory := Factory{newClient: func(storage.WebDAVConfig) client { return &fakeClient{data: make(map[string]string)} }}
|
||||
providerAny, err := factory.New(context.Background(), map[string]any{"endpoint": "http://dav.example.com", "basePath": "/storage"})
|
||||
if err != nil {
|
||||
t.Fatalf("Factory.New returned error: %v", err)
|
||||
}
|
||||
provider := providerAny.(*Provider)
|
||||
if err := provider.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("TestConnection returned error: %v", err)
|
||||
}
|
||||
if err := provider.Upload(context.Background(), "backup.tar.gz", strings.NewReader("payload"), 7, nil); err != nil {
|
||||
t.Fatalf("Upload returned error: %v", err)
|
||||
}
|
||||
reader, err := provider.Download(context.Background(), "backup.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatalf("Download returned error: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
content, _ := io.ReadAll(reader)
|
||||
if string(content) != "payload" {
|
||||
t.Fatalf("unexpected content: %s", string(content))
|
||||
}
|
||||
items, err := provider.List(context.Background(), "storage")
|
||||
if err != nil {
|
||||
t.Fatalf("List returned error: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Key != "storage/backup.tar.gz" {
|
||||
t.Fatalf("unexpected list result: %#v", items)
|
||||
}
|
||||
if err := provider.Delete(context.Background(), "backup.tar.gz"); err != nil {
|
||||
t.Fatalf("Delete returned error: %v", err)
|
||||
}
|
||||
}
|
||||
9
server/internal/storage/webdavprovider/provider.go
Normal file
9
server/internal/storage/webdavprovider/provider.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package webdavprovider
|
||||
|
||||
import "backupx/server/internal/storage/webdav"
|
||||
|
||||
type Factory = webdav.Factory
|
||||
|
||||
func NewFactory() Factory {
|
||||
return webdav.NewFactory()
|
||||
}
|
||||
Reference in New Issue
Block a user