release: prepare v1.2.33
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:
zxyszx
2026-08-12 16:59:20 +08:00
parent ff5578368a
commit 48a1d53133
8 changed files with 180 additions and 20 deletions
+9
View File
@@ -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)
+1 -1
View File
@@ -1 +1 @@
1.2.32 1.2.33
+85 -11
View File
@@ -15,6 +15,7 @@ import (
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"net"
"net/http" "net/http"
"net/textproto" "net/textproto"
"net/url" "net/url"
@@ -72,6 +73,42 @@ type backupSchedule struct {
GoogleDriveEnabled bool `json:"googleDriveEnabled"` 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 { type updateBackupScheduleRequest struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Days int `json:"days"` Days int `json:"days"`
@@ -136,9 +173,10 @@ func (a *App) handleListBackups(w http.ResponseWriter, r *http.Request) {
} }
a.backupMu.Unlock() a.backupMu.Unlock()
schedule, _ := a.loadBackupSchedule(r.Context()) schedule, _ := a.loadBackupSchedule(r.Context())
schedule.ServerIP = detectPublicServerIP(r.Context(), a.config().PublicHostname)
telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule) telegramToken, telegramDestination, _ := a.backupTelegramCredentials(r.Context(), schedule)
respondJSON(w, http.StatusOK, backupListResponse{ 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), 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()),
@@ -162,8 +200,7 @@ func (a *App) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("两次输入的备份密码不一致")) badRequest(w, errors.New("两次输入的备份密码不一致"))
return return
} }
cfg := a.config() if !a.backupAssetsAvailable() {
if strings.TrimSpace(cfg.BackupSourceDir) == "" || strings.TrimSpace(cfg.BackupDir) == "" {
respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份") respondError(w, http.StatusServiceUnavailable, "当前部署尚未启用完整备份")
return return
} }
@@ -291,7 +328,7 @@ func (a *App) handleUpdateBackupSettings(w http.ResponseWriter, r *http.Request)
} }
values := map[string]string{ values := map[string]string{
"backupScheduleEnabled": fmt.Sprint(req.Enabled), "backupScheduleDays": fmt.Sprint(req.Days), "backupScheduleEnabled": fmt.Sprint(req.Enabled), "backupScheduleDays": fmt.Sprint(req.Days),
"backupServerIp": strings.TrimSpace(req.ServerIP), "backupTelegramChatId": chatID, "backupServerIp": "", "backupTelegramChatId": chatID,
"backupTelegramMode": telegramMode, "backupTelegramMode": telegramMode,
"backupPasswordCipher": ciphertext, "backupTelegramEnabled": fmt.Sprint(req.TelegramEnabled), "backupPasswordCipher": ciphertext, "backupTelegramEnabled": fmt.Sprint(req.TelegramEnabled),
"backupGoogleDriveEnabled": fmt.Sprint(req.GoogleDriveEnabled), "backupGoogleClientId": strings.TrimSpace(req.GoogleClientID), "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, "保存失败") respondError(w, 500, "保存失败")
return 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 { 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) { func (a *App) createDisasterBackup(ctx context.Context, password string) (string, error) {
cfg := a.config() cfg := a.config()
if cfg.BackupSourceDir == "" || cfg.BackupDir == "" { if !a.backupAssetsAvailable() {
return "", errors.New("backup directories are not configured") return "", errors.New("backup directories are not configured")
} }
if err := os.MkdirAll(cfg.BackupDir, 0o700); err != nil { 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 return "", err
} }
} }
for _, name := range []string{".env", "docker-compose.yml"} { if err := copyFile(filepath.Join(cfg.BackupSourceDir, "docker-compose.yml"), filepath.Join(root, "docker-compose.yml")); err != nil {
if err := copyFile(filepath.Join(cfg.BackupSourceDir, name), filepath.Join(root, name)); 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 return "", err
} }
} }
@@ -598,6 +641,38 @@ func (a *App) createDisasterBackup(ctx context.Context, password string) (string
return outPath, nil 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) { func (a *App) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
if !a.requireSystemAdmin(w, r) { if !a.requireSystemAdmin(w, r) {
return 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) { func (a *App) backupTelegramReport(ctx context.Context, path string, info os.FileInfo) (string, error) {
cfg := a.config() cfg := a.config()
schedule, _ := a.loadBackupSchedule(ctx)
sum, _ := fileSHA256(path) sum, _ := fileSHA256(path)
domains, err := queryBackupStrings(ctx, a.db, `SELECT name FROM domains ORDER BY name`) domains, err := queryBackupStrings(ctx, a.db, `SELECT name FROM domains ORDER BY name`)
if err != nil { if err != nil {
@@ -914,9 +988,9 @@ func (a *App) backupTelegramReport(ctx context.Context, path string, info os.Fil
} }
return strings.Join(items, "、") + suffix return strings.Join(items, "、") + suffix
} }
serverIP := strings.TrimSpace(schedule.ServerIP) serverIP := detectPublicServerIP(ctx, cfg.PublicHostname)
if serverIP == "" { if serverIP == "" {
serverIP = "未填写" serverIP = "未检测到"
} }
return fmt.Sprintf("<b>%s 备份成功</b>\n\n<b>邮局域名:</b>%s\n<b>服务器 IP</b>%s\n<b>系统版本:</b>%s\n\n<b>已有域名:</b>\n%s\n\n<b>管理员账号:</b>\n%s\n\n<b>普通用户账号:</b>\n%s\n\n<b>邮箱账号:</b>\n%s\n\n<b>备份文件:</b>%s\n<b>文件大小:</b>%s\n<b>SHA-256</b><code>%s</code>\n\n<b>恢复教程:</b>\n1. 请不要解压、改名或修改压缩备份文件。\n2. 将原始附件上传到新服务器的 <code>/root/</code> 目录。\n3. 运行官方安装脚本,显示管理菜单后输入 2,选择“备份恢复”。\n4. 选择“本地上传”,系统会自动检测 /root/ 中的备份。\n5. 只有一份时自动选中;多份时显示 1、2、3 等序号。\n6. 输入对应序号,例如输入 1 恢复第 1 份。\n7. 输入备份密码后开始恢复。没有检测到文件时才手动输入路径。\n8. 恢复完成后,账号继续使用原登录密码。\n9. 以后需要管理系统时,可以直接输入 ns 打开管理菜单。\n\n<b>安全提示:</b>备份密码不会发送到 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 return fmt.Sprintf("<b>%s 备份成功</b>\n\n<b>邮局域名:</b>%s\n<b>服务器 IP</b>%s\n<b>系统版本:</b>%s\n\n<b>已有域名:</b>\n%s\n\n<b>管理员账号:</b>\n%s\n\n<b>普通用户账号:</b>\n%s\n\n<b>邮箱账号:</b>\n%s\n\n<b>备份文件:</b>%s\n<b>文件大小:</b>%s\n<b>SHA-256</b><code>%s</code>\n\n<b>恢复教程:</b>\n1. 请不要解压、改名或修改压缩备份文件。\n2. 将原始附件上传到新服务器的 <code>/root/</code> 目录。\n3. 运行官方安装脚本,显示管理菜单后输入 2,选择“备份恢复”。\n4. 选择“本地上传”,系统会自动检测 /root/ 中的备份。\n5. 只有一份时自动选中;多份时显示 1、2、3 等序号。\n6. 输入对应序号,例如输入 1 恢复第 1 份。\n7. 输入备份密码后开始恢复。没有检测到文件时才手动输入路径。\n8. 恢复完成后,账号继续使用原登录密码。\n9. 以后需要管理系统时,可以直接输入 ns 打开管理菜单。\n\n<b>安全提示:</b>备份密码不会发送到 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
} }
@@ -6,6 +6,7 @@ import (
"io" "io"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "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) { func TestBackupPasswordEncryptionAndTelegramReport(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
a := newTestAppWithConfig(t, Config{ a := newTestAppWithConfig(t, Config{
+1 -1
View File
@@ -133,7 +133,7 @@ func LoadConfig() Config {
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"), ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""), UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""), 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")), BackupDir: getenv("LANQIN_BACKUP_DIR", filepath.Join(dataDir, "disaster-backups")),
} }
} }
+5 -7
View File
@@ -287,7 +287,6 @@ function BackupsSection() {
const [schedulePassword, setSchedulePassword] = React.useState("") const [schedulePassword, setSchedulePassword] = React.useState("")
const [scheduleConfirmPassword, setScheduleConfirmPassword] = React.useState("") const [scheduleConfirmPassword, setScheduleConfirmPassword] = React.useState("")
const [showSchedulePassword, setShowSchedulePassword] = React.useState(false) const [showSchedulePassword, setShowSchedulePassword] = React.useState(false)
const [serverIp, setServerIp] = React.useState("")
const [backupChatId, setBackupChatId] = React.useState("") const [backupChatId, setBackupChatId] = React.useState("")
const [telegramMode, setTelegramMode] = React.useState<"system" | "custom">("system") const [telegramMode, setTelegramMode] = React.useState<"system" | "custom">("system")
const [telegramEnabled, setTelegramEnabled] = React.useState(true) const [telegramEnabled, setTelegramEnabled] = React.useState(true)
@@ -305,7 +304,6 @@ function BackupsSection() {
setScheduleEnabled(backups.data.schedule.enabled) setScheduleEnabled(backups.data.schedule.enabled)
setScheduleDays([3, 5, 7, 30].includes(days) ? String(days) : "custom") setScheduleDays([3, 5, 7, 30].includes(days) ? String(days) : "custom")
setCustomDays(String(days)) setCustomDays(String(days))
setServerIp(backups.data.schedule.serverIp || "")
setBackupChatId(backups.data.schedule.chatId || "") setBackupChatId(backups.data.schedule.chatId || "")
setTelegramMode(backups.data.schedule.telegramMode === "custom" ? "custom" : "system") setTelegramMode(backups.data.schedule.telegramMode === "custom" ? "custom" : "system")
setTelegramEnabled(backups.data.schedule.telegramEnabled) setTelegramEnabled(backups.data.schedule.telegramEnabled)
@@ -331,7 +329,7 @@ function BackupsSection() {
onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }), onError: (error) => toast({ title: "无法创建备份", description: error instanceof Error ? error.message : "请稍后重试" }),
}) })
const saveSchedule = useMutation({ 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: "备份设置已保存" }) }, onSuccess: async () => { setSchedulePassword(""); setScheduleConfirmPassword(""); setGoogleClientSecret(""); await qc.invalidateQueries({ queryKey: ["admin", "backups"] }); toast({ title: "备份设置已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }), onError: (error) => toast({ title: "保存失败", description: error instanceof Error ? error.message : "请稍后重试" }),
}) })
@@ -371,7 +369,7 @@ function BackupsSection() {
}) })
const connectDrive = useMutation({ const connectDrive = useMutation({
mutationFn: async () => { 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() return api.connectGoogleDrive()
}, },
onSuccess: ({ url }) => { window.location.href = url }, onSuccess: ({ url }) => { window.location.href = url },
@@ -450,7 +448,7 @@ function BackupsSection() {
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{!backups.data?.enabled && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"></div>} {!backups.data?.enabled && <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"></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 === "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>}
@@ -490,7 +488,7 @@ function BackupsSection() {
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2"><Label></Label><div className={cn("grid gap-2", scheduleDays === "custom" && "grid-cols-[minmax(0,1fr)_5.5rem]")}><Select value={scheduleDays} onValueChange={setScheduleDays}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="3"> 3 </SelectItem><SelectItem value="5"> 5 </SelectItem><SelectItem value="7"> 7 </SelectItem><SelectItem value="30"> 30 </SelectItem><SelectItem value="custom"></SelectItem></SelectContent></Select>{scheduleDays === "custom" && <Input id="backup-custom-days" aria-label="自定义天数" title="自定义天数" type="number" min={1} max={365} value={customDays} onChange={(event) => setCustomDays(event.target.value)} />}</div></div> <div className="space-y-2"><Label></Label><div className={cn("grid gap-2", scheduleDays === "custom" && "grid-cols-[minmax(0,1fr)_5.5rem]")}><Select value={scheduleDays} onValueChange={setScheduleDays}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="3"> 3 </SelectItem><SelectItem value="5"> 5 </SelectItem><SelectItem value="7"> 7 </SelectItem><SelectItem value="30"> 30 </SelectItem><SelectItem value="custom"></SelectItem></SelectContent></Select>{scheduleDays === "custom" && <Input id="backup-custom-days" aria-label="自定义天数" title="自定义天数" type="number" min={1} max={365} value={customDays} onChange={(event) => setCustomDays(event.target.value)} />}</div></div>
<div className="space-y-2"><Label htmlFor="backup-server-ip"> IP</Label><Input id="backup-server-ip" value={serverIp} onChange={(event) => setServerIp(event.target.value)} placeholder="例如 165.99.42.243" /></div> <div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-server-ip"> IP</Label><Button type="button" variant="ghost" size="icon" className="h-7 w-7" title="重新检测" aria-label="重新检测服务器 IP" disabled={backups.isFetching} onClick={() => backups.refetch()}><RefreshCcw className={cn("h-4 w-4", backups.isFetching && "animate-spin")} /></Button></div><Input id="backup-server-ip" readOnly value={backups.data?.schedule.serverIp || ""} placeholder={backups.isLoading ? "正在自动检测" : "未检测到,请检查邮局主机名 DNS"} /><p className="text-xs text-muted-foreground"> DNS </p></div>
<div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-schedule-password"></Label><PasswordTools value={schedulePassword} visible={showSchedulePassword} onVisibleChange={setShowSchedulePassword} onGenerate={generateSchedulePassword} /></div><Input id="backup-schedule-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={schedulePassword} onChange={(event) => setSchedulePassword(event.target.value)} placeholder={backups.data?.schedule.passwordSet ? "已保存,留空不变" : "至少 8 个字符"} /></div> <div className="space-y-2"><div className="flex h-7 items-center justify-between gap-2"><Label htmlFor="backup-schedule-password"></Label><PasswordTools value={schedulePassword} visible={showSchedulePassword} onVisibleChange={setShowSchedulePassword} onGenerate={generateSchedulePassword} /></div><Input id="backup-schedule-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={schedulePassword} onChange={(event) => setSchedulePassword(event.target.value)} placeholder={backups.data?.schedule.passwordSet ? "已保存,留空不变" : "至少 8 个字符"} /></div>
<div className="space-y-2"><div className="flex h-7 items-center"><Label htmlFor="backup-schedule-confirm-password"></Label></div><Input id="backup-schedule-confirm-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={scheduleConfirmPassword} onChange={(event) => setScheduleConfirmPassword(event.target.value)} placeholder={schedulePassword ? "再次输入备份密码" : "留空则不修改"} /></div> <div className="space-y-2"><div className="flex h-7 items-center"><Label htmlFor="backup-schedule-confirm-password"></Label></div><Input id="backup-schedule-confirm-password" type={showSchedulePassword ? "text" : "password"} autoComplete="new-password" value={scheduleConfirmPassword} onChange={(event) => setScheduleConfirmPassword(event.target.value)} placeholder={schedulePassword ? "再次输入备份密码" : "留空则不修改"} /></div>
</div> </div>
@@ -547,7 +545,7 @@ function BackupsSection() {
<div className="space-y-2"><Label htmlFor="google-client-id">OAuth ID</Label><Input id="google-client-id" value={googleClientId} onChange={(e) => setGoogleClientId(e.target.value)} /></div> <div className="space-y-2"><Label htmlFor="google-client-id">OAuth ID</Label><Input id="google-client-id" value={googleClientId} onChange={(e) => setGoogleClientId(e.target.value)} /></div>
<div className="space-y-2"><Label htmlFor="google-client-secret">OAuth </Label><Input id="google-client-secret" type="password" value={googleClientSecret} onChange={(e) => setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} /></div> <div className="space-y-2"><Label htmlFor="google-client-secret">OAuth </Label><Input id="google-client-secret" type="password" value={googleClientSecret} onChange={(e) => setGoogleClientSecret(e.target.value)} placeholder={backups.data?.googleDrive.clientSecretSet ? "已安全保存,留空不变" : "请输入客户端密钥"} /></div>
<div className="space-y-2"><Label htmlFor="google-folder-name"></Label><Input id="google-folder-name" value={googleFolderName} onChange={(e) => setGoogleFolderName(e.target.value)} /></div> <div className="space-y-2"><Label htmlFor="google-folder-name"></Label><Input id="google-folder-name" value={googleFolderName} onChange={(e) => setGoogleFolderName(e.target.value)} /></div>
<p className="text-xs text-muted-foreground">Google Cloud {window.location.origin}/api/admin/backups/google-drive/callback</p> <div className="space-y-2"><Label htmlFor="google-callback-url">Google Cloud </Label><div className="flex gap-2"><Input id="google-callback-url" readOnly className="min-w-0 font-mono text-xs" value={`${window.location.origin}/api/admin/backups/google-drive/callback`} /><Button type="button" variant="outline" size="icon" className="shrink-0" title="复制回调地址" aria-label="复制 Google Cloud 回调地址" onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/api/admin/backups/google-drive/callback`); toast({ title: "回调地址已复制" }) }}><Copy className="h-4 w-4" /></Button></div></div>
</div> </div>
<DialogFooter className="gap-2 sm:justify-between"> <DialogFooter className="gap-2 sm:justify-between">
{backups.data?.googleDrive.connected ? <Button type="button" variant="outline" className="text-destructive" onClick={() => { disconnectDrive.mutate(); setGoogleConfigOpen(false) }}></Button> : <span />} {backups.data?.googleDrive.connected ? <Button type="button" variant="outline" className="text-destructive" onClick={() => { disconnectDrive.mutate(); setGoogleConfigOpen(false) }}></Button> : <span />}
+1
View File
@@ -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/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/nginx.conf /etc/nginx/sites-enabled/default
COPY deploy/all-in-one/entrypoint.sh /entrypoint.sh 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/main.cf /etc/postfix/main.cf
COPY deploy/postfix/master.cf /etc/postfix/master.cf COPY deploy/postfix/master.cf /etc/postfix/master.cf
+1
View File
@@ -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 apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
WORKDIR /app WORKDIR /app
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api 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 EXPOSE 8080 465 587
CMD ["lanqin-api"] CMD ["lanqin-api"]