Compare commits

...

5 Commits

Author SHA1 Message Date
Awuqing
3023a089fb 修复: 存储目标创建/连接测试/类型选择三个关键问题
1. 修复 oneof 白名单仅含 4 种类型,阿里云/腾讯/七牛/FTP/Rclone
   类型的存储目标无法创建(binding 验证直接拒绝)
2. 修复本地磁盘 TestConnection 报 "directory not found",
   在 List 前先 Mkdir 确保目录存在
3. 前端存储类型选项明确标注 Rclone 支持 SFTP/Azure/Dropbox 等
2026-04-01 00:06:08 +08:00
Wu Qing
c437a72aad 功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持 (#23)
功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持
2026-03-31 23:46:02 +08:00
Awuqing
93bf8435b0 功能: 集成 rclone 高级传输特性 + 全 70+ 后端支持
1. 失败自动重试:rclone Pacer 指数退避,默认 10 次底层 HTTP 重试
2. 带宽限制:配置 bandwidth_limit + Settings 运行时可调
3. 上传实时进度:progressReader + LogHub SSE 推送字节级进度/速率
4. 存储空间查询:StorageAbout 可选接口,GetUsage 返回远端真实空间
5. 全 rclone 后端:backend/all 引入 70+ 后端,新增 rclone 存储类型,
   API 驱动的可搜索后端选择器 + 动态配置表单
2026-03-31 23:37:59 +08:00
Wu Qing
b2055c08f1 重构: 存储传输层集成 rclone 替代自研实现 (#22)
重构: 存储传输层集成 rclone 替代自研实现
2026-03-31 22:55:41 +08:00
Awuqing
f4d2271cc1 重构: 存储传输层集成 rclone 替代自研实现
将 8 种存储后端(本地磁盘、S3、WebDAV、Google Drive、FTP、阿里云 OSS、
腾讯云 COS、七牛 Kodo)的底层传输从 4 个独立 SDK 自研实现替换为 rclone
fs 接口统一驱动。

- 新建 storage/rclone/ 包(~410 行胶水代码),包含通用 Provider 和 8 种
  配置映射 Factory
- 删除 10 个旧 provider 包(~1000 行),净减少约 1000 行代码
- StorageProvider 接口、前端 UI、数据库模型、备份执行引擎全部零改动
- 获得 rclone 工业级传输能力(分片上传、断点续传、自动重试)
2026-03-31 22:52:16 +08:00
37 changed files with 2372 additions and 1628 deletions

View File

@@ -3,97 +3,258 @@ module backupx/server
go 1.25.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.3
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/rclone/rclone v1.73.3
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0
github.com/studio-b12/gowebdav v0.12.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.45.0
golang.org/x/oauth2 v0.25.0
google.golang.org/api v0.215.0
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.255.0
gorm.io/gorm v1.25.12
)
require (
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jlaffaye/ftp v0.2.0 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncw/swift/v2 v2.0.5 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
google.golang.org/grpc v1.67.3 // indirect
google.golang.org/protobuf v1.36.5 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/image v0.32.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.13.1 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,7 @@ import (
"backupx/server/internal/service"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/internal/storage/googledrive"
"backupx/server/internal/storage/localdisk"
storageAliyun "backupx/server/internal/storage/aliyun"
storageFTP "backupx/server/internal/storage/ftp"
storageTencent "backupx/server/internal/storage/tencent"
storageQiniu "backupx/server/internal/storage/qiniu"
storageS3 "backupx/server/internal/storage/s3"
storageWebDAV "backupx/server/internal/storage/webdav"
storageRclone "backupx/server/internal/storage/rclone"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -70,14 +63,15 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
storageRegistry := storage.NewRegistry(
localdisk.NewFactory(),
storageS3.NewFactory(),
storageWebDAV.NewFactory(),
googledrive.NewFactory(),
storageAliyun.NewFactory(),
storageTencent.NewFactory(),
storageQiniu.NewFactory(),
storageFTP.NewFactory(),
storageRclone.NewLocalDiskFactory(),
storageRclone.NewS3Factory(),
storageRclone.NewWebDAVFactory(),
storageRclone.NewGoogleDriveFactory(),
storageRclone.NewAliyunOSSFactory(),
storageRclone.NewTencentCOSFactory(),
storageRclone.NewQiniuKodoFactory(),
storageRclone.NewFTPFactory(),
storageRclone.NewRcloneFactory(),
)
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
@@ -88,7 +82,14 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
retentionService := backupretention.NewService(backupRecordRepo)
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent)
// 初始化 rclone 传输配置(重试 + 带宽限制)
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
LowLevelRetries: cfg.Backup.Retries,
BandwidthLimit: cfg.Backup.BandwidthLimit,
})
storageRclone.StartAccounting(rcloneCtx)
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit)
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
backupTaskService.SetScheduler(schedulerService)
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)

View File

@@ -1,6 +1,7 @@
package backup
import (
"fmt"
"sync"
"time"
)
@@ -99,6 +100,41 @@ func (h *LogHub) Complete(recordID uint, status string) {
}
}
// AppendProgress 推送上传进度事件(节流:每个 recordID 每 500ms 最多一次,最终值始终推送)。
func (h *LogHub) AppendProgress(recordID uint, progress ProgressInfo) {
h.mu.Lock()
defer h.mu.Unlock()
state := h.ensureState(recordID)
// 节流:距上次 progress 事件不足 500ms 且未完成则跳过100% 始终推送)
now := time.Now().UTC()
isFinal := progress.Percent >= 100
if !isFinal && len(state.events) > 0 {
last := state.events[len(state.events)-1]
if last.Progress != nil && now.Sub(last.Timestamp) < 500*time.Millisecond {
return
}
}
state.nextSequence++
event := LogEvent{
RecordID: recordID,
Sequence: state.nextSequence,
Level: "progress",
Message: fmt.Sprintf("上传进度: %.1f%%", progress.Percent),
Timestamp: now,
Status: state.status,
Progress: &progress,
}
state.events = append(state.events, event)
for _, subscriber := range state.subscribers {
select {
case subscriber <- event:
default:
}
}
}
func (h *LogHub) ensureState(recordID uint) *logStreamState {
state, ok := h.streams[recordID]
if ok {

View File

@@ -41,13 +41,23 @@ type RunResult struct {
}
type LogEvent struct {
RecordID uint `json:"recordId"`
Sequence int64 `json:"sequence"`
Level string `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Completed bool `json:"completed"`
Status string `json:"status"`
RecordID uint `json:"recordId"`
Sequence int64 `json:"sequence"`
Level string `json:"level"`
Message string `json:"message"`
Timestamp time.Time `json:"timestamp"`
Completed bool `json:"completed"`
Status string `json:"status"`
Progress *ProgressInfo `json:"progress,omitempty"`
}
// ProgressInfo 描述上传进度,通过 SSE 实时推送给前端。
type ProgressInfo struct {
BytesSent int64 `json:"bytesSent"`
TotalBytes int64 `json:"totalBytes"`
Percent float64 `json:"percent"`
SpeedBps float64 `json:"speedBps"` // bytes/sec
TargetName string `json:"targetName"`
}
type LogWriter interface {

View File

@@ -33,8 +33,10 @@ type SecurityConfig struct {
}
type BackupConfig struct {
TempDir string `mapstructure:"temp_dir"`
MaxConcurrent int `mapstructure:"max_concurrent"`
TempDir string `mapstructure:"temp_dir"`
MaxConcurrent int `mapstructure:"max_concurrent"`
Retries int `mapstructure:"retries"` // 底层 HTTP 请求重试次数,默认 10
BandwidthLimit string `mapstructure:"bandwidth_limit"` // 带宽限制,如 "10M",空不限
}
type LogConfig struct {
@@ -96,6 +98,9 @@ func Load(configPath string) (Config, error) {
if cfg.Backup.MaxConcurrent <= 0 {
cfg.Backup.MaxConcurrent = 2
}
if cfg.Backup.Retries <= 0 {
cfg.Backup.Retries = 10
}
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
@@ -135,6 +140,8 @@ func applyDefaults(v *viper.Viper) {
v.SetDefault("security.jwt_expire", "24h")
v.SetDefault("backup.temp_dir", "/tmp/backupx")
v.SetDefault("backup.max_concurrent", 2)
v.SetDefault("backup.retries", 10)
v.SetDefault("backup.bandwidth_limit", "")
v.SetDefault("log.level", "info")
v.SetDefault("log.file", "./data/backupx.log")
v.SetDefault("log.max_size", 100)

View File

@@ -0,0 +1,21 @@
package http
import (
storageRclone "backupx/server/internal/storage/rclone"
"backupx/server/pkg/response"
"github.com/gin-gonic/gin"
)
// RcloneHandler 处理 rclone 后端元数据查询。
type RcloneHandler struct{}
func NewRcloneHandler() *RcloneHandler {
return &RcloneHandler{}
}
// ListBackends 返回所有可用的 rclone 后端及其配置选项。
func (h *RcloneHandler) ListBackends(c *gin.Context) {
backends := storageRclone.ListBackends()
response.Success(c, backends)
}

View File

@@ -71,18 +71,22 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
storageTargets := api.Group("/storage-targets")
storageTargets.Use(AuthMiddleware(deps.JWTManager))
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
storageTargets.GET("", storageTargetHandler.List)
storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.POST("", storageTargetHandler.Create)
storageTargets.PUT("/:id", storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
storageTargets.POST("/test", storageTargetHandler.TestConnection)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
rcloneHandler := NewRcloneHandler()
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
// 参数路由
storageTargets.GET("/:id", storageTargetHandler.Get)
storageTargets.PUT("/:id", storageTargetHandler.Update)
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
backupTasks := api.Group("/backup/tasks")

View File

@@ -21,6 +21,7 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/internal/storage/rclone"
"backupx/server/pkg/compress"
backupcrypto "backupx/server/pkg/crypto"
)
@@ -84,6 +85,8 @@ type BackupExecutionService struct {
now func() time.Time
tempDir string
semaphore chan struct{}
retries int // rclone 底层重试次数
bandwidthLimit string // rclone 带宽限制
}
func NewBackupExecutionService(
@@ -98,6 +101,8 @@ func NewBackupExecutionService(
notifier BackupResultNotifier,
tempDir string,
maxConcurrent int,
retries int,
bandwidthLimit string,
) *BackupExecutionService {
if notifier == nil {
notifier = noopBackupNotifier{}
@@ -121,9 +126,11 @@ func NewBackupExecutionService(
async: func(job func()) {
go job()
},
now: func() time.Time { return time.Now().UTC() },
tempDir: tempDir,
semaphore: make(chan struct{}, maxConcurrent),
now: func() time.Time { return time.Now().UTC() },
tempDir: tempDir,
semaphore: make(chan struct{}, maxConcurrent),
retries: retries,
bandwidthLimit: bandwidthLimit,
}
}
@@ -366,7 +373,21 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
logger.Infof("开始上传备份到存储目标:%s", targetName)
// hashingReader: 上传过程中同步计算字节数 + SHA-256单次读取零额外 I/O
hr := newHashingReader(artifact)
if uploadErr := provider.Upload(ctx, storagePath, hr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil {
// progressReader: 包装 hashingReader通过 LogHub 推送实时上传进度
pr := newProgressReader(hr, fileSize, func(bytesRead int64, speedBps float64) {
percent := float64(0)
if fileSize > 0 {
percent = float64(bytesRead) / float64(fileSize) * 100
}
s.logHub.AppendProgress(recordID, backup.ProgressInfo{
BytesSent: bytesRead,
TotalBytes: fileSize,
Percent: percent,
SpeedBps: speedBps,
TargetName: targetName,
})
})
if uploadErr := provider.Upload(ctx, storagePath, pr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil {
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()}
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
return
@@ -447,6 +468,11 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
}
func (s *BackupExecutionService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
// 注入 rclone 传输配置(重试、带宽限制)
ctx = rclone.ConfiguredContext(ctx, rclone.TransferConfig{
LowLevelRetries: s.retries,
BandwidthLimit: s.bandwidthLimit,
})
target, err := s.targets.FindByID(ctx, targetID)
if err != nil {
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)

View File

@@ -15,7 +15,7 @@ import (
"backupx/server/internal/repository"
"backupx/server/internal/storage"
"backupx/server/internal/storage/codec"
"backupx/server/internal/storage/localdisk"
storageRclone "backupx/server/internal/storage/rclone"
)
func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRecordService, repository.BackupTaskRepository, repository.StorageTargetRepository, repository.BackupRecordRepository, string, string) {
@@ -53,13 +53,13 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec
}
logHub := backup.NewLogHub()
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewMySQLRunner(nil), backup.NewSQLiteRunner(), backup.NewPostgreSQLRunner(nil))
storageRegistry := storage.NewRegistry(localdisk.NewFactory())
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
retentionService := backupretention.NewService(records)
tempDir := filepath.Join(baseDir, "tmp")
if err := os.MkdirAll(tempDir, 0o755); err != nil {
t.Fatalf("MkdirAll tempDir returned error: %v", err)
}
executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, tempDir, 2)
executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, tempDir, 2, 10, "")
recordService := NewBackupRecordService(records, executionService, logHub)
return executionService, recordService, tasks, targets, records, sourceDir, storageDir
}

View File

@@ -0,0 +1,52 @@
package service
import (
"io"
"sync/atomic"
"time"
)
// progressCallback 在每次读取时被调用,报告已读字节数和估算速率。
type progressCallback func(bytesRead int64, speedBps float64)
// progressReader 包装 io.Reader定期通过回调报告传输进度。
type progressReader struct {
reader io.Reader
total int64
read atomic.Int64
callback progressCallback
startTime time.Time
lastCall time.Time
interval time.Duration
}
func newProgressReader(reader io.Reader, total int64, callback progressCallback) *progressReader {
now := time.Now()
return &progressReader{
reader: reader,
total: total,
callback: callback,
startTime: now,
lastCall: now,
interval: 500 * time.Millisecond,
}
}
func (r *progressReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
if n > 0 {
current := r.read.Add(int64(n))
now := time.Now()
isFinal := err == io.EOF || (r.total > 0 && current >= r.total)
if isFinal || now.Sub(r.lastCall) >= r.interval {
r.lastCall = now
elapsed := now.Sub(r.startTime).Seconds()
speed := float64(0)
if elapsed > 0 {
speed = float64(current) / elapsed
}
r.callback(current, speed)
}
}
return n, err
}

View File

@@ -22,6 +22,7 @@ var settingsKeys = []string{
"language",
"timezone",
"backup_notification_enabled",
"bandwidth_limit",
}
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {

View File

@@ -21,7 +21,7 @@ import (
type StorageTargetUpsertInput struct {
Name string `json:"name" binding:"required,min=1,max=128"`
Type string `json:"type" binding:"required,oneof=local_disk google_drive s3 webdav"`
Type string `json:"type" binding:"required,oneof=local_disk google_drive s3 webdav aliyun_oss tencent_cos qiniu_kodo ftp rclone"`
Description string `json:"description" binding:"max=255"`
Enabled bool `json:"enabled"`
Config map[string]any `json:"config" binding:"required"`
@@ -544,10 +544,11 @@ func cloneMap(source map[string]any) map[string]any {
}
type StorageTargetUsage struct {
TargetID uint `json:"targetId"`
TargetName string `json:"targetName"`
RecordCount int64 `json:"recordCount"`
TotalSize int64 `json:"totalSize"`
TargetID uint `json:"targetId"`
TargetName string `json:"targetName"`
RecordCount int64 `json:"recordCount"`
TotalSize int64 `json:"totalSize"`
DiskUsage *storage.StorageUsageInfo `json:"diskUsage,omitempty"`
}
func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageTargetUsage, error) {
@@ -570,5 +571,16 @@ func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageT
}
}
}
// 尝试查询远端真实存储空间(部分后端如 local/Google Drive/WebDAV 支持)
configMap := map[string]any{}
if decryptErr := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); decryptErr == nil {
if provider, createErr := s.registry.Create(ctx, target.Type, configMap); createErr == nil {
if abouter, ok := provider.(storage.StorageAbout); ok {
if diskUsage, aboutErr := abouter.About(ctx); aboutErr == nil {
result.DiskUsage = diskUsage
}
}
}
}
return result, nil
}

View File

@@ -1,66 +0,0 @@
// 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)
}

View File

@@ -1,226 +0,0 @@
package ftp
import (
"bytes"
"context"
"fmt"
"io"
"path"
"strings"
"time"
"backupx/server/internal/storage"
"github.com/jlaffaye/ftp"
)
// Provider implements storage.StorageProvider for FTP.
type Provider struct {
config storage.FTPConfig
}
// Factory creates FTP storage providers.
type Factory struct{}
// NewFactory returns a new FTP Factory.
func NewFactory() Factory {
return Factory{}
}
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeFTP }
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.FTPConfig](rawConfig)
if err != nil {
return nil, err
}
if strings.TrimSpace(cfg.Host) == "" {
return nil, fmt.Errorf("FTP host is required")
}
if cfg.Port == 0 {
cfg.Port = 21
}
return &Provider{config: cfg}, nil
}
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeFTP }
// dial establishes a connection to the FTP server and logs in.
func (p *Provider) dial() (*ftp.ServerConn, error) {
addr := fmt.Sprintf("%s:%d", p.config.Host, p.config.Port)
var opts []ftp.DialOption
opts = append(opts, ftp.DialWithTimeout(30*time.Second))
if p.config.UseTLS {
opts = append(opts, ftp.DialWithExplicitTLS(nil))
}
conn, err := ftp.Dial(addr, opts...)
if err != nil {
return nil, fmt.Errorf("connect to FTP server %s: %w", addr, err)
}
username := p.config.Username
if username == "" {
username = "anonymous"
}
if err := conn.Login(username, p.config.Password); err != nil {
conn.Quit()
return nil, fmt.Errorf("FTP login: %w", err)
}
return conn, nil
}
func (p *Provider) TestConnection(_ context.Context) error {
conn, err := p.dial()
if err != nil {
return err
}
defer conn.Quit()
basePath := p.normalizeBasePath()
if err := p.ensureDir(conn, basePath); err != nil {
return fmt.Errorf("ensure FTP base path: %w", err)
}
_, err = conn.List(basePath)
if err != nil {
return fmt.Errorf("list FTP base path: %w", err)
}
return nil
}
func (p *Provider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
conn, err := p.dial()
if err != nil {
return err
}
defer conn.Quit()
objectPath := p.resolvePath(objectKey)
dir := path.Dir(objectPath)
if err := p.ensureDir(conn, dir); err != nil {
return fmt.Errorf("create FTP directories: %w", err)
}
// Read all data into buffer since FTP STOR needs the full stream
data, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("read upload data: %w", err)
}
if err := conn.Stor(objectPath, bytes.NewReader(data)); err != nil {
return fmt.Errorf("FTP upload: %w", err)
}
return nil
}
func (p *Provider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
conn, err := p.dial()
if err != nil {
return nil, err
}
objectPath := p.resolvePath(objectKey)
resp, err := conn.Retr(objectPath)
if err != nil {
conn.Quit()
return nil, fmt.Errorf("FTP download: %w", err)
}
// Wrap the response to also close the FTP connection when done
return &ftpReadCloser{ReadCloser: resp, conn: conn}, nil
}
func (p *Provider) Delete(_ context.Context, objectKey string) error {
conn, err := p.dial()
if err != nil {
return err
}
defer conn.Quit()
objectPath := p.resolvePath(objectKey)
if err := conn.Delete(objectPath); err != nil {
return fmt.Errorf("FTP delete: %w", err)
}
return nil
}
func (p *Provider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
conn, err := p.dial()
if err != nil {
return nil, err
}
defer conn.Quit()
basePath := p.normalizeBasePath()
entries, err := conn.List(basePath)
if err != nil {
return nil, fmt.Errorf("FTP list: %w", err)
}
items := make([]storage.ObjectInfo, 0, len(entries))
for _, entry := range entries {
if entry.Type == ftp.EntryTypeFolder {
continue
}
key := strings.TrimPrefix(path.Join(strings.TrimPrefix(basePath, "/"), entry.Name), "/")
if prefix != "" && !strings.HasPrefix(key, prefix) {
continue
}
items = append(items, storage.ObjectInfo{
Key: key,
Size: int64(entry.Size),
UpdatedAt: entry.Time.UTC(),
})
}
return items, nil
}
// normalizeBasePath returns a cleaned base path with leading slash.
func (p *Provider) normalizeBasePath() string {
clean := path.Clean("/" + strings.TrimSpace(p.config.BasePath))
if clean == "." {
return "/"
}
return clean
}
// resolvePath returns the full FTP path for the given object key.
func (p *Provider) resolvePath(objectKey string) string {
cleanKey := path.Clean("/" + strings.TrimSpace(objectKey))
return path.Clean(path.Join(p.normalizeBasePath(), cleanKey))
}
// ensureDir creates all directories in the path recursively.
func (p *Provider) ensureDir(conn *ftp.ServerConn, dirPath string) error {
parts := strings.Split(strings.Trim(dirPath, "/"), "/")
current := ""
for _, part := range parts {
if part == "" {
continue
}
current = current + "/" + part
if err := conn.MakeDir(current); err != nil {
// Ignore errors if directory already exists
// FTP doesn't have a standard "mkdir if not exists"
_ = err
}
}
return nil
}
// ftpReadCloser wraps an io.ReadCloser from FTP and closes the connection when done.
type ftpReadCloser struct {
io.ReadCloser
conn *ftp.ServerConn
}
func (f *ftpReadCloser) Close() error {
err := f.ReadCloser.Close()
if f.conn != nil {
f.conn.Quit()
}
return err
}

View File

@@ -1,299 +0,0 @@
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, "'", "\\'")
}

View File

@@ -1,75 +0,0 @@
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)
}
}

View File

@@ -1,137 +0,0 @@
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
}

View File

@@ -1,52 +0,0 @@
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")
}
}

View File

@@ -1,73 +0,0 @@
// 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)
}

View File

@@ -0,0 +1,5 @@
// Package rclone 提供基于 rclone 的统一存储后端实现。
// 引入全部 rclone backend支持 70+ 存储后端。
package rclone
import _ "github.com/rclone/rclone/backend/all"

View File

@@ -0,0 +1,36 @@
package rclone
import (
"context"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/accounting"
)
// TransferConfig 控制 rclone 传输层行为。
type TransferConfig struct {
LowLevelRetries int // 底层 HTTP 请求重试次数0 保持 rclone 默认10
BandwidthLimit string // 带宽限制,如 "10M"、"1M:500k"(上传:下载),空或 "0" 不限
}
// ConfiguredContext 返回注入了 rclone 传输配置的 context。
// 各 rclone 后端在 fs.NewFs 时读取 context 中的配置,自动应用重试和限速。
func ConfiguredContext(ctx context.Context, cfg TransferConfig) context.Context {
ctx, ci := fs.AddConfig(ctx)
if cfg.LowLevelRetries > 0 {
ci.LowLevelRetries = cfg.LowLevelRetries
}
if cfg.BandwidthLimit != "" && cfg.BandwidthLimit != "0" {
var bwTable fs.BwTimetable
if err := bwTable.Set(cfg.BandwidthLimit); err == nil {
ci.BwLimit = bwTable
}
}
return ctx
}
// StartAccounting 初始化 rclone 的传输统计和令牌桶限速系统。
// 应在应用启动时调用一次。
func StartAccounting(ctx context.Context) {
accounting.Start(ctx)
}

View File

@@ -0,0 +1,436 @@
package rclone
import (
"context"
"fmt"
"strings"
"backupx/server/internal/storage"
"github.com/rclone/rclone/fs"
)
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
// quoteParam 对 rclone 连接字符串中含特殊字符的值加单引号保护。
func quoteParam(s string) string {
if s == "" {
return s
}
if !strings.ContainsAny(s, ",:='") {
return s
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// newFs 创建 rclone fs.Fs 实例并包装为 Provider。
func newFs(ctx context.Context, providerType storage.ProviderType, remote string) (*Provider, error) {
rfs, err := fs.NewFs(ctx, remote)
if err != nil {
return nil, fmt.Errorf("create rclone fs for %s: %w", providerType, err)
}
return newProvider(providerType, rfs), nil
}
// ---------------------------------------------------------------------------
// LocalDisk
// ---------------------------------------------------------------------------
type LocalDiskFactory struct{}
func NewLocalDiskFactory() LocalDiskFactory { return LocalDiskFactory{} }
func (LocalDiskFactory) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
func (LocalDiskFactory) SensitiveFields() []string { return nil }
func (LocalDiskFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.LocalDiskConfig](rawConfig)
if err != nil {
return nil, err
}
basePath := strings.TrimSpace(cfg.BasePath)
if basePath == "" {
return nil, fmt.Errorf("local disk basePath is required")
}
return newFs(ctx, storage.ProviderTypeLocalDisk, basePath)
}
// ---------------------------------------------------------------------------
// S3
// ---------------------------------------------------------------------------
type S3Factory struct{}
func NewS3Factory() S3Factory { return S3Factory{} }
func (S3Factory) Type() storage.ProviderType { return storage.ProviderTypeS3 }
func (S3Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (S3Factory) New(ctx 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")
}
return newFs(ctx, storage.ProviderTypeS3, buildS3Remote("Other", cfg.AccessKeyID, cfg.SecretAccessKey, cfg.Endpoint, cfg.Region, cfg.Bucket, cfg.ForcePathStyle))
}
// buildS3Remote 构建 S3 兼容存储的 rclone 连接字符串。
func buildS3Remote(provider, keyID, secret, endpoint, region, bucket string, pathStyle bool) string {
var b strings.Builder
b.WriteString(":s3,provider=")
b.WriteString(quoteParam(provider))
b.WriteString(",access_key_id=")
b.WriteString(quoteParam(keyID))
b.WriteString(",secret_access_key=")
b.WriteString(quoteParam(secret))
if strings.TrimSpace(endpoint) != "" {
b.WriteString(",endpoint=")
b.WriteString(quoteParam(strings.TrimRight(endpoint, "/")))
}
if strings.TrimSpace(region) != "" {
b.WriteString(",region=")
b.WriteString(quoteParam(region))
}
if pathStyle {
b.WriteString(",force_path_style=true")
}
b.WriteString(",env_auth=false,no_check_bucket=true:")
b.WriteString(bucket)
return b.String()
}
// ---------------------------------------------------------------------------
// WebDAV
// ---------------------------------------------------------------------------
type WebDAVFactory struct{}
func NewWebDAVFactory() WebDAVFactory { return WebDAVFactory{} }
func (WebDAVFactory) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
func (WebDAVFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (WebDAVFactory) New(ctx 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")
}
remote := fmt.Sprintf(":webdav,url=%s,user=%s,pass=%s:%s",
quoteParam(strings.TrimRight(cfg.Endpoint, "/")),
quoteParam(cfg.Username),
quoteParam(cfg.Password),
strings.TrimSpace(cfg.BasePath))
return newFs(ctx, storage.ProviderTypeWebDAV, remote)
}
// ---------------------------------------------------------------------------
// Google Drive
// ---------------------------------------------------------------------------
type GoogleDriveFactory struct{}
func NewGoogleDriveFactory() GoogleDriveFactory { return GoogleDriveFactory{} }
func (GoogleDriveFactory) Type() storage.ProviderType { return storage.ProviderTypeGoogleDrive }
func (GoogleDriveFactory) SensitiveFields() []string {
return []string{"clientId", "clientSecret", "refreshToken"}
}
func (GoogleDriveFactory) 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")
}
// 构造 rclone 所需的 OAuth2 token JSON
tokenJSON := fmt.Sprintf(`{"access_token":"","token_type":"Bearer","refresh_token":"%s","expiry":"0001-01-01T00:00:00Z"}`,
strings.ReplaceAll(cfg.RefreshToken, `"`, `\"`))
var b strings.Builder
b.WriteString(":drive,client_id=")
b.WriteString(quoteParam(cfg.ClientID))
b.WriteString(",client_secret=")
b.WriteString(quoteParam(cfg.ClientSecret))
b.WriteString(",token=")
b.WriteString(quoteParam(tokenJSON))
if strings.TrimSpace(cfg.FolderID) != "" {
b.WriteString(",root_folder_id=")
b.WriteString(quoteParam(cfg.FolderID))
}
b.WriteString(":")
return newFs(ctx, storage.ProviderTypeGoogleDrive, b.String())
}
// ---------------------------------------------------------------------------
// FTP
// ---------------------------------------------------------------------------
type FTPFactory struct{}
func NewFTPFactory() FTPFactory { return FTPFactory{} }
func (FTPFactory) Type() storage.ProviderType { return storage.ProviderTypeFTP }
func (FTPFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (FTPFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.FTPConfig](rawConfig)
if err != nil {
return nil, err
}
if strings.TrimSpace(cfg.Host) == "" {
return nil, fmt.Errorf("FTP host is required")
}
port := cfg.Port
if port == 0 {
port = 21
}
username := strings.TrimSpace(cfg.Username)
if username == "" {
username = "anonymous"
}
var b strings.Builder
b.WriteString(fmt.Sprintf(":ftp,host=%s,port=%d,user=%s,pass=%s",
quoteParam(cfg.Host), port, quoteParam(username), quoteParam(cfg.Password)))
if cfg.UseTLS {
b.WriteString(",tls=true,explicit_tls=true")
}
b.WriteString(":")
basePath := strings.TrimSpace(cfg.BasePath)
if basePath != "" {
b.WriteString(basePath)
}
return newFs(ctx, storage.ProviderTypeFTP, b.String())
}
// ---------------------------------------------------------------------------
// 阿里云 OSS委托 S3 引擎)
// ---------------------------------------------------------------------------
type AliyunOSSFactory struct{}
func NewAliyunOSSFactory() AliyunOSSFactory { return AliyunOSSFactory{} }
func (AliyunOSSFactory) Type() storage.ProviderType { return storage.ProviderTypeAliyunOSS }
func (AliyunOSSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// AliyunConfig 是阿里云 OSS 的用户配置。
type AliyunConfig struct {
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
Endpoint string `json:"endpoint"`
InternalNetwork bool `json:"internalNetwork"`
}
func (AliyunOSSFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[AliyunConfig](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")
}
if cfg.InternalNetwork {
endpoint = fmt.Sprintf("https://oss-%s-internal.aliyuncs.com", region)
} else {
endpoint = fmt.Sprintf("https://oss-%s.aliyuncs.com", region)
}
}
return newFs(ctx, storage.ProviderTypeAliyunOSS, buildS3Remote("Alibaba", cfg.AccessKeyID, cfg.SecretAccessKey, endpoint, cfg.Region, cfg.Bucket, false))
}
// ---------------------------------------------------------------------------
// 腾讯云 COS委托 S3 引擎)
// ---------------------------------------------------------------------------
type TencentCOSFactory struct{}
func NewTencentCOSFactory() TencentCOSFactory { return TencentCOSFactory{} }
func (TencentCOSFactory) Type() storage.ProviderType { return storage.ProviderTypeTencentCOS }
func (TencentCOSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// TencentConfig 是腾讯云 COS 的用户配置。
type TencentConfig struct {
Region string `json:"region"`
Bucket string `json:"bucket"`
SecretID string `json:"accessKeyId"`
SecretKey string `json:"secretAccessKey"`
Endpoint string `json:"endpoint"`
}
func (TencentCOSFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[TencentConfig](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")
}
endpoint = fmt.Sprintf("https://cos.%s.myqcloud.com", region)
}
return newFs(ctx, storage.ProviderTypeTencentCOS, buildS3Remote("TencentCOS", cfg.SecretID, cfg.SecretKey, endpoint, cfg.Region, cfg.Bucket, false))
}
// ---------------------------------------------------------------------------
// 七牛云 Kodo委托 S3 引擎)
// ---------------------------------------------------------------------------
type QiniuKodoFactory struct{}
func NewQiniuKodoFactory() QiniuKodoFactory { return QiniuKodoFactory{} }
func (QiniuKodoFactory) Type() storage.ProviderType { return storage.ProviderTypeQiniuKodo }
func (QiniuKodoFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// QiniuConfig 是七牛云 Kodo 的用户配置。
type QiniuConfig struct {
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKey string `json:"accessKeyId"`
SecretKey string `json:"secretAccessKey"`
Endpoint string `json:"endpoint"`
}
// regionEndpoints 映射七牛区域代码到 S3 兼容 endpoint。
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",
}
func (QiniuKodoFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[QiniuConfig](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)
}
}
return newFs(ctx, storage.ProviderTypeQiniuKodo, buildS3Remote("Qiniu", cfg.AccessKey, cfg.SecretKey, endpoint, cfg.Region, cfg.Bucket, true))
}
// ---------------------------------------------------------------------------
// 通用 Rclone 后端(支持全部 70+ 后端)
// ---------------------------------------------------------------------------
type RcloneFactory struct{}
func NewRcloneFactory() RcloneFactory { return RcloneFactory{} }
func (RcloneFactory) Type() storage.ProviderType { return storage.ProviderTypeRclone }
func (RcloneFactory) SensitiveFields() []string { return []string{"pass", "password", "secret_access_key", "client_secret", "token"} }
func (RcloneFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
backend, _ := rawConfig["backend"].(string)
backend = strings.TrimSpace(backend)
if backend == "" {
return nil, fmt.Errorf("rclone backend type is required")
}
root, _ := rawConfig["root"].(string)
root = strings.TrimSpace(root)
// 构建连接字符串::backend,key1=val1,key2=val2:root
var b strings.Builder
b.WriteString(":")
b.WriteString(backend)
for key, val := range rawConfig {
if key == "backend" || key == "root" {
continue
}
strVal := fmt.Sprintf("%v", val)
if strings.TrimSpace(strVal) == "" {
continue
}
b.WriteString(",")
b.WriteString(key)
b.WriteString("=")
b.WriteString(quoteParam(strVal))
}
b.WriteString(":")
b.WriteString(root)
return newFs(ctx, storage.ProviderTypeRclone, b.String())
}
// ListBackends 返回所有可用的 rclone 后端及其配置选项。
func ListBackends() []BackendInfo {
var backends []BackendInfo
for _, ri := range fs.Registry {
if ri.Name == "union" || ri.Name == "crypt" || ri.Name == "chunker" || ri.Name == "compress" || ri.Name == "hasher" || ri.Name == "combine" {
continue // 跳过组合/加密类后端
}
info := BackendInfo{
Name: ri.Name,
Description: ri.Description,
}
for _, opt := range ri.Options {
if opt.Hide != 0 {
continue
}
// 跳过 rclone 为每个后端自动添加的通用选项
if opt.Name == "description" {
continue
}
info.Options = append(info.Options, BackendOption{
Key: opt.Name,
Label: opt.Help,
Required: opt.Required,
IsPassword: opt.IsPassword,
})
}
backends = append(backends, info)
}
return backends
}
// BackendInfo 描述一个 rclone 后端。
type BackendInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Options []BackendOption `json:"options"`
}
// BackendOption 描述一个后端配置选项。
type BackendOption struct {
Key string `json:"key"`
Label string `json:"label"`
Required bool `json:"required"`
IsPassword bool `json:"isPassword"`
}

View File

@@ -0,0 +1,134 @@
package rclone
import (
"context"
"fmt"
"io"
"strings"
"time"
"backupx/server/internal/storage"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/object"
"github.com/rclone/rclone/fs/walk"
)
// Provider 包装 rclone fs.Fs实现 storage.StorageProvider 接口。
type Provider struct {
providerType storage.ProviderType
rfs fs.Fs
}
func newProvider(providerType storage.ProviderType, rfs fs.Fs) *Provider {
return &Provider{providerType: providerType, rfs: rfs}
}
func (p *Provider) Type() storage.ProviderType { return p.providerType }
// TestConnection 验证连通性。对本地磁盘会先确保目录存在。
func (p *Provider) TestConnection(ctx context.Context) error {
// 确保根目录存在(本地磁盘等后端需要预创建)
if err := p.rfs.Mkdir(ctx, ""); err != nil {
return fmt.Errorf("rclone test connection (mkdir): %w", err)
}
_, err := p.rfs.List(ctx, "")
if err != nil {
return fmt.Errorf("rclone test connection: %w", err)
}
return nil
}
// Upload 通过 rclone fs.Fs.Put 上传文件。
func (p *Provider) Upload(ctx context.Context, objectKey string, reader io.Reader, size int64, _ map[string]string) error {
dir := pathDir(objectKey)
if dir != "" && dir != "." {
if err := p.rfs.Mkdir(ctx, dir); err != nil {
return fmt.Errorf("rclone mkdir %s: %w", dir, err)
}
}
info := object.NewStaticObjectInfo(objectKey, time.Now(), size, true, nil, nil)
if _, err := p.rfs.Put(ctx, reader, info); err != nil {
return fmt.Errorf("rclone upload %s: %w", objectKey, err)
}
return nil
}
// Download 通过 rclone 获取对象并返回 io.ReadCloser。
func (p *Provider) Download(ctx context.Context, objectKey string) (io.ReadCloser, error) {
obj, err := p.rfs.NewObject(ctx, objectKey)
if err != nil {
return nil, fmt.Errorf("rclone find object %s: %w", objectKey, err)
}
reader, err := obj.Open(ctx)
if err != nil {
return nil, fmt.Errorf("rclone download %s: %w", objectKey, err)
}
return reader, nil
}
// Delete 通过 rclone 删除远端对象。
func (p *Provider) Delete(ctx context.Context, objectKey string) error {
obj, err := p.rfs.NewObject(ctx, objectKey)
if err != nil {
return fmt.Errorf("rclone find object %s: %w", objectKey, err)
}
if err := obj.Remove(ctx); err != nil {
return fmt.Errorf("rclone delete %s: %w", objectKey, err)
}
return nil
}
// List 递归列出指定前缀下的所有对象。
func (p *Provider) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
var items []storage.ObjectInfo
err := walk.ListR(ctx, p.rfs, prefix, true, -1, walk.ListObjects, func(entries fs.DirEntries) error {
for _, entry := range entries {
obj, ok := entry.(fs.Object)
if !ok {
continue
}
key := obj.Remote()
if prefix != "" && !strings.HasPrefix(key, prefix) {
continue
}
items = append(items, storage.ObjectInfo{
Key: key,
Size: obj.Size(),
UpdatedAt: obj.ModTime(ctx),
})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("rclone list %s: %w", prefix, err)
}
return items, nil
}
// About 查询远端存储空间。并非所有 rclone 后端都支持。
func (p *Provider) About(ctx context.Context) (*storage.StorageUsageInfo, error) {
about := p.rfs.Features().About
if about == nil {
return nil, fmt.Errorf("rclone about: backend %s does not support About", p.providerType)
}
usage, err := about(ctx)
if err != nil {
return nil, fmt.Errorf("rclone about: %w", err)
}
return &storage.StorageUsageInfo{
Total: usage.Total,
Used: usage.Used,
Free: usage.Free,
Objects: usage.Objects,
}, nil
}
// pathDir 返回 objectKey 的目录部分(正斜杠分隔)。
func pathDir(objectKey string) string {
idx := strings.LastIndex(objectKey, "/")
if idx < 0 {
return ""
}
return objectKey[:idx]
}

View File

@@ -0,0 +1,202 @@
package rclone
import (
"context"
"io"
"strings"
"testing"
)
func TestProviderLocalDiskCRUD(t *testing.T) {
factory := NewLocalDiskFactory()
provider, err := factory.New(context.Background(), map[string]any{"basePath": t.TempDir()})
if err != nil {
t.Fatalf("Factory.New returned error: %v", err)
}
if err := provider.TestConnection(context.Background()); err != nil {
t.Fatalf("TestConnection returned error: %v", err)
}
// Upload
if err := provider.Upload(context.Background(), "daily/backup.txt", strings.NewReader("hello"), 5, nil); err != nil {
t.Fatalf("Upload returned error: %v", err)
}
// Download
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 'hello', got %q", string(content))
}
// List with prefix
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)
}
// Delete
if err := provider.Delete(context.Background(), "daily/backup.txt"); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
// List after delete should be empty
items, err = provider.List(context.Background(), "daily")
if err != nil {
t.Fatalf("List after delete returned error: %v", err)
}
if len(items) != 0 {
t.Fatalf("expected empty list after delete, got %d items", len(items))
}
}
func TestProviderLocalDiskRequiresBasePath(t *testing.T) {
_, err := NewLocalDiskFactory().New(context.Background(), map[string]any{"basePath": ""})
if err == nil {
t.Fatal("expected error for empty basePath")
}
}
func TestProviderS3RequiresBucketAndCredentials(t *testing.T) {
factory := NewS3Factory()
_, err := factory.New(context.Background(), map[string]any{"bucket": "", "accessKeyId": "a", "secretAccessKey": "b"})
if err == nil || !strings.Contains(err.Error(), "bucket") {
t.Fatalf("expected bucket required error, got %v", err)
}
_, err = factory.New(context.Background(), map[string]any{"bucket": "demo", "accessKeyId": "", "secretAccessKey": "b"})
if err == nil || !strings.Contains(err.Error(), "credentials") {
t.Fatalf("expected credentials required error, got %v", err)
}
}
func TestQuoteParam(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"simple", "simple"},
{"", ""},
{"has,comma", "'has,comma'"},
{"has:colon", "'has:colon'"},
{"has=equals", "'has=equals'"},
{"has'quote", "'has''quote'"},
{"a,b:c=d'e", "'a,b:c=d''e'"},
}
for _, tt := range tests {
got := quoteParam(tt.input)
if got != tt.expected {
t.Errorf("quoteParam(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}
func TestBuildS3Remote(t *testing.T) {
remote := buildS3Remote("Alibaba", "keyID", "secret", "https://oss-cn-hangzhou.aliyuncs.com", "cn-hangzhou", "my-bucket", false)
if !strings.Contains(remote, "provider=Alibaba") {
t.Fatalf("expected provider=Alibaba in remote: %s", remote)
}
if !strings.Contains(remote, ":my-bucket") {
t.Fatalf("expected :my-bucket suffix in remote: %s", remote)
}
if !strings.HasPrefix(remote, ":s3,") {
t.Fatalf("expected :s3, prefix in remote: %s", remote)
}
}
func TestRcloneFactoryCRUD(t *testing.T) {
dir := t.TempDir()
factory := NewRcloneFactory()
// 使用 rclone 的 local 后端
provider, err := factory.New(context.Background(), map[string]any{
"backend": "local",
"root": dir,
})
if err != nil {
t.Fatalf("RcloneFactory.New returned error: %v", err)
}
if err := provider.Upload(context.Background(), "test.txt", strings.NewReader("rclone"), 6, nil); err != nil {
t.Fatalf("Upload via rclone factory returned error: %v", err)
}
reader, err := provider.Download(context.Background(), "test.txt")
if err != nil {
t.Fatalf("Download returned error: %v", err)
}
defer reader.Close()
content, _ := io.ReadAll(reader)
if string(content) != "rclone" {
t.Fatalf("expected 'rclone', got %q", string(content))
}
if err := provider.Delete(context.Background(), "test.txt"); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
}
func TestRcloneFactoryRequiresBackend(t *testing.T) {
_, err := NewRcloneFactory().New(context.Background(), map[string]any{"root": "/tmp"})
if err == nil || !strings.Contains(err.Error(), "backend") {
t.Fatalf("expected backend required error, got %v", err)
}
}
func TestListBackends(t *testing.T) {
backends := ListBackends()
if len(backends) < 30 {
t.Fatalf("expected at least 30 backends, got %d", len(backends))
}
// 确认 sftp 在列表中
found := false
for _, b := range backends {
if b.Name == "sftp" {
found = true
if len(b.Options) == 0 {
t.Fatal("sftp backend should have options")
}
break
}
}
if !found {
t.Fatal("sftp backend not found in ListBackends()")
}
}
func TestProviderAbout(t *testing.T) {
factory := NewLocalDiskFactory()
provider, err := factory.New(context.Background(), map[string]any{"basePath": t.TempDir()})
if err != nil {
t.Fatalf("Factory.New returned error: %v", err)
}
// local 后端支持 About
rcloneProvider := provider.(*Provider)
usage, err := rcloneProvider.About(context.Background())
if err != nil {
t.Fatalf("About returned error: %v", err)
}
if usage.Total == nil || *usage.Total <= 0 {
t.Fatalf("expected non-zero total disk space, got %v", usage.Total)
}
}
func TestPathDir(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"BackupX/file/260308/backup.tar.gz", "BackupX/file/260308"},
{"backup.tar.gz", ""},
{"a/b", "a"},
{"", ""},
}
for _, tt := range tests {
got := pathDir(tt.input)
if got != tt.expected {
t.Errorf("pathDir(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}

View File

@@ -1,126 +0,0 @@
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
}

View File

@@ -1,78 +0,0 @@
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)
}
}

View File

@@ -1,9 +0,0 @@
package s3provider
import "backupx/server/internal/storage/s3"
type Factory = s3.Factory
func NewFactory() Factory {
return s3.NewFactory()
}

View File

@@ -1,60 +0,0 @@
// 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)
}

View File

@@ -20,6 +20,7 @@ const (
ProviderTypeTencentCOS ProviderType = "tencent_cos"
ProviderTypeQiniuKodo ProviderType = "qiniu_kodo"
ProviderTypeFTP ProviderType = "ftp"
ProviderTypeRclone ProviderType = "rclone"
)
const (
@@ -52,6 +53,20 @@ type ProviderFactory interface {
Type() ProviderType
}
// StorageAbout 是可选能力接口,支持查询远端存储空间。
// 并非所有后端都支持(如 S3/FTP 不支持),通过 type assertion 检测。
type StorageAbout interface {
About(ctx context.Context) (*StorageUsageInfo, error)
}
// StorageUsageInfo 描述远端存储的空间使用情况。
type StorageUsageInfo struct {
Total *int64 `json:"total,omitempty"` // 总空间(字节)
Used *int64 `json:"used,omitempty"` // 已用空间
Free *int64 `json:"free,omitempty"` // 可用空间
Objects *int64 `json:"objects,omitempty"` // 对象数量
}
func DecodeConfig[T any](raw map[string]any) (T, error) {
var cfg T
encoded, err := json.Marshal(raw)

View File

@@ -1,126 +0,0 @@
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))
}

View File

@@ -1,79 +0,0 @@
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)
}
}

View File

@@ -1,9 +0,0 @@
package webdavprovider
import "backupx/server/internal/storage/webdav"
type Factory = webdav.Factory
func NewFactory() Factory {
return webdav.NewFactory()
}

View File

@@ -2,6 +2,7 @@ import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typograph
import { useEffect, useMemo, useState } from 'react'
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, storageTargetTypeOptions } from './field-config'
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
interface StorageTargetFormDrawerProps {
visible: boolean
@@ -38,6 +39,10 @@ export function StorageTargetFormDrawer({
const [error, setError] = useState('')
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
// rclone 后端列表API 驱动)
const [rcloneBackends, setRcloneBackends] = useState<RcloneBackendInfo[]>([])
const [rcloneBackendsLoading, setRcloneBackendsLoading] = useState(false)
useEffect(() => {
if (!visible) {
return
@@ -59,8 +64,35 @@ export function StorageTargetFormDrawer({
setTestResult(null)
}, [initialValue, visible])
// 当类型切换到 rclone 时,加载后端列表
useEffect(() => {
if (draft.type === 'rclone' && rcloneBackends.length === 0 && !rcloneBackendsLoading) {
setRcloneBackendsLoading(true)
listRcloneBackends()
.then(setRcloneBackends)
.catch(() => {})
.finally(() => setRcloneBackendsLoading(false))
}
}, [draft.type, rcloneBackends.length, rcloneBackendsLoading])
const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type])
// 当前选中的 rclone 后端信息
const selectedRcloneBackend = useMemo(() => {
if (draft.type !== 'rclone') return null
const backendName = draft.config.backend as string
if (!backendName) return null
return rcloneBackends.find((b) => b.name === backendName) || null
}, [draft.type, draft.config.backend, rcloneBackends])
// rclone 后端下拉选项
const rcloneBackendOptions = useMemo(() => {
return rcloneBackends.map((b) => ({
label: `${b.name}${b.description}`,
value: b.name,
}))
}, [rcloneBackends])
function updateConfig(key: string, value: string | boolean) {
setDraft((current) => ({
...current,
@@ -75,6 +107,13 @@ export function StorageTargetFormDrawer({
if (!value.name.trim()) {
return '请输入存储目标名称'
}
// rclone 类型需要选择后端
if (value.type === 'rclone') {
if (!value.config.backend || !(value.config.backend as string).trim()) {
return '请选择 Rclone 后端类型'
}
return ''
}
for (const field of fieldConfigs) {
if (!field.required) {
continue
@@ -121,6 +160,131 @@ export function StorageTargetFormDrawer({
await onGoogleDriveAuth(draft, initialValue?.id)
}
// 渲染 rclone 类型的动态配置表单
function renderRcloneFields() {
return (
<>
<div>
<Typography.Text>Rclone *</Typography.Text>
<Select
showSearch
allowClear
placeholder="搜索并选择后端(如 sftp, azureblob, dropbox..."
loading={rcloneBackendsLoading}
value={(draft.config.backend as string) || undefined}
options={rcloneBackendOptions}
filterOption={(inputValue, option) => {
const label = (option?.props?.children ?? option?.props?.label ?? '') as string
return label.toLowerCase().includes(inputValue.toLowerCase())
}}
onChange={(value) => {
// 切换后端时清空旧配置,保留 backend 和 root
const root = draft.config.root || ''
setDraft((current) => ({
...current,
config: { backend: value || '', root },
}))
}}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
SFTPAzure BlobDropboxOneDriveB2SMB 70+
</Typography.Paragraph>
</div>
<div>
<Typography.Text></Typography.Text>
<Input
value={(draft.config.root as string) || ''}
placeholder="/backups 或 bucket-name"
onChange={(value) => updateConfig('root', value)}
/>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
使
</Typography.Paragraph>
</div>
{selectedRcloneBackend && selectedRcloneBackend.options.length > 0 && (
<>
<Divider orientation="left" style={{ margin: '8px 0' }}>
{selectedRcloneBackend.name}
</Divider>
{selectedRcloneBackend.options.map((opt) => (
<div key={opt.key}>
<Typography.Text>
{opt.key}
{opt.required ? ' *' : ''}
</Typography.Text>
{opt.isPassword ? (
<Input.Password
value={(draft.config[opt.key] as string) || ''}
placeholder={opt.label}
onChange={(value) => updateConfig(opt.key, value)}
/>
) : (
<Input
value={(draft.config[opt.key] as string) || ''}
placeholder={opt.label}
onChange={(value) => updateConfig(opt.key, value)}
/>
)}
{opt.label && (
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 0, marginTop: 2, fontSize: 12, lineHeight: '18px' }}
ellipsis={{ rows: 2, expandable: true }}
>
{opt.label}
</Typography.Paragraph>
)}
</div>
))}
</>
)}
</>
)
}
// 渲染常规类型的静态字段
function renderStaticFields() {
return fieldConfigs.map((field) => {
const value = draft.config[field.key]
const normalizedValue = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : ''
return (
<div key={field.key}>
<Typography.Text>
{field.label}
{field.required ? ' *' : ''}
</Typography.Text>
{field.type === 'switch' ? (
<Space align="center" size="medium">
<Switch checked={Boolean(normalizedValue)} onChange={(checked) => updateConfig(field.key, checked)} />
{field.description ? <Typography.Text type="secondary">{field.description}</Typography.Text> : null}
</Space>
) : field.type === 'password' ? (
<Input.Password
value={String(normalizedValue)}
placeholder={field.placeholder}
onChange={(nextValue) => updateConfig(field.key, nextValue)}
/>
) : (
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(nextValue) => updateConfig(field.key, nextValue)} />
)}
{field.description && field.type !== 'switch' ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{field.description}
</Typography.Paragraph>
) : null}
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
</Typography.Paragraph>
) : null}
</div>
)
})
}
return (
<Drawer
width={560}
@@ -176,43 +340,7 @@ export function StorageTargetFormDrawer({
{getStorageTargetTypeLabel(draft.type)}
</Typography.Title>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{fieldConfigs.map((field) => {
const value = draft.config[field.key]
const normalizedValue = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : ''
return (
<div key={field.key}>
<Typography.Text>
{field.label}
{field.required ? ' *' : ''}
</Typography.Text>
{field.type === 'switch' ? (
<Space align="center" size="medium">
<Switch checked={Boolean(normalizedValue)} onChange={(checked) => updateConfig(field.key, checked)} />
{field.description ? <Typography.Text type="secondary">{field.description}</Typography.Text> : null}
</Space>
) : field.type === 'password' ? (
<Input.Password
value={String(normalizedValue)}
placeholder={field.placeholder}
onChange={(nextValue) => updateConfig(field.key, nextValue)}
/>
) : (
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(nextValue) => updateConfig(field.key, nextValue)} />
)}
{field.description && field.type !== 'switch' ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
{field.description}
</Typography.Paragraph>
) : null}
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
</Typography.Paragraph>
) : null}
</div>
)
})}
{draft.type === 'rclone' ? renderRcloneFields() : renderStaticFields()}
</Space>
</div>

View File

@@ -216,6 +216,7 @@ const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> =
placeholder: '输入新的 SecretKey',
},
],
rclone: [], // 动态表单,字段从 API 获取(见 StorageTargetFormDrawer
ftp: [
{
key: 'host',
@@ -284,6 +285,8 @@ export function getStorageTargetTypeLabel(type: StorageTargetType) {
return '七牛云 Kodo'
case 'ftp':
return 'FTP'
case 'rclone':
return 'Rclone (70+ 后端)'
default:
return type
}
@@ -298,4 +301,5 @@ export const storageTargetTypeOptions = [
{ label: 'Google Drive', value: 'google_drive' },
{ label: 'WebDAV', value: 'webdav' },
{ label: 'FTP', value: 'ftp' },
{ label: 'Rclone — SFTP / Azure / Dropbox / OneDrive 等 70+ 后端', value: 'rclone' },
] as const

View File

@@ -0,0 +1,19 @@
import { http } from './http'
export interface RcloneBackendOption {
key: string
label: string
required: boolean
isPassword: boolean
}
export interface RcloneBackendInfo {
name: string
description: string
options: RcloneBackendOption[]
}
export async function listRcloneBackends(): Promise<RcloneBackendInfo[]> {
const { data } = await http.get<{ data: RcloneBackendInfo[] }>('/api/storage-targets/rclone/backends')
return data.data
}

View File

@@ -1,4 +1,4 @@
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp'
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp' | 'rclone'
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
export type StorageFieldType = 'input' | 'password' | 'switch'