release: prepare v1.2.34
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
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:
@@ -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":
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user