From 72ecb6904d6146509c5e0125269689865822e73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Tue, 20 Jan 2026 02:18:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=87=E4=BB=BD=E9=87=8D=E6=9E=841?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 25 +- go.sum | 49 +++- internal/biz/backup.go | 10 +- internal/biz/backup_account.go | 2 +- internal/data/backup.go | 453 ++++++++++++++++++-------------- internal/data/backup_account.go | 10 + internal/http/request/backup.go | 9 +- internal/job/panel_task.go | 9 +- internal/route/cli.go | 26 +- internal/service/backup.go | 14 +- internal/service/cli.go | 26 +- pkg/storage/local.go | 143 ++++++++++ pkg/storage/s3.go | 240 +++++++++++++++++ pkg/storage/sftp.go | 215 +++++++++++++++ pkg/storage/types.go | 21 ++ pkg/storage/webdav.go | 138 ++++++++++ pkg/types/backup.go | 12 +- 17 files changed, 1148 insertions(+), 254 deletions(-) create mode 100644 pkg/storage/local.go create mode 100644 pkg/storage/s3.go create mode 100644 pkg/storage/sftp.go create mode 100644 pkg/storage/types.go create mode 100644 pkg/storage/webdav.go diff --git a/go.mod b/go.mod index 37e78489..3ca6965c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,11 @@ go 1.25 require ( github.com/DeRuina/timberjack v1.3.9 + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.19 + github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 github.com/bddjr/hlfhr v1.4.0 github.com/beevik/ntp v1.5.0 github.com/coder/websocket v1.8.14 @@ -45,6 +50,7 @@ require ( github.com/ncruces/go-sqlite3 v0.30.4 github.com/ncruces/go-sqlite3/gormlite v0.30.2 github.com/orandin/slog-gorm v1.4.0 + github.com/pkg/sftp v1.13.10 github.com/pquerna/otp v1.5.0 github.com/robfig/cron/v3 v3.0.1 github.com/samber/lo v1.52.0 @@ -52,6 +58,7 @@ require ( github.com/shirou/gopsutil/v4 v4.25.12 github.com/spf13/cast v1.10.0 github.com/stretchr/testify v1.11.1 + github.com/studio-b12/gowebdav v0.11.0 github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701 github.com/urfave/cli/v3 v3.6.2 go.yaml.in/yaml/v4 v4.0.0-rc.3 @@ -64,6 +71,21 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/G-Core/gcore-dns-sdk-go v0.3.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // 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.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/boombuler/barcode v1.1.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect @@ -79,6 +101,7 @@ require ( github.com/jaevor/go-nanoid v1.4.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/libtnb/securecookie v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -103,7 +126,7 @@ require ( replace ( github.com/mholt/acmez/v3 => github.com/libtnb/acmez/v3 v3.0.0-20260103184942-a835890fc93e - github.com/moby/moby/client => github.com/libtnb/moby/client v0.0.0-20260103192150-39cfd5376055 + github.com/moby/moby/client => github.com/libtnb/moby/client v0.0.0-20260119133723-7d7dd88cf643 github.com/stretchr/testify => github.com/libtnb/testify v0.0.0-20260103194301-c7a63ea79696 ) diff --git a/go.sum b/go.sum index 65823fd8..02627d73 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,46 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.19 h1:Gxj3kAlmM+a/VVO4YNsmgHGVUZhSxs0tuVwLIxZBCtM= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.19/go.mod h1:XGq5kImVqQT4HUNbbG+0Y8O74URsPNH7CGPg1s1HW5E= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bddjr/hlfhr v1.4.0 h1:EVryUs0mLzQ455bAIKhASqwxFV9IYrGOFjuz0HE6oYE= github.com/bddjr/hlfhr v1.4.0/go.mod h1:oyIv4Q9JpCgZFdtH3KyTNWp7YYRWl4zl8k4ozrMAB4g= github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4= @@ -186,6 +226,7 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -225,8 +266,8 @@ github.com/libtnb/chix v1.3.2 h1:LkA/+OaHeMyTiQoWEcCQKAMSKDZ0LxBXSENQO5d+OKk= github.com/libtnb/chix v1.3.2/go.mod h1:ItssStBa/ov7v4qujyYzORqIf7Z6DO7Q4carOM7pyAQ= github.com/libtnb/gormstore v1.1.1 h1:FG/3P4PuWM6/vB4weVJ31meiSaoeXns1NQlP66quKeg= github.com/libtnb/gormstore v1.1.1/go.mod h1:8A5QzeZxi1MpSmjUVsHTDAL6KnU84feIXMutFLPawwA= -github.com/libtnb/moby/client v0.0.0-20260103192150-39cfd5376055 h1:KriYNjuNbSP6xqg36DbRHBdeITZCCq+p8blgb+PXaqU= -github.com/libtnb/moby/client v0.0.0-20260103192150-39cfd5376055/go.mod h1:OSq/ZzzXkqIGNpzASnhZLSZMuYoPC6Wue6AV7jr/etg= +github.com/libtnb/moby/client v0.0.0-20260119133723-7d7dd88cf643 h1:B7uVTSsIh/9b+gpVOHWcg+8xmYf1g3E9v4OQYcPCkt8= +github.com/libtnb/moby/client v0.0.0-20260119133723-7d7dd88cf643/go.mod h1:y8zxhZOdysHyNZgn9osm6SBDNmissvoai5HLhAdSIFo= github.com/libtnb/securecookie v1.2.0 h1:2uc0PBDm0foeSTrcZ9QTX1IEjf6kFEwfgEYSIXQSKrA= github.com/libtnb/securecookie v1.2.0/go.mod h1:ja+wNGnQzYqcqXQnJWu6icsaWi5JEBwNEMJ2ReTVDxA= github.com/libtnb/sessions v1.2.2 h1:VTTzzeBDJEkJbaPaIU9C4bRj2oAqD0rgQ7UHFkkaNT4= @@ -275,6 +316,8 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -324,6 +367,8 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU= +github.com/studio-b12/gowebdav v0.11.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= diff --git a/internal/biz/backup.go b/internal/biz/backup.go index d40d869f..2d6c1cc2 100644 --- a/internal/biz/backup.go +++ b/internal/biz/backup.go @@ -32,12 +32,14 @@ type Backup struct { type BackupRepo interface { List(page, limit uint, typ BackupType) ([]*Backup, int64, error) - Create(ctx context.Context, typ BackupType, target string, path ...string) error - Delete(ctx context.Context, typ BackupType, name string) error - Restore(ctx context.Context, typ BackupType, backup, target string) error + Create(ctx context.Context, typ BackupType, target string, account uint) error + CreatePanel() error + Delete(ctx context.Context, id uint) error + Restore(ctx context.Context, id uint, target string) error ClearExpired(path, prefix string, save int) error + ClearAccountExpired(account uint, typ BackupType, prefix string, save int) error CutoffLog(path, target string) error - GetPath(typ BackupType) (string, error) + GetDefaultPath(typ BackupType) string FixPanel() error UpdatePanel(version, url, checksum string) error } diff --git a/internal/biz/backup_account.go b/internal/biz/backup_account.go index 02359db2..0f6dffae 100644 --- a/internal/biz/backup_account.go +++ b/internal/biz/backup_account.go @@ -14,7 +14,7 @@ const ( BackupAccountTypeLocal BackupAccountType = "local" BackupAccountTypeS3 BackupAccountType = "s3" BackupAccountTypeSFTP BackupAccountType = "sftp" - BackupAccountTypeWebDAV BackupAccountType = "webdav" + BackupAccountTypeWebDav BackupAccountType = "webdav" ) type BackupAccount struct { diff --git a/internal/data/backup.go b/internal/data/backup.go index ad197473..9163aa96 100644 --- a/internal/data/backup.go +++ b/internal/data/backup.go @@ -11,8 +11,8 @@ import ( "strings" "time" + "github.com/acepanel/panel/pkg/storage" "github.com/leonelquinteros/gotext" - "github.com/shirou/gopsutil/v4/disk" "gorm.io/gorm" "github.com/acepanel/panel/internal/app" @@ -55,32 +55,34 @@ func (r *backupRepo) List(page, limit uint, typ biz.BackupType) ([]*biz.Backup, // Create 创建备份 // typ 备份类型 // target 目标名称 -// path 可选备份保存路径 -func (r *backupRepo) Create(ctx context.Context, typ biz.BackupType, target string, path ...string) error { - defPath, err := r.GetPath(typ) +// account 备份账号ID +func (r *backupRepo) Create(ctx context.Context, typ biz.BackupType, target string, account uint) error { + backupAccount := new(biz.BackupAccount) + if err := r.db.First(backupAccount, account).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(r.t.Get("backup account not found")) + } + return err + } + + client, err := r.getStorage(*backupAccount) if err != nil { return err } - if len(path) > 0 && path[0] != "" { - defPath = path[0] - } - var createErr error switch typ { case biz.BackupTypeWebsite: - createErr = r.createWebsite(defPath, target) + err = r.createWebsite(client, target) case biz.BackupTypeMySQL: - createErr = r.createMySQL(defPath, target) + err = r.createMySQL(client, target) case biz.BackupTypePostgres: - createErr = r.createPostgres(defPath, target) - case biz.BackupTypePanel: - createErr = r.createPanel(defPath) + err = r.createPostgres(client, target) default: return errors.New(r.t.Get("unknown backup type")) } - if createErr != nil { - return createErr + if err != nil { + return err } // 记录日志 @@ -89,20 +91,67 @@ func (r *backupRepo) Create(ctx context.Context, typ biz.BackupType, target stri return nil } +// CreatePanel 创建面板备份 +// 面板备份始终保存在本地 +func (r *backupRepo) CreatePanel() error { + start := time.Now() + + backup := filepath.Join(r.GetDefaultPath(biz.BackupTypePanel), "panel", fmt.Sprintf("panel_%s.zip", time.Now().Format("20060102150405"))) + + temp, err := os.MkdirTemp("", "acepanel-backup-*") + if err != nil { + return err + } + defer func(path string) { _ = os.RemoveAll(path) }(temp) + + if err = io.Cp(filepath.Join(app.Root, "panel"), temp); err != nil { + return err + } + if err = io.Cp("/usr/local/sbin/acepanel", temp); err != nil { + return err + } + + _ = io.Chmod(temp, 0600) + if err = io.Compress(temp, nil, backup); err != nil { + return err + } + if err = io.Chmod(backup, 0600); err != nil { + return err + } + + if app.IsCli { + fmt.Println(r.t.Get("|-Backup time: %s", time.Since(start).String())) + fmt.Println(r.t.Get("|-Backed up to file: %s", filepath.Base(backup))) + } + + return nil +} + // Delete 删除备份 -func (r *backupRepo) Delete(ctx context.Context, typ biz.BackupType, name string) error { - path, err := r.GetPath(typ) +func (r *backupRepo) Delete(ctx context.Context, id uint) error { + backup := new(biz.Backup) + if err := r.db.Preload("Account").First(backup, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(r.t.Get("backup record not found")) + } + return err + } + + client, err := r.getStorage(*backup.Account) if err != nil { return err } - file := filepath.Join(path, name) - if err = io.Remove(file); err != nil { + path := filepath.Join(backup.Account.Info.Path, string(backup.Type), backup.Name) + if !client.Exists(path) { + return errors.New(r.t.Get("backup file %s not exists", path)) + } + if err = client.Delete(path); err != nil { return err } // 记录日志 - r.log.Info("backup deleted", slog.String("type", biz.OperationTypeBackup), slog.Uint64("operator_id", getOperatorID(ctx)), slog.String("backup_type", string(typ)), slog.String("name", name)) + r.log.Info("backup deleted", slog.String("type", biz.OperationTypeBackup), slog.Uint64("operator_id", getOperatorID(ctx)), slog.String("backup_type", string(backup.Type)), slog.String("name", backup.Name)) return nil } @@ -111,37 +160,53 @@ func (r *backupRepo) Delete(ctx context.Context, typ biz.BackupType, name string // typ 备份类型 // backup 备份压缩包,可以是绝对路径或者相对路径 // target 目标名称 -func (r *backupRepo) Restore(ctx context.Context, typ biz.BackupType, backup, target string) error { - if !io.Exists(backup) { - path, err := r.GetPath(typ) - if err != nil { - return err +func (r *backupRepo) Restore(ctx context.Context, id uint, target string) error { + backup := new(biz.Backup) + if err := r.db.Preload("Account").First(backup, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(r.t.Get("backup record not found")) } - backup = filepath.Join(path, backup) + return err } - var restoreErr error - switch typ { + if backup.Account.Type != biz.BackupAccountTypeLocal { + return errors.New(r.t.Get("only local backup can be restored")) + } + if !io.Exists(filepath.Join(backup.Account.Info.Path, backup.Name)) { + return errors.New(r.t.Get("backup file %s not exists", backup)) + } + + var err error + switch backup.Type { case biz.BackupTypeWebsite: - restoreErr = r.restoreWebsite(backup, target) + err = r.restoreWebsite(filepath.Join(backup.Account.Info.Path, backup.Name), target) case biz.BackupTypeMySQL: - restoreErr = r.restoreMySQL(backup, target) + err = r.restoreMySQL(filepath.Join(backup.Account.Info.Path, backup.Name), target) case biz.BackupTypePostgres: - restoreErr = r.restorePostgres(backup, target) + err = r.restorePostgres(filepath.Join(backup.Account.Info.Path, backup.Name), target) default: return errors.New(r.t.Get("unknown backup type")) } - if restoreErr != nil { - return restoreErr + if err != nil { + return err } // 记录日志 - r.log.Info("backup restored", slog.String("type", biz.OperationTypeBackup), slog.Uint64("operator_id", getOperatorID(ctx)), slog.String("backup_type", string(typ)), slog.String("target", target)) + r.log.Info("backup restored", slog.String("type", biz.OperationTypeBackup), slog.Uint64("operator_id", getOperatorID(ctx)), slog.String("backup_type", string(backup.Type)), slog.String("target", target)) return nil } +// GetDefaultPath 获取默认备份路径 +func (r *backupRepo) GetDefaultPath(typ biz.BackupType) string { + backupPath, err := r.setting.Get(biz.SettingKeyBackupPath) + if err != nil { + return filepath.Join(app.Root, "backup", string(typ)) + } + return filepath.Join(backupPath, string(typ)) +} + // CutoffLog 切割日志 // path 保存目录绝对路径 // target 待切割日志文件绝对路径 @@ -213,52 +278,150 @@ func (r *backupRepo) ClearExpired(path, prefix string, save int) error { return nil } -// GetPath 获取备份路径 -func (r *backupRepo) GetPath(typ biz.BackupType) (string, error) { - backupPath, err := r.setting.Get(biz.SettingKeyBackupPath) - if err != nil { - return "", err - } - if !slices.Contains([]biz.BackupType{biz.BackupTypePath, biz.BackupTypeWebsite, biz.BackupTypeMySQL, biz.BackupTypePostgres, biz.BackupTypeRedis, biz.BackupTypePanel}, typ) { - return "", errors.New(r.t.Get("unknown backup type")) +// ClearAccountExpired 清理备份账号过期备份 +func (r *backupRepo) ClearAccountExpired(account uint, typ biz.BackupType, prefix string, save int) error { + backupAccount := new(biz.BackupAccount) + if err := r.db.First(backupAccount, account).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(r.t.Get("backup account not found")) + } + return err } - backupPath = filepath.Join(backupPath, string(typ)) - if !io.Exists(backupPath) { - if err = os.MkdirAll(backupPath, 0644); err != nil { - return "", err + client, err := r.getStorage(*backupAccount) + if err != nil { + return err + } + + files, err := client.List(string(typ)) + if err != nil { + return err + } + + type fileInfo struct { + name string + modTime time.Time + } + var filtered []fileInfo + for _, file := range files { + if strings.HasPrefix(file, prefix) && strings.HasSuffix(file, ".zip") { + lastModified, modErr := client.LastModified(filepath.Join(string(typ), file)) + if modErr != nil { + continue + } + filtered = append(filtered, fileInfo{name: file, modTime: lastModified}) } } - return backupPath, nil + // 排序所有备份文件,从新到旧 + slices.SortFunc(filtered, func(a, b fileInfo) int { + if a.modTime.After(b.modTime) { + return -1 + } + if a.modTime.Before(b.modTime) { + return 1 + } + return 0 + }) + if len(filtered) <= save { + return nil + } + + // 切片保留 save 份,删除剩余 + toDelete := filtered[save:] + for _, file := range toDelete { + filePath := filepath.Join(string(typ), file.name) + if app.IsCli { + fmt.Println(r.t.Get("|-Cleaning expired file: %s", filePath)) + } + if err = client.Delete(filePath); err != nil { + return errors.New(r.t.Get("Cleanup failed: %v", err)) + } + } + + return nil +} + +// getStorage 获取存储器 +func (r *backupRepo) getStorage(account biz.BackupAccount) (storage.Storage, error) { + switch account.Type { + case biz.BackupAccountTypeLocal: + return storage.NewLocal(account.Info.Path) + case biz.BackupAccountTypeS3: + return storage.NewS3(storage.S3Config{ + Region: account.Info.Region, + Bucket: account.Info.Bucket, + AccessKeyID: account.Info.AccessKey, + SecretAccessKey: account.Info.SecretKey, + Endpoint: account.Info.Endpoint, + BasePath: account.Info.Path, + AddressingStyle: storage.S3AddressingStyle(account.Info.Style), + }) + case biz.BackupAccountTypeSFTP: + return storage.NewSFTP(storage.SFTPConfig{ + Host: account.Info.Host, + Port: account.Info.Port, + Username: account.Info.Username, + Password: account.Info.Password, + PrivateKey: account.Info.PrivateKey, + BasePath: account.Info.Path, + }) + case biz.BackupAccountTypeWebDav: + return storage.NewWebDav(storage.WebDavConfig{ + URL: account.Info.URL, + Username: account.Info.Username, + Password: account.Info.Password, + BasePath: account.Info.Path, + }) + default: + return nil, errors.New(r.t.Get("unknown storage type")) + } } // createWebsite 创建网站备份 -func (r *backupRepo) createWebsite(to string, name string) error { +func (r *backupRepo) createWebsite(storage storage.Storage, name string) error { + start := time.Now() + website, err := r.website.GetByName(name) if err != nil { return err } - if err = r.preCheckPath(to, website.Path); err != nil { + // 创建用于压缩的临时目录 + tmpDir, err := os.MkdirTemp("", "acepanel-backup-*") + if err != nil { + return err + } + defer func(path string) { _ = os.RemoveAll(path) }(tmpDir) + + // 压缩网站 + backup := fmt.Sprintf("%s_%s.zip", website.Name, time.Now().Format("20060102150405")) + if err = io.Compress(website.Path, nil, filepath.Join(tmpDir, backup)); err != nil { return err } - start := time.Now() - backup := filepath.Join(to, fmt.Sprintf("%s_%s.zip", website.Name, time.Now().Format("20060102150405"))) - if err = io.Compress(website.Path, nil, backup); err != nil { + // 上传备份文件到存储器 + file, err := os.Open(filepath.Join(tmpDir, backup)) + if err != nil { + return err + } + defer func(file *os.File) { _ = file.Close() }(file) + + if err = storage.Put(filepath.Join("website", backup), file); err != nil { return err } if app.IsCli { fmt.Println(r.t.Get("|-Backup time: %s", time.Since(start).String())) - fmt.Println(r.t.Get("|-Backed up to file: %s", filepath.Base(backup))) + fmt.Println(r.t.Get("|-Backed up to file: %s", backup)) } return nil } // createMySQL 创建 MySQL 备份 -func (r *backupRepo) createMySQL(to string, name string) error { +func (r *backupRepo) createMySQL(storage storage.Storage, name string) error { + start := time.Now() + rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword) if err != nil { return err @@ -271,30 +434,35 @@ func (r *backupRepo) createMySQL(to string, name string) error { if exist, _ := mysql.DatabaseExists(name); !exist { return errors.New(r.t.Get("database does not exist: %s", name)) } - size, err := mysql.DatabaseSize(name) + + // 创建用于压缩的临时目录 + tmpDir, err := os.MkdirTemp("", "acepanel-backup-*") if err != nil { return err } - if err = r.preCheckDB(to, size); err != nil { + defer func(path string) { _ = os.RemoveAll(path) }(tmpDir) + + // 导出数据库 + _ = os.Setenv("MYSQL_PWD", rootPassword) + backup := fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405")) + if _, err = shell.Execf(`mysqldump -u root '%s' > '%s'`, name, filepath.Join(tmpDir, backup)); err != nil { + return err + } + _ = os.Unsetenv("MYSQL_PWD") + + // 压缩备份文件 + if err = io.Compress(tmpDir, []string{backup}, filepath.Join(tmpDir, backup+".zip")); err != nil { return err } - if err = os.Setenv("MYSQL_PWD", rootPassword); err != nil { - return err - } - start := time.Now() - backup := filepath.Join(to, fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405"))) - if _, err = shell.Execf(`mysqldump -u root '%s' > '%s'`, name, backup); err != nil { - return err - } - if err = os.Unsetenv("MYSQL_PWD"); err != nil { + // 上传备份文件到存储器 + file, err := os.Open(filepath.Join(tmpDir, backup)) + if err != nil { return err } + defer func(file *os.File) { _ = file.Close() }(file) - if err = io.Compress(filepath.Dir(backup), []string{filepath.Base(backup)}, backup+".zip"); err != nil { - return err - } - if err = io.Remove(backup); err != nil { + if err = storage.Put(filepath.Join("mysql", backup), file); err != nil { return err } @@ -306,7 +474,9 @@ func (r *backupRepo) createMySQL(to string, name string) error { } // createPostgres 创建 PostgreSQL 备份 -func (r *backupRepo) createPostgres(to string, name string) error { +func (r *backupRepo) createPostgres(storage storage.Storage, name string) error { + start := time.Now() + postgres, err := db.NewPostgres("postgres", "", "127.0.0.1", 5432) if err != nil { return err @@ -315,24 +485,33 @@ func (r *backupRepo) createPostgres(to string, name string) error { if exist, _ := postgres.DatabaseExists(name); !exist { return errors.New(r.t.Get("database does not exist: %s", name)) } - size, err := postgres.DatabaseSize(name) + + // 创建用于压缩的临时目录 + tmpDir, err := os.MkdirTemp("", "acepanel-backup-*") if err != nil { return err } - if err = r.preCheckDB(to, size); err != nil { + defer func(path string) { _ = os.RemoveAll(path) }(tmpDir) + + // 导出数据库 + backup := fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405")) + if _, err = shell.Execf(`su - postgres -c "pg_dump '%s'" > '%s'`, name, filepath.Join(tmpDir, backup)); err != nil { return err } - start := time.Now() - backup := filepath.Join(to, fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405"))) - if _, err = shell.Execf(`su - postgres -c "pg_dump '%s'" > '%s'`, name, backup); err != nil { + // 压缩备份文件 + if err = io.Compress(tmpDir, []string{backup}, filepath.Join(tmpDir, backup+".zip")); err != nil { return err } - if err = io.Compress(filepath.Dir(backup), []string{filepath.Base(backup)}, backup+".zip"); err != nil { + // 上传备份文件到存储器 + file, err := os.Open(filepath.Join(tmpDir, backup)) + if err != nil { return err } - if err = io.Remove(backup); err != nil { + defer func(file *os.File) { _ = file.Close() }(file) + + if err = storage.Put(filepath.Join("postgres", backup), file); err != nil { return err } @@ -343,50 +522,8 @@ func (r *backupRepo) createPostgres(to string, name string) error { return nil } -// createPanel 创建面板备份 -func (r *backupRepo) createPanel(to string) error { - backup := filepath.Join(to, fmt.Sprintf("panel_%s.zip", time.Now().Format("20060102150405"))) - - if err := r.preCheckPath(to, filepath.Join(app.Root, "panel")); err != nil { - return err - } - - start := time.Now() - - temp, err := os.MkdirTemp("", "panel-backup") - if err != nil { - return err - } - - if err = io.Cp(filepath.Join(app.Root, "panel"), temp); err != nil { - return err - } - if err = io.Cp("/usr/local/sbin/acepanel", temp); err != nil { - return err - } - - _ = io.Chmod(temp, 0600) - if err = io.Compress(temp, nil, backup); err != nil { - return err - } - if err = io.Chmod(backup, 0600); err != nil { - return err - } - - if app.IsCli { - fmt.Println(r.t.Get("|-Backup time: %s", time.Since(start).String())) - fmt.Println(r.t.Get("|-Backed up to file: %s", filepath.Base(backup))) - } - - return io.Remove(temp) -} - // restoreWebsite 恢复网站备份 func (r *backupRepo) restoreWebsite(backup, target string) error { - if !io.Exists(backup) { - return errors.New(r.t.Get("backup file %s not exists", backup)) - } - website, err := r.website.GetByName(target) if err != nil { return err @@ -410,10 +547,6 @@ func (r *backupRepo) restoreWebsite(backup, target string) error { // restoreMySQL 恢复 MySQL 备份 func (r *backupRepo) restoreMySQL(backup, target string) error { - if !io.Exists(backup) { - return errors.New(r.t.Get("backup file %s not exists", backup)) - } - rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword) if err != nil { return err @@ -454,10 +587,6 @@ func (r *backupRepo) restoreMySQL(backup, target string) error { // restorePostgres 恢复 PostgreSQL 备份 func (r *backupRepo) restorePostgres(backup, target string) error { - if !io.Exists(backup) { - return errors.New(r.t.Get("backup file %s not exists", backup)) - } - postgres, err := db.NewPostgres("postgres", "", "127.0.0.1", 5432) if err != nil { return err @@ -486,67 +615,9 @@ func (r *backupRepo) restorePostgres(backup, target string) error { return nil } -// preCheckPath 预检空间和 inode 是否足够 -// to 备份保存目录 -// path 待备份目录 -func (r *backupRepo) preCheckPath(to, path string) error { - size, err := io.SizeX(path) - if err != nil { - return err - } - files, err := io.CountX(path) - if err != nil { - return err - } - - usage, err := disk.Usage(to) - if err != nil { - return err - } - - if app.IsCli { - fmt.Println(r.t.Get("|-Target size: %s", tools.FormatBytes(float64(size)))) - fmt.Println(r.t.Get("|-Target file count: %d", files)) - fmt.Println(r.t.Get("|-Backup directory available space: %s", tools.FormatBytes(float64(usage.Free)))) - fmt.Println(r.t.Get("|-Backup directory available Inode: %d", usage.InodesFree)) - } - - if uint64(size) > usage.Free { - return errors.New(r.t.Get("Insufficient backup directory space")) - } - // 对于 fuse 等文件系统,可能没有 inode 的概念 - /*if uint64(files) > usage.InodesFree { - return errors.New(r.t.Get("Insufficient backup directory inode")) - }*/ - - return nil -} - -// preCheckDB 预检空间和 inode 是否足够 -// to 备份保存目录 -// size 数据库大小 -func (r *backupRepo) preCheckDB(to string, size int64) error { - usage, err := disk.Usage(to) - if err != nil { - return err - } - - if app.IsCli { - fmt.Println(r.t.Get("|-Target size: %s", tools.FormatBytes(float64(size)))) - fmt.Println(r.t.Get("|-Backup directory available space: %s", tools.FormatBytes(float64(usage.Free)))) - fmt.Println(r.t.Get("|-Backup directory available Inode: %d", usage.InodesFree)) - } - - if uint64(size) > usage.Free { - return errors.New(r.t.Get("Insufficient backup directory space")) - } - - return nil -} - // autoUnCompressSQL 自动处理压缩文件 func (r *backupRepo) autoUnCompressSQL(backup string) (string, error) { - temp, err := os.MkdirTemp("", "sql-uncompress") + temp, err := os.MkdirTemp("", "acepanel-sql-*") if err != nil { return "", err } @@ -610,11 +681,7 @@ func (r *backupRepo) FixPanel() error { } // 从备份目录中找最新的备份文件 - backupPath, err := r.GetPath(biz.BackupTypePanel) - if err != nil { - return err - } - files, err := os.ReadDir(backupPath) + files, err := os.ReadDir(r.GetDefaultPath(biz.BackupTypePanel)) if err != nil { return err } @@ -633,7 +700,7 @@ func (r *backupRepo) FixPanel() error { return errors.New(r.t.Get("No backup file found, unable to automatically repair")) } latest := list[0] - latestPath := filepath.Join(backupPath, latest.Name()) + latestPath := filepath.Join(r.GetDefaultPath(biz.BackupTypePanel), latest.Name()) if app.IsCli { fmt.Println(r.t.Get("|-Backup file used: %s", latest.Name())) } @@ -766,7 +833,7 @@ func (r *backupRepo) UpdatePanel(version, url, checksum string) error { fmt.Println(r.t.Get("|-Backup panel data...")) } // 备份面板 - if err := r.Create(context.Background(), biz.BackupTypePanel, ""); err != nil { + if err := r.CreatePanel(); err != nil { return errors.New(r.t.Get("|-Backup panel data failed: %v", err)) } if err := io.Compress(filepath.Join(app.Root, "panel/storage"), nil, "/tmp/panel-storage.zip"); err != nil { diff --git a/internal/data/backup_account.go b/internal/data/backup_account.go index eab4bff6..4cf7ddbc 100644 --- a/internal/data/backup_account.go +++ b/internal/data/backup_account.go @@ -2,6 +2,7 @@ package data import ( "context" + "errors" "log/slog" "github.com/leonelquinteros/gotext" @@ -74,6 +75,15 @@ func (r backupAccountRepo) Update(ctx context.Context, req *request.BackupAccoun } func (r backupAccountRepo) Delete(ctx context.Context, id uint) error { + // 检查是否有备份关联 + var count int64 + if err := r.db.Model(&biz.Backup{}).Where("account_id = ?", id).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New(r.t.Get("Cannot delete backup account with existing backups")) + } + if err := r.db.Model(&biz.BackupAccount{}).Where("id = ?", id).Delete(&biz.BackupAccount{}).Error; err != nil { return err } diff --git a/internal/http/request/backup.go b/internal/http/request/backup.go index daf3ce53..f2908f9d 100644 --- a/internal/http/request/backup.go +++ b/internal/http/request/backup.go @@ -8,9 +8,9 @@ type BackupList struct { } type BackupCreate struct { - Type string `uri:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"` - Target string `json:"target" form:"target" validate:"required|regex:^[a-zA-Z0-9_-]+$"` - Path string `json:"path" form:"path"` + Type string `uri:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"` + Target string `json:"target" form:"target" validate:"required|regex:^[a-zA-Z0-9_-]+$"` + AccountID uint `form:"account_id" json:"account_id" validate:"required|exists:backup_accounts,id"` } type BackupUpload struct { @@ -24,7 +24,6 @@ type BackupFile struct { } type BackupRestore struct { - Type string `uri:"type" form:"type" validate:"required|in:website,mysql,postgres,redis,panel"` - File string `json:"file" form:"file" validate:"required"` + ID Target string `json:"target" form:"target" validate:"required|regex:^[a-zA-Z0-9_-]+$"` } diff --git a/internal/job/panel_task.go b/internal/job/panel_task.go index b260d22a..5395cac2 100644 --- a/internal/job/panel_task.go +++ b/internal/job/panel_task.go @@ -1,7 +1,6 @@ package job import ( - "context" "fmt" "log/slog" "math/rand/v2" @@ -65,15 +64,13 @@ func (r *PanelTask) Run() { } // 备份面板 - if err := r.backupRepo.Create(context.Background(), biz.BackupTypePanel, ""); err != nil { + if err := r.backupRepo.CreatePanel(); err != nil { r.log.Warn("failed to backup panel", slog.String("type", biz.OperationTypePanel), slog.Uint64("operator_id", 0), slog.Any("err", err)) } // 清理备份 - if path, err := r.backupRepo.GetPath("panel"); err == nil { - if err = r.backupRepo.ClearExpired(path, "panel_", 10); err != nil { - r.log.Warn("failed to clear backup", slog.String("type", biz.OperationTypePanel), slog.Uint64("operator_id", 0), slog.Any("err", err)) - } + if err := r.backupRepo.ClearExpired(r.backupRepo.GetDefaultPath(biz.BackupTypePanel), "panel_", 10); err != nil { + r.log.Warn("failed to clear backup", slog.String("type", biz.OperationTypePanel), slog.Uint64("operator_id", 0), slog.Any("err", err)) } // 非离线模式下任务 diff --git a/internal/route/cli.go b/internal/route/cli.go index dbb1f885..c6be7ce1 100644 --- a/internal/route/cli.go +++ b/internal/route/cli.go @@ -302,9 +302,9 @@ func (route *Cli) Commands() []*cli.Command { Required: true, }, &cli.StringFlag{ - Name: "path", - Aliases: []string{"p"}, - Usage: route.t.Get("Save directory (default path if not filled)"), + Name: "account", + Aliases: []string{"a"}, + Usage: route.t.Get("Account ID (default account if not filled)"), }, }, }, @@ -326,9 +326,9 @@ func (route *Cli) Commands() []*cli.Command { Required: true, }, &cli.StringFlag{ - Name: "path", - Aliases: []string{"p"}, - Usage: route.t.Get("Save directory (default path if not filled)"), + Name: "account", + Aliases: []string{"a"}, + Usage: route.t.Get("Account ID (default account if not filled)"), }, }, }, @@ -336,13 +336,7 @@ func (route *Cli) Commands() []*cli.Command { Name: "panel", Usage: route.t.Get("Backup panel"), Action: route.cli.BackupPanel, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "path", - Aliases: []string{"p"}, - Usage: route.t.Get("Save directory (default path if not filled)"), - }, - }, + Flags: []cli.Flag{}, }, { Name: "clear", @@ -368,9 +362,9 @@ func (route *Cli) Commands() []*cli.Command { Required: true, }, &cli.StringFlag{ - Name: "path", - Aliases: []string{"p"}, - Usage: route.t.Get("Backup directory (default path if not filled)"), + Name: "account", + Aliases: []string{"a"}, + Usage: route.t.Get("Account ID (default account if not filled)"), }, }, }, diff --git a/internal/service/backup.go b/internal/service/backup.go index 27e8dcbd..27ccd192 100644 --- a/internal/service/backup.go +++ b/internal/service/backup.go @@ -53,7 +53,7 @@ func (s *BackupService) Create(w http.ResponseWriter, r *http.Request) { return } - if err = s.backupRepo.Create(r.Context(), biz.BackupType(req.Type), req.Target, req.Path); err != nil { + if err = s.backupRepo.Create(r.Context(), biz.BackupType(req.Type), req.Target, req.AccountID); err != nil { Error(w, http.StatusInternalServerError, "%v", err) return } @@ -81,11 +81,7 @@ func (s *BackupService) Upload(w http.ResponseWriter, r *http.Request) { return } - path, err := s.backupRepo.GetPath(biz.BackupType(req.Type)) - if err != nil { - Error(w, http.StatusInternalServerError, "%v", err) - return - } + path := s.backupRepo.GetDefaultPath(biz.BackupType(req.Type)) if io.Exists(filepath.Join(path, req.File.Filename)) { Error(w, http.StatusForbidden, s.t.Get("target backup %s already exists", path)) return @@ -108,13 +104,13 @@ func (s *BackupService) Upload(w http.ResponseWriter, r *http.Request) { } func (s *BackupService) Delete(w http.ResponseWriter, r *http.Request) { - req, err := Bind[request.BackupFile](r) + req, err := Bind[request.ID](r) if err != nil { Error(w, http.StatusUnprocessableEntity, "%v", err) return } - if err = s.backupRepo.Delete(r.Context(), biz.BackupType(req.Type), req.File); err != nil { + if err = s.backupRepo.Delete(r.Context(), req.ID); err != nil { Error(w, http.StatusInternalServerError, "%v", err) return } @@ -129,7 +125,7 @@ func (s *BackupService) Restore(w http.ResponseWriter, r *http.Request) { return } - if err = s.backupRepo.Restore(r.Context(), biz.BackupType(req.Type), req.File, req.Target); err != nil { + if err = s.backupRepo.Restore(r.Context(), req.ID.ID, req.Target); err != nil { Error(w, http.StatusInternalServerError, "%v", err) return } diff --git a/internal/service/cli.go b/internal/service/cli.go index 2cffbee1..7dad8886 100644 --- a/internal/service/cli.go +++ b/internal/service/cli.go @@ -633,7 +633,7 @@ func (s *CliService) BackupWebsite(ctx context.Context, cmd *cli.Command) error fmt.Println(s.hr) fmt.Println(s.t.Get("|-Backup type: website")) fmt.Println(s.t.Get("|-Backup target: %s", cmd.String("name"))) - if err := s.backupRepo.Create(ctx, biz.BackupTypeWebsite, cmd.String("name"), cmd.String("path")); err != nil { + if err := s.backupRepo.Create(ctx, biz.BackupTypeWebsite, cmd.String("name"), cmd.Uint("account")); err != nil { return errors.New(s.t.Get("Backup failed: %v", err)) } fmt.Println(s.hr) @@ -649,7 +649,7 @@ func (s *CliService) BackupDatabase(ctx context.Context, cmd *cli.Command) error fmt.Println(s.t.Get("|-Backup type: database")) fmt.Println(s.t.Get("|-Database: %s", cmd.String("type"))) fmt.Println(s.t.Get("|-Backup target: %s", cmd.String("name"))) - if err := s.backupRepo.Create(ctx, biz.BackupType(cmd.String("type")), cmd.String("name"), cmd.String("path")); err != nil { + if err := s.backupRepo.Create(ctx, biz.BackupType(cmd.String("type")), cmd.String("name"), cmd.Uint("account")); err != nil { return errors.New(s.t.Get("Backup failed: %v", err)) } fmt.Println(s.hr) @@ -663,7 +663,7 @@ func (s *CliService) BackupPanel(ctx context.Context, cmd *cli.Command) error { fmt.Println(s.t.Get("★ Start backup [%s]", time.Now().Format(time.DateTime))) fmt.Println(s.hr) fmt.Println(s.t.Get("|-Backup type: panel")) - if err := s.backupRepo.Create(ctx, biz.BackupTypePanel, "", cmd.String("path")); err != nil { + if err := s.backupRepo.CreatePanel(); err != nil { return errors.New(s.t.Get("Backup failed: %v", err)) } fmt.Println(s.hr) @@ -673,13 +673,7 @@ func (s *CliService) BackupPanel(ctx context.Context, cmd *cli.Command) error { } func (s *CliService) BackupClear(ctx context.Context, cmd *cli.Command) error { - path, err := s.backupRepo.GetPath(biz.BackupType(cmd.String("type"))) - if err != nil { - return err - } - if cmd.String("path") != "" { - path = cmd.String("path") - } + path := s.backupRepo.GetDefaultPath(biz.BackupType(cmd.String("type"))) fmt.Println(s.hr) fmt.Println(s.t.Get("★ Start cleaning [%s]", time.Now().Format(time.DateTime))) @@ -687,9 +681,17 @@ func (s *CliService) BackupClear(ctx context.Context, cmd *cli.Command) error { fmt.Println(s.t.Get("|-Cleaning type: %s", cmd.String("type"))) fmt.Println(s.t.Get("|-Cleaning target: %s", cmd.String("file"))) fmt.Println(s.t.Get("|-Keep count: %d", cmd.Int("save"))) - if err = s.backupRepo.ClearExpired(path, cmd.String("file"), cmd.Int("save")); err != nil { - return errors.New(s.t.Get("Cleaning failed: %v", err)) + + if cmd.String("account") != "" { + if err := s.backupRepo.ClearAccountExpired(cmd.Uint("account"), biz.BackupType(cmd.String("type")), cmd.String("file"), cmd.Int("save")); err != nil { + return errors.New(s.t.Get("Cleaning failed: %v", err)) + } + } else { + if err := s.backupRepo.ClearExpired(path, cmd.String("file"), cmd.Int("save")); err != nil { + return errors.New(s.t.Get("Cleaning failed: %v", err)) + } } + fmt.Println(s.hr) fmt.Println(s.t.Get("☆ Cleaning successful [%s]", time.Now().Format(time.DateTime))) fmt.Println(s.hr) diff --git a/pkg/storage/local.go b/pkg/storage/local.go new file mode 100644 index 00000000..ac4fe693 --- /dev/null +++ b/pkg/storage/local.go @@ -0,0 +1,143 @@ +package storage + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + pkgio "github.com/acepanel/panel/pkg/io" + "github.com/shirou/gopsutil/v4/disk" +) + +type Local struct { + basePath string +} + +func NewLocal(basePath string) (Storage, error) { + if basePath == "" { + return nil, errors.New("base path is empty") + } + return &Local{ + basePath: basePath, + }, nil +} + +// Delete 删除文件 +func (l *Local) Delete(files ...string) error { + for _, file := range files { + fullPath := l.fullPath(file) + if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// Exists 检查文件是否存在 +func (l *Local) Exists(file string) bool { + fullPath := l.fullPath(file) + _, err := os.Stat(fullPath) + return !os.IsNotExist(err) +} + +// LastModified 获取文件最后修改时间 +func (l *Local) LastModified(file string) (time.Time, error) { + fullPath := l.fullPath(file) + info, err := os.Stat(fullPath) + if err != nil { + return time.Time{}, err + } + return info.ModTime(), nil +} + +// List 列出目录下的所有文件 +func (l *Local) List(path string) ([]string, error) { + fullPath := l.fullPath(path) + entries, err := os.ReadDir(fullPath) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + return files, nil +} + +// Put 写入文件内容 +func (l *Local) Put(file string, content io.Reader) error { + fullPath := l.fullPath(file) + + // 确保目录存在 + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return err + } + + // 预检查空间 + if err := l.preCheckPath(fullPath); err != nil { + return fmt.Errorf("pre check path failed: %w", err) + } + + f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + + defer func(f *os.File) { _ = f.Close() }(f) + + _, err = io.Copy(f, content) + return err +} + +// Size 获取文件大小 +func (l *Local) Size(file string) (int64, error) { + fullPath := l.fullPath(file) + info, err := os.Stat(fullPath) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +func (l *Local) fullPath(path string) string { + path = strings.TrimPrefix(path, "/") + if path == "" { + return l.basePath + } + if filepath.IsAbs(path) { + return path + } + return filepath.Join(l.basePath, path) +} + +func (l *Local) preCheckPath(path string) error { + size, err := pkgio.SizeX(path) + if err != nil { + return err + } + files, err := pkgio.CountX(path) + if err != nil { + return err + } + + usage, err := disk.Usage(l.basePath) + if err != nil { + return err + } + + if uint64(size) > usage.Free { + return errors.New("insufficient backup directory space") + } + if uint64(files) > usage.InodesFree { + return errors.New("insufficient backup directory inode") + } + + return nil +} diff --git a/pkg/storage/s3.go b/pkg/storage/s3.go new file mode 100644 index 00000000..bf66d486 --- /dev/null +++ b/pkg/storage/s3.go @@ -0,0 +1,240 @@ +package storage + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// S3AddressingStyle S3 地址模式 +type S3AddressingStyle string + +const ( + // S3AddressingStylePath Path 模式:https://s3.region.amazonaws.com/bucket/key + S3AddressingStylePath S3AddressingStyle = "path" + // S3AddressingStyleVirtualHosted Virtual Hosted 模式:https://bucket.s3.region.amazonaws.com/key + S3AddressingStyleVirtualHosted S3AddressingStyle = "virtual-hosted" +) + +type S3Config struct { + Region string // AWS 区域 + Bucket string // S3 存储桶名称 + AccessKeyID string // 访问密钥 ID + SecretAccessKey string // 访问密钥 + Endpoint string // 自定义端点 + BasePath string // 基础路径前缀 + AddressingStyle S3AddressingStyle // 地址模式 +} + +type S3 struct { + client *s3.Client + config S3Config +} + +func NewS3(cfg S3Config) (Storage, error) { + if cfg.AddressingStyle == "" { + cfg.AddressingStyle = S3AddressingStyleVirtualHosted + } + + cfg.BasePath = strings.Trim(cfg.BasePath, "/") + + var awsCfg aws.Config + var err error + + awsCfg, err = config.LoadDefaultConfig(context.TODO(), + config.WithRegion(cfg.Region), + config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), + ), + config.WithRequestChecksumCalculation(aws.RequestChecksumCalculationWhenRequired), + config.WithResponseChecksumValidation(aws.ResponseChecksumValidationWhenRequired), + config.WithRetryMaxAttempts(10), + ) + + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + var client *s3.Client + if cfg.Endpoint != "" { + // 自定义端点 + client = s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = cfg.AddressingStyle == S3AddressingStylePath + o.BaseEndpoint = aws.String(cfg.Endpoint) + }) + } else { + // 标准 AWS S3 + client = s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = cfg.AddressingStyle == S3AddressingStylePath + }) + } + + return &S3{ + client: client, + config: cfg, + }, nil +} + +// Delete 删除文件 +func (s *S3) Delete(files ...string) error { + if len(files) == 0 { + return nil + } + + // 批量删除 + var objects []types.ObjectIdentifier + for _, file := range files { + key := s.getKey(file) + objects = append(objects, types.ObjectIdentifier{ + Key: aws.String(key), + }) + } + + _, err := s.client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(s.config.Bucket), + Delete: &types.Delete{ + Objects: objects, + }, + }) + + waiter := s3.NewObjectNotExistsWaiter(s.client) + for _, file := range files { + key := s.getKey(file) + err = waiter.Wait(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }, 30*time.Second) + if err != nil { + return err + } + } + + return err +} + +// Exists 检查文件是否存在 +func (s *S3) Exists(file string) bool { + key := s.getKey(file) + _, err := s.client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + return err == nil +} + +// LastModified 获取文件最后修改时间 +func (s *S3) LastModified(file string) (time.Time, error) { + key := s.getKey(file) + output, err := s.client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + if err != nil { + return time.Time{}, err + } + + if output.LastModified != nil { + return *output.LastModified, nil + } + return time.Time{}, nil +} + +// List 列出目录下的所有文件 +func (s *S3) List(path string) ([]string, error) { + prefix := s.getKey(path) + if prefix != "" && !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + var files []string + paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{ + Bucket: aws.String(s.config.Bucket), + Prefix: aws.String(prefix), + Delimiter: aws.String("/"), + }) + + for paginator.HasMorePages() { + page, err := paginator.NextPage(context.TODO()) + if err != nil { + return nil, err + } + for _, obj := range page.Contents { + key := aws.ToString(obj.Key) + // 跳过目录本身 + if key == prefix { + continue + } + // 提取文件名 + name := strings.TrimPrefix(key, prefix) + if name != "" && !strings.Contains(name, "/") { + files = append(files, name) + } + } + } + + return files, nil +} + +// Put 写入文件内容 +func (s *S3) Put(file string, content io.Reader) error { + key := s.getKey(file) + + // For S3-compatible providers, disable automatic checksum calculation on the Uploader. + // The S3 client's RequestChecksumCalculation setting only affects single-part uploads. + // Multipart uploads via the Uploader require this separate setting (added in s3/manager v1.20.0). + // See: https://github.com/aws/aws-sdk-go-v2/issues/3007 + uploader := manager.NewUploader(s.client, func(u *manager.Uploader) { + u.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired + }) + _, err := uploader.Upload(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + Body: content, + }) + if err != nil { + return err + } + + waiter := s3.NewObjectExistsWaiter(s.client) + err = waiter.Wait(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }, 30*time.Second) + + return err +} + +// Size 获取文件大小 +func (s *S3) Size(file string) (int64, error) { + key := s.getKey(file) + output, err := s.client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(s.config.Bucket), + Key: aws.String(key), + }) + if err != nil { + return 0, err + } + + return aws.ToInt64(output.ContentLength), nil +} + +// getKey 获取完整的对象键 +func (s *S3) getKey(file string) string { + file = strings.TrimPrefix(file, "/") + if s.config.BasePath == "" { + return file + } + if file == "" { + return s.config.BasePath + } + return fmt.Sprintf("%s/%s", s.config.BasePath, file) +} diff --git a/pkg/storage/sftp.go b/pkg/storage/sftp.go new file mode 100644 index 00000000..75900fc4 --- /dev/null +++ b/pkg/storage/sftp.go @@ -0,0 +1,215 @@ +package storage + +import ( + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type SFTPConfig struct { + Host string // SFTP 服务器地址 + Port int // SFTP 端口,默认 22 + Username string // 用户名 + Password string // 密码 + PrivateKey string // SSH 私钥 + BasePath string // 基础路径 + Timeout time.Duration // 连接超时时间 +} + +type SFTP struct { + config SFTPConfig +} + +func NewSFTP(config SFTPConfig) (Storage, error) { + if config.Port == 0 { + config.Port = 22 + } + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + config.BasePath = strings.Trim(config.BasePath, "/") + + if config.Username == "" || (config.Password == "" && config.PrivateKey == "") { + return nil, fmt.Errorf("username and either password or private key must be provided") + } + + return &SFTP{config: config}, nil +} + +// connect 建立 SFTP 连接,返回 client 和 cleanup 函数 +func (s *SFTP) connect() (*sftp.Client, func(), error) { + var auth []ssh.AuthMethod + // 密码认证 + if s.config.Password != "" { + auth = append(auth, ssh.Password(s.config.Password)) + } + // 私钥认证 + if s.config.PrivateKey != "" { + signer, err := ssh.ParsePrivateKey([]byte(s.config.PrivateKey)) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse private key: %w", err) + } + auth = append(auth, ssh.PublicKeys(signer)) + } + + clientConfig := &ssh.ClientConfig{ + User: s.config.Username, + Auth: auth, + Timeout: s.config.Timeout, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) + sshClient, err := ssh.Dial("tcp", addr, clientConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to connect to SSH server: %w", err) + } + + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + _ = sshClient.Close() + return nil, nil, fmt.Errorf("failed to create SFTP client: %w", err) + } + + cleanup := func() { + _ = sftpClient.Close() + _ = sshClient.Close() + } + + return sftpClient, cleanup, nil +} + +// Delete 删除文件 +func (s *SFTP) Delete(files ...string) error { + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + for _, file := range files { + remotePath := s.getRemotePath(file) + if err = client.Remove(remotePath); err != nil { + return err + } + } + return nil +} + +// Exists 检查文件是否存在 +func (s *SFTP) Exists(file string) bool { + client, cleanup, err := s.connect() + if err != nil { + return false + } + defer cleanup() + + remotePath := s.getRemotePath(file) + _, err = client.Stat(remotePath) + return err == nil +} + +// LastModified 获取文件最后修改时间 +func (s *SFTP) LastModified(file string) (time.Time, error) { + client, cleanup, err := s.connect() + if err != nil { + return time.Time{}, err + } + defer cleanup() + + remotePath := s.getRemotePath(file) + stat, err := client.Stat(remotePath) + if err != nil { + return time.Time{}, err + } + + return stat.ModTime(), nil +} + +// List 列出目录下的所有文件 +func (s *SFTP) List(path string) ([]string, error) { + client, cleanup, err := s.connect() + if err != nil { + return nil, err + } + defer cleanup() + + remotePath := s.getRemotePath(path) + entries, err := client.ReadDir(remotePath) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + return files, nil +} + +// Put 写入文件内容 +func (s *SFTP) Put(file string, content io.Reader) error { + client, cleanup, err := s.connect() + if err != nil { + return err + } + defer cleanup() + + remotePath := s.getRemotePath(file) + + // 确保目录存在 + remoteDir := filepath.Dir(remotePath) + if remoteDir != "." { + _ = client.MkdirAll(remoteDir) + } + + // 确保基础路径存在 + if s.config.BasePath != "" { + _ = client.MkdirAll(s.config.BasePath) + } + + remoteFile, err := client.Create(remotePath) + if err != nil { + return err + } + defer func() { _ = remoteFile.Close() }() + + _, err = io.Copy(remoteFile, content) + return err +} + +// Size 获取文件大小 +func (s *SFTP) Size(file string) (int64, error) { + client, cleanup, err := s.connect() + if err != nil { + return 0, err + } + defer cleanup() + + remotePath := s.getRemotePath(file) + stat, err := client.Stat(remotePath) + if err != nil { + return 0, err + } + + return stat.Size(), nil +} + +// getRemotePath 获取远程路径 +func (s *SFTP) getRemotePath(path string) string { + path = strings.TrimPrefix(path, "/") + if s.config.BasePath == "" { + return path + } + if path == "" { + return s.config.BasePath + } + return filepath.Join(s.config.BasePath, path) +} diff --git a/pkg/storage/types.go b/pkg/storage/types.go new file mode 100644 index 00000000..13c15e54 --- /dev/null +++ b/pkg/storage/types.go @@ -0,0 +1,21 @@ +package storage + +import ( + "io" + "time" +) + +type Storage interface { + // Delete deletes the given file(s). + Delete(file ...string) error + // Exists determines if a file exists. + Exists(file string) bool + // LastModified gets the file's last modified time. + LastModified(file string) (time.Time, error) + // List lists all files (not directories) in the given path. + List(path string) ([]string, error) + // Put writes the contents of a file. + Put(file string, content io.Reader) error + // Size gets the file size of a given file. + Size(file string) (int64, error) +} diff --git a/pkg/storage/webdav.go b/pkg/storage/webdav.go new file mode 100644 index 00000000..229f73c0 --- /dev/null +++ b/pkg/storage/webdav.go @@ -0,0 +1,138 @@ +package storage + +import ( + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/studio-b12/gowebdav" +) + +type WebDavConfig struct { + URL string // WebDAV 服务器 URL + Username string // 用户名 + Password string // 密码 + BasePath string // 基础路径 + Timeout time.Duration // 连接超时时间 +} + +type WebDav struct { + client *gowebdav.Client + config WebDavConfig +} + +func NewWebDav(config WebDavConfig) (Storage, error) { + if config.Timeout == 0 { + config.Timeout = 30 * time.Second + } + config.BasePath = strings.Trim(config.BasePath, "/") + + client := gowebdav.NewClient(config.URL, config.Username, config.Password) + client.SetTimeout(config.Timeout) + + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("failed to connect to WebDAV server: %w", err) + } + + w := &WebDav{ + client: client, + config: config, + } + + if w.config.BasePath != "" { + if err := w.client.MkdirAll(w.config.BasePath, 0755); err != nil { + return nil, fmt.Errorf("failed to create base path: %w", err) + } + } + + return w, nil +} + +// Delete 删除文件 +func (w *WebDav) Delete(files ...string) error { + for _, file := range files { + remotePath := w.fullPath(file) + if err := w.client.Remove(remotePath); err != nil { + return err + } + } + return nil +} + +// Exists 检查文件是否存在 +func (w *WebDav) Exists(file string) bool { + remotePath := w.fullPath(file) + _, err := w.client.Stat(remotePath) + return err == nil +} + +// LastModified 获取文件最后修改时间 +func (w *WebDav) LastModified(file string) (time.Time, error) { + remotePath := w.fullPath(file) + stat, err := w.client.Stat(remotePath) + if err != nil { + return time.Time{}, err + } + + return stat.ModTime(), nil +} + +// Put 写入文件内容 +func (w *WebDav) Put(file string, content io.Reader) error { + remotePath := w.fullPath(file) + + // 确保目录存在 + remoteDir := filepath.Dir(remotePath) + if remoteDir != "." { + if err := w.client.MkdirAll(remoteDir, 0755); err != nil { + return err + } + } + + // 调整超时 + w.client.SetTimeout(0) + defer w.client.SetTimeout(w.config.Timeout) + + return w.client.WriteStream(remotePath, content, 0644) +} + +// Size 获取文件大小 +func (w *WebDav) Size(file string) (int64, error) { + remotePath := w.fullPath(file) + stat, err := w.client.Stat(remotePath) + if err != nil { + return 0, err + } + + return stat.Size(), nil +} + +// List 列出目录下的所有文件 +func (w *WebDav) List(path string) ([]string, error) { + remotePath := w.fullPath(path) + entries, err := w.client.ReadDir(remotePath) + if err != nil { + return nil, err + } + + var files []string + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + return files, nil +} + +func (w *WebDav) fullPath(path string) string { + path = strings.TrimPrefix(path, "/") + if w.config.BasePath == "" { + return path + } + if path == "" { + return w.config.BasePath + } + return filepath.Join(w.config.BasePath, path) +} diff --git a/pkg/types/backup.go b/pkg/types/backup.go index bb36eaab..a6b0721f 100644 --- a/pkg/types/backup.go +++ b/pkg/types/backup.go @@ -6,16 +6,18 @@ type BackupAccountInfo struct { // S3 AccessKey string `json:"access_key"` // 访问密钥 SecretKey string `json:"secret_key"` // 私钥 - Style string `json:"style"` // virtual_hosted, path + Style string `json:"style"` // virtual-hosted, path Region string `json:"region"` // 地区 Endpoint string `json:"endpoint"` // 端点 Bucket string `json:"bucket"` // 存储桶 // SFTP / WebDAV - Host string `json:"host"` // 主机 - Port int `json:"port"` // 端口 - User string `json:"user"` // 用户名 - Password string `json:"password"` // 密码 + URL string `json:"url"` // 网址 + Host string `json:"host"` // 主机 + Port int `json:"port"` // 端口 + Username string `json:"username"` // 用户名 + Password string `json:"password"` // 密码 + PrivateKey string `json:"private_key"` // 私钥 Path string `json:"path"` // 路径 }