%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() {
创建时必须设置独立备份密码。密码不会保存,丢失后无法解密恢复。
} @@ -490,7 +488,7 @@ function BackupsSection() {根据当前邮局主机名的公网 DNS 自动识别。
Google Cloud 回调地址:{window.location.origin}/api/admin/backups/google-drive/callback
+