From 48a1d5313353cc9064cd1829b3fce2fa4adaca22 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:59:20 +0800 Subject: [PATCH] release: prepare v1.2.33 --- .github/release-notes/v1.2.33.md | 9 ++ VERSION | 2 +- apps/api/internal/app/backup_handlers.go | 96 ++++++++++++++++--- apps/api/internal/app/backup_handlers_test.go | 77 +++++++++++++++ apps/api/internal/app/config.go | 2 +- apps/web/src/pages/admin.tsx | 12 +-- deploy/all-in-one/Dockerfile | 1 + deploy/api.Dockerfile | 1 + 8 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 .github/release-notes/v1.2.33.md diff --git a/.github/release-notes/v1.2.33.md b/.github/release-notes/v1.2.33.md new file mode 100644 index 0000000..356c9bb --- /dev/null +++ b/.github/release-notes/v1.2.33.md @@ -0,0 +1,9 @@ +- 修复 `v1.2.32` 在线更新只替换镜像、未同步宿主机 Compose 文件时,“创建备份”按钮持续灰色的问题。 +- 完整备份组件改为随 API 和一体化镜像提供;旧服务器升级后可直接使用现有 `/data` 持久化目录创建备份,无需手动修改部署文件。 +- 备份会根据当前容器运行配置生成可恢复的 `.env`,并过滤只适用于旧容器内部的更新和备份路径变量。 +- 服务器 IP 改为根据邮局主机名的公网 DNS 自动检测,移除私人 IP 示例和手动填写项,支持一键重新检测。 +- Telegram 备份报告实时使用自动检测到的服务器 IP;检测失败时明确显示“未检测到”,不保存或暴露固定地址。 +- Google Cloud OAuth 回调地址改为单行只读输入框并增加复制按钮,修复长地址断行影响查看和复制的问题。 +- 优化备份组件缺失提示,并完成桌面、手机页面溢出检查以及备份、恢复、安装、回滚和 DKIM 回归测试。 + +**完整更新日志**:[v1.2.32...v1.2.33](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.32...v1.2.33) diff --git a/VERSION b/VERSION index 3725851..47f5bfd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.32 +1.2.33 diff --git a/apps/api/internal/app/backup_handlers.go b/apps/api/internal/app/backup_handlers.go index 3d7f5cf..5a47244 100644 --- a/apps/api/internal/app/backup_handlers.go +++ b/apps/api/internal/app/backup_handlers.go @@ -15,6 +15,7 @@ import ( "fmt" "io" "mime/multipart" + "net" "net/http" "net/textproto" "net/url" @@ -72,6 +73,42 @@ type backupSchedule struct { GoogleDriveEnabled bool `json:"googleDriveEnabled"` } +func detectPublicServerIP(ctx context.Context, hostname string) string { + hostname = strings.TrimSpace(hostname) + if hostname == "" { + return "" + } + if ip := net.ParseIP(hostname); ip != nil { + if isPublicIP(ip) { + return ip.String() + } + return "" + } + lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + addresses, err := net.DefaultResolver.LookupIPAddr(lookupCtx, hostname) + if err != nil { + return "" + } + var ipv6 string + for _, address := range addresses { + if !isPublicIP(address.IP) { + continue + } + if address.IP.To4() != nil { + return address.IP.String() + } + if ipv6 == "" { + ipv6 = address.IP.String() + } + } + return ipv6 +} + +func isPublicIP(ip net.IP) bool { + return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() +} + type updateBackupScheduleRequest struct { Enabled bool `json:"enabled"` Days int `json:"days"` @@ -136,9 +173,10 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) { } a.backupMu.Unlock() schedule, _ := a.loadBackupSchedule(r.Context()) + schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname) telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule) respondJSON(w, http.StatusOK, backupListResponse{ - Enabled: strings.TrimSpace(a.config().BackupSourceDir) != "" && strings.TrimSpace(a.config().BackupDir) != "", + Enabled: a.backupAssetsAvailable(), TelegramSet: strings.TrimSpace(telegramToken) != "" && validTelegramPrivateChatID(telegramDestination), TelegramLimit: backupTelegramLimit, Job: job, Items: items, Schedule: schedule, GoogleDrive: a.loadGoogleDriveStatus(r.Context()), @@ -162,8 +200,7 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) { badRequest(w, errors.New("两次输入的备份密码不一致")) return } - cfg := a.config() - if strings.TrimSpace(cfg.BackupSourceDir) == "" || strings.TrimSpace(cfg.BackupDir) == "" { + if !a.backupAssetsAvailable() { respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份") return } @@ -291,7 +328,7 @@ func (a *App) handleUpdateBackupSettings(w http.ResponseWriter, r *http.Request) } values := map[string]string{ "backupScheduleEnabled": fmt.Sprint(req.Enabled), "backupScheduleDays": fmt.Sprint(req.Days), - "backupServerIp": strings.TrimSpace(req.ServerIP), "backupTelegramChatId": chatID, + "backupServerIp": "", "backupTelegramChatId": chatID, "backupTelegramMode": telegramMode, "backupPasswordCipher": ciphertext, "backupTelegramEnabled": fmt.Sprint(req.TelegramEnabled), "backupGoogleDriveEnabled": fmt.Sprint(req.GoogleDriveEnabled), "backupGoogleClientId": strings.TrimSpace(req.GoogleClientID), @@ -314,7 +351,7 @@ 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: strings.TrimSpace(req.ServerIP), ChatID: chatID, TelegramMode: telegramMode, TelegramEnabled: req.TelegramEnabled, GoogleDriveEnabled: req.GoogleDriveEnabled}) + 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}) } func validBackupPassword(password string) bool { @@ -519,7 +556,7 @@ func (a *App) loadGoogleDriveStatus(ctx context.Context) googleDriveStatus { func (a *App) createDisasterBackup(ctx context.Context, password string) (string, error) { cfg := a.config() - if cfg.BackupSourceDir == "" || cfg.BackupDir == "" { + if !a.backupAssetsAvailable() { return "", errors.New("backup directories are not configured") } if err := os.MkdirAll(cfg.BackupDir, 0o700); err != nil { @@ -553,8 +590,14 @@ func (a *App) createDisasterBackup(ctx context.Context, password string) (string return "", err } } - for _, name := range []string{".env", "docker-compose.yml"} { - if err := copyFile(filepath.Join(cfg.BackupSourceDir, name), filepath.Join(root, name)); err != nil { + if err := copyFile(filepath.Join(cfg.BackupSourceDir, "docker-compose.yml"), filepath.Join(root, "docker-compose.yml")); err != nil { + return "", err + } + if err := copyFile(filepath.Join(cfg.BackupSourceDir, ".env"), filepath.Join(root, ".env")); err != nil { + if !os.IsNotExist(err) { + return "", err + } + if err := writeRuntimeBackupEnv(filepath.Join(root, ".env")); err != nil { return "", err } } @@ -598,6 +641,38 @@ func (a *App) createDisasterBackup(ctx context.Context, password string) (string return outPath, nil } +func (a *App) backupAssetsAvailable() bool { + cfg := a.config() + if strings.TrimSpace(cfg.BackupDir) == "" || strings.TrimSpace(cfg.BackupSourceDir) == "" { + return false + } + info, err := os.Stat(filepath.Join(cfg.BackupSourceDir, "docker-compose.yml")) + return err == nil && info.Mode().IsRegular() +} + +func writeRuntimeBackupEnv(path string) error { + values := make([]string, 0) + containerOnly := map[string]bool{ + "LANQIN_BACKUP_DIR": true, + "LANQIN_BACKUP_SOURCE_DIR": true, + "LANQIN_UPDATE_SERVICE_TOKEN": true, + "LANQIN_UPDATE_SERVICE_URL": true, + } + for _, item := range os.Environ() { + key, value, found := strings.Cut(item, "=") + if !found || containerOnly[key] || (!strings.HasPrefix(key, "LANQIN_") && key != "TZ") { + continue + } + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "'", "\\'") + value = strings.ReplaceAll(value, "\r", "\\r") + value = strings.ReplaceAll(value, "\n", "\\n") + values = append(values, key+"='"+value+"'") + } + sort.Strings(values) + return os.WriteFile(path, []byte(strings.Join(values, "\n")+"\n"), 0o600) +} + func (a *App) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { if !a.requireSystemAdmin(w, r) { return @@ -878,7 +953,6 @@ func (a *App) backupTelegramCredentials(ctx context.Context, schedule backupSche func (a *App) backupTelegramReport(ctx context.Context, path string, info os.FileInfo) (string, error) { cfg := a.config() - schedule, _ := a.loadBackupSchedule(ctx) sum, _ := fileSHA256(path) domains, err := queryBackupStrings(ctx, a.db, `SELECT name FROM domains ORDER BY name`) if err != nil { @@ -914,9 +988,9 @@ func (a *App) backupTelegramReport(ctx context.Context, path string, info os.Fil } return strings.Join(items, "、") + suffix } - serverIP := strings.TrimSpace(schedule.ServerIP) + serverIP := detectPublicServerIP(ctx, cfg.PublicHostname) if serverIP == "" { - serverIP = "未填写" + serverIP = "未检测到" } return fmt.Sprintf("%s 备份成功\n\n邮局域名:%s\n服务器 IP:%s\n系统版本:%s\n\n已有域名:\n%s\n\n管理员账号:\n%s\n\n普通用户账号:\n%s\n\n邮箱账号:\n%s\n\n备份文件:%s\n文件大小:%s\nSHA-256:%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 } diff --git a/apps/api/internal/app/backup_handlers_test.go b/apps/api/internal/app/backup_handlers_test.go index 302c34d..4a9c7bc 100644 --- a/apps/api/internal/app/backup_handlers_test.go +++ b/apps/api/internal/app/backup_handlers_test.go @@ -6,6 +6,7 @@ import ( "io" "mime" "mime/multipart" + "net" "net/http" "net/http/httptest" "os" @@ -119,6 +120,82 @@ func TestBackupPasswordValidation(t *testing.T) { } } +func TestPublicServerIPValidation(t *testing.T) { + for _, value := range []string{"203.0.113.10", "2001:4860:4860::8888"} { + if !isPublicIP(net.ParseIP(value)) { + t.Errorf("public IP rejected: %s", value) + } + } + for _, value := range []string{"127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1"} { + if isPublicIP(net.ParseIP(value)) { + t.Errorf("non-public IP accepted: %s", value) + } + } + if got := detectPublicServerIP(context.Background(), "203.0.113.10"); got != "203.0.113.10" { + t.Fatalf("literal public IP = %q", got) + } + if got := detectPublicServerIP(context.Background(), "127.0.0.1"); got != "" { + t.Fatalf("literal private IP = %q", got) + } +} + +func TestWriteRuntimeBackupEnv(t *testing.T) { + t.Setenv("LANQIN_PUBLIC_HOSTNAME", "mail.example.com") + t.Setenv("LANQIN_TEST_QUOTED", "value'with\\slashes\nand-newline") + t.Setenv("LANQIN_BACKUP_DIR", "/backups") + t.Setenv("LANQIN_UPDATE_SERVICE_URL", "http://updater:8080/v1/update") + t.Setenv("UNRELATED_SECRET", "must-not-be-backed-up") + path := filepath.Join(t.TempDir(), ".env") + if err := writeRuntimeBackupEnv(path); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + contents := string(raw) + for _, expected := range []string{"LANQIN_PUBLIC_HOSTNAME='mail.example.com'", `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'`} { + if !strings.Contains(contents, expected) { + t.Errorf("backup environment missing %q: %s", expected, contents) + } + } + for _, excluded := range []string{"UNRELATED_SECRET", "must-not-be-backed-up", "LANQIN_BACKUP_DIR", "LANQIN_UPDATE_SERVICE_URL", "http://updater:8080"} { + if strings.Contains(contents, excluded) { + t.Fatalf("backup environment included excluded value %q", excluded) + } + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("backup environment permissions = %v, %v", info.Mode().Perm(), err) + } +} + +func TestBackupAssetsAvailableWithBundledCompose(t *testing.T) { + dir := t.TempDir() + compose := filepath.Join(dir, "deploy", "docker-compose.yml") + if err := os.MkdirAll(filepath.Dir(compose), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(compose, []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, BackupSourceDir: filepath.Dir(compose), BackupDir: filepath.Join(dir, "data", "disaster-backups"), + }) + stopTestWorkers(a) + if !a.backupAssetsAvailable() { + t.Fatal("bundled compose did not enable complete backups") + } + if err := os.Remove(compose); err != nil { + t.Fatal(err) + } + if a.backupAssetsAvailable() { + t.Fatal("missing bundled compose incorrectly enabled complete backups") + } +} + func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) { dir := t.TempDir() a := newTestAppWithConfig(t, Config{ diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 151c8af..cf3b38b 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -133,7 +133,7 @@ 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", ""), + BackupSourceDir: getenv("LANQIN_BACKUP_SOURCE_DIR", "/usr/share/newszxcn-email/deploy"), BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")), } } diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index b894790..6874c0c 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -287,7 +287,6 @@ function BackupsSection() { const [schedulePassword, setSchedulePassword] = React.useState("") const [scheduleConfirmPassword, setScheduleConfirmPassword] = React.useState("") const [showSchedulePassword, setShowSchedulePassword] = React.useState(false) - const [serverIp, setServerIp] = React.useState("") const [backupChatId, setBackupChatId] = React.useState("") const [telegramMode, setTelegramMode] = React.useState<"system" | "custom">("system") const [telegramEnabled, setTelegramEnabled] = React.useState(true) @@ -305,7 +304,6 @@ function BackupsSection() { setScheduleEnabled(backups.data.schedule.enabled) setScheduleDays([3, 5, 7, 30].includes(days) ? String(days) : "custom") setCustomDays(String(days)) - setServerIp(backups.data.schedule.serverIp || "") setBackupChatId(backups.data.schedule.chatId || "") setTelegramMode(backups.data.schedule.telegramMode === "custom" ? "custom" : "system") setTelegramEnabled(backups.data.schedule.telegramEnabled) @@ -331,7 +329,7 @@ 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 }), + 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: "备份设置已保存" }) }, onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }), }) @@ -371,7 +369,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: schedulePassword, confirmPassword: scheduleConfirmPassword, serverIp: "", chatId: backupChatId, telegramMode, telegramEnabled, googleDriveEnabled: false, googleClientId, googleClientSecret, googleFolderName }) return api.connectGoogleDrive() }, onSuccess: ({ url }) => { window.location.href = url }, @@ -450,7 +448,7 @@ function BackupsSection() { - {!backups.data?.enabled &&
当前部署尚未启用完整备份目录,请先更新服务器部署文件。
} + {!backups.data?.enabled &&
当前版本缺少完整备份组件。请更新到最新修复版本,更新完成后刷新本页即可创建备份。
} {job?.status === "failed" &&
{job.error || "备份生成失败"}
} {job?.status === "success" &&
最近一次备份已完成。
} {!job &&

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

} @@ -490,7 +488,7 @@ function BackupsSection() {
{scheduleDays === "custom" && setCustomDays(event.target.value)} />}
-
setServerIp(event.target.value)} placeholder="例如 165.99.42.243" />
+

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

setSchedulePassword(event.target.value)} placeholder={backups.data?.schedule.passwordSet ? "已保存,留空不变" : "至少 8 个字符"} />
setScheduleConfirmPassword(event.target.value)} placeholder={schedulePassword ? "再次输入备份密码" : "留空则不修改"} />
@@ -547,7 +545,7 @@ function BackupsSection() {
setGoogleClientId(e.target.value)} />
setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} />
setGoogleFolderName(e.target.value)} />
-

Google Cloud 回调地址:{window.location.origin}/api/admin/backups/google-drive/callback

+
{backups.data?.googleDrive.connected ? : } diff --git a/deploy/all-in-one/Dockerfile b/deploy/all-in-one/Dockerfile index f94a686..da2292d 100644 --- a/deploy/all-in-one/Dockerfile +++ b/deploy/all-in-one/Dockerfile @@ -43,6 +43,7 @@ COPY --from=web-build /src/apps/web/dist /usr/share/nginx/html COPY deploy/all-in-one/supervisord.conf /etc/supervisor/conf.d/lanqin.conf COPY deploy/all-in-one/nginx.conf /etc/nginx/sites-enabled/default COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh +COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml COPY deploy/postfix/main.cf /etc/postfix/main.cf COPY deploy/postfix/master.cf /etc/postfix/master.cf diff --git a/deploy/api.Dockerfile b/deploy/api.Dockerfile index 0209696..dcc3d67 100644 --- a/deploy/api.Dockerfile +++ b/deploy/api.Dockerfile @@ -21,5 +21,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata WORKDIR /app COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api +COPY deploy/docker-compose.yml /usr/share/newszxcn-email/deploy/docker-compose.yml EXPOSE 8080 465 587 CMD ["lanqin-api"]