Преглед изворни кода

feat(backup): name DB backup files after the server address

Panel downloads and Telegram backups were always named x-ui.db / x-ui.dump, so backups from different servers were indistinguishable. Name them after the panel address instead: the configured web domain, or the public IP (IPv4 before IPv6) when no domain is set, falling back to x-ui.

Centralized in ServerService.BackupFilename(); host is sanitized to the getDb filename charset (IPv6 colons become hyphens) and read from the mutex-guarded LastStatus to avoid racing the status goroutine.
MHSanaei пре 15 часа
родитељ
комит
a7e959ff49

+ 1 - 5
internal/web/controller/server.go

@@ -8,7 +8,6 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
@@ -294,10 +293,7 @@ func (a *ServerController) getDb(c *gin.Context) {
 		return
 	}
 
-	filename := "x-ui.db"
-	if database.IsPostgres() {
-		filename = "x-ui.dump"
-	}
+	filename := a.serverService.BackupFilename()
 	if !filenameRegex.MatchString(filename) {
 		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
 		return

+ 38 - 0
internal/web/service/backup_filename_test.go

@@ -0,0 +1,38 @@
+package service
+
+import (
+	"regexp"
+	"testing"
+)
+
+// getDb (controller) only accepts a Content-Disposition filename matching this
+// pattern, so every sanitizeBackupHost output must satisfy it.
+var backupFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
+
+func TestSanitizeBackupHost(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"domain", "panel.example.com", "panel.example.com"},
+		{"ipv4", "203.0.113.5", "203.0.113.5"},
+		{"ipv6", "2001:db8::1", "2001-db8--1"},
+		{"ipv6 bracketed", "[fe80::1]", "fe80--1"},
+		{"domain with port", "example.com:8443", "example.com-8443"},
+		{"trims edge dots and dashes", "-.example.com.-", "example.com"},
+		{"empty falls back", "", "x-ui"},
+		{"all invalid falls back", ":::", "x-ui"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := sanitizeBackupHost(tc.in)
+			if got != tc.want {
+				t.Errorf("sanitizeBackupHost(%q) = %q, want %q", tc.in, got, tc.want)
+			}
+			if !backupFilenameRegex.MatchString(got) {
+				t.Errorf("sanitizeBackupHost(%q) = %q, not a valid download filename", tc.in, got)
+			}
+		})
+	}
+}

+ 55 - 0
internal/web/service/server.go

@@ -1229,6 +1229,61 @@ func (s *ServerService) GetDb() ([]byte, error) {
 	return fileContents, nil
 }
 
+// BackupFilename returns the filename for a database backup, named after the
+// panel's address — the configured web domain, or the server's public IP when
+// no domain is set — so a downloaded or Telegram-sent backup identifies the
+// server it came from. The extension is .dump on PostgreSQL and .db on SQLite;
+// the base falls back to "x-ui" when no address is known.
+func (s *ServerService) BackupFilename() string {
+	ext := ".db"
+	if database.IsPostgres() {
+		ext = ".dump"
+	}
+	return s.backupHost() + ext
+}
+
+// backupHost picks the address used to name backup files, preferring the
+// configured web domain and otherwise the cached public IP (IPv4 before IPv6),
+// reduced to safe filename characters.
+func (s *ServerService) backupHost() string {
+	host := ""
+	if domain, err := s.settingService.GetWebDomain(); err == nil {
+		host = strings.TrimSpace(domain)
+	}
+	if host == "" {
+		if st := s.LastStatus(); st != nil {
+			if ip := st.PublicIP.IPv4; ip != "" && ip != "N/A" {
+				host = ip
+			} else if ip := st.PublicIP.IPv6; ip != "" && ip != "N/A" {
+				host = ip
+			}
+		}
+	}
+	return sanitizeBackupHost(host)
+}
+
+// sanitizeBackupHost reduces a host to characters safe in a download filename
+// (the getDb handler enforces ^[a-zA-Z0-9_\-.]+$). IPv6 brackets are stripped
+// and any other character — such as the colons in an IPv6 address — becomes a
+// hyphen. Returns "x-ui" when nothing usable remains.
+func sanitizeBackupHost(host string) string {
+	host = strings.Trim(host, "[]")
+	var b strings.Builder
+	for _, r := range host {
+		switch {
+		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
+			b.WriteRune(r)
+		default:
+			b.WriteRune('-')
+		}
+	}
+	out := strings.Trim(b.String(), ".-")
+	if out == "" {
+		return "x-ui"
+	}
+	return out
+}
+
 // GetMigration produces a cross-engine migration file plus its filename: on a
 // SQLite panel it returns a portable .dump (SQL text), and on a PostgreSQL panel
 // it returns a .db SQLite database built from the live data. Either output can

+ 1 - 5
internal/web/service/tgbot/tgbot_report.go

@@ -10,7 +10,6 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
-	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -403,10 +402,7 @@ func (t *Tgbot) sendBackup(chatId int64) {
 	// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
 	dbData, err := t.serverService.GetDb()
 	if err == nil {
-		dbFilename := "x-ui.db"
-		if database.IsPostgres() {
-			dbFilename = "x-ui.dump"
-		}
+		dbFilename := t.serverService.BackupFilename()
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		document := tu.Document(
 			tu.ID(chatId),