diff --git a/.github/release-notes/v1.2.34.md b/.github/release-notes/v1.2.34.md new file mode 100644 index 0000000..e7e7c4b --- /dev/null +++ b/.github/release-notes/v1.2.34.md @@ -0,0 +1,9 @@ +- 手动备份与定时备份统一使用同一个恢复密码,避免每次创建备份时再次输入不同密码造成混淆。 +- 已保存备份密码时,点击“创建备份”不再显示第二套密码输入框,直接使用系统安全保存的密码。 +- 首次创建备份且尚未设置密码时,仍要求输入并二次确认;首次密码会保存为后续手动与定时备份的统一恢复密码。 +- 定时备份页面精简为“恢复密码”摘要,仅显示首尾字符掩码,例如 `A••••••••9`;设置或更换密码时使用独立弹窗,不再挤占主页面。 +- 密码更新使用独立接口,不会连带修改尚未保存的备份周期、Telegram 或 Google 云端硬盘设置。 +- 页面只接收密码首尾掩码,不会返回完整恢复密码;更换密码时仍必须重新输入并确认。 +- 增加统一密码、密码掩码、已保存密码手动备份及首次并发创建的后端保护与回归测试。 + +**完整更新日志**:[v1.2.33...v1.2.34](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.33...v1.2.34) diff --git a/VERSION b/VERSION index 47f5bfd..e8c4ceb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.33 +1.2.34 diff --git a/apps/api/internal/app/backup_handlers.go b/apps/api/internal/app/backup_handlers.go index 5a47244..c7f73e1 100644 --- a/apps/api/internal/app/backup_handlers.go +++ b/apps/api/internal/app/backup_handlers.go @@ -66,6 +66,7 @@ type backupSchedule struct { Enabled bool `json:"enabled"` Days int `json:"days"` PasswordSet bool `json:"passwordSet"` + PasswordHint string `json:"passwordHint,omitempty"` ServerIP string `json:"serverIp"` ChatID string `json:"chatId"` TelegramMode string `json:"telegramMode"` @@ -124,6 +125,11 @@ type updateBackupScheduleRequest struct { GoogleFolderName string `json:"googleFolderName"` } +type updateBackupPasswordRequest struct { + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` +} + type testBackupTelegramRequest struct { Mode string `json:"mode"` ChatID string `json:"chatId"` @@ -192,27 +198,53 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) { badRequest(w, err) return } - if !validBackupPassword(req.Password) { - badRequest(w, errors.New("备份密码至少需要 8 个字符")) + a.backupMu.Lock() + locked := true + defer func() { + if locked { + a.backupMu.Unlock() + } + }() + if a.backupJob != nil && a.backupJob.Status == "running" { + respondError(w, http.StatusConflict, "已有备份任务正在运行") return } - if req.Password != req.ConfirmPassword { - badRequest(w, errors.New("两次输入的备份密码不一致")) + password, err := a.savedBackupPassword(r.Context()) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusInternalServerError, "无法读取已保存的备份密码") return } + if password == "" { + if !validBackupPassword(req.Password) { + badRequest(w, errors.New("首次创建备份时,密码至少需要 8 个字符")) + return + } + if req.Password != req.ConfirmPassword { + badRequest(w, errors.New("两次输入的备份密码不一致")) + return + } + } if !a.backupAssetsAvailable() { respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份") return } - a.backupMu.Lock() - if a.backupJob != nil && a.backupJob.Status == "running" { - a.backupMu.Unlock() - respondError(w, http.StatusConflict, "已有备份任务正在运行") - return + if password == "" { + ciphertext, encryptErr := a.encryptBackupPassword(req.Password) + if encryptErr != nil { + respondError(w, http.StatusInternalServerError, "无法安全保存备份密码") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil { + respondError(w, http.StatusInternalServerError, "无法保存备份密码") + return + } + password = req.Password } a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()} a.backupMu.Unlock() - password, sendTelegram, uploadGoogleDrive := req.Password, req.SendTelegram, req.UploadGoogleDrive + locked = false + sendTelegram, uploadGoogleDrive := req.SendTelegram, req.UploadGoogleDrive go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) defer cancel() @@ -351,13 +383,66 @@ func (a *App) handleUpdateBackupSettings(w http.ResponseWriter, r *http.Request) respondError(w, 500, "保存失败") return } - respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", ServerIP: detectPublicServerIP(r.Context(), a.config().PublicHostname), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled}) + passwordHint := "" + if password, err := a.decryptBackupPassword(ciphertext); err == nil { + passwordHint = backupPasswordHint(password) + } + respondJSON(w, 200, backupSchedule{Enabled: req.Enabled, Days: req.Days, PasswordSet: ciphertext != "", PasswordHint: passwordHint, ServerIP: detectPublicServerIP(r.Context(), a.config().PublicHostname), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled}) +} + +func (a *App) handleUpdateBackupPassword(w http.ResponseWriter, r *http.Request) { + if !a.requireSystemAdmin(w, r) { + return + } + var req updateBackupPasswordRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if !validBackupPassword(req.Password) { + badRequest(w, errors.New("备份密码至少需要 8 个字符")) + return + } + if req.Password != req.ConfirmPassword { + badRequest(w, errors.New("两次输入的备份密码不一致")) + return + } + ciphertext, err := a.encryptBackupPassword(req.Password) + if err != nil { + respondError(w, http.StatusInternalServerError, "无法安全保存备份密码") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err = a.db.ExecContext(r.Context(), `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at`, ciphertext, now); err != nil { + respondError(w, http.StatusInternalServerError, "无法保存备份密码") + return + } + respondJSON(w, http.StatusOK, map[string]any{"passwordSet": true, "passwordHint": backupPasswordHint(req.Password)}) } func validBackupPassword(password string) bool { return len(password) >= 8 && len(password) <= 1024 && !strings.ContainsAny(password, "\r\n\x00") } +func backupPasswordHint(password string) string { + runes := []rune(password) + if len(runes) < 2 { + return "" + } + return string(runes[0]) + strings.Repeat("•", minimumInt(len(runes)-2, 10)) + string(runes[len(runes)-1]) +} + +func (a *App) savedBackupPassword(ctx context.Context) (string, error) { + var ciphertext string + if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext); err != nil { + return "", err + } + if strings.TrimSpace(ciphertext) == "" { + return "", sql.ErrNoRows + } + return a.decryptBackupPassword(ciphertext) +} + func (a *App) handleTestBackupTelegram(w http.ResponseWriter, r *http.Request) { if !a.requireSystemAdmin(w, r) { return @@ -653,8 +738,8 @@ func (a *App) backupAssetsAvailable() bool { func writeRuntimeBackupEnv(path string) error { values := make([]string, 0) containerOnly := map[string]bool{ - "LANQIN_BACKUP_DIR": true, - "LANQIN_BACKUP_SOURCE_DIR": true, + "LANQIN_BACKUP_DIR": true, + "LANQIN_BACKUP_SOURCE_DIR": true, "LANQIN_UPDATE_SERVICE_TOKEN": true, "LANQIN_UPDATE_SERVICE_URL": true, } @@ -1160,6 +1245,11 @@ func (a *App) loadBackupSchedule(ctx context.Context) (backupSchedule, error) { } case "backupPasswordCipher": result.PasswordSet = value != "" + if value != "" { + if password, err := a.decryptBackupPassword(value); err == nil { + result.PasswordHint = backupPasswordHint(password) + } + } case "backupTelegramEnabled": result.TelegramEnabled = value == "true" case "backupGoogleDriveEnabled": diff --git a/apps/api/internal/app/backup_handlers_test.go b/apps/api/internal/app/backup_handlers_test.go index 4a9c7bc..756c0ca 100644 --- a/apps/api/internal/app/backup_handlers_test.go +++ b/apps/api/internal/app/backup_handlers_test.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) { @@ -120,6 +121,137 @@ func TestBackupPasswordValidation(t *testing.T) { } } +func TestBackupPasswordHint(t *testing.T) { + if got := backupPasswordHint("A23456789Z"); got != "A••••••••Z" { + t.Fatalf("password hint = %q", got) + } + if got := backupPasswordHint("ab"); got != "ab" { + t.Fatalf("two-character password hint = %q", got) + } + if got := backupPasswordHint(""); got != "" { + t.Fatalf("empty password hint = %q", got) + } +} + +func TestSavedBackupPasswordAndHint(t *testing.T) { + dir := t.TempDir() + a := newTestAppWithConfig(t, Config{ + Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", + AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", + }) + stopTestWorkers(a) + ciphertext, err := a.encryptBackupPassword("A23456789Z") + if err != nil { + t.Fatal(err) + } + now := a.now().UTC().Format("2006-01-02T15:04:05Z") + if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil { + t.Fatal(err) + } + password, err := a.savedBackupPassword(context.Background()) + if err != nil || password != "A23456789Z" { + t.Fatalf("saved password = %q, %v", password, err) + } + schedule, err := a.loadBackupSchedule(context.Background()) + if err != nil || !schedule.PasswordSet || schedule.PasswordHint != "A••••••••Z" { + t.Fatalf("schedule password state = %+v, %v", schedule, err) + } +} + +func TestUpdateBackupPasswordDoesNotChangeScheduleSettings(t *testing.T) { + dir := t.TempDir() + a := newTestAppWithConfig(t, Config{ + Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", + AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", + }) + stopTestWorkers(a) + now := a.now().UTC().Format(time.RFC3339Nano) + for key, value := range map[string]string{ + "backupScheduleEnabled": "true", + "backupScheduleDays": "30", + "backupTelegramMode": "custom", + "backupTelegramChatId": "-1001234567890", + "backupGoogleFolderName": "Existing Backups", + } { + if _, err := a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, key, value, now); err != nil { + t.Fatal(err) + } + } + server := httptest.NewServer(a.Router()) + defer server.Close() + admin := &testClient{t: t, server: server} + var response map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, response) + } + response = nil + if code := admin.do("POST", "/api/admin/backups/password", map[string]string{"password": "NewSharedPassword9", "confirmPassword": "NewSharedPassword9"}, &response); code != http.StatusOK { + t.Fatalf("password update code=%d body=%v", code, response) + } + if response["passwordHint"] != "N••••••••••9" { + t.Fatalf("password hint = %v", response["passwordHint"]) + } + password, err := a.savedBackupPassword(context.Background()) + if err != nil || password != "NewSharedPassword9" { + t.Fatalf("saved password = %q, %v", password, err) + } + for key, want := range map[string]string{ + "backupScheduleEnabled": "true", + "backupScheduleDays": "30", + "backupTelegramMode": "custom", + "backupTelegramChatId": "-1001234567890", + "backupGoogleFolderName": "Existing Backups", + } { + var got string + if err := a.db.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key).Scan(&got); err != nil || got != want { + t.Fatalf("setting %s = %q, %v; want %q", key, got, err, want) + } + } +} + +func TestManualBackupReusesSavedPassword(t *testing.T) { + dir := t.TempDir() + deployDir := filepath.Join(dir, "deploy") + if err := os.MkdirAll(deployDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(deployDir, "docker-compose.yml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + a := newTestAppWithConfig(t, Config{ + Addr: ":0", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@example.com", AdminPassword: "ChangeMe123!", + AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret", BackupSourceDir: deployDir, + BackupDir: filepath.Join(dir, "data", "disaster-backups"), + }) + stopTestWorkers(a) + ciphertext, err := a.encryptBackupPassword("SharedBackupPassword9") + if err != nil { + t.Fatal(err) + } + now := a.now().UTC().Format("2006-01-02T15:04:05Z") + if _, err = a.db.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)`, ciphertext, now); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(a.Router()) + defer server.Close() + admin := &testClient{t: t, server: server} + var response map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@example.com", "password": "ChangeMe123!"}, &response); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, response) + } + response = nil + if code := admin.do("POST", "/api/admin/backups", map[string]any{"password": "", "confirmPassword": "", "sendTelegram": false, "uploadGoogleDrive": false}, &response); code != http.StatusAccepted { + t.Fatalf("manual backup code=%d body=%v", code, response) + } + password, err := a.savedBackupPassword(context.Background()) + if err != nil || password != "SharedBackupPassword9" { + t.Fatalf("saved password changed: %q, %v", password, err) + } +} + func TestPublicServerIPValidation(t *testing.T) { for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} { if !isPublicIP(net.ParseIP(value)) { diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index 0c4f2b7..fe66725 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -141,6 +141,7 @@ func (a *App) Router() http.Handler { r.Post("/admin/system/update", a.handleSystemUpdate) r.Get("/admin/backups", a.handleListBackups) r.Post("/admin/backups/settings", a.handleUpdateBackupSettings) + r.Post("/admin/backups/password", a.handleUpdateBackupPassword) r.Post("/admin/backups/telegram/test", a.handleTestBackupTelegram) r.Post("/admin/backups/telegram/discover-group", a.handleDiscoverBackupTelegramGroup) r.Post("/admin/backups/google-drive/connect", a.handleGoogleDriveConnect) diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 5bb7245..955ae0a 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -204,7 +204,7 @@ export type SystemUpdateResult = { } export type BackupItem = { name: string; size: number; createdAt: string; sha256?: string } export type BackupJob = { status: "running" | "success" | "failed"; startedAt: string; error?: string } -export type BackupSchedule = { enabled: boolean; days: number; passwordSet: boolean; 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 BackupList = { enabled: boolean; telegramSet: boolean; telegramLimit: number; job?: BackupJob; items: BackupItem[]; schedule: BackupSchedule; googleDrive: GoogleDriveBackupStatus } export type SystemSettings = { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0f14d6c..dd2da74 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -214,6 +214,7 @@ export const api = { backups: () => request("/api/admin/backups"), createBackup: (password: string, confirmPassword: string, sendTelegram: boolean, uploadGoogleDrive: boolean) => request<{ ok: boolean; message: string }>("/api/admin/backups", { method: "POST", body: JSON.stringify({ password, confirmPassword, sendTelegram, uploadGoogleDrive }) }), updateBackupSettings: (payload: { enabled: boolean; days: number; password: string; confirmPassword: string; serverIp: string; chatId: string; telegramMode: "system" | "custom"; telegramEnabled: boolean; googleDriveEnabled: boolean; googleClientId: string; googleClientSecret: string; googleFolderName: string }) => request("/api/admin/backups/settings", { method: "POST", body: JSON.stringify(payload) }), + updateBackupPassword: (password: string, confirmPassword: string) => request<{ passwordSet: boolean; passwordHint: string }>("/api/admin/backups/password", { method: "POST", body: JSON.stringify({ password, confirmPassword }) }), testBackupTelegram: (payload: { mode: "system" | "custom"; chatId: string }) => request<{ ok: boolean }>("/api/admin/backups/telegram/test", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), discoverBackupTelegramGroup: (pairingCode: string) => request<{ items: TelegramPrivateChat[] }>("/api/admin/backups/telegram/discover-group", { method: "POST", body: JSON.stringify({ pairingCode }) }), connectGoogleDrive: () => request<{ url: string }>("/api/admin/backups/google-drive/connect", { method: "POST" }), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 6874c0c..0b50744 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -298,6 +298,7 @@ function BackupsSection() { const [backupGroupPairing, setBackupGroupPairing] = React.useState(null) const [discoveredBackupGroups, setDiscoveredBackupGroups] = React.useState<{ chatId: string; displayName: string }[]>([]) const [googleConfigOpen, setGoogleConfigOpen] = React.useState(false) + const [passwordConfigOpen, setPasswordConfigOpen] = React.useState(false) React.useEffect(() => { if (!backups.data) return const days = backups.data.schedule.days || 7 @@ -329,10 +330,15 @@ function BackupsSection() { onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }), }) const saveSchedule = useMutation({ - mutationFn: () => api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled, googleClientId, googleClientSecret, googleFolderName }), - onSuccess: async () => { setSchedulePassword(""); setScheduleConfirmPassword(""); setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) }, + mutationFn: () => api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: "", confirmPassword: "", serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled, googleClientId, googleClientSecret, googleFolderName }), + onSuccess: async () => { setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }), }) + const savePassword = useMutation({ + mutationFn: () => api.updateBackupPassword(schedulePassword, scheduleConfirmPassword), + onSuccess: async () => { setPasswordConfigOpen(false); setSchedulePassword(""); setScheduleConfirmPassword(""); setShowSchedulePassword(false); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "统一备份密码已保存", description: "以后创建的手动和定时备份都会使用新密码。" }) }, + onError: (error) => toast({ title: "密码保存失败", description: error instanceof Error ? error.message : "请稍后重试" }), + }) const verify = useMutation({ mutationFn: api.verifyBackup, onSuccess: (result) => toast({ title: result.ok ? "备份校验通过" : "备份校验失败", description: result.ok ? `SHA-256:${result.sha256.slice(0, 16)}...` : "文件可能已损坏,请勿用于恢复。" }), @@ -369,7 +375,7 @@ function BackupsSection() { }) const connectDrive = useMutation({ mutationFn: async () => { - await api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName }) + await api.updateBackupSettings({ enabled: scheduleEnabled, days: scheduleDays === "custom" ? Number(customDays) : Number(scheduleDays), password: "", confirmPassword: "", serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName }) return api.connectGoogleDrive() }, onSuccess: ({ url }) => { window.location.href = url }, @@ -387,8 +393,8 @@ function BackupsSection() { const job = backups.data?.job const canCreate = backups.data?.enabled && job?.status !== "running" function submitCreate() { - if (password.length < 8) { toast({ title: "密码至少需要 8 个字符" }); return } - if (password !== confirmPassword) { toast({ title: "两次输入的密码不一致" }); return } + if (!backups.data?.schedule.passwordSet && password.length < 8) { toast({ title: "密码至少需要 8 个字符" }); return } + if (!backups.data?.schedule.passwordSet && password !== confirmPassword) { toast({ title: "两次输入的密码不一致" }); return } create.mutate() } function generateCreatePassword() { @@ -431,11 +437,14 @@ function BackupsSection() { } function submitSchedule() { - if (schedulePassword && schedulePassword.length < 8) { toast({ title: "备份密码至少需要 8 个字符" }); return } - if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return } - if (scheduleEnabled && !schedulePassword && !backups.data?.schedule.passwordSet) { toast({ title: "请设置并确认备份密码" }); return } + if (scheduleEnabled && !backups.data?.schedule.passwordSet) { setPasswordConfigOpen(true); toast({ title: "请先设置统一备份密码" }); return } saveSchedule.mutate() } + function submitPassword() { + if (schedulePassword.length < 8) { toast({ title: "备份密码至少需要 8 个字符" }); return } + if (schedulePassword !== scheduleConfirmPassword) { toast({ title: "两次输入的备份密码不一致" }); return } + savePassword.mutate() + } return (
@@ -451,7 +460,7 @@ function BackupsSection() { {!backups.data?.enabled &&
当前版本缺少完整备份组件。请更新到最新修复版本,更新完成后刷新本页即可创建备份。
} {job?.status === "failed" &&
{job.error || "备份生成失败"}
} {job?.status === "success" &&
最近一次备份已完成。
} - {!job &&

创建时必须设置独立备份密码。密码不会保存,丢失后无法解密恢复。

} + {!job &&

手动备份与定时备份共用同一个恢复密码,避免不同备份使用不同密码。

}
本地备份保留最近 10 份
@@ -486,11 +495,10 @@ function BackupsSection() {
定时备份

按周期创建加密备份并保存到选定位置。

-
+
{scheduleDays === "custom" && setCustomDays(event.target.value)} />}

根据当前邮局主机名的公网 DNS 自动识别。

-
setSchedulePassword(event.target.value)} placeholder={backups.data?.schedule.passwordSet ? "已保存,留空不变" : "至少 8 个字符"} />
-
setScheduleConfirmPassword(event.target.value)} placeholder={schedulePassword ? "再次输入备份密码" : "留空则不修改"} />
+
{backups.data?.schedule.passwordHint || "尚未设置"}

手动与定时备份共用。

@@ -553,15 +561,26 @@ function BackupsSection() { + { if (!savePassword.isPending) { setPasswordConfigOpen(open); if (!open) { setSchedulePassword(""); setScheduleConfirmPassword(""); setShowSchedulePassword(false) } } }}> + + {backups.data?.schedule.passwordSet ? "更换统一备份密码" : "设置统一备份密码"} +
+ {backups.data?.schedule.passwordSet &&
当前密码:{backups.data.schedule.passwordHint}。更换只影响以后创建的备份,已有备份仍需原密码恢复。
} +
setSchedulePassword(event.target.value)} placeholder="至少 8 个字符" />
+
setScheduleConfirmPassword(event.target.value)} placeholder="再次输入新备份密码" />
+

生成密码后可查看、复制或下载密码文本。请存入密码管理器,并与备份文件分开保存。

+
+ +
+
{ if (!create.isPending) setCreateOpen(open) }}> 创建完整备份
-
setPassword(event.target.value)} placeholder="自己输入或自动生成" />
-
setConfirmPassword(event.target.value)} />
+ {backups.data?.schedule.passwordSet ?
使用已保存的备份密码
{backups.data.schedule.passwordHint || "密码已安全保存"}

与定时备份共用同一个恢复密码。

: <>
setPassword(event.target.value)} placeholder="自己输入或自动生成" />
setConfirmPassword(event.target.value)} />
}
完成后发送到 Telegram
同时发送详细恢复说明和加密附件。
上传到 Google 云端硬盘
保存加密备份到已连接的云端文件夹。
-

下载的是明文密码文本,请导入密码管理器后妥善处理,不要与备份文件存放在同一位置。

+ {!backups.data?.schedule.passwordSet &&

首次设置后,手动和定时备份都会使用这个密码。请保存到密码管理器,不要与备份文件放在同一位置。

}