2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 07:57:21 +08:00

feat: 备份重构1

This commit is contained in:
2026-01-20 02:18:13 +08:00
parent 5c6892c7f5
commit 72ecb6904d
17 changed files with 1148 additions and 254 deletions

25
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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
}

View File

@@ -14,7 +14,7 @@ const (
BackupAccountTypeLocal BackupAccountType = "local"
BackupAccountTypeS3 BackupAccountType = "s3"
BackupAccountTypeSFTP BackupAccountType = "sftp"
BackupAccountTypeWebDAV BackupAccountType = "webdav"
BackupAccountTypeWebDav BackupAccountType = "webdav"
)
type BackupAccount struct {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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_-]+$"`
}

View File

@@ -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))
}
// 非离线模式下任务

View File

@@ -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)"),
},
},
},

View File

@@ -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
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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)
}

View File

@@ -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"` // 路径
}