diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index f22051a0e..192c8fb09 100644 --- a/internal/web/controller/server.go +++ b/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 diff --git a/internal/web/service/backup_filename_test.go b/internal/web/service/backup_filename_test.go new file mode 100644 index 000000000..5bc4e563b --- /dev/null +++ b/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) + } + }) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index a1994155f..ed87c7291 100644 --- a/internal/web/service/server.go +++ b/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 diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go index 819209fe2..4650846b3 100644 --- a/internal/web/service/tgbot/tgbot_report.go +++ b/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),