mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 07:57:21 +08:00
feat: 备份重构1
This commit is contained in:
25
go.mod
25
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
|
||||
)
|
||||
|
||||
|
||||
49
go.sum
49
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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const (
|
||||
BackupAccountTypeLocal BackupAccountType = "local"
|
||||
BackupAccountTypeS3 BackupAccountType = "s3"
|
||||
BackupAccountTypeSFTP BackupAccountType = "sftp"
|
||||
BackupAccountTypeWebDAV BackupAccountType = "webdav"
|
||||
BackupAccountTypeWebDav BackupAccountType = "webdav"
|
||||
)
|
||||
|
||||
type BackupAccount struct {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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_-]+$"`
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 非离线模式下任务
|
||||
|
||||
@@ -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)"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
143
pkg/storage/local.go
Normal file
143
pkg/storage/local.go
Normal file
@@ -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
|
||||
}
|
||||
240
pkg/storage/s3.go
Normal file
240
pkg/storage/s3.go
Normal file
@@ -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)
|
||||
}
|
||||
215
pkg/storage/sftp.go
Normal file
215
pkg/storage/sftp.go
Normal file
@@ -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)
|
||||
}
|
||||
21
pkg/storage/types.go
Normal file
21
pkg/storage/types.go
Normal file
@@ -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)
|
||||
}
|
||||
138
pkg/storage/webdav.go
Normal file
138
pkg/storage/webdav.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"` // 路径
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user