diff --git a/internal/web/service/backup_filename_test.go b/internal/web/service/backup_filename_test.go index 5bc4e563b..44a4fabfd 100644 --- a/internal/web/service/backup_filename_test.go +++ b/internal/web/service/backup_filename_test.go @@ -3,6 +3,7 @@ package service import ( "regexp" "testing" + "time" ) // getDb (controller) only accepts a Content-Disposition filename matching this @@ -36,3 +37,32 @@ func TestSanitizeBackupHost(t *testing.T) { }) } } + +// dateSuffixRegex narrows backupFilenameRegex to the exact _YYYY-MM-DD_HHMMSS shape. +var dateSuffixRegex = regexp.MustCompile(`^_\d{4}-\d{2}-\d{2}_\d{6}$`) + +func TestBackupDateSuffix(t *testing.T) { + cases := []struct { + name string + now time.Time + want string + }{ + {"utc midnight", time.Date(2026, 6, 27, 0, 0, 0, 0, time.UTC), "_2026-06-27_000000"}, + {"end of year", time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), "_2025-12-31_235959"}, + {"single digit month/day padded", time.Date(2026, 1, 5, 9, 4, 0, 0, time.UTC), "_2026-01-05_090400"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := backupDateSuffix(tc.now) + if got != tc.want { + t.Errorf("backupDateSuffix(%v) = %q, want %q", tc.now, got, tc.want) + } + if !dateSuffixRegex.MatchString(got) { + t.Errorf("backupDateSuffix(%v) = %q, not a valid date suffix", tc.now, got) + } + if !backupFilenameRegex.MatchString(got) { + t.Errorf("backupDateSuffix(%v) = %q, not a valid download filename char", tc.now, got) + } + }) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index da235a1b2..2d98a2880 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -1298,18 +1298,28 @@ func (s *ServerService) GetDb() ([]byte, error) { // BackupFilename returns the filename for a database backup, named after the // panel's address so a downloaded or Telegram-sent backup identifies the server -// it came from. requestHost is the browser's address: the getDb handler passes -// c.Request.Host so a panel download is named after whatever address the user -// reached the panel with, no Listen Domain needed. The Telegram bot has no -// request and passes "", falling back to the configured Listen Domain (webDomain) -// and then the public IP. The extension is .dump on PostgreSQL and .db on SQLite; -// the base falls back to "x-ui" when no address is known. +// it came from, followed by the current date and time (_YYYY-MM-DD_HHMMSS) so +// files accumulated in Telegram chat history group by server then sort +// chronologically and same-day backups stay distinct. requestHost is the +// browser's address: the getDb handler passes c.Request.Host so a panel download +// is named after whatever address the user reached the panel with, no Listen +// Domain needed. The Telegram bot has no request and passes "", falling back to +// the configured Listen Domain (webDomain) and then the public IP. 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(requestHost string) string { ext := ".db" if database.IsPostgres() { ext = ".dump" } - return s.backupHost(requestHost) + ext + return s.backupHost(requestHost) + backupDateSuffix(time.Now()) + ext +} + +// backupDateSuffix returns the _YYYY-MM-DD_HHMMSS chronological suffix appended +// after the host in backup filenames. Uses server-local time for consistency +// with the timestamp printed in the Telegram backup message body. +func backupDateSuffix(now time.Time) string { + return "_" + now.Format("2006-01-02_150405") } // backupHost picks the address used to name backup files: the browser's request