%s\n\n恢复教程:\n1. 请不要解压、改名或修改压缩备份文件。\n2. 将原始附件上传到新服务器的 /root/ 目录。\n3. 运行官方安装脚本,显示管理菜单后输入 2,选择“备份恢复”。\n4. 选择“本地上传”,系统会自动检测 /root/ 中的备份。\n5. 只有一份时自动选中;多份时显示 1、2、3 等序号。\n6. 输入对应序号,例如输入 1 恢复第 1 份。\n7. 输入备份密码后开始恢复。没有检测到文件时才手动输入路径。\n8. 恢复完成后,账号继续使用原登录密码。\n9. 以后需要管理系统时,可以直接输入 ns 打开管理菜单。\n\n安全提示:备份密码不会发送到 Telegram,请从 1Password 等独立位置取用。", info.ModTime().Local().Format("2006-01-02"), htmlEscape(cfg.PublicHostname), htmlEscape(serverIP), htmlEscape(cfg.AppVersion), list(domains), list(admins), list(users), list(mailboxes), htmlEscape(filepath.Base(path)), humanBackupBytes(info.Size()), sum), nil
+}
+
+func queryBackupStrings(ctx context.Context, db *sql.DB, query string) ([]string, error) {
+ rows, err := db.QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var values []string
+ for rows.Next() {
+ var value string
+ if err := rows.Scan(&value); err != nil {
+ return nil, err
+ }
+ values = append(values, value)
+ }
+ return values, rows.Err()
+}
+
+func humanBackupBytes(value int64) string {
+ if value < 1024 {
+ return fmt.Sprintf("%d B", value)
+ }
+ units := []string{"KB", "MB", "GB", "TB"}
+ size := float64(value)
+ unit := "B"
+ for _, next := range units {
+ size /= 1024
+ unit = next
+ if size < 1024 {
+ break
+ }
+ }
+ return fmt.Sprintf("%.1f %s", size, unit)
+}
+
+func (a *App) sendTelegramDocument(ctx context.Context, token, chatID, path string) error {
+ reader, pipeWriter := io.Pipe()
+ writer := multipart.NewWriter(pipeWriter)
+ go func() {
+ var writeErr error
+ defer func() { _ = writer.Close(); _ = pipeWriter.CloseWithError(writeErr) }()
+ if writeErr = writer.WriteField("chat_id", chatID); writeErr != nil {
+ return
+ }
+ if writeErr = writer.WriteField("caption", "NewSzxcn 加密备份\n请将备份密码单独保管,不要发送到同一聊天。"); writeErr != nil {
+ return
+ }
+ var part io.Writer
+ part, writeErr = writer.CreateFormFile("document", filepath.Base(path))
+ if writeErr != nil {
+ return
+ }
+ var file *os.File
+ file, writeErr = os.Open(path)
+ if writeErr != nil {
+ return
+ }
+ defer file.Close()
+ _, writeErr = io.Copy(part, file)
+ }()
+ endpoint := strings.TrimRight(a.telegramURL, "/") + "/bot" + token + "/sendDocument"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, reader)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return fmt.Errorf("telegram %s: %s", resp.Status, raw)
+ }
+ return nil
+}
+
+func (a *App) listBackups() ([]backupItem, error) {
+ dir := a.config().BackupDir
+ if dir == "" {
+ return []backupItem{}, nil
+ }
+ entries, err := os.ReadDir(dir)
+ if os.IsNotExist(err) {
+ return []backupItem{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ items := make([]backupItem, 0)
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar.zst.enc") {
+ continue
+ }
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+ item := backupItem{Name: entry.Name(), Size: info.Size(), CreatedAt: info.ModTime().UTC()}
+ if raw, err := os.ReadFile(filepath.Join(dir, entry.Name()+".sha256")); err == nil {
+ fields := strings.Fields(string(raw))
+ if len(fields) > 0 {
+ item.SHA256 = fields[0]
+ }
+ }
+ items = append(items, item)
+ }
+ sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) })
+ return items, nil
+}
+
+func (a *App) pruneDisasterBackups(keep int) error {
+ items, err := a.listBackups()
+ if err != nil {
+ return err
+ }
+ for _, item := range items[minimumInt(keep, len(items)):] {
+ path, ok := a.backupPath(item.Name)
+ if !ok {
+ continue
+ }
+ if err := os.Remove(path); err != nil {
+ return err
+ }
+ _ = os.Remove(path + ".sha256")
+ }
+ return nil
+}
+
+func minimumInt(left, right int) int {
+ if left < right {
+ return left
+ }
+ return right
+}
+
+func (a *App) loadBackupSchedule(ctx context.Context) (backupSchedule, error) {
+ result := backupSchedule{Days: 7, TelegramMode: "system", TelegramEnabled: true}
+ rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings WHERE key IN ('backupScheduleEnabled','backupScheduleDays','backupServerIp','backupTelegramChatId','backupTelegramMode','backupPasswordCipher','backupTelegramEnabled','backupGoogleDriveEnabled')`)
+ if err != nil {
+ return result, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var key, value string
+ if err := rows.Scan(&key, &value); err != nil {
+ return result, err
+ }
+ switch key {
+ case "backupScheduleEnabled":
+ result.Enabled = value == "true"
+ case "backupScheduleDays":
+ if _, err := fmt.Sscan(value, &result.Days); err != nil || result.Days < 1 {
+ result.Days = 7
+ }
+ case "backupServerIp":
+ result.ServerIP = value
+ case "backupTelegramChatId":
+ result.ChatID = value
+ case "backupTelegramMode":
+ if value == "custom" {
+ result.TelegramMode = "custom"
+ }
+ case "backupPasswordCipher":
+ result.PasswordSet = value != ""
+ case "backupTelegramEnabled":
+ result.TelegramEnabled = value == "true"
+ case "backupGoogleDriveEnabled":
+ result.GoogleDriveEnabled = value == "true"
+ }
+ }
+ return result, rows.Err()
+}
+
+func (a *App) backupScheduleWorker(ctx context.Context) {
+ ticker := time.NewTicker(time.Hour)
+ defer ticker.Stop()
+ a.runScheduledBackup(ctx)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ a.runScheduledBackup(ctx)
+ }
+ }
+}
+
+func (a *App) runScheduledBackup(ctx context.Context) {
+ schedule, err := a.loadBackupSchedule(ctx)
+ if err != nil || !schedule.Enabled {
+ return
+ }
+ var last string
+ _ = a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupScheduleLastRun'`).Scan(&last)
+ if parsed, err := time.Parse(time.RFC3339Nano, last); err == nil && a.now().UTC().Before(parsed.Add(time.Duration(schedule.Days)*24*time.Hour)) {
+ return
+ }
+ var ciphertext string
+ if err := a.db.QueryRowContext(ctx, `SELECT value FROM system_settings WHERE key='backupPasswordCipher'`).Scan(&ciphertext); err != nil {
+ return
+ }
+ password, err := a.decryptBackupPassword(ciphertext)
+ if err != nil {
+ a.log.Error("decrypt scheduled backup password", "error", err)
+ return
+ }
+ a.backupMu.Lock()
+ if a.backupJob != nil && a.backupJob.Status == "running" {
+ a.backupMu.Unlock()
+ return
+ }
+ a.backupJob = &backupJob{Status: "running", StartedAt: a.now().UTC()}
+ a.backupMu.Unlock()
+ path, runErr := a.createDisasterBackup(ctx, password)
+ password = ""
+ localBackupSucceeded := runErr == nil
+ if localBackupSucceeded {
+ 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)
+ var deliveryErrors []error
+ if schedule.GoogleDriveEnabled {
+ if driveErr := a.uploadBackupToGoogleDrive(ctx, path); driveErr != nil {
+ deliveryErrors = append(deliveryErrors, fmt.Errorf("google drive: %w", driveErr))
+ }
+ }
+ if schedule.TelegramEnabled {
+ if telegramErr := a.sendBackupToTelegram(ctx, path); telegramErr != nil {
+ deliveryErrors = append(deliveryErrors, fmt.Errorf("telegram: %w", telegramErr))
+ }
+ }
+ runErr = errors.Join(deliveryErrors...)
+ }
+ status := "success"
+ publicError := ""
+ if runErr != nil {
+ status = "failed"
+ publicError = "定时备份或云端推送失败,请查看服务日志"
+ a.log.Error("scheduled backup", "error", runErr)
+ }
+ a.backupMu.Lock()
+ a.backupJob.Status, a.backupJob.Error = status, publicError
+ a.backupMu.Unlock()
+}
+
+func (a *App) backupEncryptionKey() []byte {
+ cfg := a.config()
+ secret := strings.TrimSpace(cfg.UpdateServiceToken)
+ if secret == "" {
+ secret = strings.TrimSpace(cfg.ExternalIMAPSecretKey)
+ }
+ sum := sha256.Sum256([]byte("newszxcn-backup-schedule:" + secret))
+ return sum[:]
+}
+
+func (a *App) encryptBackupPassword(password string) (string, error) {
+ if strings.TrimSpace(a.config().UpdateServiceToken) == "" && strings.TrimSpace(a.config().ExternalIMAPSecretKey) == "" {
+ return "", errors.New("backup encryption key is not configured")
+ }
+ block, err := aes.NewCipher(a.backupEncryptionKey())
+ if err != nil {
+ return "", err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ return "", err
+ }
+ return base64.StdEncoding.EncodeToString(append(nonce, gcm.Seal(nil, nonce, []byte(password), nil)...)), nil
+}
+
+func (a *App) decryptBackupPassword(value string) (string, error) {
+ if strings.TrimSpace(a.config().UpdateServiceToken) == "" && strings.TrimSpace(a.config().ExternalIMAPSecretKey) == "" {
+ return "", errors.New("backup encryption key is not configured")
+ }
+ raw, err := base64.StdEncoding.DecodeString(value)
+ if err != nil {
+ return "", err
+ }
+ block, err := aes.NewCipher(a.backupEncryptionKey())
+ if err != nil {
+ return "", err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+ if len(raw) < gcm.NonceSize() {
+ return "", errors.New("invalid backup password ciphertext")
+ }
+ plain, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
+ if err != nil {
+ return "", err
+ }
+ return string(plain), nil
+}
+
+func (a *App) backupPath(name string) (string, bool) {
+ if filepath.Base(name) != name || !strings.HasPrefix(name, "newszxcn-backup-") || !strings.HasSuffix(name, ".tar.zst.enc") {
+ return "", false
+ }
+ path := filepath.Join(a.config().BackupDir, name)
+ info, err := os.Stat(path)
+ return path, err == nil && !info.IsDir()
+}
+
+func copyTree(src, dst string, skip map[string]bool) error {
+ return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+ if rel == "." {
+ return os.MkdirAll(dst, info.Mode().Perm())
+ }
+ first := strings.Split(rel, string(os.PathSeparator))[0]
+ if skip[first] {
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ target := filepath.Join(dst, rel)
+ if info.IsDir() {
+ return os.MkdirAll(target, info.Mode().Perm())
+ }
+ if !info.Mode().IsRegular() {
+ return nil
+ }
+ return copyFile(path, target)
+ })
+}
+
+func copyFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ info, err := in.Stat()
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
+ if err != nil {
+ return err
+ }
+ _, cpErr := io.Copy(out, in)
+ closeErr := out.Close()
+ if cpErr != nil {
+ return cpErr
+ }
+ return closeErr
+}
+
+func writeTar(path, root string) error {
+ out, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ tw := tar.NewWriter(out)
+ err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(filepath.Dir(root), path)
+ header, err := tar.FileInfoHeader(info, "")
+ if err != nil {
+ return err
+ }
+ header.Name = filepath.ToSlash(rel)
+ if err := tw.WriteHeader(header); err != nil {
+ return err
+ }
+ if info.Mode().IsRegular() {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(tw, f)
+ f.Close()
+ return err
+ }
+ return nil
+ })
+ closeErr := tw.Close()
+ fileCloseErr := out.Close()
+ if err != nil {
+ return err
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ return fileCloseErr
+}
+func fileSHA256(path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ hash := sha256.New()
+ if _, err := io.Copy(hash, f); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(hash.Sum(nil)), nil
+}
diff --git a/apps/api/internal/app/backup_handlers_test.go b/apps/api/internal/app/backup_handlers_test.go
new file mode 100644
index 0000000..302c34d
--- /dev/null
+++ b/apps/api/internal/app/backup_handlers_test.go
@@ -0,0 +1,173 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "mime"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestBackupEndpointsRejectMismatchedConfirmation(t *testing.T) {
+ a := newTestApp(t)
+ stopTestWorkers(a)
+ 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@lanqin.local", "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": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
+ t.Fatalf("manual backup mismatch code=%d body=%v", code, response)
+ }
+ response = nil
+ if code := admin.do("POST", "/api/admin/backups/settings", map[string]any{"enabled": false, "days": 7, "password": "BackupPassword123!", "confirmPassword": "DifferentPassword123!"}, &response); code != http.StatusBadRequest {
+ t.Fatalf("scheduled backup mismatch code=%d body=%v", code, response)
+ }
+}
+
+func TestDiscoverTelegramGroupsReturnsUniqueCandidates(t *testing.T) {
+ telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"ok":true,"result":[`+
+ `{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
+ `{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},`+
+ `{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},`+
+ `{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}`)
+ }))
+ defer telegramServer.Close()
+ a := newTestApp(t)
+ stopTestWorkers(a)
+ a.telegramURL = telegramServer.URL
+ groups, err := a.discoverTelegramGroups(context.Background(), "test-token", "ABC123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(groups) != 2 || groups[0].ChatID != "-1001" || groups[1].ChatID != "-1002" {
+ t.Fatalf("unexpected groups: %+v", groups)
+ }
+}
+
+func TestGoogleDriveUploadRequestUsesMultipartRelated(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "newszxcn-backup-test.tar.zst.enc")
+ if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ req, err := newGoogleDriveUploadRequest(context.Background(), path, "folder-123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ mediaType, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
+ if err != nil || mediaType != "multipart/related" || params["boundary"] == "" {
+ t.Fatalf("content type = %q, %v", req.Header.Get("Content-Type"), err)
+ }
+ reader := multipart.NewReader(req.Body, params["boundary"])
+ metadataPart, err := reader.NextPart()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var metadata struct {
+ Name string `json:"name"`
+ Parents []string `json:"parents"`
+ }
+ if err := json.NewDecoder(metadataPart).Decode(&metadata); err != nil {
+ t.Fatal(err)
+ }
+ if metadata.Name != filepath.Base(path) || len(metadata.Parents) != 1 || metadata.Parents[0] != "folder-123" {
+ t.Fatalf("metadata = %+v", metadata)
+ }
+ filePart, err := reader.NextPart()
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := io.ReadAll(filePart)
+ if err != nil || string(raw) != "encrypted backup" {
+ t.Fatalf("uploaded bytes = %q, %v", raw, err)
+ }
+}
+
+func TestBackupEncryptionRequiresDeploymentSecret(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,
+ })
+ if _, err := a.encryptBackupPassword("BackupPassword123!"); err == nil {
+ t.Fatal("backup password encryption succeeded without a deployment secret")
+ }
+}
+
+func TestBackupPasswordValidation(t *testing.T) {
+ for _, valid := range []string{"12345678", "Restore Password 123!"} {
+ if !validBackupPassword(valid) {
+ t.Errorf("valid password rejected: %q", valid)
+ }
+ }
+ for _, invalid := range []string{"1234567", "password\nvalue", "password\x00value", strings.Repeat("x", 1025)} {
+ if validBackupPassword(invalid) {
+ t.Errorf("invalid password accepted: %q", invalid)
+ }
+ }
+}
+
+func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
+ dir := t.TempDir()
+ a := newTestAppWithConfig(t, Config{
+ Addr: ":0", AppVersion: "v1.2.31", DBPath: filepath.Join(dir, "data", "lanqin.db"), DataDir: filepath.Join(dir, "data"),
+ CookieName: "lanqin_test", SessionTTLHours: 24, AdminEmail: "admin@newszxcn.com", AdminPassword: "ChangeMe123!",
+ PublicHostname: "mail.newszxcn.com", PublicBaseURL: "https://mail.newszxcn.com", AllowInsecureHTTP: true, UpdateServiceToken: "test-update-secret",
+ })
+
+ ciphertext, err := a.encryptBackupPassword("BackupPassword123!")
+ if err != nil || ciphertext == "BackupPassword123!" {
+ t.Fatalf("password encryption failed: %q %v", ciphertext, err)
+ }
+ plain, err := a.decryptBackupPassword(ciphertext)
+ if err != nil || plain != "BackupPassword123!" {
+ t.Fatalf("password decryption = %q, %v", plain, err)
+ }
+ if !validTelegramPrivateChatID("-1001234567890") {
+ t.Fatal("private Telegram group chat ID was rejected")
+ }
+
+ now := a.now().UTC().Format("2006-01-02T15:04:05Z")
+ if _, err := a.db.Exec(`INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)`, now, now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := a.db.Exec(`INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)`, now, now); err != nil {
+ t.Fatal(err)
+ }
+
+ path := filepath.Join(dir, "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc")
+ if err := os.WriteFile(path, []byte("encrypted backup"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ report, err := a.backupTelegramReport(context.Background(), path, info)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, expected := range []string{"备份成功", "mail.newszxcn.com", "已有域名", "newszxcn.com", "xyes.me", "管理员账号", "admin@newszxcn.com", "普通用户账号", "user@xyes.me", "请不要解压", "本地上传", "1Password"} {
+ if !strings.Contains(report, expected) {
+ t.Errorf("report missing %q: %s", expected, report)
+ }
+ }
+ if strings.Contains(report, "newszxcn.com(管理员)") {
+ t.Fatal("domain list incorrectly contains account role")
+ }
+ if strings.Contains(report, "BackupPassword123!") || strings.Contains(report, "ChangeMe123!") {
+ t.Fatal("report leaked a password")
+ }
+}
diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go
index 2d235c9..151c8af 100644
--- a/apps/api/internal/app/config.go
+++ b/apps/api/internal/app/config.go
@@ -67,6 +67,8 @@ type Config struct {
ReleaseAPIURL string
UpdateServiceURL string
UpdateServiceToken string
+ BackupSourceDir string
+ BackupDir string
}
func LoadConfig() Config {
@@ -131,6 +133,8 @@ func LoadConfig() Config {
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
+ BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", ""),
+ BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
}
}
diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go
index d791406..0c4f2b7 100644
--- a/apps/api/internal/app/router_auth.go
+++ b/apps/api/internal/app/router_auth.go
@@ -139,6 +139,19 @@ func (a *App) Router() http.Handler {
r.Use(a.requireAdminAccess)
r.Get("/admin/system/version", a.handleSystemVersion)
r.Post("/admin/system/update", a.handleSystemUpdate)
+ r.Get("/admin/backups", a.handleListBackups)
+ r.Post("/admin/backups/settings", a.handleUpdateBackupSettings)
+ 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)
+ r.Get("/admin/backups/google-drive/callback", a.handleGoogleDriveCallback)
+ r.Delete("/admin/backups/google-drive", a.handleGoogleDriveDisconnect)
+ r.Post("/admin/backups", a.handleCreateBackup)
+ r.Get("/admin/backups/{name}/download", a.handleDownloadBackup)
+ r.Post("/admin/backups/{name}/verify", a.handleVerifyBackup)
+ r.Post("/admin/backups/{name}/telegram", a.handleSendBackupTelegram)
+ r.Post("/admin/backups/{name}/google-drive", a.handleSendBackupGoogleDrive)
+ r.Delete("/admin/backups/{name}", a.handleDeleteBackup)
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
diff --git a/apps/api/internal/app/telegram.go b/apps/api/internal/app/telegram.go
index 7d62dd9..c0a9d3e 100644
--- a/apps/api/internal/app/telegram.go
+++ b/apps/api/internal/app/telegram.go
@@ -71,6 +71,7 @@ type telegramUpdate struct {
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
+ Title string `json:"title"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
@@ -112,7 +113,7 @@ func normalizeTelegramBodyMode(value string) string {
func validTelegramPrivateChatID(value string) bool {
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
- return err == nil && id > 0
+ return err == nil && id != 0
}
func (a *App) handleCreateTelegramPairing(w http.ResponseWriter, r *http.Request) {
@@ -259,6 +260,50 @@ func (a *App) discoverTelegramPrivateChat(ctx context.Context, token, pairingCod
return "", "", errors.New("未找到匹配的私聊,请打开机器人发送绑定码后重试")
}
+type telegramDiscoveredChat struct {
+ ChatID string `json:"chatId"`
+ DisplayName string `json:"displayName"`
+}
+
+func (a *App) discoverTelegramGroups(ctx context.Context, token, pairingCode string) ([]telegramDiscoveredChat, error) {
+ var updates []telegramUpdate
+ if err := a.callTelegram(ctx, token, "getUpdates", map[string]any{
+ "limit": 100, "timeout": 0, "allowed_updates": []string{"message"},
+ }, &updates); err != nil {
+ return nil, err
+ }
+ found := make([]telegramDiscoveredChat, 0)
+ seen := make(map[int64]bool)
+ for i := len(updates) - 1; i >= 0; i-- {
+ message := updates[i].Message
+ if message == nil || (message.Chat.Type != "group" && message.Chat.Type != "supergroup") || message.Chat.ID >= 0 {
+ continue
+ }
+ text := strings.TrimSpace(message.Text)
+ fields := strings.Fields(text)
+ matches := strings.EqualFold(text, pairingCode)
+ if len(fields) == 2 && strings.HasPrefix(strings.ToLower(fields[0]), "/newszxcn") {
+ matches = strings.EqualFold(fields[1], pairingCode)
+ }
+ if !matches {
+ continue
+ }
+ if seen[message.Chat.ID] {
+ continue
+ }
+ seen[message.Chat.ID] = true
+ name := strings.TrimSpace(message.Chat.Title)
+ if name == "" {
+ name = "Telegram 群组"
+ }
+ found = append(found, telegramDiscoveredChat{ChatID: strconv.FormatInt(message.Chat.ID, 10), DisplayName: name})
+ }
+ if len(found) == 0 {
+ return nil, errors.New("未找到匹配的群组,请确认机器人已加入群组,并在群里发送查询命令")
+ }
+ return found, nil
+}
+
func newTelegramPairingCode() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx
index d0d3b90..e445435 100644
--- a/apps/web/src/components/protected-layout.tsx
+++ b/apps/web/src/components/protected-layout.tsx
@@ -1,6 +1,6 @@
import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom"
-import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
+import { ArchiveRestore, BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
@@ -35,6 +35,7 @@ const adminSections: { key: string; label: string; icon: React.ReactNode; permis
{ key: "aliases", label: "邮件转发", icon: 包含账号、邮件、附件、DKIM、证书和部署配置。
创建时必须设置独立备份密码。密码不会保存,丢失后无法解密恢复。
} +1. 将原始加密备份上传到 /root/,不要解压。
+2. 运行官方安装脚本,菜单输入 2。
+3. 选择“本地上传”;多份备份会显示 1、2、3。
+4. 输入序号和备份密码开始恢复。
+按周期创建加密备份并保存到选定位置。
{telegramMode === "custom" ? "使用系统机器人推送到备份群组" : "沿用邮件通知接收方"}
{backups.data?.googleDrive.connected ? `保存到 ${googleFolderName}` : "长期保存加密备份"}