release: prepare v1.2.35
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
- 修复完整备份上传 Google 云端硬盘失败:由小文件上传改为官方可恢复分块上传,支持大型邮箱备份。
|
||||||
|
- 同一份本地加密备份只显示一次文件名,下方分别显示 Telegram 与 Google 云端硬盘的上传百分比、已上传大小和结果。
|
||||||
|
- 手动发送改为后台任务,刷新或离开页面后上传仍会继续,返回备份页可继续查看进度。
|
||||||
|
- Google 授权失效、空间不足、请求限流、Drive API 未启用及网络超时会显示对应中文处理建议。
|
||||||
|
- 定时备份的云端推送失败也会直接显示具体原因,不再只提示查看服务器日志。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.34...v1.2.35](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.34...v1.2.35)
|
||||||
@@ -40,6 +40,7 @@ type App struct {
|
|||||||
telegramDeliveryMu sync.Mutex
|
telegramDeliveryMu sync.Mutex
|
||||||
backupMu sync.Mutex
|
backupMu sync.Mutex
|
||||||
backupJob *backupJob
|
backupJob *backupJob
|
||||||
|
backupTransfers map[string]*backupTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -83,7 +84,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
}
|
}
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}}
|
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker(), telegramURL: "https://api.telegram.org", telegramPairs: map[string]telegramPairing{}, backupTransfers: map[string]*backupTransfer{}}
|
||||||
a.externalIMAP = a
|
a.externalIMAP = a
|
||||||
if err := a.configureSQLite(context.Background()); err != nil {
|
if err := a.configureSQLite(context.Background()); err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/textproto"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -31,6 +30,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const backupTelegramLimit = 49 << 20
|
const backupTelegramLimit = 49 << 20
|
||||||
|
const googleDriveUploadChunkSize = 8 << 20
|
||||||
|
|
||||||
type backupJob struct {
|
type backupJob struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -45,6 +45,17 @@ type backupItem struct {
|
|||||||
SHA256 string `json:"sha256,omitempty"`
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type backupTransfer struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Uploaded int64 `json:"uploaded"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type backupListResponse struct {
|
type backupListResponse struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
TelegramSet bool `json:"telegramSet"`
|
TelegramSet bool `json:"telegramSet"`
|
||||||
@@ -53,6 +64,7 @@ type backupListResponse struct {
|
|||||||
Items []backupItem `json:"items"`
|
Items []backupItem `json:"items"`
|
||||||
Schedule backupSchedule `json:"schedule"`
|
Schedule backupSchedule `json:"schedule"`
|
||||||
GoogleDrive googleDriveStatus `json:"googleDrive"`
|
GoogleDrive googleDriveStatus `json:"googleDrive"`
|
||||||
|
Transfers []backupTransfer `json:"transfers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createBackupRequest struct {
|
type createBackupRequest struct {
|
||||||
@@ -177,7 +189,13 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
|||||||
copy := *job
|
copy := *job
|
||||||
job = ©
|
job = ©
|
||||||
}
|
}
|
||||||
|
transfers := make([]backupTransfer, 0, len(a.backupTransfers))
|
||||||
|
for _, transfer := range a.backupTransfers {
|
||||||
|
copy := *transfer
|
||||||
|
transfers = append(transfers, copy)
|
||||||
|
}
|
||||||
a.backupMu.Unlock()
|
a.backupMu.Unlock()
|
||||||
|
sort.Slice(transfers, func(i, j int) bool { return transfers[i].StartedAt.After(transfers[j].StartedAt) })
|
||||||
schedule, _ := a.loadBackupSchedule(r.Context())
|
schedule, _ := a.loadBackupSchedule(r.Context())
|
||||||
schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname)
|
schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname)
|
||||||
telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule)
|
telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule)
|
||||||
@@ -185,7 +203,7 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
|||||||
Enabled: a.backupAssetsAvailable(),
|
Enabled: a.backupAssetsAvailable(),
|
||||||
TelegramSet: strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination),
|
TelegramSet: strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination),
|
||||||
TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule,
|
TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule,
|
||||||
GoogleDrive: a.loadGoogleDriveStatus(r.Context()),
|
GoogleDrive: a.loadGoogleDriveStatus(r.Context()), Transfers: transfers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,16 +268,32 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
path, err := a.createDisasterBackup(ctx, password)
|
path, err := a.createDisasterBackup(ctx, password)
|
||||||
password = ""
|
password = ""
|
||||||
|
var deliveryMessages []string
|
||||||
if err == nil {
|
if err == nil {
|
||||||
var deliveryErrors []error
|
var deliveryErrors []error
|
||||||
|
if uploadGoogleDrive {
|
||||||
|
a.queueBackupTransfer("googleDrive", path)
|
||||||
|
}
|
||||||
|
if sendTelegram {
|
||||||
|
a.queueBackupTransfer("telegram", path)
|
||||||
|
}
|
||||||
if uploadGoogleDrive {
|
if uploadGoogleDrive {
|
||||||
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
||||||
|
message := googleDriveUploadMessage(driveErr)
|
||||||
|
a.finishBackupTransfer("googleDrive", path, message)
|
||||||
|
deliveryMessages = append(deliveryMessages, message)
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("googleDrive", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sendTelegram {
|
if sendTelegram {
|
||||||
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
||||||
|
a.finishBackupTransfer("telegram", path, telegramErr.Error())
|
||||||
|
deliveryMessages = append(deliveryMessages, "Telegram 发送失败:"+telegramErr.Error())
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("telegram", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err = errors.Join(deliveryErrors...)
|
err = errors.Join(deliveryErrors...)
|
||||||
@@ -267,7 +301,11 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.backupMu.Lock()
|
a.backupMu.Lock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.backupJob.Status = "failed"
|
a.backupJob.Status = "failed"
|
||||||
a.backupJob.Error = "本地备份或所选推送未全部完成,请查看服务日志"
|
if len(deliveryMessages) > 0 {
|
||||||
|
a.backupJob.Error = strings.Join(deliveryMessages, ";")
|
||||||
|
} else {
|
||||||
|
a.backupJob.Error = "本地备份创建失败,请检查服务器存储空间"
|
||||||
|
}
|
||||||
a.log.Error("create disaster backup", "error", err)
|
a.log.Error("create disaster backup", "error", err)
|
||||||
} else {
|
} else {
|
||||||
a.backupJob.Status = "success"
|
a.backupJob.Status = "success"
|
||||||
@@ -810,6 +848,10 @@ func (a *App) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = os.Remove(path + ".sha256")
|
_ = os.Remove(path + ".sha256")
|
||||||
|
a.backupMu.Lock()
|
||||||
|
delete(a.backupTransfers, backupTransferKey("telegram", path))
|
||||||
|
delete(a.backupTransfers, backupTransferKey("googleDrive", path))
|
||||||
|
a.backupMu.Unlock()
|
||||||
respondJSON(w, 200, map[string]any{"ok": true})
|
respondJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,12 +864,21 @@ func (a *App) handleSendBackupTelegram(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, 404, "备份不存在")
|
respondError(w, 404, "备份不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.sendBackupToTelegram(r.Context(), path); err != nil {
|
if !a.startBackupTransfer("telegram", path) {
|
||||||
a.log.Error("send backup telegram", "error", err)
|
respondError(w, http.StatusConflict, "该备份正在发送到 Telegram")
|
||||||
respondError(w, 502, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, 200, map[string]any{"ok": true})
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.sendBackupToTelegram(ctx, path); err != nil {
|
||||||
|
a.finishBackupTransfer("telegram", path, err.Error())
|
||||||
|
a.log.Error("send backup telegram", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.finishBackupTransfer("telegram", path, "")
|
||||||
|
}()
|
||||||
|
respondJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -839,12 +890,89 @@ func (a *App) handleSendBackupGoogleDrive(w http.ResponseWriter, r *http.Request
|
|||||||
respondError(w, 404, "备份不存在")
|
respondError(w, 404, "备份不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadBackupToGoogleDrive(r.Context(), path); err != nil {
|
if !a.startBackupTransfer("googleDrive", path) {
|
||||||
a.log.Error("upload backup to google drive", "error", err)
|
respondError(w, http.StatusConflict, "该备份正在上传到 Google 云端硬盘")
|
||||||
respondError(w, 502, "上传 Google 云端硬盘失败")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, 200, map[string]bool{"ok": true})
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.uploadBackupToGoogleDrive(ctx, path); err != nil {
|
||||||
|
message := googleDriveUploadMessage(err)
|
||||||
|
a.finishBackupTransfer("googleDrive", path, message)
|
||||||
|
a.log.Error("upload backup to google drive", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.finishBackupTransfer("googleDrive", path, "")
|
||||||
|
}()
|
||||||
|
respondJSON(w, http.StatusAccepted, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupTransferKey(provider, path string) string {
|
||||||
|
return provider + ":" + filepath.Base(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) startBackupTransfer(provider, path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
key := backupTransferKey(provider, path)
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[key]; transfer != nil && transfer.Status == "running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureBackupTransfer(provider, path string) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := backupTransferKey(provider, path)
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[key]; transfer == nil {
|
||||||
|
a.backupTransfers[key] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "running", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||||
|
} else if transfer.Status == "queued" {
|
||||||
|
transfer.Status = "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) queueBackupTransfer(provider, path string) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
a.backupTransfers[backupTransferKey(provider, path)] = &backupTransfer{Provider: provider, Name: filepath.Base(path), Status: "queued", Total: info.Size(), StartedAt: a.now().UTC()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateBackupTransfer(provider, path string, uploaded int64) {
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
|
||||||
|
transfer.Uploaded = uploaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) finishBackupTransfer(provider, path, message string) {
|
||||||
|
a.backupMu.Lock()
|
||||||
|
defer a.backupMu.Unlock()
|
||||||
|
if transfer := a.backupTransfers[backupTransferKey(provider, path)]; transfer != nil {
|
||||||
|
transfer.FinishedAt = a.now().UTC()
|
||||||
|
if message == "" {
|
||||||
|
transfer.Status = "success"
|
||||||
|
transfer.Uploaded = transfer.Total
|
||||||
|
} else {
|
||||||
|
transfer.Status = "failed"
|
||||||
|
transfer.Error = message
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) googleDriveClient(ctx context.Context) (*http.Client, error) {
|
func (a *App) googleDriveClient(ctx context.Context) (*http.Client, error) {
|
||||||
@@ -898,7 +1026,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return "", fmt.Errorf("drive folder lookup %s: %s", resp.Status, raw)
|
return "", &googleDriveAPIError{Operation: "folder lookup", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
}
|
}
|
||||||
var list struct {
|
var list struct {
|
||||||
Files []struct {
|
Files []struct {
|
||||||
@@ -921,7 +1049,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return "", fmt.Errorf("drive folder create %s: %s", resp.Status, raw)
|
return "", &googleDriveAPIError{Operation: "folder create", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
}
|
}
|
||||||
var created struct {
|
var created struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -936,6 +1064,7 @@ func (a *App) googleDriveFolderID(ctx context.Context, client *http.Client, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error {
|
func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error {
|
||||||
|
a.ensureBackupTransfer("googleDrive", path)
|
||||||
client, err := a.googleDriveClient(ctx)
|
client, err := a.googleDriveClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -945,7 +1074,7 @@ func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req, err := newGoogleDriveUploadRequest(ctx, path, folderID)
|
req, size, err := newGoogleDriveResumableRequest(ctx, path, folderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -956,53 +1085,107 @@ func (a *App) uploadBackupToGoogleDrive(ctx context.Context, path string) error
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
return fmt.Errorf("drive upload %s: %s", resp.Status, raw)
|
return &googleDriveAPIError{Operation: "start upload", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
}
|
}
|
||||||
return nil
|
location := strings.TrimSpace(resp.Header.Get("Location"))
|
||||||
|
if location == "" {
|
||||||
|
return errors.New("Google 云端硬盘未返回可恢复上传地址")
|
||||||
|
}
|
||||||
|
return a.uploadGoogleDriveChunks(ctx, client, location, path, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGoogleDriveUploadRequest(ctx context.Context, path, folderID string) (*http.Request, error) {
|
type googleDriveAPIError struct {
|
||||||
file, err := os.Open(path)
|
Operation string
|
||||||
if err != nil {
|
StatusCode int
|
||||||
return nil, err
|
Body string
|
||||||
}
|
}
|
||||||
reader, writerSide := io.Pipe()
|
|
||||||
mw := multipart.NewWriter(writerSide)
|
func (e *googleDriveAPIError) Error() string {
|
||||||
go func() {
|
return fmt.Sprintf("google drive %s returned %d: %s", e.Operation, e.StatusCode, e.Body)
|
||||||
var writeErr error
|
}
|
||||||
defer func() { _ = file.Close(); _ = mw.Close(); _ = writerSide.CloseWithError(writeErr) }()
|
|
||||||
head := textproto.MIMEHeader{}
|
func newGoogleDriveResumableRequest(ctx context.Context, path, folderID string) (*http.Request, int64, error) {
|
||||||
head.Set("Content-Type", "application/json; charset=UTF-8")
|
info, err := os.Stat(path)
|
||||||
part, err := mw.CreatePart(head)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr = err
|
return nil, 0, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}})
|
metadata, _ := json.Marshal(map[string]any{"name": filepath.Base(path), "parents": []string{folderID}})
|
||||||
if _, err = part.Write(metadata); err != nil {
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name", strings.NewReader(string(metadata)))
|
||||||
writeErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
head = textproto.MIMEHeader{}
|
|
||||||
head.Set("Content-Type", "application/octet-stream")
|
|
||||||
part, err = mw.CreatePart(head)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr = err
|
return nil, 0, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
_, writeErr = io.Copy(part, file)
|
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||||
}()
|
req.Header.Set("X-Upload-Content-Type", "application/octet-stream")
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name", reader)
|
req.Header.Set("X-Upload-Content-Length", fmt.Sprint(info.Size()))
|
||||||
|
return req, info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) uploadGoogleDriveChunks(ctx context.Context, client *http.Client, location, path string, size int64) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = file.Close()
|
return err
|
||||||
_ = writerSide.CloseWithError(err)
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "multipart/related; boundary="+mw.Boundary())
|
defer file.Close()
|
||||||
return req, nil
|
for offset := int64(0); offset < size; {
|
||||||
|
length := int64(googleDriveUploadChunkSize)
|
||||||
|
if remaining := size - offset; remaining < length {
|
||||||
|
length = remaining
|
||||||
|
}
|
||||||
|
end := offset + length - 1
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, location, io.NewSectionReader(file, offset, length))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.ContentLength = length
|
||||||
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
|
req.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, end, size))
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusPermanentRedirect {
|
||||||
|
offset += length
|
||||||
|
a.updateBackupTransfer("googleDrive", path, offset)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 && end+1 == size {
|
||||||
|
a.updateBackupTransfer("googleDrive", path, size)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &googleDriveAPIError{Operation: "upload chunk", StatusCode: resp.StatusCode, Body: string(raw)}
|
||||||
|
}
|
||||||
|
return errors.New("Google 云端硬盘不能上传空备份文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
func googleDriveUploadMessage(err error) string {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return "Google 云端硬盘上传超时,请检查服务器网络后重试"
|
||||||
|
}
|
||||||
|
var apiErr *googleDriveAPIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
body := strings.ToLower(apiErr.Body)
|
||||||
|
switch {
|
||||||
|
case apiErr.StatusCode == http.StatusUnauthorized || strings.Contains(body, "invalid_grant"):
|
||||||
|
return "Google 授权已失效,请打开配置,断开后重新连接"
|
||||||
|
case strings.Contains(body, "storagequota") || strings.Contains(body, "storage quota"):
|
||||||
|
return "Google 云端硬盘空间不足,请清理空间后重试"
|
||||||
|
case apiErr.StatusCode == http.StatusTooManyRequests || strings.Contains(body, "ratelimit"):
|
||||||
|
return "Google 云端硬盘请求过于频繁,请稍后重试"
|
||||||
|
case apiErr.StatusCode == http.StatusForbidden:
|
||||||
|
return "Google 云端硬盘无上传权限,请确认 Drive API 已启用并重新连接"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message := strings.ToLower(err.Error())
|
||||||
|
if strings.Contains(message, "oauth2") || strings.Contains(message, "token") {
|
||||||
|
return "Google 授权已失效,请打开配置,断开后重新连接"
|
||||||
|
}
|
||||||
|
return "上传 Google 云端硬盘失败,请检查服务器网络或重新连接 Google 账号"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) sendBackupToTelegram(ctx context.Context, path string) error {
|
func (a *App) sendBackupToTelegram(ctx context.Context, path string) error {
|
||||||
|
a.ensureBackupTransfer("telegram", path)
|
||||||
schedule, _ := a.loadBackupSchedule(ctx)
|
schedule, _ := a.loadBackupSchedule(ctx)
|
||||||
token, chatID, err := a.backupTelegramCredentials(ctx, schedule)
|
token, chatID, err := a.backupTelegramCredentials(ctx, schedule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1137,7 +1320,7 @@ func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
_, writeErr = io.Copy(part, file)
|
_, writeErr = io.Copy(part, &backupProgressReader{reader: file, onProgress: func(uploaded int64) { a.updateBackupTransfer("telegram", path, uploaded) }})
|
||||||
}()
|
}()
|
||||||
endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument"
|
endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader)
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader)
|
||||||
@@ -1157,6 +1340,21 @@ func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type backupProgressReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
uploaded int64
|
||||||
|
onProgress func(int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *backupProgressReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
r.uploaded += int64(n)
|
||||||
|
r.onProgress(r.uploaded)
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) listBackups() ([]backupItem, error) {
|
func (a *App) listBackups() ([]backupItem, error) {
|
||||||
dir := a.config().BackupDir
|
dir := a.config().BackupDir
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
@@ -1302,18 +1500,34 @@ func (a *App) runScheduledBackup(ctx context.Context) {
|
|||||||
path, runErr := a.createDisasterBackup(ctx, password)
|
path, runErr := a.createDisasterBackup(ctx, password)
|
||||||
password = ""
|
password = ""
|
||||||
localBackupSucceeded := runErr == nil
|
localBackupSucceeded := runErr == nil
|
||||||
|
var deliveryMessages []string
|
||||||
if localBackupSucceeded {
|
if localBackupSucceeded {
|
||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
_, _ = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, now, now)
|
_, _ = a.db.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES('backupScheduleLastRun',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, now, now)
|
||||||
var deliveryErrors []error
|
var deliveryErrors []error
|
||||||
|
if schedule.GoogleDriveEnabled {
|
||||||
|
a.queueBackupTransfer("googleDrive", path)
|
||||||
|
}
|
||||||
|
if schedule.TelegramEnabled {
|
||||||
|
a.queueBackupTransfer("telegram", path)
|
||||||
|
}
|
||||||
if schedule.GoogleDriveEnabled {
|
if schedule.GoogleDriveEnabled {
|
||||||
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
|
||||||
|
message := googleDriveUploadMessage(driveErr)
|
||||||
|
a.finishBackupTransfer("googleDrive", path, message)
|
||||||
|
deliveryMessages = append(deliveryMessages, message)
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("googleDrive", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if schedule.TelegramEnabled {
|
if schedule.TelegramEnabled {
|
||||||
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
|
||||||
|
a.finishBackupTransfer("telegram", path, telegramErr.Error())
|
||||||
|
deliveryMessages = append(deliveryMessages, "Telegram 发送失败:"+telegramErr.Error())
|
||||||
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
|
||||||
|
} else {
|
||||||
|
a.finishBackupTransfer("telegram", path, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runErr = errors.Join(deliveryErrors...)
|
runErr = errors.Join(deliveryErrors...)
|
||||||
@@ -1322,7 +1536,11 @@ func (a *App) runScheduledBackup(ctx context.Context) {
|
|||||||
publicError := ""
|
publicError := ""
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
status = "failed"
|
status = "failed"
|
||||||
publicError = "定时备份或云端推送失败,请查看服务日志"
|
if len(deliveryMessages) > 0 {
|
||||||
|
publicError = strings.Join(deliveryMessages, ";")
|
||||||
|
} else {
|
||||||
|
publicError = "定时备份创建失败,请检查服务器存储空间"
|
||||||
|
}
|
||||||
a.log.Error("scheduled backup", "error", runErr)
|
a.log.Error("scheduled backup", "error", runErr)
|
||||||
}
|
}
|
||||||
a.backupMu.Lock()
|
a.backupMu.Lock()
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
|
||||||
"mime/multipart"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -58,42 +57,109 @@ func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGoogleDriveUploadRequestUsesMultipartRelated(t *testing.T) {
|
func TestGoogleDriveResumableRequest(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
|
||||||
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
req, err := newGoogleDriveUploadRequest(context.Background(), path, "folder-123")
|
req, size, err := newGoogleDriveResumableRequest(context.Background(), path, "folder-123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mediaType, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
|
if size != int64(len("encrypted backup")) {
|
||||||
if err != nil || mediaType != "multipart/related" || params["boundary"] == "" {
|
t.Fatalf("upload size = %d", size)
|
||||||
t.Fatalf("content type = %q, %v", req.Header.Get("Content-Type"), err)
|
|
||||||
}
|
}
|
||||||
reader := multipart.NewReader(req.Body, params["boundary"])
|
if req.URL.Query().Get("uploadType") != "resumable" || req.Header.Get("X-Upload-Content-Length") != fmt.Sprint(size) {
|
||||||
metadataPart, err := reader.NextPart()
|
t.Fatalf("resumable request = %s headers=%v", req.URL, req.Header)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
var metadata struct {
|
var metadata struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Parents []string `json:"parents"`
|
Parents []string `json:"parents"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(metadataPart).Decode(&metadata); err != nil {
|
if err := json.NewDecoder(req.Body).Decode(&metadata); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
|
||||||
t.Fatalf("metadata = %+v", metadata)
|
t.Fatalf("metadata = %+v", metadata)
|
||||||
}
|
}
|
||||||
filePart, err := reader.NextPart()
|
}
|
||||||
|
|
||||||
|
func TestBackupProgressReaderReportsBytes(t *testing.T) {
|
||||||
|
var updates []int64
|
||||||
|
reader := &backupProgressReader{reader: strings.NewReader("encrypted backup"), onProgress: func(uploaded int64) {
|
||||||
|
updates = append(updates, uploaded)
|
||||||
|
}}
|
||||||
|
raw, err := io.ReadAll(reader)
|
||||||
|
if err != nil || string(raw) != "encrypted backup" {
|
||||||
|
t.Fatalf("read = %q, %v", raw, err)
|
||||||
|
}
|
||||||
|
if len(updates) == 0 || updates[len(updates)-1] != int64(len(raw)) {
|
||||||
|
t.Fatalf("progress updates = %v", updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleDriveUploadMessage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{http.StatusUnauthorized, `{}`, "授权已失效"},
|
||||||
|
{http.StatusForbidden, `{"reason":"storageQuotaExceeded"}`, "空间不足"},
|
||||||
|
{http.StatusForbidden, `{}`, "无上传权限"},
|
||||||
|
{http.StatusTooManyRequests, `{}`, "请求过于频繁"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
message := googleDriveUploadMessage(&googleDriveAPIError{Operation: "upload", StatusCode: test.status, Body: test.body})
|
||||||
|
if !strings.Contains(message, test.want) {
|
||||||
|
t.Fatalf("message %q does not contain %q", message, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleDriveChunkUploadAndProgress(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "large-backup.tar.zst.enc")
|
||||||
|
size := int64(googleDriveUploadChunkSize + 3)
|
||||||
|
file, err := os.Create(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
raw, err := io.ReadAll(filePart)
|
if err := file.Truncate(size); err != nil {
|
||||||
if err != nil || string(raw) != "encrypted backup" {
|
t.Fatal(err)
|
||||||
t.Fatalf("uploaded bytes = %q, %v", raw, err)
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
var ranges []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ranges = append(ranges, r.Header.Get("Content-Range"))
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
if len(ranges) == 1 {
|
||||||
|
w.WriteHeader(http.StatusPermanentRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, `{"id":"uploaded"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
a := newTestApp(t)
|
||||||
|
stopTestWorkers(a)
|
||||||
|
if !a.startBackupTransfer("googleDrive", path) {
|
||||||
|
t.Fatal("failed to start transfer")
|
||||||
|
}
|
||||||
|
if err := a.uploadGoogleDriveChunks(context.Background(), server.Client(), server.URL, path, size); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantRanges := []string{
|
||||||
|
fmt.Sprintf("bytes 0-%d/%d", googleDriveUploadChunkSize-1, size),
|
||||||
|
fmt.Sprintf("bytes %d-%d/%d", googleDriveUploadChunkSize, size-1, size),
|
||||||
|
}
|
||||||
|
if len(ranges) != len(wantRanges) || ranges[0] != wantRanges[0] || ranges[1] != wantRanges[1] {
|
||||||
|
t.Fatalf("content ranges = %v, want %v", ranges, wantRanges)
|
||||||
|
}
|
||||||
|
transfer := a.backupTransfers[backupTransferKey("googleDrive", path)]
|
||||||
|
if transfer == nil || transfer.Uploaded != size {
|
||||||
|
t.Fatalf("transfer = %+v", transfer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,9 +204,10 @@ export type SystemUpdateResult = {
|
|||||||
}
|
}
|
||||||
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
|
export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string }
|
||||||
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
|
export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string }
|
||||||
|
export type BackupTransfer = { provider: "telegram" | "googleDrive"; name: string; status: "queued" | "running" | "success" | "failed"; uploaded: number; total: number; startedAt: string; finishedAt?: string; error?: string }
|
||||||
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; passwordHint?: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
|
export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; passwordHint?: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean }
|
||||||
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
|
export type GoogleDriveBackupStatus = { clientId: string; clientSecretSet: boolean; connected: boolean; folderName: string }
|
||||||
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus }
|
export type BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus; transfers: BackupTransfer[] }
|
||||||
export type SystemSettings = {
|
export type SystemSettings = {
|
||||||
publicHostname: string
|
publicHostname: string
|
||||||
publicBaseUrl: string
|
publicBaseUrl: string
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { SystemVersionDialog } from "@/components/system-version-dialog"
|
|||||||
import { useMe } from "@/hooks/use-me"
|
import { useMe } from "@/hooks/use-me"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||||
import type { PermissionKey, TelegramPairing } from "@/lib/api-types"
|
import type { BackupTransfer, PermissionKey, TelegramPairing } from "@/lib/api-types"
|
||||||
|
|
||||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
|
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "backups" | "settings"
|
||||||
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
type SettingsTab = "base" | "smtp" | "storage" | "mail" | "notifications" | "externalImap" | "templates" | "security" | "about"
|
||||||
@@ -271,7 +271,7 @@ function BackupsSection() {
|
|||||||
const backups = useQuery({
|
const backups = useQuery({
|
||||||
queryKey: ["admin", "backups"],
|
queryKey: ["admin", "backups"],
|
||||||
queryFn: api.backups,
|
queryFn: api.backups,
|
||||||
refetchInterval: (query) => query.state.data?.job?.status === "running" ? 2000 : false,
|
refetchInterval: (query) => query.state.data?.job?.status === "running" || query.state.data?.transfers?.some((item) => item.status === "running" || item.status === "queued") ? 2000 : false,
|
||||||
})
|
})
|
||||||
const [createOpen, setCreateOpen] = React.useState(false)
|
const [createOpen, setCreateOpen] = React.useState(false)
|
||||||
const [password, setPassword] = React.useState("")
|
const [password, setPassword] = React.useState("")
|
||||||
@@ -346,7 +346,7 @@ function BackupsSection() {
|
|||||||
})
|
})
|
||||||
const sendTelegram = useMutation({
|
const sendTelegram = useMutation({
|
||||||
mutationFn: api.sendBackupTelegram,
|
mutationFn: api.sendBackupTelegram,
|
||||||
onSuccess: () => toast({ title: "已发送到 Telegram" }),
|
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始发送到 Telegram" }) },
|
||||||
onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "发送失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
const testBackupTelegram = useMutation({
|
const testBackupTelegram = useMutation({
|
||||||
@@ -370,7 +370,7 @@ function BackupsSection() {
|
|||||||
})
|
})
|
||||||
const sendDrive = useMutation({
|
const sendDrive = useMutation({
|
||||||
mutationFn: api.sendBackupGoogleDrive,
|
mutationFn: api.sendBackupGoogleDrive,
|
||||||
onSuccess: () => toast({ title: "已上传到 Google 云端硬盘" }),
|
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "已开始上传到 Google 云端硬盘" }) },
|
||||||
onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
onError: (error) => toast({ title: "上传失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||||
})
|
})
|
||||||
const connectDrive = useMutation({
|
const connectDrive = useMutation({
|
||||||
@@ -445,6 +445,10 @@ function BackupsSection() {
|
|||||||
if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return }
|
if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return }
|
||||||
savePassword.mutate()
|
savePassword.mutate()
|
||||||
}
|
}
|
||||||
|
const transferGroups = Object.values((backups.data?.transfers || []).reduce<Record<string, BackupTransfer[]>>((groups, transfer) => {
|
||||||
|
;(groups[transfer.name] ||= []).push(transfer)
|
||||||
|
return groups
|
||||||
|
}, {})).sort((left, right) => Date.parse(right[0]?.startedAt || "") - Date.parse(left[0]?.startedAt || ""))
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]">
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,.65fr)]">
|
||||||
@@ -461,6 +465,18 @@ function BackupsSection() {
|
|||||||
{job?.status === "failed" && <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{job.error || "备份生成失败"}</div>}
|
{job?.status === "failed" && <div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">{job.error || "备份生成失败"}</div>}
|
||||||
{job?.status === "success" && <div className="rounded-md border border-green-300 bg-green-50 px-3 py-2 text-sm text-green-800">最近一次备份已完成。</div>}
|
{job?.status === "success" && <div className="rounded-md border border-green-300 bg-green-50 px-3 py-2 text-sm text-green-800">最近一次备份已完成。</div>}
|
||||||
{!job && <p className="text-sm text-muted-foreground">手动备份与定时备份共用同一个恢复密码,避免不同备份使用不同密码。</p>}
|
{!job && <p className="text-sm text-muted-foreground">手动备份与定时备份共用同一个恢复密码,避免不同备份使用不同密码。</p>}
|
||||||
|
{transferGroups.slice(0, 3).map((transfers) => <div key={transfers[0].name} className="space-y-3 rounded-md border px-3 py-3">
|
||||||
|
<div className="truncate text-sm font-medium" title={transfers[0].name}>{transfers[0].name}</div>
|
||||||
|
{transfers.sort((a, b) => (a.provider === "telegram" ? 0 : 1) - (b.provider === "telegram" ? 0 : 1)).map((transfer) => {
|
||||||
|
const percent = transfer.total > 0 ? Math.min(100, Math.round(transfer.uploaded * 100 / transfer.total)) : 0
|
||||||
|
const label = transfer.provider === "telegram" ? "Telegram" : "Google 云端硬盘"
|
||||||
|
return <div key={transfer.provider} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs"><span>{label}</span><span className={cn("shrink-0", transfer.status === "failed" ? "text-destructive" : "text-muted-foreground")}>{transfer.status === "queued" ? "等待上传" : transfer.status === "running" ? `${percent}% · ${formatBytes(transfer.uploaded)} / ${formatBytes(transfer.total)}` : transfer.status === "success" ? "上传完成" : "上传失败"}</span></div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted"><div className={cn("h-full transition-[width]", transfer.status === "failed" ? "bg-destructive" : transfer.status === "success" ? "bg-green-600" : "bg-primary")} style={{ width: `${transfer.status === "success" ? 100 : percent}%` }} /></div>
|
||||||
|
{transfer.error && <p className="text-xs text-destructive">{transfer.error}</p>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>)}
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<div className="mb-2 flex items-center justify-between"><span className="text-sm font-medium">本地备份</span><span className="text-xs text-muted-foreground">保留最近 10 份</span></div>
|
<div className="mb-2 flex items-center justify-between"><span className="text-sm font-medium">本地备份</span><span className="text-xs text-muted-foreground">保留最近 10 份</span></div>
|
||||||
<div className="divide-y rounded-md border">
|
<div className="divide-y rounded-md border">
|
||||||
|
|||||||
Reference in New Issue
Block a user