mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user