mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 11:33:42 +08:00
Compare commits
23 Commits
V1.2.1
...
fix/compre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd15bf3fd | ||
|
|
5ae7fb2f5d | ||
|
|
37ad6b1db1 | ||
|
|
d9e0609089 | ||
|
|
ab9919f15f | ||
|
|
d70b4094af | ||
|
|
eeec7678a1 | ||
|
|
cefbdf3a53 | ||
|
|
4a56ad05fc | ||
|
|
9ea02566cb | ||
|
|
a45b1f7bfb | ||
|
|
bfc8728785 | ||
|
|
3023a089fb | ||
|
|
c437a72aad | ||
|
|
93bf8435b0 | ||
|
|
b2055c08f1 | ||
|
|
f4d2271cc1 | ||
|
|
7c81810019 | ||
|
|
deb7cf9a5e | ||
|
|
ad5c25f38e | ||
|
|
7568d8a2a2 | ||
|
|
e5a4aaadb2 | ||
|
|
51f1909a73 |
@@ -1,14 +1,15 @@
|
|||||||
APP_NAME=backupx
|
APP_NAME=backupx
|
||||||
BUILD_DIR=./bin
|
BUILD_DIR=./bin
|
||||||
|
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
|
||||||
.PHONY: build run test
|
.PHONY: build run test
|
||||||
|
|
||||||
build:
|
build:
|
||||||
mkdir -p $(BUILD_DIR)
|
mkdir -p $(BUILD_DIR)
|
||||||
go build -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
|
go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run ./cmd/backupx
|
go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|||||||
245
server/go.mod
245
server/go.mod
@@ -3,97 +3,258 @@ module backupx/server
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
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/gin-gonic/gin v1.10.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
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/natefinch/lumberjack v2.0.0+incompatible
|
||||||
|
github.com/rclone/rclone v1.73.3
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/spf13/viper v1.20.0
|
github.com/spf13/viper v1.20.0
|
||||||
github.com/studio-b12/gowebdav v0.12.0
|
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/oauth2 v0.25.0
|
golang.org/x/oauth2 v0.34.0
|
||||||
google.golang.org/api v0.215.0
|
google.golang.org/api v0.255.0
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/auth v0.13.0 // indirect
|
cloud.google.com/go/auth v0.17.0 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.6.0 // 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/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/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/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/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/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/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/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/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/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/aws/smithy-go v1.24.2 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/boombuler/barcode v1.1.0 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.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/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/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/flynn/noise v1.1.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/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-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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-playground/validator/v10 v10.28.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-resty/resty/v2 v2.16.5 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/s2a-go v0.1.8 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
github.com/hashicorp/errwrap v1.0.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-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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // 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/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/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-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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/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/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/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/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/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/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
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 // 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
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/arch v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/image v0.32.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
google.golang.org/grpc v1.67.3 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // 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/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.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.5.0 // indirect
|
||||||
modernc.org/sqlite v1.23.1 // 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
|
||||||
)
|
)
|
||||||
|
|||||||
1027
server/go.sum
1027
server/go.sum
File diff suppressed because it is too large
Load Diff
@@ -20,14 +20,7 @@ import (
|
|||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/internal/storage"
|
"backupx/server/internal/storage"
|
||||||
"backupx/server/internal/storage/codec"
|
"backupx/server/internal/storage/codec"
|
||||||
"backupx/server/internal/storage/googledrive"
|
storageRclone "backupx/server/internal/storage/rclone"
|
||||||
"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"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -70,15 +63,18 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||||
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
||||||
storageRegistry := storage.NewRegistry(
|
storageRegistry := storage.NewRegistry(
|
||||||
localdisk.NewFactory(),
|
storageRclone.NewLocalDiskFactory(),
|
||||||
storageS3.NewFactory(),
|
storageRclone.NewS3Factory(),
|
||||||
storageWebDAV.NewFactory(),
|
storageRclone.NewWebDAVFactory(),
|
||||||
googledrive.NewFactory(),
|
storageRclone.NewGoogleDriveFactory(),
|
||||||
storageAliyun.NewFactory(),
|
storageRclone.NewAliyunOSSFactory(),
|
||||||
storageTencent.NewFactory(),
|
storageRclone.NewTencentCOSFactory(),
|
||||||
storageQiniu.NewFactory(),
|
storageRclone.NewQiniuKodoFactory(),
|
||||||
storageFTP.NewFactory(),
|
storageRclone.NewFTPFactory(),
|
||||||
|
storageRclone.NewRcloneFactory(),
|
||||||
)
|
)
|
||||||
|
// 将全部 rclone 后端注册为独立存储类型(sftp、azureblob、dropbox 等与 s3、ftp 完全平级)
|
||||||
|
storageRclone.RegisterAllBackends(storageRegistry)
|
||||||
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
|
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
|
||||||
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
||||||
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
||||||
@@ -88,9 +84,17 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
retentionService := backupretention.NewService(backupRecordRepo)
|
retentionService := backupretention.NewService(backupRecordRepo)
|
||||||
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
||||||
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
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)
|
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
|
||||||
backupTaskService.SetScheduler(schedulerService)
|
backupTaskService.SetScheduler(schedulerService)
|
||||||
|
// 审计日志注入延迟到 auditService 创建后(见下方)
|
||||||
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
||||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||||
@@ -99,6 +103,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
auditLogRepo := repository.NewAuditLogRepository(db)
|
auditLogRepo := repository.NewAuditLogRepository(db)
|
||||||
auditService := service.NewAuditService(auditLogRepo)
|
auditService := service.NewAuditService(auditLogRepo)
|
||||||
authService.SetAuditService(auditService)
|
authService.SetAuditService(auditService)
|
||||||
|
schedulerService.SetAuditRecorder(auditService)
|
||||||
|
|
||||||
// Database discovery
|
// Database discovery
|
||||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package backup
|
package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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 {
|
func (h *LogHub) ensureState(recordID uint) *logStreamState {
|
||||||
state, ok := h.streams[recordID]
|
state, ok := h.streams[recordID]
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -41,13 +41,23 @@ type RunResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LogEvent struct {
|
type LogEvent struct {
|
||||||
RecordID uint `json:"recordId"`
|
RecordID uint `json:"recordId"`
|
||||||
Sequence int64 `json:"sequence"`
|
Sequence int64 `json:"sequence"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
Status string `json:"status"`
|
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 {
|
type LogWriter interface {
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ type SecurityConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BackupConfig struct {
|
type BackupConfig struct {
|
||||||
TempDir string `mapstructure:"temp_dir"`
|
TempDir string `mapstructure:"temp_dir"`
|
||||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||||
|
Retries int `mapstructure:"retries"` // 底层 HTTP 请求重试次数,默认 10
|
||||||
|
BandwidthLimit string `mapstructure:"bandwidth_limit"` // 带宽限制,如 "10M",空不限
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
@@ -96,6 +98,9 @@ func Load(configPath string) (Config, error) {
|
|||||||
if cfg.Backup.MaxConcurrent <= 0 {
|
if cfg.Backup.MaxConcurrent <= 0 {
|
||||||
cfg.Backup.MaxConcurrent = 2
|
cfg.Backup.MaxConcurrent = 2
|
||||||
}
|
}
|
||||||
|
if cfg.Backup.Retries <= 0 {
|
||||||
|
cfg.Backup.Retries = 10
|
||||||
|
}
|
||||||
if cfg.Log.Level == "" {
|
if cfg.Log.Level == "" {
|
||||||
cfg.Log.Level = "info"
|
cfg.Log.Level = "info"
|
||||||
}
|
}
|
||||||
@@ -135,6 +140,8 @@ func applyDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("security.jwt_expire", "24h")
|
v.SetDefault("security.jwt_expire", "24h")
|
||||||
v.SetDefault("backup.temp_dir", "/tmp/backupx")
|
v.SetDefault("backup.temp_dir", "/tmp/backupx")
|
||||||
v.SetDefault("backup.max_concurrent", 2)
|
v.SetDefault("backup.max_concurrent", 2)
|
||||||
|
v.SetDefault("backup.retries", 10)
|
||||||
|
v.SetDefault("backup.bandwidth_limit", "")
|
||||||
v.SetDefault("log.level", "info")
|
v.SetDefault("log.level", "info")
|
||||||
v.SetDefault("log.file", "./data/backupx.log")
|
v.SetDefault("log.file", "./data/backupx.log")
|
||||||
v.SetDefault("log.max_size", 100)
|
v.SetDefault("log.max_size", 100)
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", "")
|
recordAudit(c, h.auditService, "backup_record", "restore", "backup_record", fmt.Sprintf("%d", id), "", fmt.Sprintf("恢复备份记录 #%d", id))
|
||||||
response.Success(c, gin.H{"restored": true})
|
response.Success(c, gin.H{"restored": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +143,28 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", "")
|
recordAudit(c, h.auditService, "backup_record", "delete", "backup_record", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除备份记录 #%d", id))
|
||||||
response.Success(c, gin.H{"deleted": true})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, apperror.BadRequest("BACKUP_RECORD_BATCH_INVALID", "批量删除参数不合法", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted := 0
|
||||||
|
for _, id := range input.IDs {
|
||||||
|
if err := h.service.Delete(c.Request.Context(), id); err == nil {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recordAudit(c, h.auditService, "backup_record", "batch_delete", "backup_record", "", "", fmt.Sprintf("批量删除 %d 条备份记录", deleted))
|
||||||
|
response.Success(c, gin.H{"deleted": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
|
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
|
||||||
var filter service.BackupRecordListInput
|
var filter service.BackupRecordListInput
|
||||||
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
|
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
|
recordAudit(c, h.auditService, "backup_task", "create", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
|
||||||
response.Success(c, item)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, "")
|
recordAudit(c, h.auditService, "backup_task", "update", "backup_task", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s, Cron: %s", input.Type, input.CronExpr))
|
||||||
response.Success(c, item)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", "")
|
recordAudit(c, h.auditService, "backup_task", "delete", "backup_task", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除备份任务 #%d", id))
|
||||||
response.Success(c, gin.H{"deleted": true})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +115,6 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) {
|
|||||||
if !enabled {
|
if !enabled {
|
||||||
action = "disable"
|
action = "disable"
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, "")
|
recordAudit(c, h.auditService, "backup_task", action, "backup_task", fmt.Sprintf("%d", id), item.Name, fmt.Sprintf("%s 备份任务", action))
|
||||||
response.Success(c, item)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|||||||
21
server/internal/http/rclone_handler.go
Normal file
21
server/internal/http/rclone_handler.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -71,18 +71,22 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
|
|
||||||
storageTargets := api.Group("/storage-targets")
|
storageTargets := api.Group("/storage-targets")
|
||||||
storageTargets.Use(AuthMiddleware(deps.JWTManager))
|
storageTargets.Use(AuthMiddleware(deps.JWTManager))
|
||||||
|
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
|
||||||
storageTargets.GET("", storageTargetHandler.List)
|
storageTargets.GET("", storageTargetHandler.List)
|
||||||
storageTargets.GET("/:id", storageTargetHandler.Get)
|
|
||||||
storageTargets.POST("", storageTargetHandler.Create)
|
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("/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/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
|
||||||
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
|
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
|
||||||
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
|
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)
|
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
|
||||||
|
|
||||||
backupTasks := api.Group("/backup/tasks")
|
backupTasks := api.Group("/backup/tasks")
|
||||||
@@ -102,6 +106,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
||||||
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
||||||
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
|
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
|
||||||
|
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
|
||||||
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
|
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
|
||||||
dashboard := api.Group("/dashboard")
|
dashboard := api.Group("/dashboard")
|
||||||
dashboard.Use(AuthMiddleware(deps.JWTManager))
|
dashboard.Use(AuthMiddleware(deps.JWTManager))
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"backupx/server/internal/apperror"
|
"backupx/server/internal/apperror"
|
||||||
"backupx/server/internal/service"
|
"backupx/server/internal/service"
|
||||||
"backupx/server/pkg/response"
|
"backupx/server/pkg/response"
|
||||||
@@ -36,6 +39,10 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", "")
|
keys := make([]string, 0, len(input))
|
||||||
|
for k := range input {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
recordAudit(c, h.auditService, "settings", "update", "settings", "", "", fmt.Sprintf("修改设置: %s", strings.Join(keys, ", ")))
|
||||||
response.Success(c, settings)
|
response.Success(c, settings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
|
recordAudit(c, h.auditService, "storage_target", "create", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
|
||||||
response.Success(c, item)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, "")
|
recordAudit(c, h.auditService, "storage_target", "update", "storage_target", fmt.Sprintf("%d", item.ID), item.Name, fmt.Sprintf("类型: %s", input.Type))
|
||||||
response.Success(c, item)
|
response.Success(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", "")
|
recordAudit(c, h.auditService, "storage_target", "delete", "storage_target", fmt.Sprintf("%d", id), "", fmt.Sprintf("删除存储目标 #%d", id))
|
||||||
response.Success(c, gin.H{"deleted": true})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type BackupRecord struct {
|
|||||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
||||||
|
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
|
||||||
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
||||||
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
||||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ type TaskRunner interface {
|
|||||||
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
|
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuditRecorder 记录审计日志(可选依赖)
|
||||||
|
type AuditRecorder interface {
|
||||||
|
Record(servicepkg.AuditEntry)
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
tasks repository.BackupTaskRepository
|
tasks repository.BackupTaskRepository
|
||||||
runner TaskRunner
|
runner TaskRunner
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit AuditRecorder
|
||||||
entries map[uint]cron.EntryID
|
entries map[uint]cron.EntryID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +37,8 @@ func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger
|
|||||||
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
|
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetAuditRecorder(audit AuditRecorder) { s.audit = audit }
|
||||||
|
|
||||||
func (s *Service) Start(ctx context.Context) error {
|
func (s *Service) Start(ctx context.Context) error {
|
||||||
if err := s.Reload(ctx); err != nil {
|
if err := s.Reload(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -96,9 +104,19 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
|
|||||||
if !task.Enabled || task.CronExpr == "" {
|
if !task.Enabled || task.CronExpr == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
taskID := task.ID
|
||||||
|
taskName := task.Name
|
||||||
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
|
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
|
||||||
if _, runErr := s.runner.RunTaskByID(context.Background(), task.ID); runErr != nil && s.logger != nil {
|
// 自动调度任务记录审计日志
|
||||||
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", task.ID), zap.Error(runErr))
|
if s.audit != nil {
|
||||||
|
s.audit.Record(servicepkg.AuditEntry{
|
||||||
|
Username: "system", Category: "backup_task", Action: "scheduled_run",
|
||||||
|
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
|
||||||
|
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, task.CronExpr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil {
|
||||||
|
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", taskID), zap.Error(runErr))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,6 +21,7 @@ import (
|
|||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
"backupx/server/internal/storage"
|
"backupx/server/internal/storage"
|
||||||
"backupx/server/internal/storage/codec"
|
"backupx/server/internal/storage/codec"
|
||||||
|
"backupx/server/internal/storage/rclone"
|
||||||
"backupx/server/pkg/compress"
|
"backupx/server/pkg/compress"
|
||||||
backupcrypto "backupx/server/pkg/crypto"
|
backupcrypto "backupx/server/pkg/crypto"
|
||||||
)
|
)
|
||||||
@@ -81,6 +85,8 @@ type BackupExecutionService struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
tempDir string
|
tempDir string
|
||||||
semaphore chan struct{}
|
semaphore chan struct{}
|
||||||
|
retries int // rclone 底层重试次数
|
||||||
|
bandwidthLimit string // rclone 带宽限制
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBackupExecutionService(
|
func NewBackupExecutionService(
|
||||||
@@ -95,6 +101,8 @@ func NewBackupExecutionService(
|
|||||||
notifier BackupResultNotifier,
|
notifier BackupResultNotifier,
|
||||||
tempDir string,
|
tempDir string,
|
||||||
maxConcurrent int,
|
maxConcurrent int,
|
||||||
|
retries int,
|
||||||
|
bandwidthLimit string,
|
||||||
) *BackupExecutionService {
|
) *BackupExecutionService {
|
||||||
if notifier == nil {
|
if notifier == nil {
|
||||||
notifier = noopBackupNotifier{}
|
notifier = noopBackupNotifier{}
|
||||||
@@ -118,9 +126,11 @@ func NewBackupExecutionService(
|
|||||||
async: func(job func()) {
|
async: func(job func()) {
|
||||||
go job()
|
go job()
|
||||||
},
|
},
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
tempDir: tempDir,
|
tempDir: tempDir,
|
||||||
semaphore: make(chan struct{}, maxConcurrent),
|
semaphore: make(chan struct{}, maxConcurrent),
|
||||||
|
retries: retries,
|
||||||
|
bandwidthLimit: bandwidthLimit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,10 +263,11 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
errMessage := ""
|
errMessage := ""
|
||||||
var fileName string
|
var fileName string
|
||||||
var fileSize int64
|
var fileSize int64
|
||||||
|
var checksum string
|
||||||
var storagePath string
|
var storagePath string
|
||||||
var uploadResults []StorageUploadResultItem
|
var uploadResults []StorageUploadResultItem
|
||||||
completeRecord := func() {
|
completeRecord := func() {
|
||||||
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, storagePath); finalizeErr != nil {
|
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath); finalizeErr != nil {
|
||||||
logger.Errorf("写回备份记录失败:%v", finalizeErr)
|
logger.Errorf("写回备份记录失败:%v", finalizeErr)
|
||||||
}
|
}
|
||||||
// 写入多目标上传结果
|
// 写入多目标上传结果
|
||||||
@@ -335,6 +346,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
|
|
||||||
// 并行上传到所有目标
|
// 并行上传到所有目标
|
||||||
uploadResults = make([]StorageUploadResultItem, len(targetIDs))
|
uploadResults = make([]StorageUploadResultItem, len(targetIDs))
|
||||||
|
var checksumOnce sync.Once
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, tid := range targetIDs {
|
for i, tid := range targetIDs {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -351,21 +363,60 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
|
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
artifact, openErr := os.Open(finalPath)
|
|
||||||
if openErr != nil {
|
|
||||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()}
|
|
||||||
logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer artifact.Close()
|
|
||||||
logger.Infof("开始上传备份到存储目标:%s", targetName)
|
logger.Infof("开始上传备份到存储目标:%s", targetName)
|
||||||
if uploadErr := provider.Upload(ctx, storagePath, artifact, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil {
|
// 上传级重试:最多 3 次,指数退避(10s, 30s, 90s)
|
||||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()}
|
maxAttempts := 3
|
||||||
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
|
var lastUploadErr error
|
||||||
|
var hr *hashingReader
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
if attempt > 1 {
|
||||||
|
backoff := time.Duration(attempt*attempt) * 10 * time.Second
|
||||||
|
logger.Warnf("存储目标 %s 第 %d 次重试(等待 %v):%v", targetName, attempt, backoff, lastUploadErr)
|
||||||
|
time.Sleep(backoff)
|
||||||
|
}
|
||||||
|
artifact, openErr := os.Open(finalPath)
|
||||||
|
if openErr != nil {
|
||||||
|
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()}
|
||||||
|
logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hr = newHashingReader(artifact)
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
lastUploadErr = provider.Upload(ctx, storagePath, pr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)})
|
||||||
|
artifact.Close()
|
||||||
|
if lastUploadErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastUploadErr != nil {
|
||||||
|
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: lastUploadErr.Error()}
|
||||||
|
logger.Warnf("存储目标 %s 上传失败(已重试 %d 次):%v", targetName, maxAttempts, lastUploadErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 完整性校验:对比实际传输字节数
|
||||||
|
if hr.n != fileSize {
|
||||||
|
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: fmt.Sprintf("完整性校验失败: 预期 %d bytes, 实际传输 %d bytes", fileSize, hr.n)}
|
||||||
|
logger.Errorf("存储目标 %s 完整性校验失败:预期 %d bytes, 实际传输 %d bytes", targetName, fileSize, hr.n)
|
||||||
|
_ = provider.Delete(ctx, storagePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 取第一个成功目标的哈希写入 record(所有目标读同一文件,哈希一定相同)
|
||||||
|
targetChecksum := hr.Sum()
|
||||||
|
checksumOnce.Do(func() { checksum = targetChecksum })
|
||||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "success", StoragePath: storagePath, FileSize: fileSize}
|
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "success", StoragePath: storagePath, FileSize: fileSize}
|
||||||
logger.Infof("存储目标 %s 上传成功", targetName)
|
logger.Infof("存储目标 %s 上传成功 (%d bytes, SHA-256=%s)", targetName, fileSize, targetChecksum)
|
||||||
// 每个成功目标独立执行保留策略
|
// 每个成功目标独立执行保留策略
|
||||||
if s.retention != nil {
|
if s.retention != nil {
|
||||||
cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider)
|
cleanupResult, cleanupErr := s.retention.Cleanup(ctx, task, provider)
|
||||||
@@ -403,7 +454,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, storagePath string) error {
|
func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time, status string, errorMessage string, logContent string, fileName string, fileSize int64, checksum string, storagePath string) error {
|
||||||
record, err := s.records.FindByID(ctx, recordID)
|
record, err := s.records.FindByID(ctx, recordID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -415,6 +466,7 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
|
|||||||
record.Status = status
|
record.Status = status
|
||||||
record.FileName = fileName
|
record.FileName = fileName
|
||||||
record.FileSize = fileSize
|
record.FileSize = fileSize
|
||||||
|
record.Checksum = checksum
|
||||||
record.StoragePath = storagePath
|
record.StoragePath = storagePath
|
||||||
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
|
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
|
||||||
record.ErrorMessage = strings.TrimSpace(errorMessage)
|
record.ErrorMessage = strings.TrimSpace(errorMessage)
|
||||||
@@ -429,6 +481,11 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *BackupExecutionService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
|
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)
|
target, err := s.targets.FindByID(ctx, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||||||
@@ -578,3 +635,28 @@ func buildStorageProviderFromRepos(ctx context.Context, storageTargetID uint, st
|
|||||||
}
|
}
|
||||||
return provider, target, nil
|
return provider, target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashingReader 在上传过程中同步计算字节数和 SHA-256,零额外 I/O
|
||||||
|
type hashingReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
hash hash.Hash
|
||||||
|
n int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHashingReader(reader io.Reader) *hashingReader {
|
||||||
|
h := sha256.New()
|
||||||
|
return &hashingReader{
|
||||||
|
reader: io.TeeReader(reader, h),
|
||||||
|
hash: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *hashingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
r.n += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *hashingReader) Sum() string {
|
||||||
|
return hex.EncodeToString(r.hash.Sum(nil))
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"backupx/server/internal/repository"
|
"backupx/server/internal/repository"
|
||||||
"backupx/server/internal/storage"
|
"backupx/server/internal/storage"
|
||||||
"backupx/server/internal/storage/codec"
|
"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) {
|
func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRecordService, repository.BackupTaskRepository, repository.StorageTargetRepository, repository.BackupRecordRepository, string, string) {
|
||||||
@@ -53,9 +53,13 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec
|
|||||||
}
|
}
|
||||||
logHub := backup.NewLogHub()
|
logHub := backup.NewLogHub()
|
||||||
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewMySQLRunner(nil), backup.NewSQLiteRunner(), backup.NewPostgreSQLRunner(nil))
|
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)
|
retentionService := backupretention.NewService(records)
|
||||||
executionService := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, retentionService, cipher, nil, "", 2)
|
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, 10, "")
|
||||||
recordService := NewBackupRecordService(records, executionService, logHub)
|
recordService := NewBackupRecordService(records, executionService, logHub)
|
||||||
return executionService, recordService, tasks, targets, records, sourceDir, storageDir
|
return executionService, recordService, tasks, targets, records, sourceDir, storageDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type BackupRecordSummary struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
FileName string `json:"fileName"`
|
FileName string `json:"fileName"`
|
||||||
FileSize int64 `json:"fileSize"`
|
FileSize int64 `json:"fileSize"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
StoragePath string `json:"storagePath"`
|
StoragePath string `json:"storagePath"`
|
||||||
DurationSeconds int `json:"durationSeconds"`
|
DurationSeconds int `json:"durationSeconds"`
|
||||||
ErrorMessage string `json:"errorMessage"`
|
ErrorMessage string `json:"errorMessage"`
|
||||||
@@ -111,6 +112,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
|||||||
Status: item.Status,
|
Status: item.Status,
|
||||||
FileName: item.FileName,
|
FileName: item.FileName,
|
||||||
FileSize: item.FileSize,
|
FileSize: item.FileSize,
|
||||||
|
Checksum: item.Checksum,
|
||||||
StoragePath: item.StoragePath,
|
StoragePath: item.StoragePath,
|
||||||
DurationSeconds: item.DurationSeconds,
|
DurationSeconds: item.DurationSeconds,
|
||||||
ErrorMessage: item.ErrorMessage,
|
ErrorMessage: item.ErrorMessage,
|
||||||
|
|||||||
52
server/internal/service/progress_reader.go
Normal file
52
server/internal/service/progress_reader.go
Normal 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
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ var settingsKeys = []string{
|
|||||||
"language",
|
"language",
|
||||||
"timezone",
|
"timezone",
|
||||||
"backup_notification_enabled",
|
"backup_notification_enabled",
|
||||||
|
"bandwidth_limit",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {
|
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
type StorageTargetUpsertInput struct {
|
type StorageTargetUpsertInput struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
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,min=1"`
|
||||||
Description string `json:"description" binding:"max=255"`
|
Description string `json:"description" binding:"max=255"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Config map[string]any `json:"config" binding:"required"`
|
Config map[string]any `json:"config" binding:"required"`
|
||||||
@@ -544,10 +544,11 @@ func cloneMap(source map[string]any) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StorageTargetUsage struct {
|
type StorageTargetUsage struct {
|
||||||
TargetID uint `json:"targetId"`
|
TargetID uint `json:"targetId"`
|
||||||
TargetName string `json:"targetName"`
|
TargetName string `json:"targetName"`
|
||||||
RecordCount int64 `json:"recordCount"`
|
RecordCount int64 `json:"recordCount"`
|
||||||
TotalSize int64 `json:"totalSize"`
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
DiskUsage *storage.StorageUsageInfo `json:"diskUsage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageTargetUsage, error) {
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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, "'", "\\'")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
5
server/internal/storage/rclone/backends.go
Normal file
5
server/internal/storage/rclone/backends.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Package rclone 提供基于 rclone 的统一存储后端实现。
|
||||||
|
// 引入全部 rclone backend,支持 70+ 存储后端。
|
||||||
|
package rclone
|
||||||
|
|
||||||
|
import _ "github.com/rclone/rclone/backend/all"
|
||||||
36
server/internal/storage/rclone/config.go
Normal file
36
server/internal/storage/rclone/config.go
Normal 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)
|
||||||
|
}
|
||||||
508
server/internal/storage/rclone/factory.go
Normal file
508
server/internal/storage/rclone/factory.go
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 通用 BackendFactory — 为任意 rclone 后端自动生成独立 Factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GenericBackendFactory 为单个 rclone 后端创建独立的 ProviderFactory。
|
||||||
|
// 用户存储目标的 type 直接是后端名(如 "sftp"),与 "s3"、"ftp" 完全平级。
|
||||||
|
type GenericBackendFactory struct {
|
||||||
|
backendType string
|
||||||
|
sensitive []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackendFactory 为指定 rclone 后端创建一个 Factory。
|
||||||
|
func NewBackendFactory(backendType string) GenericBackendFactory {
|
||||||
|
var sensitive []string
|
||||||
|
for _, ri := range fs.Registry {
|
||||||
|
if ri.Name == backendType {
|
||||||
|
for _, opt := range ri.Options {
|
||||||
|
if opt.IsPassword {
|
||||||
|
sensitive = append(sensitive, opt.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GenericBackendFactory{backendType: backendType, sensitive: sensitive}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f GenericBackendFactory) Type() storage.ProviderType { return storage.ProviderType(f.backendType) }
|
||||||
|
func (f GenericBackendFactory) SensitiveFields() []string { return f.sensitive }
|
||||||
|
|
||||||
|
func (f GenericBackendFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||||
|
root, _ := rawConfig["root"].(string)
|
||||||
|
root = strings.TrimSpace(root)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(f.backendType)
|
||||||
|
for key, val := range rawConfig {
|
||||||
|
if 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.ProviderType(f.backendType), b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAllBackends 将所有 rclone 后端注册为独立 Factory 到 Registry。
|
||||||
|
// 已存在的内置类型(s3, ftp 等)不会被覆盖。
|
||||||
|
func RegisterAllBackends(registry *storage.Registry) {
|
||||||
|
builtinTypes := map[string]bool{
|
||||||
|
"local_disk": true, "s3": true, "webdav": true, "google_drive": true,
|
||||||
|
"ftp": true, "aliyun_oss": true, "tencent_cos": true, "qiniu_kodo": true,
|
||||||
|
"rclone": true, "local": true,
|
||||||
|
}
|
||||||
|
for _, info := range ListBackends() {
|
||||||
|
if builtinTypes[info.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
registry.Register(NewBackendFactory(info.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
134
server/internal/storage/rclone/provider.go
Normal file
134
server/internal/storage/rclone/provider.go
Normal 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]
|
||||||
|
}
|
||||||
202
server/internal/storage/rclone/provider_test.go
Normal file
202
server/internal/storage/rclone/provider_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package s3provider
|
|
||||||
|
|
||||||
import "backupx/server/internal/storage/s3"
|
|
||||||
|
|
||||||
type Factory = s3.Factory
|
|
||||||
|
|
||||||
func NewFactory() Factory {
|
|
||||||
return s3.NewFactory()
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
ProviderTypeTencentCOS ProviderType = "tencent_cos"
|
ProviderTypeTencentCOS ProviderType = "tencent_cos"
|
||||||
ProviderTypeQiniuKodo ProviderType = "qiniu_kodo"
|
ProviderTypeQiniuKodo ProviderType = "qiniu_kodo"
|
||||||
ProviderTypeFTP ProviderType = "ftp"
|
ProviderTypeFTP ProviderType = "ftp"
|
||||||
|
ProviderTypeRclone ProviderType = "rclone"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -52,6 +53,20 @@ type ProviderFactory interface {
|
|||||||
Type() ProviderType
|
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) {
|
func DecodeConfig[T any](raw map[string]any) (T, error) {
|
||||||
var cfg T
|
var cfg T
|
||||||
encoded, err := json.Marshal(raw)
|
encoded, err := json.Marshal(raw)
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package webdavprovider
|
|
||||||
|
|
||||||
import "backupx/server/internal/storage/webdav"
|
|
||||||
|
|
||||||
type Factory = webdav.Factory
|
|
||||||
|
|
||||||
func NewFactory() Factory {
|
|
||||||
return webdav.NewFactory()
|
|
||||||
}
|
|
||||||
@@ -1,196 +1,327 @@
|
|||||||
import { Input, Space, Switch, Tabs, Typography, Radio, Checkbox, Select } from '@arco-design/web-react'
|
import { Button, Divider, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
export interface CronInputProps {
|
export interface CronInputProps {
|
||||||
value?: string
|
value?: string
|
||||||
onChange?: (value: string) => void
|
onChange?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CRON = '* * * * *'
|
const DEFAULT_CRON = '0 2 * * *'
|
||||||
|
|
||||||
type CronPart = 'minute' | 'hour' | 'day' | 'month' | 'week'
|
// 常用预设
|
||||||
|
const PRESETS = [
|
||||||
interface CronState {
|
{ label: '每天 02:00', value: '0 2 * * *' },
|
||||||
minute: string
|
{ label: '每天 00:00', value: '0 0 * * *' },
|
||||||
hour: string
|
{ label: '每 6 小时', value: '0 */6 * * *' },
|
||||||
day: string
|
{ label: '每 12 小时', value: '0 */12 * * *' },
|
||||||
month: string
|
{ label: '每周日 03:00', value: '0 3 * * 0' },
|
||||||
week: string
|
{ label: '每月 1 日 02:00', value: '0 2 1 * *' },
|
||||||
}
|
{ label: '每 30 分钟', value: '*/30 * * * *' },
|
||||||
|
{ label: '每小时整点', value: '0 * * * *' },
|
||||||
function parseCron(expr: string): CronState {
|
|
||||||
const parts = (expr || DEFAULT_CRON).trim().split(/\s+/)
|
|
||||||
return {
|
|
||||||
minute: parts[0] || '*',
|
|
||||||
hour: parts[1] || '*',
|
|
||||||
day: parts[2] || '*',
|
|
||||||
month: parts[3] || '*',
|
|
||||||
week: parts[4] || '*',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyCron(state: CronState): string {
|
|
||||||
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateOptions(min: number, max: number) {
|
|
||||||
return Array.from({ length: max - min + 1 }, (_, i) => ({
|
|
||||||
label: String(i + min),
|
|
||||||
value: String(i + min),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const MINUTES_OPTIONS = generateOptions(0, 59)
|
|
||||||
const HOURS_OPTIONS = generateOptions(0, 23)
|
|
||||||
const DAYS_OPTIONS = generateOptions(1, 31)
|
|
||||||
const MONTHS_OPTIONS = generateOptions(1, 12)
|
|
||||||
const WEEKS_OPTIONS = [
|
|
||||||
{ label: '星期日', value: '0' },
|
|
||||||
{ label: '星期一', value: '1' },
|
|
||||||
{ label: '星期二', value: '2' },
|
|
||||||
{ label: '星期三', value: '3' },
|
|
||||||
{ label: '星期四', value: '4' },
|
|
||||||
{ label: '星期五', value: '5' },
|
|
||||||
{ label: '星期六', value: '6' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({
|
||||||
|
label: `${String(i).padStart(2, '0')} 时`,
|
||||||
|
value: String(i),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const MINUTE_OPTIONS = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
label: `${String(i * 5).padStart(2, '0')} 分`,
|
||||||
|
value: String(i * 5),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const WEEKDAY_OPTIONS = [
|
||||||
|
{ label: '周一', value: '1' },
|
||||||
|
{ label: '周二', value: '2' },
|
||||||
|
{ label: '周三', value: '3' },
|
||||||
|
{ label: '周四', value: '4' },
|
||||||
|
{ label: '周五', value: '5' },
|
||||||
|
{ label: '周六', value: '6' },
|
||||||
|
{ label: '周日', value: '0' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DAY_OPTIONS = Array.from({ length: 31 }, (_, i) => ({
|
||||||
|
label: `${i + 1} 日`,
|
||||||
|
value: String(i + 1),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type ScheduleMode = 'daily' | 'weekly' | 'monthly' | 'interval'
|
||||||
|
|
||||||
|
// 将 cron 表达式转为自然语言中文描述
|
||||||
|
function describeCron(expr: string): string {
|
||||||
|
const parts = expr.trim().split(/\s+/)
|
||||||
|
if (parts.length !== 5) return ''
|
||||||
|
const [minute, hour, day, _month, week] = parts
|
||||||
|
|
||||||
|
// 每 N 分钟
|
||||||
|
if (minute.includes('/') && hour === '*' && day === '*' && week === '*') {
|
||||||
|
return `每 ${minute.split('/')[1]} 分钟执行一次`
|
||||||
|
}
|
||||||
|
// 每 N 小时
|
||||||
|
if (minute !== '*' && hour.includes('/') && day === '*' && week === '*') {
|
||||||
|
return `每 ${hour.split('/')[1]} 小时执行一次(在第 ${minute} 分)`
|
||||||
|
}
|
||||||
|
// 每小时
|
||||||
|
if (minute !== '*' && hour === '*' && day === '*' && week === '*') {
|
||||||
|
return `每小时的第 ${minute} 分执行`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hh = hour.padStart(2, '0')
|
||||||
|
const mm = minute.padStart(2, '0')
|
||||||
|
const time = `${hh}:${mm}`
|
||||||
|
|
||||||
|
// 每周某天
|
||||||
|
if (day === '*' && week !== '*') {
|
||||||
|
const weekNames: Record<string, string> = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' }
|
||||||
|
const days = week.split(',').map((w) => `周${weekNames[w] || w}`).join('、')
|
||||||
|
return `每${days} ${time} 执行`
|
||||||
|
}
|
||||||
|
// 每月某日
|
||||||
|
if (day !== '*' && week === '*') {
|
||||||
|
return `每月 ${day} 日 ${time} 执行`
|
||||||
|
}
|
||||||
|
// 每天
|
||||||
|
if (day === '*' && week === '*' && hour !== '*' && !hour.includes('/')) {
|
||||||
|
return `每天 ${time} 执行`
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
export function CronInput({ value, onChange }: CronInputProps) {
|
export function CronInput({ value, onChange }: CronInputProps) {
|
||||||
const [internalValue, setInternalValue] = useState(value || DEFAULT_CRON)
|
const [cronExpr, setCronExpr] = useState(value || DEFAULT_CRON)
|
||||||
const [isAdvanced, setIsAdvanced] = useState(false)
|
const [isAdvanced, setIsAdvanced] = useState(false)
|
||||||
const [state, setState] = useState<CronState>(parseCron(internalValue))
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
|
|
||||||
// Sync prop to internal state
|
// 自定义模式的状态
|
||||||
|
const [mode, setMode] = useState<ScheduleMode>('daily')
|
||||||
|
const [customHour, setCustomHour] = useState('2')
|
||||||
|
const [customMinute, setCustomMinute] = useState('0')
|
||||||
|
const [customWeekdays, setCustomWeekdays] = useState<string[]>(['0'])
|
||||||
|
const [customDay, setCustomDay] = useState('1')
|
||||||
|
const [customInterval, setCustomInterval] = useState('6')
|
||||||
|
|
||||||
|
// 从 prop 同步
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value !== undefined && value !== internalValue) {
|
if (value !== undefined && value !== cronExpr) {
|
||||||
setInternalValue(value || DEFAULT_CRON)
|
setCronExpr(value || DEFAULT_CRON)
|
||||||
if (!isAdvanced) {
|
|
||||||
setState(parseCron(value || DEFAULT_CRON))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [value, isAdvanced, internalValue])
|
}, [value])
|
||||||
|
|
||||||
const notifyChange = (nextValue: string) => {
|
const description = useMemo(() => describeCron(cronExpr), [cronExpr])
|
||||||
setInternalValue(nextValue)
|
const isPreset = PRESETS.some((p) => p.value === cronExpr)
|
||||||
if (onChange) {
|
|
||||||
onChange(nextValue)
|
const emit = (expr: string) => {
|
||||||
}
|
setCronExpr(expr)
|
||||||
|
onChange?.(expr)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStateChange = (part: CronPart, val: string) => {
|
// 从自定义选择器构建 cron
|
||||||
const nextState = { ...state, [part]: val }
|
const buildCustomCron = (
|
||||||
setState(nextState)
|
m: ScheduleMode,
|
||||||
notifyChange(stringifyCron(nextState))
|
h: string,
|
||||||
}
|
min: string,
|
||||||
|
weekdays: string[],
|
||||||
const renderPartTab = (
|
day: string,
|
||||||
part: CronPart,
|
interval: string,
|
||||||
title: string,
|
|
||||||
options: { label: string; value: string }[],
|
|
||||||
allowAnyVal = '*',
|
|
||||||
) => {
|
) => {
|
||||||
const currentVal = state[part]
|
switch (m) {
|
||||||
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
|
case 'daily':
|
||||||
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
|
return `${min} ${h} * * *`
|
||||||
|
case 'weekly':
|
||||||
// For simplicity in this visual editor, we only support "every" (*) and "specific values" (1,2,3).
|
return `${min} ${h} * * ${weekdays.sort().join(',') || '0'}`
|
||||||
const type = isAny ? 'any' : 'specific'
|
case 'monthly':
|
||||||
const specificValues = isSpecific ? currentVal.split(',') : []
|
return `${min} ${h} ${day} * *`
|
||||||
|
case 'interval':
|
||||||
|
return `0 */${interval} * * *`
|
||||||
|
default:
|
||||||
|
return DEFAULT_CRON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const handleCustomChange = (updates: {
|
||||||
<div style={{ padding: '16px 0' }}>
|
mode?: ScheduleMode
|
||||||
<Radio.Group
|
hour?: string
|
||||||
direction="vertical"
|
minute?: string
|
||||||
value={type}
|
weekdays?: string[]
|
||||||
onChange={(val) => {
|
day?: string
|
||||||
if (val === 'any') {
|
interval?: string
|
||||||
handleStateChange(part, allowAnyVal)
|
}) => {
|
||||||
} else {
|
const m = updates.mode ?? mode
|
||||||
handleStateChange(part, options[0].value) // Default to first valid item
|
const h = updates.hour ?? customHour
|
||||||
}
|
const min = updates.minute ?? customMinute
|
||||||
}}
|
const w = updates.weekdays ?? customWeekdays
|
||||||
>
|
const d = updates.day ?? customDay
|
||||||
<Radio value="any">
|
const iv = updates.interval ?? customInterval
|
||||||
<Typography.Text>通配 ({allowAnyVal}) - 任意{title}</Typography.Text>
|
|
||||||
</Radio>
|
|
||||||
<Radio value="specific">
|
|
||||||
<Typography.Text>指定{title}</Typography.Text>
|
|
||||||
</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
|
|
||||||
{type === 'specific' && (
|
if (updates.mode !== undefined) setMode(m)
|
||||||
<div style={{ paddingLeft: 24, marginTop: 12 }}>
|
if (updates.hour !== undefined) setCustomHour(h)
|
||||||
<Select
|
if (updates.minute !== undefined) setCustomMinute(min)
|
||||||
mode="multiple"
|
if (updates.weekdays !== undefined) setCustomWeekdays(w)
|
||||||
placeholder={`请选择${title}`}
|
if (updates.day !== undefined) setCustomDay(d)
|
||||||
value={specificValues}
|
if (updates.interval !== undefined) setCustomInterval(iv)
|
||||||
options={options}
|
|
||||||
onChange={(vals: string[]) => {
|
emit(buildCustomCron(m, h, min, w, d, iv))
|
||||||
if (vals.length === 0) {
|
|
||||||
handleStateChange(part, allowAnyVal)
|
|
||||||
} else {
|
|
||||||
// Sort numerically to keep things neat
|
|
||||||
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
|
|
||||||
handleStateChange(part, sorted.join(','))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ width: '100%', maxWidth: 400 }}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cron-input-container">
|
<div>
|
||||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
{/* 预设按钮 */}
|
||||||
<Input
|
<Space wrap size="small" style={{ marginBottom: 12 }}>
|
||||||
value={internalValue}
|
{PRESETS.map((preset) => (
|
||||||
onChange={(val) => {
|
<Button
|
||||||
setInternalValue(val)
|
key={preset.value}
|
||||||
if (isAdvanced && onChange) {
|
size="small"
|
||||||
onChange(val)
|
type={cronExpr === preset.value ? 'primary' : 'secondary'}
|
||||||
}
|
onClick={() => {
|
||||||
}}
|
emit(preset.value)
|
||||||
readOnly={!isAdvanced}
|
setShowCustom(false)
|
||||||
style={{ width: 240, fontFamily: 'monospace' }}
|
setIsAdvanced(false)
|
||||||
placeholder="* * * * *"
|
|
||||||
/>
|
|
||||||
<Space>
|
|
||||||
<Typography.Text type="secondary">高级模式 (手动输入)</Typography.Text>
|
|
||||||
<Switch
|
|
||||||
checked={isAdvanced}
|
|
||||||
onChange={(checked) => {
|
|
||||||
setIsAdvanced(checked)
|
|
||||||
if (!checked) {
|
|
||||||
// When switching back to visual, parse the current raw value
|
|
||||||
setState(parseCron(internalValue))
|
|
||||||
notifyChange(stringifyCron(parseCron(internalValue)))
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</Space>
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={!isPreset && !isAdvanced ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => {
|
||||||
|
setShowCustom(true)
|
||||||
|
setIsAdvanced(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
自定义...
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 中文描述 + cron 表达式 */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||||
|
<Input
|
||||||
|
value={cronExpr}
|
||||||
|
readOnly={!isAdvanced}
|
||||||
|
style={{ width: 180, fontFamily: 'monospace', fontSize: 13 }}
|
||||||
|
placeholder="0 2 * * *"
|
||||||
|
onChange={(val) => {
|
||||||
|
if (isAdvanced) emit(val)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{description && (
|
||||||
|
<Typography.Text type="secondary">{description}</Typography.Text>
|
||||||
|
)}
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<Space size="mini">
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>手动输入</Typography.Text>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isAdvanced}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setIsAdvanced(checked)
|
||||||
|
setShowCustom(false)
|
||||||
|
if (!checked) {
|
||||||
|
setCronExpr(cronExpr)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isAdvanced && (
|
{/* 自定义选择器 */}
|
||||||
<Tabs type="card-gutter" size="small">
|
{showCustom && !isAdvanced && (
|
||||||
<Tabs.TabPane key="minute" title="分钟">
|
<div style={{ padding: '12px 16px', background: 'var(--color-fill-1)', borderRadius: 6 }}>
|
||||||
{renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')}
|
<Space size="large" style={{ marginBottom: 12 }}>
|
||||||
</Tabs.TabPane>
|
<Button size="small" type={mode === 'daily' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'daily' })}>
|
||||||
<Tabs.TabPane key="hour" title="小时">
|
每天
|
||||||
{renderPartTab('hour', '小时', HOURS_OPTIONS, '*')}
|
</Button>
|
||||||
</Tabs.TabPane>
|
<Button size="small" type={mode === 'weekly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'weekly' })}>
|
||||||
<Tabs.TabPane key="day" title="日">
|
每周
|
||||||
{renderPartTab('day', '日', DAYS_OPTIONS, '*')}
|
</Button>
|
||||||
</Tabs.TabPane>
|
<Button size="small" type={mode === 'monthly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'monthly' })}>
|
||||||
<Tabs.TabPane key="month" title="月">
|
每月
|
||||||
{renderPartTab('month', '月', MONTHS_OPTIONS, '*')}
|
</Button>
|
||||||
</Tabs.TabPane>
|
<Button size="small" type={mode === 'interval' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'interval' })}>
|
||||||
<Tabs.TabPane key="week" title="周">
|
间隔
|
||||||
{renderPartTab('week', '周', WEEKS_OPTIONS, '*')}
|
</Button>
|
||||||
</Tabs.TabPane>
|
</Space>
|
||||||
</Tabs>
|
|
||||||
|
{mode === 'interval' ? (
|
||||||
|
<Space align="center">
|
||||||
|
<Typography.Text>每</Typography.Text>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={customInterval}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
options={[
|
||||||
|
{ label: '1', value: '1' },
|
||||||
|
{ label: '2', value: '2' },
|
||||||
|
{ label: '3', value: '3' },
|
||||||
|
{ label: '4', value: '4' },
|
||||||
|
{ label: '6', value: '6' },
|
||||||
|
{ label: '8', value: '8' },
|
||||||
|
{ label: '12', value: '12' },
|
||||||
|
]}
|
||||||
|
onChange={(val) => handleCustomChange({ interval: val })}
|
||||||
|
/>
|
||||||
|
<Typography.Text>小时执行一次</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{mode === 'weekly' && (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Space wrap size="mini">
|
||||||
|
{WEEKDAY_OPTIONS.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
size="mini"
|
||||||
|
type={customWeekdays.includes(opt.value) ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => {
|
||||||
|
const next = customWeekdays.includes(opt.value)
|
||||||
|
? customWeekdays.filter((v) => v !== opt.value)
|
||||||
|
: [...customWeekdays, opt.value]
|
||||||
|
handleCustomChange({ weekdays: next.length > 0 ? next : [opt.value] })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'monthly' && (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Space align="center">
|
||||||
|
<Typography.Text>每月</Typography.Text>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={customDay}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
options={DAY_OPTIONS}
|
||||||
|
onChange={(val) => handleCustomChange({ day: val })}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Space align="center">
|
||||||
|
<Typography.Text>执行时间</Typography.Text>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={customHour}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
options={HOUR_OPTIONS}
|
||||||
|
onChange={(val) => handleCustomChange({ hour: val })}
|
||||||
|
/>
|
||||||
|
<Typography.Text>:</Typography.Text>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={customMinute}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
options={MINUTE_OPTIONS}
|
||||||
|
onChange={(val) => handleCustomChange({ minute: val })}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, storageTargetTypeOptions } from './field-config'
|
import { getStorageTargetFieldConfigs, getStorageTargetTypeLabel, isBuiltinType, builtinTypeOptions } from './field-config'
|
||||||
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
|
import type { StorageConnectionTestResult, StorageTargetDetail, StorageTargetPayload, StorageTargetType } from '../../types/storage-targets'
|
||||||
|
import { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
|
||||||
|
|
||||||
interface StorageTargetFormDrawerProps {
|
interface StorageTargetFormDrawerProps {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -15,33 +16,29 @@ interface StorageTargetFormDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
||||||
return {
|
return { name: '', type, description: '', enabled: true, config: {} }
|
||||||
name: '',
|
|
||||||
type,
|
|
||||||
description: '',
|
|
||||||
enabled: true,
|
|
||||||
config: {},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StorageTargetFormDrawer({
|
export function StorageTargetFormDrawer({
|
||||||
visible,
|
visible, loading, testing, initialValue, onCancel, onSubmit, onTest, onGoogleDriveAuth,
|
||||||
loading,
|
|
||||||
testing,
|
|
||||||
initialValue,
|
|
||||||
onCancel,
|
|
||||||
onSubmit,
|
|
||||||
onTest,
|
|
||||||
onGoogleDriveAuth,
|
|
||||||
}: StorageTargetFormDrawerProps) {
|
}: StorageTargetFormDrawerProps) {
|
||||||
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
|
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
|
const [testResult, setTestResult] = useState<StorageConnectionTestResult | null>(null)
|
||||||
|
const [rcloneBackends, setRcloneBackends] = useState<RcloneBackendInfo[]>([])
|
||||||
|
const [backendsLoaded, setBackendsLoaded] = useState(false)
|
||||||
|
|
||||||
|
// 加载 rclone 后端列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && !backendsLoaded) {
|
||||||
|
listRcloneBackends()
|
||||||
|
.then((data) => { setRcloneBackends(data); setBackendsLoaded(true) })
|
||||||
|
.catch(() => setBackendsLoaded(true))
|
||||||
|
}
|
||||||
|
}, [visible, backendsLoaded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) {
|
if (!visible) return
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
setDraft(createEmptyDraft())
|
setDraft(createEmptyDraft())
|
||||||
setError('')
|
setError('')
|
||||||
@@ -59,97 +56,137 @@ export function StorageTargetFormDrawer({
|
|||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
}, [initialValue, visible])
|
}, [initialValue, visible])
|
||||||
|
|
||||||
const fieldConfigs = useMemo(() => getStorageTargetFieldConfigs(draft.type), [draft.type])
|
// 合并类型选项:内置 + 全部 rclone 后端
|
||||||
|
const allTypeOptions = useMemo(() => {
|
||||||
|
const builtinValues = new Set(builtinTypeOptions.map((o) => o.value))
|
||||||
|
const rcloneOptions = rcloneBackends
|
||||||
|
.filter((b) => !builtinValues.has(b.name) && b.name !== 'local' && b.name !== 'rclone')
|
||||||
|
.map((b) => ({ label: `${b.name.toUpperCase()} — ${b.description}`, value: b.name }))
|
||||||
|
return [
|
||||||
|
...builtinTypeOptions.map((o) => ({ ...o, label: o.label, value: o.value as string })),
|
||||||
|
...rcloneOptions,
|
||||||
|
]
|
||||||
|
}, [rcloneBackends])
|
||||||
|
|
||||||
|
// 当前类型是否为非内置(rclone 动态后端)
|
||||||
|
const isDynamicType = !isBuiltinType(draft.type)
|
||||||
|
const staticFields = isBuiltinType(draft.type) ? getStorageTargetFieldConfigs(draft.type) : []
|
||||||
|
|
||||||
|
// 当前 rclone 后端的动态字段
|
||||||
|
const dynamicBackend = useMemo(() => {
|
||||||
|
if (!isDynamicType) return null
|
||||||
|
return rcloneBackends.find((b) => b.name === draft.type) || null
|
||||||
|
}, [isDynamicType, draft.type, rcloneBackends])
|
||||||
|
|
||||||
function updateConfig(key: string, value: string | boolean) {
|
function updateConfig(key: string, value: string | boolean) {
|
||||||
setDraft((current) => ({
|
setDraft((c) => ({ ...c, config: { ...c.config, [key]: value } }))
|
||||||
...current,
|
|
||||||
config: {
|
|
||||||
...current.config,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate(value: StorageTargetPayload) {
|
function validate(value: StorageTargetPayload) {
|
||||||
if (!value.name.trim()) {
|
if (!value.name.trim()) return '请输入存储目标名称'
|
||||||
return '请输入存储目标名称'
|
if (!value.type.trim()) return '请选择存储类型'
|
||||||
}
|
if (isBuiltinType(value.type)) {
|
||||||
for (const field of fieldConfigs) {
|
for (const field of staticFields) {
|
||||||
if (!field.required) {
|
if (!field.required || field.type === 'switch') continue
|
||||||
continue
|
const v = value.config[field.key]
|
||||||
}
|
if (typeof v !== 'string' || !v.trim()) return `请填写${field.label}`
|
||||||
const currentValue = value.config[field.key]
|
|
||||||
if (field.type === 'switch') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (typeof currentValue !== 'string' || !currentValue.trim()) {
|
|
||||||
return `请填写${field.label}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const validationError = validate(draft)
|
const e = validate(draft); if (e) { setError(e); return }
|
||||||
if (validationError) {
|
setError(''); await onSubmit(draft, initialValue?.id)
|
||||||
setError(validationError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
await onSubmit(draft, initialValue?.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
const validationError = validate(draft)
|
const e = validate(draft); if (e) { setError(e); return }
|
||||||
if (validationError) {
|
setError(''); setTestResult(await onTest(draft, initialValue?.id))
|
||||||
setError(validationError)
|
}
|
||||||
return
|
async function handleGoogleDriveAuth() {
|
||||||
}
|
const e = validate(draft); if (e) { setError(e); return }
|
||||||
setError('')
|
setError(''); await onGoogleDriveAuth(draft, initialValue?.id)
|
||||||
const result = await onTest(draft, initialValue?.id)
|
|
||||||
setTestResult(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGoogleDriveAuth() {
|
// 渲染静态字段(内置类型)
|
||||||
const validationError = validate(draft)
|
function renderStaticFields() {
|
||||||
if (validationError) {
|
return staticFields.map((field) => {
|
||||||
setError(validationError)
|
const value = draft.config[field.key]
|
||||||
return
|
const normalized = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : ''
|
||||||
}
|
return (
|
||||||
setError('')
|
<div key={field.key}>
|
||||||
await onGoogleDriveAuth(draft, initialValue?.id)
|
<Typography.Text>{field.label}{field.required ? ' *' : ''}</Typography.Text>
|
||||||
|
{field.type === 'switch' ? (
|
||||||
|
<Space align="center" size="medium">
|
||||||
|
<Switch checked={Boolean(normalized)} onChange={(v) => updateConfig(field.key, v)} />
|
||||||
|
{field.description && <Typography.Text type="secondary">{field.description}</Typography.Text>}
|
||||||
|
</Space>
|
||||||
|
) : field.type === 'password' ? (
|
||||||
|
<Input.Password value={String(normalized)} placeholder={field.placeholder} onChange={(v) => updateConfig(field.key, v)} />
|
||||||
|
) : (
|
||||||
|
<Input value={String(normalized)} placeholder={field.placeholder} onChange={(v) => updateConfig(field.key, v)} />
|
||||||
|
)}
|
||||||
|
{field.description && field.type !== 'switch' && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>{field.description}</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>已存在敏感配置,留空则保持不变。</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染动态字段(rclone 后端)
|
||||||
|
function renderDynamicFields() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>远端路径</Typography.Text>
|
||||||
|
<Input value={(draft.config.root as string) || ''} placeholder="如 /backups 或 bucket 名" onChange={(v) => updateConfig('root', v)} />
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>远端根路径、桶名或挂载点,留空使用根目录</Typography.Paragraph>
|
||||||
|
</div>
|
||||||
|
{dynamicBackend && dynamicBackend.options.length > 0 && dynamicBackend.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={(v) => updateConfig(opt.key, v)} />
|
||||||
|
) : (
|
||||||
|
<Input value={(draft.config[opt.key] as string) || ''} placeholder={opt.label} onChange={(v) => updateConfig(opt.key, v)} />
|
||||||
|
)}
|
||||||
|
{opt.label && (
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 2, fontSize: 12 }} ellipsis={{ rows: 2, expandable: true }}>{opt.label}</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer width={560} title={initialValue ? '编辑存储目标' : '新建存储目标'} visible={visible} onCancel={onCancel} unmountOnExit={false}>
|
||||||
width={560}
|
|
||||||
title={initialValue ? '编辑存储目标' : '新建存储目标'}
|
|
||||||
visible={visible}
|
|
||||||
onCancel={onCancel}
|
|
||||||
unmountOnExit={false}
|
|
||||||
>
|
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="存储目标提供备份文件的最终去向,请确保服务端网络连通性并通过测试。" />}
|
{error ? <Alert type="error" content={error} /> : <Alert type="info" content="存储目标提供备份文件的最终去向,请确保服务端网络连通性并通过测试。" />}
|
||||||
{testResult ? <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} /> : null}
|
{testResult && <Alert type={testResult.success ? 'success' : 'warning'} content={testResult.message} />}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text>名称</Typography.Text>
|
<Typography.Text>名称</Typography.Text>
|
||||||
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(value) => setDraft((current) => ({ ...current, name: value }))} />
|
<Input value={draft.name} placeholder="例如:生产环境 MinIO" onChange={(v) => setDraft((c) => ({ ...c, name: v }))} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text>类型</Typography.Text>
|
<Typography.Text>存储类型</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
value={draft.type}
|
showSearch
|
||||||
options={storageTargetTypeOptions as unknown as { label: string; value: string }[]}
|
value={draft.type || undefined}
|
||||||
|
placeholder="搜索存储类型(如 SFTP、Azure Blob、Dropbox...)"
|
||||||
|
options={allTypeOptions}
|
||||||
|
filterOption={(input, option) => {
|
||||||
|
const label = (option?.props?.children ?? option?.props?.label ?? '') as string
|
||||||
|
return label.toLowerCase().includes(input.toLowerCase())
|
||||||
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const nextType = value as StorageTargetType
|
setDraft((c) => ({ ...c, type: value as string, config: {} }))
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
type: nextType,
|
|
||||||
config: {},
|
|
||||||
}))
|
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -157,16 +194,12 @@ export function StorageTargetFormDrawer({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text>描述</Typography.Text>
|
<Typography.Text>描述</Typography.Text>
|
||||||
<Input.TextArea
|
<Input.TextArea value={draft.description} placeholder="可选描述" onChange={(v) => setDraft((c) => ({ ...c, description: v }))} />
|
||||||
value={draft.description}
|
|
||||||
placeholder="可选描述,例如备份上传到 NAS 或 Google Drive"
|
|
||||||
onChange={(value) => setDraft((current) => ({ ...current, description: value }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space align="center" size="medium">
|
<Space align="center" size="medium">
|
||||||
<Typography.Text>启用</Typography.Text>
|
<Typography.Text>启用</Typography.Text>
|
||||||
<Switch checked={draft.enabled} onChange={(checked) => setDraft((current) => ({ ...current, enabled: checked }))} />
|
<Switch checked={draft.enabled} onChange={(v) => setDraft((c) => ({ ...c, enabled: v }))} />
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Divider orientation="left">环境配置</Divider>
|
<Divider orientation="left">环境配置</Divider>
|
||||||
@@ -176,58 +209,18 @@ export function StorageTargetFormDrawer({
|
|||||||
{getStorageTargetTypeLabel(draft.type)}
|
{getStorageTargetTypeLabel(draft.type)}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
{fieldConfigs.map((field) => {
|
{isDynamicType ? renderDynamicFields() : renderStaticFields()}
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button loading={testing} onClick={handleTest}>
|
<Button loading={testing} onClick={handleTest}>测试连接</Button>
|
||||||
测试连接
|
{draft.type === 'google_drive' && (
|
||||||
</Button>
|
|
||||||
{draft.type === 'google_drive' ? (
|
|
||||||
<Button type="outline" onClick={handleGoogleDriveAuth}>
|
<Button type="outline" onClick={handleGoogleDriveAuth}>
|
||||||
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
|
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
<Button type="primary" loading={loading} onClick={handleSubmit}>
|
<Button type="primary" loading={loading} onClick={handleSubmit}>保存</Button>
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,295 +1,82 @@
|
|||||||
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
|
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
|
||||||
|
|
||||||
const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> = {
|
// 内置类型的静态字段配置(定制化配置结构)
|
||||||
|
const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
|
||||||
local_disk: [
|
local_disk: [
|
||||||
{
|
{ key: 'basePath', label: '基础目录', type: 'input', required: true, placeholder: '/data/backups', description: 'BackupX 将在该目录下创建和管理备份文件。' },
|
||||||
key: 'basePath',
|
|
||||||
label: '基础目录',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: '/data/backups',
|
|
||||||
description: 'BackupX 将在该目录下创建和管理备份文件。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
s3: [
|
s3: [
|
||||||
{
|
{ key: 'endpoint', label: 'Endpoint', type: 'input', required: true, placeholder: 'https://s3.amazonaws.com' },
|
||||||
key: 'endpoint',
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-east-1' },
|
||||||
label: 'Endpoint',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backupx-prod' },
|
||||||
type: 'input',
|
{ key: 'accessKeyId', label: 'Access Key ID', type: 'input', required: true, sensitive: true, placeholder: 'AKIA...' },
|
||||||
required: true,
|
{ key: 'secretAccessKey', label: 'Secret Access Key', type: 'password', required: true, sensitive: true },
|
||||||
placeholder: 'https://s3.amazonaws.com',
|
{ key: 'forcePathStyle', label: '强制 Path Style', type: 'switch', description: 'MinIO 等兼容存储需要开启。' },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'region',
|
|
||||||
label: '区域',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'ap-east-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'backupx-prod',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'Access Key ID',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'AKIA...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'Secret Access Key',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 Secret Access Key',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'forcePathStyle',
|
|
||||||
label: '强制 Path Style',
|
|
||||||
type: 'switch',
|
|
||||||
description: 'MinIO 或部分兼容对象存储通常需要开启。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
webdav: [
|
webdav: [
|
||||||
{
|
{ key: 'endpoint', label: 'WebDAV 地址', type: 'input', required: true, placeholder: 'https://dav.example.com/...' },
|
||||||
key: 'endpoint',
|
{ key: 'username', label: '用户名', type: 'input', required: true },
|
||||||
label: 'WebDAV 地址',
|
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backupx' },
|
||||||
required: true,
|
|
||||||
placeholder: 'https://dav.example.com/remote.php/dav/files/admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'username',
|
|
||||||
label: '用户名',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'password',
|
|
||||||
label: '密码',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 WebDAV 密码',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'basePath',
|
|
||||||
label: '基础目录',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '/backupx',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
google_drive: [
|
google_drive: [
|
||||||
{
|
{ key: 'clientId', label: 'Client ID', type: 'input', required: true, sensitive: true },
|
||||||
key: 'clientId',
|
{ key: 'clientSecret', label: 'Client Secret', type: 'password', required: true, sensitive: true },
|
||||||
label: 'Client ID',
|
{ key: 'folderId', label: '目标文件夹 ID', type: 'input', placeholder: '留空则使用根目录' },
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'Google OAuth Client ID',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'clientSecret',
|
|
||||||
label: 'Client Secret',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 Google Client Secret',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'folderId',
|
|
||||||
label: '目标文件夹 ID',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '留空则使用根目录',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
aliyun_oss: [
|
aliyun_oss: [
|
||||||
{
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'cn-hangzhou' },
|
||||||
key: 'region',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
|
||||||
label: '区域 (Region)',
|
{ key: 'accessKeyId', label: 'AccessKey ID', type: 'input', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'secretAccessKey', label: 'AccessKey Secret', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
{ key: 'internalNetwork', label: '使用内网', type: 'switch' },
|
||||||
placeholder: 'cn-hangzhou',
|
|
||||||
description: '如 cn-hangzhou, cn-shanghai, cn-beijing, cn-shenzhen 等。系统会自动组装 Endpoint。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'my-backup-bucket',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'AccessKey ID',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'LTAI...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'AccessKey Secret',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 AccessKey Secret',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'internalNetwork',
|
|
||||||
label: '使用内网 Endpoint',
|
|
||||||
type: 'switch',
|
|
||||||
description: '同一区域的 ECS 实例可启用内网传输,节省流量费用。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
tencent_cos: [
|
tencent_cos: [
|
||||||
{
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-guangzhou' },
|
||||||
key: 'region',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backup-1250000000' },
|
||||||
label: '区域 (Region)',
|
{ key: 'accessKeyId', label: 'SecretId', type: 'input', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
|
||||||
placeholder: 'ap-guangzhou',
|
|
||||||
description: '如 ap-guangzhou, ap-shanghai, ap-beijing, ap-chengdu 等。系统会自动组装 Endpoint。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'backup-1250000000',
|
|
||||||
description: '格式为 BucketName-APPID,如 backup-1250000000。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'SecretId',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: 'AKIDxxxxxxxx',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'SecretKey',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 SecretKey',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
qiniu_kodo: [
|
qiniu_kodo: [
|
||||||
{
|
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'z0' },
|
||||||
key: 'region',
|
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
|
||||||
label: '区域 (Region)',
|
{ key: 'accessKeyId', label: 'AccessKey', type: 'input', required: true, sensitive: true },
|
||||||
type: 'input',
|
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
|
||||||
placeholder: 'z0',
|
|
||||||
description: '支持 z0(华东), cn-east-2(华东-浙江2), z1(华北), z2(华南), na0(北美), as0(东南亚)。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bucket',
|
|
||||||
label: 'Bucket',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'my-backup',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accessKeyId',
|
|
||||||
label: 'AccessKey',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '七牛云 AccessKey',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'secretAccessKey',
|
|
||||||
label: 'SecretKey',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 SecretKey',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
ftp: [
|
ftp: [
|
||||||
{
|
{ key: 'host', label: '主机地址', type: 'input', required: true, placeholder: 'ftp.example.com' },
|
||||||
key: 'host',
|
{ key: 'port', label: '端口', type: 'input', placeholder: '21' },
|
||||||
label: '主机地址',
|
{ key: 'username', label: '用户名', type: 'input', required: true },
|
||||||
type: 'input',
|
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
|
||||||
required: true,
|
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backups' },
|
||||||
placeholder: 'ftp.example.com',
|
{ key: 'useTLS', label: 'TLS (FTPS)', type: 'switch' },
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'port',
|
|
||||||
label: '端口',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '21',
|
|
||||||
description: '默认 FTP 端口为 21。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'username',
|
|
||||||
label: '用户名',
|
|
||||||
type: 'input',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'backup_user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'password',
|
|
||||||
label: '密码',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
sensitive: true,
|
|
||||||
placeholder: '输入新的 FTP 密码',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'basePath',
|
|
||||||
label: '基础目录',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '/backups',
|
|
||||||
description: 'FTP 服务器上的目标目录,留空使用根目录。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'useTLS',
|
|
||||||
label: '使用 TLS (FTPS)',
|
|
||||||
type: 'switch',
|
|
||||||
description: '启用 Explicit TLS 加密连接。',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageTargetFieldConfigs(type: StorageTargetType) {
|
const BUILTIN_TYPES = new Set(Object.keys(BUILTIN_FIELD_CONFIG))
|
||||||
return FIELD_CONFIG_MAP[type]
|
|
||||||
|
/** 是否为内置类型 */
|
||||||
|
export function isBuiltinType(type: StorageTargetType): boolean {
|
||||||
|
return BUILTIN_TYPES.has(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageTargetTypeLabel(type: StorageTargetType) {
|
/** 获取静态字段配置 */
|
||||||
switch (type) {
|
export function getStorageTargetFieldConfigs(type: StorageTargetType): StorageTargetFieldConfig[] {
|
||||||
case 'local_disk':
|
return BUILTIN_FIELD_CONFIG[type] ?? []
|
||||||
return '本地磁盘'
|
|
||||||
case 'google_drive':
|
|
||||||
return 'Google Drive'
|
|
||||||
case 's3':
|
|
||||||
return 'S3 Compatible'
|
|
||||||
case 'webdav':
|
|
||||||
return 'WebDAV'
|
|
||||||
case 'aliyun_oss':
|
|
||||||
return '阿里云 OSS'
|
|
||||||
case 'tencent_cos':
|
|
||||||
return '腾讯云 COS'
|
|
||||||
case 'qiniu_kodo':
|
|
||||||
return '七牛云 Kodo'
|
|
||||||
case 'ftp':
|
|
||||||
return 'FTP'
|
|
||||||
default:
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageTargetTypeOptions = [
|
const BUILTIN_LABELS: Record<string, string> = {
|
||||||
|
local_disk: '本地磁盘', google_drive: 'Google Drive', s3: 'S3 Compatible',
|
||||||
|
webdav: 'WebDAV', aliyun_oss: '阿里云 OSS', tencent_cos: '腾讯云 COS',
|
||||||
|
qiniu_kodo: '七牛云 Kodo', ftp: 'FTP', rclone: 'Rclone',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageTargetTypeLabel(type: StorageTargetType): string {
|
||||||
|
return BUILTIN_LABELS[type] || type.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内置类型选项(下拉框"常用"分组) */
|
||||||
|
export const builtinTypeOptions = [
|
||||||
{ label: '本地磁盘', value: 'local_disk' },
|
{ label: '本地磁盘', value: 'local_disk' },
|
||||||
{ label: '阿里云 OSS', value: 'aliyun_oss' },
|
{ label: '阿里云 OSS', value: 'aliyun_oss' },
|
||||||
{ label: '腾讯云 COS', value: 'tencent_cos' },
|
{ label: '腾讯云 COS', value: 'tencent_cos' },
|
||||||
@@ -298,4 +85,4 @@ export const storageTargetTypeOptions = [
|
|||||||
{ label: 'Google Drive', value: 'google_drive' },
|
{ label: 'Google Drive', value: 'google_drive' },
|
||||||
{ label: 'WebDAV', value: 'webdav' },
|
{ label: 'WebDAV', value: 'webdav' },
|
||||||
{ label: 'FTP', value: 'ftp' },
|
{ label: 'FTP', value: 'ftp' },
|
||||||
] as const
|
]
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export function BackupRecordsPage() {
|
|||||||
<Space direction="vertical" size={2}>
|
<Space direction="vertical" size={2}>
|
||||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||||
|
{record.checksum && (
|
||||||
|
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
|
||||||
|
SHA-256: {record.checksum.substring(0, 16)}...
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
19
web/src/services/rclone.ts
Normal file
19
web/src/services/rclone.ts
Normal 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[] }>('/storage-targets/rclone/backends')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export interface BackupRecordSummary {
|
|||||||
status: BackupRecordStatus
|
status: BackupRecordStatus
|
||||||
fileName: string
|
fileName: string
|
||||||
fileSize: number
|
fileSize: number
|
||||||
|
checksum: string
|
||||||
storagePath: string
|
storagePath: string
|
||||||
durationSeconds: number
|
durationSeconds: number
|
||||||
errorMessage: string
|
errorMessage: string
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp'
|
// 内置类型 + 全部 rclone 后端名(sftp, azureblob, dropbox 等)
|
||||||
|
export type StorageTargetType = string
|
||||||
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
|
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
|
||||||
export type StorageFieldType = 'input' | 'password' | 'switch'
|
export type StorageFieldType = 'input' | 'password' | 'switch'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user