mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 03:23:41 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a772b94ca5 | ||
|
|
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
|
||||
BUILD_DIR=./bin
|
||||
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
|
||||
.PHONY: build run test
|
||||
|
||||
build:
|
||||
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:
|
||||
go run ./cmd/backupx
|
||||
go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
245
server/go.mod
245
server/go.mod
@@ -3,97 +3,258 @@ module backupx/server
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/rclone/rclone v1.73.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
github.com/studio-b12/gowebdav v0.12.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
google.golang.org/api v0.215.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
google.golang.org/api v0.255.0
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.13.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 // indirect
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
|
||||
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
|
||||
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
|
||||
github.com/ProtonMail/go-srp v0.0.7 // indirect
|
||||
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
|
||||
github.com/abbot/go-http-auth v0.4.0 // indirect
|
||||
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bradenaw/juniper v0.15.3 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/buengese/sgzip v0.1.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/calebcase/tmpfile v1.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
|
||||
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
|
||||
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
|
||||
github.com/coreos/go-semver v0.3.1 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
|
||||
github.com/creasty/defaults v1.8.0 // indirect
|
||||
github.com/cronokirby/saferith v0.33.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/diskfs/go-diskfs v1.7.0 // indirect
|
||||
github.com/dromara/dongle v1.0.1 // indirect
|
||||
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/flynn/noise v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/geoffgarside/ber v1.2.0 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/errors v0.22.4 // indirect
|
||||
github.com/go-openapi/strfmt v0.25.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-resty/resty/v2 v2.16.5 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jlaffaye/ftp v0.2.0 // indirect
|
||||
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
|
||||
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
|
||||
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lanrat/extsort v1.4.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lpar/date v1.0.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncw/swift/v2 v2.0.5 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
|
||||
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
|
||||
github.com/panjf2000/ants/v2 v2.11.3 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
|
||||
github.com/peterh/liner v1.2.2 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pkg/sftp v1.13.10 // indirect
|
||||
github.com/pkg/xattr v0.4.12 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
|
||||
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
|
||||
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
|
||||
github.com/relvacode/iso8601 v1.7.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rfjakob/eme v1.1.2 // indirect
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||
github.com/sony/gobreaker v1.0.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
||||
google.golang.org/grpc v1.67.3 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/image v0.32.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/validator.v2 v2.0.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
moul.io/http2curl/v2 v2.3.0 // indirect
|
||||
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
|
||||
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
|
||||
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
|
||||
storj.io/infectious v0.0.2 // indirect
|
||||
storj.io/picobuf v0.0.4 // indirect
|
||||
storj.io/uplink v1.13.1 // indirect
|
||||
)
|
||||
|
||||
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/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/internal/storage/googledrive"
|
||||
"backupx/server/internal/storage/localdisk"
|
||||
storageAliyun "backupx/server/internal/storage/aliyun"
|
||||
storageFTP "backupx/server/internal/storage/ftp"
|
||||
storageTencent "backupx/server/internal/storage/tencent"
|
||||
storageQiniu "backupx/server/internal/storage/qiniu"
|
||||
storageS3 "backupx/server/internal/storage/s3"
|
||||
storageWebDAV "backupx/server/internal/storage/webdav"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -70,15 +63,18 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
systemService := service.NewSystemService(cfg, version, time.Now().UTC())
|
||||
configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey)
|
||||
storageRegistry := storage.NewRegistry(
|
||||
localdisk.NewFactory(),
|
||||
storageS3.NewFactory(),
|
||||
storageWebDAV.NewFactory(),
|
||||
googledrive.NewFactory(),
|
||||
storageAliyun.NewFactory(),
|
||||
storageTencent.NewFactory(),
|
||||
storageQiniu.NewFactory(),
|
||||
storageFTP.NewFactory(),
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
storageRclone.NewS3Factory(),
|
||||
storageRclone.NewWebDAVFactory(),
|
||||
storageRclone.NewGoogleDriveFactory(),
|
||||
storageRclone.NewAliyunOSSFactory(),
|
||||
storageRclone.NewTencentCOSFactory(),
|
||||
storageRclone.NewQiniuKodoFactory(),
|
||||
storageRclone.NewFTPFactory(),
|
||||
storageRclone.NewRcloneFactory(),
|
||||
)
|
||||
// 将全部 rclone 后端注册为独立存储类型(sftp、azureblob、dropbox 等与 s3、ftp 完全平级)
|
||||
storageRclone.RegisterAllBackends(storageRegistry)
|
||||
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
|
||||
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
||||
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
||||
@@ -88,9 +84,17 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
retentionService := backupretention.NewService(backupRecordRepo)
|
||||
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
||||
notificationService := service.NewNotificationService(notificationRepo, notifyRegistry, configCipher)
|
||||
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent)
|
||||
// 初始化 rclone 传输配置(重试 + 带宽限制)
|
||||
rcloneCtx := storageRclone.ConfiguredContext(ctx, storageRclone.TransferConfig{
|
||||
LowLevelRetries: cfg.Backup.Retries,
|
||||
BandwidthLimit: cfg.Backup.BandwidthLimit,
|
||||
})
|
||||
storageRclone.StartAccounting(rcloneCtx)
|
||||
|
||||
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit)
|
||||
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
|
||||
backupTaskService.SetScheduler(schedulerService)
|
||||
// 审计日志注入延迟到 auditService 创建后(见下方)
|
||||
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||
@@ -99,6 +103,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
auditLogRepo := repository.NewAuditLogRepository(db)
|
||||
auditService := service.NewAuditService(auditLogRepo)
|
||||
authService.SetAuditService(auditService)
|
||||
schedulerService.SetAuditRecorder(auditService)
|
||||
|
||||
// Database discovery
|
||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -99,6 +100,41 @@ func (h *LogHub) Complete(recordID uint, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
// AppendProgress 推送上传进度事件(节流:每个 recordID 每 500ms 最多一次,最终值始终推送)。
|
||||
func (h *LogHub) AppendProgress(recordID uint, progress ProgressInfo) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
state := h.ensureState(recordID)
|
||||
|
||||
// 节流:距上次 progress 事件不足 500ms 且未完成则跳过(100% 始终推送)
|
||||
now := time.Now().UTC()
|
||||
isFinal := progress.Percent >= 100
|
||||
if !isFinal && len(state.events) > 0 {
|
||||
last := state.events[len(state.events)-1]
|
||||
if last.Progress != nil && now.Sub(last.Timestamp) < 500*time.Millisecond {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
state.nextSequence++
|
||||
event := LogEvent{
|
||||
RecordID: recordID,
|
||||
Sequence: state.nextSequence,
|
||||
Level: "progress",
|
||||
Message: fmt.Sprintf("上传进度: %.1f%%", progress.Percent),
|
||||
Timestamp: now,
|
||||
Status: state.status,
|
||||
Progress: &progress,
|
||||
}
|
||||
state.events = append(state.events, event)
|
||||
for _, subscriber := range state.subscribers {
|
||||
select {
|
||||
case subscriber <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LogHub) ensureState(recordID uint) *logStreamState {
|
||||
state, ok := h.streams[recordID]
|
||||
if ok {
|
||||
|
||||
@@ -41,13 +41,23 @@ type RunResult struct {
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
RecordID uint `json:"recordId"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Completed bool `json:"completed"`
|
||||
Status string `json:"status"`
|
||||
RecordID uint `json:"recordId"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Completed bool `json:"completed"`
|
||||
Status string `json:"status"`
|
||||
Progress *ProgressInfo `json:"progress,omitempty"`
|
||||
}
|
||||
|
||||
// ProgressInfo 描述上传进度,通过 SSE 实时推送给前端。
|
||||
type ProgressInfo struct {
|
||||
BytesSent int64 `json:"bytesSent"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
Percent float64 `json:"percent"`
|
||||
SpeedBps float64 `json:"speedBps"` // bytes/sec
|
||||
TargetName string `json:"targetName"`
|
||||
}
|
||||
|
||||
type LogWriter interface {
|
||||
|
||||
@@ -33,8 +33,10 @@ type SecurityConfig struct {
|
||||
}
|
||||
|
||||
type BackupConfig struct {
|
||||
TempDir string `mapstructure:"temp_dir"`
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||
TempDir string `mapstructure:"temp_dir"`
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||
Retries int `mapstructure:"retries"` // 底层 HTTP 请求重试次数,默认 10
|
||||
BandwidthLimit string `mapstructure:"bandwidth_limit"` // 带宽限制,如 "10M",空不限
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
@@ -96,6 +98,9 @@ func Load(configPath string) (Config, error) {
|
||||
if cfg.Backup.MaxConcurrent <= 0 {
|
||||
cfg.Backup.MaxConcurrent = 2
|
||||
}
|
||||
if cfg.Backup.Retries <= 0 {
|
||||
cfg.Backup.Retries = 10
|
||||
}
|
||||
if cfg.Log.Level == "" {
|
||||
cfg.Log.Level = "info"
|
||||
}
|
||||
@@ -135,6 +140,8 @@ func applyDefaults(v *viper.Viper) {
|
||||
v.SetDefault("security.jwt_expire", "24h")
|
||||
v.SetDefault("backup.temp_dir", "/tmp/backupx")
|
||||
v.SetDefault("backup.max_concurrent", 2)
|
||||
v.SetDefault("backup.retries", 10)
|
||||
v.SetDefault("backup.bandwidth_limit", "")
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("log.file", "./data/backupx.log")
|
||||
v.SetDefault("log.max_size", 100)
|
||||
|
||||
@@ -130,7 +130,7 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -143,10 +143,28 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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})
|
||||
}
|
||||
|
||||
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) {
|
||||
var filter service.BackupRecordListInput
|
||||
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
|
||||
|
||||
@@ -51,7 +51,7 @@ func (h *BackupTaskHandler) Create(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (h *BackupTaskHandler) Update(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (h *BackupTaskHandler) Delete(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -115,6 +115,6 @@ func (h *BackupTaskHandler) Toggle(c *gin.Context) {
|
||||
if !enabled {
|
||||
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)
|
||||
}
|
||||
|
||||
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.Use(AuthMiddleware(deps.JWTManager))
|
||||
// 静态路由必须在参数路由 /:id 之前注册,避免 Gin 路由冲突
|
||||
storageTargets.GET("", storageTargetHandler.List)
|
||||
storageTargets.GET("/:id", storageTargetHandler.Get)
|
||||
storageTargets.POST("", storageTargetHandler.Create)
|
||||
storageTargets.PUT("/:id", storageTargetHandler.Update)
|
||||
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
|
||||
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
|
||||
storageTargets.POST("/test", storageTargetHandler.TestConnection)
|
||||
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
|
||||
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
|
||||
storageTargets.POST("/google-drive/auth-url", storageTargetHandler.StartGoogleDriveOAuth)
|
||||
storageTargets.POST("/google-drive/complete", storageTargetHandler.CompleteGoogleDriveOAuth)
|
||||
storageTargets.GET("/google-drive/callback", storageTargetHandler.HandleGoogleDriveCallback)
|
||||
rcloneHandler := NewRcloneHandler()
|
||||
storageTargets.GET("/rclone/backends", rcloneHandler.ListBackends)
|
||||
// 参数路由
|
||||
storageTargets.GET("/:id", storageTargetHandler.Get)
|
||||
storageTargets.PUT("/:id", storageTargetHandler.Update)
|
||||
storageTargets.DELETE("/:id", storageTargetHandler.Delete)
|
||||
storageTargets.PUT("/:id/star", storageTargetHandler.ToggleStar)
|
||||
storageTargets.POST("/:id/test", storageTargetHandler.TestSavedConnection)
|
||||
storageTargets.GET("/:id/usage", storageTargetHandler.GetUsage)
|
||||
storageTargets.GET("/:id/google-drive/profile", storageTargetHandler.GoogleDriveProfile)
|
||||
|
||||
backupTasks := api.Group("/backup/tasks")
|
||||
@@ -102,6 +106,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
||||
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
||||
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
|
||||
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
|
||||
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
|
||||
dashboard := api.Group("/dashboard")
|
||||
dashboard.Use(AuthMiddleware(deps.JWTManager))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
@@ -36,6 +39,10 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func (h *StorageTargetHandler) Create(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func (h *StorageTargetHandler) Update(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (h *StorageTargetHandler) Delete(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ type BackupRecord struct {
|
||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||
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"`
|
||||
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
||||
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)
|
||||
}
|
||||
|
||||
// AuditRecorder 记录审计日志(可选依赖)
|
||||
type AuditRecorder interface {
|
||||
Record(servicepkg.AuditEntry)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
cron *cron.Cron
|
||||
tasks repository.BackupTaskRepository
|
||||
runner TaskRunner
|
||||
logger *zap.Logger
|
||||
audit AuditRecorder
|
||||
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)}
|
||||
}
|
||||
|
||||
func (s *Service) SetAuditRecorder(audit AuditRecorder) { s.audit = audit }
|
||||
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
if err := s.Reload(ctx); err != nil {
|
||||
return err
|
||||
@@ -96,9 +104,19 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
|
||||
if !task.Enabled || task.CronExpr == "" {
|
||||
return nil
|
||||
}
|
||||
taskID := task.ID
|
||||
taskName := task.Name
|
||||
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 {
|
||||
|
||||
@@ -2,8 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -18,6 +21,7 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/internal/storage/rclone"
|
||||
"backupx/server/pkg/compress"
|
||||
backupcrypto "backupx/server/pkg/crypto"
|
||||
)
|
||||
@@ -81,6 +85,8 @@ type BackupExecutionService struct {
|
||||
now func() time.Time
|
||||
tempDir string
|
||||
semaphore chan struct{}
|
||||
retries int // rclone 底层重试次数
|
||||
bandwidthLimit string // rclone 带宽限制
|
||||
}
|
||||
|
||||
func NewBackupExecutionService(
|
||||
@@ -95,6 +101,8 @@ func NewBackupExecutionService(
|
||||
notifier BackupResultNotifier,
|
||||
tempDir string,
|
||||
maxConcurrent int,
|
||||
retries int,
|
||||
bandwidthLimit string,
|
||||
) *BackupExecutionService {
|
||||
if notifier == nil {
|
||||
notifier = noopBackupNotifier{}
|
||||
@@ -118,9 +126,11 @@ func NewBackupExecutionService(
|
||||
async: func(job func()) {
|
||||
go job()
|
||||
},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
tempDir: tempDir,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
tempDir: tempDir,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
retries: retries,
|
||||
bandwidthLimit: bandwidthLimit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,10 +263,11 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
errMessage := ""
|
||||
var fileName string
|
||||
var fileSize int64
|
||||
var checksum string
|
||||
var storagePath string
|
||||
var uploadResults []StorageUploadResultItem
|
||||
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)
|
||||
}
|
||||
// 写入多目标上传结果
|
||||
@@ -335,6 +346,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
|
||||
// 并行上传到所有目标
|
||||
uploadResults = make([]StorageUploadResultItem, len(targetIDs))
|
||||
var checksumOnce sync.Once
|
||||
var wg sync.WaitGroup
|
||||
for i, tid := range targetIDs {
|
||||
wg.Add(1)
|
||||
@@ -351,21 +363,60 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
|
||||
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)
|
||||
if uploadErr := provider.Upload(ctx, storagePath, artifact, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil {
|
||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()}
|
||||
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
|
||||
// 上传级重试:最多 3 次,指数退避(10s, 30s, 90s)
|
||||
maxAttempts := 3
|
||||
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
|
||||
}
|
||||
// 完整性校验:对比实际传输字节数
|
||||
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}
|
||||
logger.Infof("存储目标 %s 上传成功", targetName)
|
||||
logger.Infof("存储目标 %s 上传成功 (%d bytes, SHA-256=%s)", targetName, fileSize, targetChecksum)
|
||||
// 每个成功目标独立执行保留策略
|
||||
if s.retention != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -415,6 +466,7 @@ func (s *BackupExecutionService) finalizeRecord(ctx context.Context, task *model
|
||||
record.Status = status
|
||||
record.FileName = fileName
|
||||
record.FileSize = fileSize
|
||||
record.Checksum = checksum
|
||||
record.StoragePath = storagePath
|
||||
record.DurationSeconds = int(completedAt.Sub(startedAt).Seconds())
|
||||
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) {
|
||||
// 注入 rclone 传输配置(重试、带宽限制)
|
||||
ctx = rclone.ConfiguredContext(ctx, rclone.TransferConfig{
|
||||
LowLevelRetries: s.retries,
|
||||
BandwidthLimit: s.bandwidthLimit,
|
||||
})
|
||||
target, err := s.targets.FindByID(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||||
@@ -578,3 +635,28 @@ func buildStorageProviderFromRepos(ctx context.Context, storageTargetID uint, st
|
||||
}
|
||||
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/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/internal/storage/localdisk"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRecordService, repository.BackupTaskRepository, repository.StorageTargetRepository, repository.BackupRecordRepository, string, string) {
|
||||
@@ -53,9 +53,13 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec
|
||||
}
|
||||
logHub := backup.NewLogHub()
|
||||
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewMySQLRunner(nil), backup.NewSQLiteRunner(), backup.NewPostgreSQLRunner(nil))
|
||||
storageRegistry := storage.NewRegistry(localdisk.NewFactory())
|
||||
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||
retentionService := backupretention.NewService(records)
|
||||
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)
|
||||
return executionService, recordService, tasks, targets, records, sourceDir, storageDir
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type BackupRecordSummary struct {
|
||||
Status string `json:"status"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
Checksum string `json:"checksum"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
@@ -111,6 +112,7 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
||||
Status: item.Status,
|
||||
FileName: item.FileName,
|
||||
FileSize: item.FileSize,
|
||||
Checksum: item.Checksum,
|
||||
StoragePath: item.StoragePath,
|
||||
DurationSeconds: item.DurationSeconds,
|
||||
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",
|
||||
"timezone",
|
||||
"backup_notification_enabled",
|
||||
"bandwidth_limit",
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
type StorageTargetUpsertInput struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||||
Type string `json:"type" binding:"required,oneof=local_disk google_drive s3 webdav"`
|
||||
Type string `json:"type" binding:"required,min=1"`
|
||||
Description string `json:"description" binding:"max=255"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]any `json:"config" binding:"required"`
|
||||
@@ -544,10 +544,11 @@ func cloneMap(source map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
type StorageTargetUsage struct {
|
||||
TargetID uint `json:"targetId"`
|
||||
TargetName string `json:"targetName"`
|
||||
RecordCount int64 `json:"recordCount"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
TargetID uint `json:"targetId"`
|
||||
TargetName string `json:"targetName"`
|
||||
RecordCount int64 `json:"recordCount"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
DiskUsage *storage.StorageUsageInfo `json:"diskUsage,omitempty"`
|
||||
}
|
||||
|
||||
func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageTargetUsage, error) {
|
||||
@@ -570,5 +571,16 @@ func (s *StorageTargetService) GetUsage(ctx context.Context, id uint) (*StorageT
|
||||
}
|
||||
}
|
||||
}
|
||||
// 尝试查询远端真实存储空间(部分后端如 local/Google Drive/WebDAV 支持)
|
||||
configMap := map[string]any{}
|
||||
if decryptErr := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); decryptErr == nil {
|
||||
if provider, createErr := s.registry.Create(ctx, target.Type, configMap); createErr == nil {
|
||||
if abouter, ok := provider.(storage.StorageAbout); ok {
|
||||
if diskUsage, aboutErr := abouter.About(ctx); aboutErr == nil {
|
||||
result.DiskUsage = diskUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
ProviderTypeQiniuKodo ProviderType = "qiniu_kodo"
|
||||
ProviderTypeFTP ProviderType = "ftp"
|
||||
ProviderTypeRclone ProviderType = "rclone"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -52,6 +53,20 @@ type ProviderFactory interface {
|
||||
Type() ProviderType
|
||||
}
|
||||
|
||||
// StorageAbout 是可选能力接口,支持查询远端存储空间。
|
||||
// 并非所有后端都支持(如 S3/FTP 不支持),通过 type assertion 检测。
|
||||
type StorageAbout interface {
|
||||
About(ctx context.Context) (*StorageUsageInfo, error)
|
||||
}
|
||||
|
||||
// StorageUsageInfo 描述远端存储的空间使用情况。
|
||||
type StorageUsageInfo struct {
|
||||
Total *int64 `json:"total,omitempty"` // 总空间(字节)
|
||||
Used *int64 `json:"used,omitempty"` // 已用空间
|
||||
Free *int64 `json:"free,omitempty"` // 可用空间
|
||||
Objects *int64 `json:"objects,omitempty"` // 对象数量
|
||||
}
|
||||
|
||||
func DecodeConfig[T any](raw map[string]any) (T, error) {
|
||||
var cfg T
|
||||
encoded, err := json.Marshal(raw)
|
||||
|
||||
@@ -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 { useEffect, useState } from 'react'
|
||||
import { Button, Divider, Input, Select, Space, Switch, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export interface CronInputProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const DEFAULT_CRON = '* * * * *'
|
||||
const DEFAULT_CRON = '0 2 * * *'
|
||||
|
||||
type CronPart = 'minute' | 'hour' | 'day' | 'month' | 'week'
|
||||
|
||||
interface CronState {
|
||||
minute: string
|
||||
hour: string
|
||||
day: string
|
||||
month: string
|
||||
week: string
|
||||
}
|
||||
|
||||
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 PRESETS = [
|
||||
{ label: '每天 02:00', value: '0 2 * * *' },
|
||||
{ label: '每天 00:00', value: '0 0 * * *' },
|
||||
{ label: '每 6 小时', value: '0 */6 * * *' },
|
||||
{ label: '每 12 小时', value: '0 */12 * * *' },
|
||||
{ label: '每周日 03:00', value: '0 3 * * 0' },
|
||||
{ label: '每月 1 日 02:00', value: '0 2 1 * *' },
|
||||
{ label: '每 30 分钟', value: '*/30 * * * *' },
|
||||
{ label: '每小时整点', value: '0 * * * *' },
|
||||
]
|
||||
|
||||
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) {
|
||||
const [internalValue, setInternalValue] = useState(value || DEFAULT_CRON)
|
||||
const [cronExpr, setCronExpr] = useState(value || DEFAULT_CRON)
|
||||
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(() => {
|
||||
if (value !== undefined && value !== internalValue) {
|
||||
setInternalValue(value || DEFAULT_CRON)
|
||||
if (!isAdvanced) {
|
||||
setState(parseCron(value || DEFAULT_CRON))
|
||||
}
|
||||
if (value !== undefined && value !== cronExpr) {
|
||||
setCronExpr(value || DEFAULT_CRON)
|
||||
}
|
||||
}, [value, isAdvanced, internalValue])
|
||||
}, [value])
|
||||
|
||||
const notifyChange = (nextValue: string) => {
|
||||
setInternalValue(nextValue)
|
||||
if (onChange) {
|
||||
onChange(nextValue)
|
||||
}
|
||||
const description = useMemo(() => describeCron(cronExpr), [cronExpr])
|
||||
const isPreset = PRESETS.some((p) => p.value === cronExpr)
|
||||
|
||||
const emit = (expr: string) => {
|
||||
setCronExpr(expr)
|
||||
onChange?.(expr)
|
||||
}
|
||||
|
||||
const handleStateChange = (part: CronPart, val: string) => {
|
||||
const nextState = { ...state, [part]: val }
|
||||
setState(nextState)
|
||||
notifyChange(stringifyCron(nextState))
|
||||
}
|
||||
|
||||
const renderPartTab = (
|
||||
part: CronPart,
|
||||
title: string,
|
||||
options: { label: string; value: string }[],
|
||||
allowAnyVal = '*',
|
||||
// 从自定义选择器构建 cron
|
||||
const buildCustomCron = (
|
||||
m: ScheduleMode,
|
||||
h: string,
|
||||
min: string,
|
||||
weekdays: string[],
|
||||
day: string,
|
||||
interval: string,
|
||||
) => {
|
||||
const currentVal = state[part]
|
||||
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
|
||||
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
|
||||
|
||||
// For simplicity in this visual editor, we only support "every" (*) and "specific values" (1,2,3).
|
||||
const type = isAny ? 'any' : 'specific'
|
||||
const specificValues = isSpecific ? currentVal.split(',') : []
|
||||
switch (m) {
|
||||
case 'daily':
|
||||
return `${min} ${h} * * *`
|
||||
case 'weekly':
|
||||
return `${min} ${h} * * ${weekdays.sort().join(',') || '0'}`
|
||||
case 'monthly':
|
||||
return `${min} ${h} ${day} * *`
|
||||
case 'interval':
|
||||
return `0 */${interval} * * *`
|
||||
default:
|
||||
return DEFAULT_CRON
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Radio.Group
|
||||
direction="vertical"
|
||||
value={type}
|
||||
onChange={(val) => {
|
||||
if (val === 'any') {
|
||||
handleStateChange(part, allowAnyVal)
|
||||
} else {
|
||||
handleStateChange(part, options[0].value) // Default to first valid item
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Radio value="any">
|
||||
<Typography.Text>通配 ({allowAnyVal}) - 任意{title}</Typography.Text>
|
||||
</Radio>
|
||||
<Radio value="specific">
|
||||
<Typography.Text>指定{title}</Typography.Text>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
const handleCustomChange = (updates: {
|
||||
mode?: ScheduleMode
|
||||
hour?: string
|
||||
minute?: string
|
||||
weekdays?: string[]
|
||||
day?: string
|
||||
interval?: string
|
||||
}) => {
|
||||
const m = updates.mode ?? mode
|
||||
const h = updates.hour ?? customHour
|
||||
const min = updates.minute ?? customMinute
|
||||
const w = updates.weekdays ?? customWeekdays
|
||||
const d = updates.day ?? customDay
|
||||
const iv = updates.interval ?? customInterval
|
||||
|
||||
{type === 'specific' && (
|
||||
<div style={{ paddingLeft: 24, marginTop: 12 }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={`请选择${title}`}
|
||||
value={specificValues}
|
||||
options={options}
|
||||
onChange={(vals: string[]) => {
|
||||
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>
|
||||
)
|
||||
if (updates.mode !== undefined) setMode(m)
|
||||
if (updates.hour !== undefined) setCustomHour(h)
|
||||
if (updates.minute !== undefined) setCustomMinute(min)
|
||||
if (updates.weekdays !== undefined) setCustomWeekdays(w)
|
||||
if (updates.day !== undefined) setCustomDay(d)
|
||||
if (updates.interval !== undefined) setCustomInterval(iv)
|
||||
|
||||
emit(buildCustomCron(m, h, min, w, d, iv))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cron-input-container">
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Input
|
||||
value={internalValue}
|
||||
onChange={(val) => {
|
||||
setInternalValue(val)
|
||||
if (isAdvanced && onChange) {
|
||||
onChange(val)
|
||||
}
|
||||
}}
|
||||
readOnly={!isAdvanced}
|
||||
style={{ width: 240, fontFamily: 'monospace' }}
|
||||
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)))
|
||||
}
|
||||
<div>
|
||||
{/* 预设按钮 */}
|
||||
<Space wrap size="small" style={{ marginBottom: 12 }}>
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
size="small"
|
||||
type={cronExpr === preset.value ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
emit(preset.value)
|
||||
setShowCustom(false)
|
||||
setIsAdvanced(false)
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{!isAdvanced && (
|
||||
<Tabs type="card-gutter" size="small">
|
||||
<Tabs.TabPane key="minute" title="分钟">
|
||||
{renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="hour" title="小时">
|
||||
{renderPartTab('hour', '小时', HOURS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="day" title="日">
|
||||
{renderPartTab('day', '日', DAYS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="month" title="月">
|
||||
{renderPartTab('month', '月', MONTHS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="week" title="周">
|
||||
{renderPartTab('week', '周', WEEKS_OPTIONS, '*')}
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
{/* 自定义选择器 */}
|
||||
{showCustom && !isAdvanced && (
|
||||
<div style={{ padding: '12px 16px', background: 'var(--color-fill-1)', borderRadius: 6 }}>
|
||||
<Space size="large" style={{ marginBottom: 12 }}>
|
||||
<Button size="small" type={mode === 'daily' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'daily' })}>
|
||||
每天
|
||||
</Button>
|
||||
<Button size="small" type={mode === 'weekly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'weekly' })}>
|
||||
每周
|
||||
</Button>
|
||||
<Button size="small" type={mode === 'monthly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'monthly' })}>
|
||||
每月
|
||||
</Button>
|
||||
<Button size="small" type={mode === 'interval' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'interval' })}>
|
||||
间隔
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Alert, Button, Divider, Drawer, Input, Select, Space, Switch, Typography } from '@arco-design/web-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 { listRcloneBackends, type RcloneBackendInfo } from '../../services/rclone'
|
||||
|
||||
interface StorageTargetFormDrawerProps {
|
||||
visible: boolean
|
||||
@@ -15,33 +16,29 @@ interface StorageTargetFormDrawerProps {
|
||||
}
|
||||
|
||||
function createEmptyDraft(type: StorageTargetType = 'local_disk'): StorageTargetPayload {
|
||||
return {
|
||||
name: '',
|
||||
type,
|
||||
description: '',
|
||||
enabled: true,
|
||||
config: {},
|
||||
}
|
||||
return { name: '', type, description: '', enabled: true, config: {} }
|
||||
}
|
||||
|
||||
export function StorageTargetFormDrawer({
|
||||
visible,
|
||||
loading,
|
||||
testing,
|
||||
initialValue,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onTest,
|
||||
onGoogleDriveAuth,
|
||||
visible, loading, testing, initialValue, onCancel, onSubmit, onTest, onGoogleDriveAuth,
|
||||
}: StorageTargetFormDrawerProps) {
|
||||
const [draft, setDraft] = useState<StorageTargetPayload>(createEmptyDraft())
|
||||
const [error, setError] = useState('')
|
||||
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(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
if (!visible) return
|
||||
if (!initialValue) {
|
||||
setDraft(createEmptyDraft())
|
||||
setError('')
|
||||
@@ -59,97 +56,137 @@ export function StorageTargetFormDrawer({
|
||||
setTestResult(null)
|
||||
}, [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) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
config: {
|
||||
...current.config,
|
||||
[key]: value,
|
||||
},
|
||||
}))
|
||||
setDraft((c) => ({ ...c, config: { ...c.config, [key]: value } }))
|
||||
}
|
||||
|
||||
function validate(value: StorageTargetPayload) {
|
||||
if (!value.name.trim()) {
|
||||
return '请输入存储目标名称'
|
||||
}
|
||||
for (const field of fieldConfigs) {
|
||||
if (!field.required) {
|
||||
continue
|
||||
}
|
||||
const currentValue = value.config[field.key]
|
||||
if (field.type === 'switch') {
|
||||
continue
|
||||
}
|
||||
if (typeof currentValue !== 'string' || !currentValue.trim()) {
|
||||
return `请填写${field.label}`
|
||||
if (!value.name.trim()) return '请输入存储目标名称'
|
||||
if (!value.type.trim()) return '请选择存储类型'
|
||||
if (isBuiltinType(value.type)) {
|
||||
for (const field of staticFields) {
|
||||
if (!field.required || field.type === 'switch') continue
|
||||
const v = value.config[field.key]
|
||||
if (typeof v !== 'string' || !v.trim()) return `请填写${field.label}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onSubmit(draft, initialValue?.id)
|
||||
const e = validate(draft); if (e) { setError(e); return }
|
||||
setError(''); await onSubmit(draft, initialValue?.id)
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
const result = await onTest(draft, initialValue?.id)
|
||||
setTestResult(result)
|
||||
const e = validate(draft); if (e) { setError(e); return }
|
||||
setError(''); setTestResult(await onTest(draft, initialValue?.id))
|
||||
}
|
||||
async function handleGoogleDriveAuth() {
|
||||
const e = validate(draft); if (e) { setError(e); return }
|
||||
setError(''); await onGoogleDriveAuth(draft, initialValue?.id)
|
||||
}
|
||||
|
||||
async function handleGoogleDriveAuth() {
|
||||
const validationError = validate(draft)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
await onGoogleDriveAuth(draft, initialValue?.id)
|
||||
// 渲染静态字段(内置类型)
|
||||
function renderStaticFields() {
|
||||
return staticFields.map((field) => {
|
||||
const value = draft.config[field.key]
|
||||
const normalized = 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(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 (
|
||||
<Drawer
|
||||
width={560}
|
||||
title={initialValue ? '编辑存储目标' : '新建存储目标'}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
unmountOnExit={false}
|
||||
>
|
||||
<Drawer width={560} title={initialValue ? '编辑存储目标' : '新建存储目标'} visible={visible} onCancel={onCancel} unmountOnExit={false}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{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>
|
||||
<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>
|
||||
<Typography.Text>类型</Typography.Text>
|
||||
<Typography.Text>存储类型</Typography.Text>
|
||||
<Select
|
||||
value={draft.type}
|
||||
options={storageTargetTypeOptions as unknown as { label: string; value: string }[]}
|
||||
showSearch
|
||||
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) => {
|
||||
const nextType = value as StorageTargetType
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
type: nextType,
|
||||
config: {},
|
||||
}))
|
||||
setDraft((c) => ({ ...c, type: value as string, config: {} }))
|
||||
setTestResult(null)
|
||||
}}
|
||||
/>
|
||||
@@ -157,16 +194,12 @@ export function StorageTargetFormDrawer({
|
||||
|
||||
<div>
|
||||
<Typography.Text>描述</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={draft.description}
|
||||
placeholder="可选描述,例如备份上传到 NAS 或 Google Drive"
|
||||
onChange={(value) => setDraft((current) => ({ ...current, description: value }))}
|
||||
/>
|
||||
<Input.TextArea value={draft.description} placeholder="可选描述" onChange={(v) => setDraft((c) => ({ ...c, description: v }))} />
|
||||
</div>
|
||||
|
||||
<Space align="center" size="medium">
|
||||
<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>
|
||||
|
||||
<Divider orientation="left">环境配置</Divider>
|
||||
@@ -176,58 +209,18 @@ export function StorageTargetFormDrawer({
|
||||
{getStorageTargetTypeLabel(draft.type)}
|
||||
</Typography.Title>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{fieldConfigs.map((field) => {
|
||||
const value = draft.config[field.key]
|
||||
const normalizedValue = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : ''
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<Typography.Text>
|
||||
{field.label}
|
||||
{field.required ? ' *' : ''}
|
||||
</Typography.Text>
|
||||
{field.type === 'switch' ? (
|
||||
<Space align="center" size="medium">
|
||||
<Switch checked={Boolean(normalizedValue)} onChange={(checked) => updateConfig(field.key, checked)} />
|
||||
{field.description ? <Typography.Text type="secondary">{field.description}</Typography.Text> : null}
|
||||
</Space>
|
||||
) : field.type === 'password' ? (
|
||||
<Input.Password
|
||||
value={String(normalizedValue)}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(nextValue) => updateConfig(field.key, nextValue)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={String(normalizedValue)} placeholder={field.placeholder} onChange={(nextValue) => updateConfig(field.key, nextValue)} />
|
||||
)}
|
||||
{field.description && field.type !== 'switch' ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
{field.description}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||
已存在敏感配置,留空则保持不变。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{isDynamicType ? renderDynamicFields() : renderStaticFields()}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
{draft.type === 'google_drive' ? (
|
||||
<Button loading={testing} onClick={handleTest}>测试连接</Button>
|
||||
{draft.type === 'google_drive' && (
|
||||
<Button type="outline" onClick={handleGoogleDriveAuth}>
|
||||
{initialValue ? '重新授权 Google Drive' : '发起 Google Drive 授权'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" loading={loading} onClick={handleSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" loading={loading} onClick={handleSubmit}>保存</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,295 +1,82 @@
|
||||
import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/storage-targets'
|
||||
|
||||
const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> = {
|
||||
// 内置类型的静态字段配置(定制化配置结构)
|
||||
const BUILTIN_FIELD_CONFIG: Record<string, StorageTargetFieldConfig[]> = {
|
||||
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: [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'https://s3.amazonaws.com',
|
||||
},
|
||||
{
|
||||
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 或部分兼容对象存储通常需要开启。',
|
||||
},
|
||||
{ key: 'endpoint', label: 'Endpoint', type: 'input', required: true, placeholder: 'https://s3.amazonaws.com' },
|
||||
{ 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 },
|
||||
{ key: 'forcePathStyle', label: '强制 Path Style', type: 'switch', description: 'MinIO 等兼容存储需要开启。' },
|
||||
],
|
||||
webdav: [
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: 'WebDAV 地址',
|
||||
type: 'input',
|
||||
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',
|
||||
},
|
||||
{ key: 'endpoint', label: 'WebDAV 地址', type: 'input', required: true, placeholder: 'https://dav.example.com/...' },
|
||||
{ key: 'username', label: '用户名', type: 'input', required: true },
|
||||
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
|
||||
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backupx' },
|
||||
],
|
||||
google_drive: [
|
||||
{
|
||||
key: 'clientId',
|
||||
label: 'Client ID',
|
||||
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: '留空则使用根目录',
|
||||
},
|
||||
{ key: 'clientId', label: 'Client ID', type: 'input', required: true, sensitive: true },
|
||||
{ key: 'clientSecret', label: 'Client Secret', type: 'password', required: true, sensitive: true },
|
||||
{ key: 'folderId', label: '目标文件夹 ID', type: 'input', placeholder: '留空则使用根目录' },
|
||||
],
|
||||
aliyun_oss: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
required: true,
|
||||
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 实例可启用内网传输,节省流量费用。',
|
||||
},
|
||||
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'cn-hangzhou' },
|
||||
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
|
||||
{ key: 'accessKeyId', label: 'AccessKey ID', type: 'input', required: true, sensitive: true },
|
||||
{ key: 'secretAccessKey', label: 'AccessKey Secret', type: 'password', required: true, sensitive: true },
|
||||
{ key: 'internalNetwork', label: '使用内网', type: 'switch' },
|
||||
],
|
||||
tencent_cos: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
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',
|
||||
},
|
||||
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-guangzhou' },
|
||||
{ key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backup-1250000000' },
|
||||
{ key: 'accessKeyId', label: 'SecretId', type: 'input', required: true, sensitive: true },
|
||||
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
|
||||
],
|
||||
qiniu_kodo: [
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
type: 'input',
|
||||
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',
|
||||
},
|
||||
{ key: 'region', label: '区域', type: 'input', required: true, placeholder: 'z0' },
|
||||
{ key: 'bucket', label: 'Bucket', type: 'input', required: true },
|
||||
{ key: 'accessKeyId', label: 'AccessKey', type: 'input', required: true, sensitive: true },
|
||||
{ key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true },
|
||||
],
|
||||
ftp: [
|
||||
{
|
||||
key: 'host',
|
||||
label: '主机地址',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'ftp.example.com',
|
||||
},
|
||||
{
|
||||
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 加密连接。',
|
||||
},
|
||||
{ key: 'host', label: '主机地址', type: 'input', required: true, placeholder: 'ftp.example.com' },
|
||||
{ key: 'port', label: '端口', type: 'input', placeholder: '21' },
|
||||
{ key: 'username', label: '用户名', type: 'input', required: true },
|
||||
{ key: 'password', label: '密码', type: 'password', required: true, sensitive: true },
|
||||
{ key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backups' },
|
||||
{ key: 'useTLS', label: 'TLS (FTPS)', type: 'switch' },
|
||||
],
|
||||
}
|
||||
|
||||
export function getStorageTargetFieldConfigs(type: StorageTargetType) {
|
||||
return FIELD_CONFIG_MAP[type]
|
||||
const BUILTIN_TYPES = new Set(Object.keys(BUILTIN_FIELD_CONFIG))
|
||||
|
||||
/** 是否为内置类型 */
|
||||
export function isBuiltinType(type: StorageTargetType): boolean {
|
||||
return BUILTIN_TYPES.has(type)
|
||||
}
|
||||
|
||||
export function getStorageTargetTypeLabel(type: StorageTargetType) {
|
||||
switch (type) {
|
||||
case 'local_disk':
|
||||
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 function getStorageTargetFieldConfigs(type: StorageTargetType): StorageTargetFieldConfig[] {
|
||||
return BUILTIN_FIELD_CONFIG[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: '阿里云 OSS', value: 'aliyun_oss' },
|
||||
{ label: '腾讯云 COS', value: 'tencent_cos' },
|
||||
@@ -298,4 +85,4 @@ export const storageTargetTypeOptions = [
|
||||
{ label: 'Google Drive', value: 'google_drive' },
|
||||
{ label: 'WebDAV', value: 'webdav' },
|
||||
{ label: 'FTP', value: 'ftp' },
|
||||
] as const
|
||||
]
|
||||
|
||||
@@ -98,6 +98,11 @@ export function BackupRecordsPage() {
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{record.fileName || '-'}</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>
|
||||
),
|
||||
},
|
||||
|
||||
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
|
||||
fileName: string
|
||||
fileSize: number
|
||||
checksum: string
|
||||
storagePath: string
|
||||
durationSeconds: number
|
||||
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 StorageFieldType = 'input' | 'password' | 'switch'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user