diff --git a/internal/web/service/tgbot/backup_delivery_test.go b/internal/web/service/tgbot/backup_delivery_test.go new file mode 100644 index 000000000..a09223140 --- /dev/null +++ b/internal/web/service/tgbot/backup_delivery_test.go @@ -0,0 +1,135 @@ +package tgbot + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/mymmrac/telego" +) + +const testXrayConfig = `{"log":{}}` + +// backupFixture gives GetDb and the config read real files in a throwaway folder. +func backupFixture(t *testing.T) (*Tgbot, func() []apiCall) { + t.Helper() + _, calls := newScreenTgbot(t, false) + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + binFolder := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", binFolder) + if err := os.WriteFile(filepath.Join(binFolder, "config.json"), []byte(testXrayConfig), 0o600); err != nil { + t.Fatal(err) + } + tb := initReportDB(t) + setTestAdmins(t, 1) + tb.resolveScreenArt() + return tb, calls +} + +func documentsOf(calls []apiCall) []apiCall { + var documents []apiCall + for _, call := range calls { + if call.Method == "sendDocument" { + documents = append(documents, call) + } + } + return documents +} + +// A Hide button on a backup deleted the admin's only copy of the database with +// one tap, so neither delivery path may offer a way to delete the files. +func TestBackupFilesCarryNoHideButton(t *testing.T) { + for _, tc := range []struct { + name string + deliver func(tb *Tgbot) + }{ + {"backup button", func(tb *Tgbot) { + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", From: telego.User{ID: 1}, Data: cbBackup, + Message: &telego.Message{MessageID: 50, Chat: telego.Chat{ID: 1, Type: "private"}}, + }, true) + }}, + {"scheduled backup", func(tb *Tgbot) { tb.SendBackupToAdmins() }}, + } { + t.Run(tc.name, func(t *testing.T) { + tb, calls := backupFixture(t) + tc.deliver(tb) + + documents := documentsOf(calls()) + if len(documents) != 2 { + t.Fatalf("documents = %d, want the database and config.json (calls: %v)", len(documents), methods(calls())) + } + dbName, _ := documents[0].Payload["filename"].(string) + if !strings.HasSuffix(dbName, ".db") || documents[1].Payload["filename"] != "config.json" { + t.Errorf("files = %q, %v; want a .db then config.json", dbName, documents[1].Payload["filename"]) + } + for _, document := range documents { + if markup, ok := document.Payload["reply_markup"]; ok { + t.Errorf("%v carries a keyboard that can delete it: %v", document.Payload["filename"], markup) + } + } + }) + } +} + +// The files used to land under the menu and bury it, so the bot looked dead to +// an admin who stayed at the bottom of the chat. +func TestBackupButtonMovesTheMenuBelowTheFiles(t *testing.T) { + tb, calls := backupFixture(t) + const chatID int64 = 1 + tb.screenCategory(chatID, "maintenance") + menu, ok := tb.screens().get(chatID) + if !ok { + t.Fatal("menu not tracked") + } + before := len(calls()) + + tb.answerCallback(&telego.CallbackQuery{ + ID: "q1", From: telego.User{ID: 1}, Data: cbBackup, + Message: &telego.Message{MessageID: menu.msgID, Chat: telego.Chat{ID: chatID, Type: "private"}}, + }, true) + + got := calls()[before:] + want := []string{"answerCallbackQuery", "sendDocument", "sendDocument", "sendPhoto", "deleteMessage"} + if !reflect.DeepEqual(methods(got), want) { + t.Fatalf("calls = %v, want %v", methods(got), want) + } + if deleted := got[4].Payload["message_id"]; deleted != float64(menu.msgID) { + t.Errorf("deleted message %v, want the old menu %d", deleted, menu.msgID) + } + current, ok := tb.screens().get(chatID) + if !ok || current.msgID == menu.msgID || !hasButton(current.rows, cbBackup) { + t.Errorf("tracked screen = %+v, want a new maintenance menu", current) + } +} + +// A scheduled backup arrives unprompted: it must not move a menu the admin may +// be using at that moment. +func TestScheduledBackupLeavesTheMenuInPlace(t *testing.T) { + tb, calls := backupFixture(t) + tb.screenCategory(1, "maintenance") + menu, _ := tb.screens().get(1) + before := len(calls()) + + tb.SendBackupToAdmins() + + if got := methods(calls()[before:]); !reflect.DeepEqual(got, []string{"sendDocument", "sendDocument"}) { + t.Errorf("calls = %v, want only the two files", got) + } + if current, _ := tb.screens().get(1); current.msgID != menu.msgID { + t.Errorf("tracked menu moved from %d to %d", menu.msgID, current.msgID) + } +} + +func hasButton(rows [][]telego.InlineKeyboardButton, data string) bool { + for _, row := range rows { + for _, button := range row { + if button.CallbackData == data { + return true + } + } + } + return false +} diff --git a/internal/web/service/tgbot/notices.go b/internal/web/service/tgbot/notices.go index d290f291b..01e59018b 100644 --- a/internal/web/service/tgbot/notices.go +++ b/internal/web/service/tgbot/notices.go @@ -129,9 +129,9 @@ func (t *Tgbot) dropNoticeFor(chatID int64, msgID int) { } } -// sendDocumentWithCaption uploads a file with its summary as caption: a document -// cannot hold a screen, so one message does the work of two. -func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption string) bool { +// sendBackupFile uploads a file with its summary as caption. It has no hide button: +// that button deletes the message, and this may be the admin's only copy. +func (t *Tgbot) sendBackupFile(chatID int64, data []byte, name, caption string) bool { if liveBot() == nil || data == nil { return false } @@ -139,10 +139,9 @@ func (t *Tgbot) sendDocumentWithCaption(chatID int64, data []byte, name, caption defer cancel() _, err := liveBot().SendDocument(ctx, &telego.SendDocumentParams{ ChatID: tu.ID(chatID), MessageThreadID: adminTopic(chatID), - Document: tu.FileFromBytes(data, name), - Caption: caption, - ParseMode: "HTML", - ReplyMarkup: t.hideButton(), + Document: tu.FileFromBytes(data, name), + Caption: caption, + ParseMode: "HTML", }) if err != nil { logger.Warning("Failed to upload a file with a caption:", err) diff --git a/internal/web/service/tgbot/notices_test.go b/internal/web/service/tgbot/notices_test.go index 1ba5faa05..ab1420048 100644 --- a/internal/web/service/tgbot/notices_test.go +++ b/internal/web/service/tgbot/notices_test.go @@ -96,7 +96,7 @@ func TestBackupArrivesAsOneMessageWithACaption(t *testing.T) { notices.reset() tb := initReportDB(t) - tb.sendDocumentWithCaption(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time") + tb.sendBackupFile(1, []byte("db-bytes"), "x-ui.db", "host\nbackup time") got := calls() sends := 0 diff --git a/internal/web/service/tgbot/screen_view.go b/internal/web/service/tgbot/screen_view.go index e147743ed..3f0d0f4de 100644 --- a/internal/web/service/tgbot/screen_view.go +++ b/internal/web/service/tgbot/screen_view.go @@ -89,11 +89,11 @@ func (t *Tgbot) categoryRows(category string) [][]telego.InlineKeyboardButton { // screenCategory draws one menu subject. The hint line names the subject, so a // user who arrived through a notification can see where they are. -func (t *Tgbot) screenCategory(chatID int64, category string) { +func (t *Tgbot) screenCategory(chatID int64, category string, delivery ...screenDelivery) { body := t.I18nBot("tgbot.messages.category_" + category) rows := [][]telego.InlineKeyboardButton{} rows = append(rows, t.categoryRows(category)...) - t.renderScreen(chatID, t.newScreen("main", body, rows...)) + t.renderScreen(chatID, t.newScreen("main", body, rows...), delivery...) } // btn is where a localized label becomes a callback button; btn2 is btn with a diff --git a/internal/web/service/tgbot/screen_views.go b/internal/web/service/tgbot/screen_views.go index 4deeaf295..73e3f4fc0 100644 --- a/internal/web/service/tgbot/screen_views.go +++ b/internal/web/service/tgbot/screen_views.go @@ -266,11 +266,11 @@ func (t *Tgbot) sendBackupScreen(chatID int64) { // carries the backup and what it is for. body := summary + "\n\n" + t.depleteReport() caption := trimCaption(body, botCaptionLimit) - if t.sendDocumentWithCaption(chatID, dbData, t.serverService.BackupFilename(""), caption) { + if t.sendBackupFile(chatID, dbData, t.serverService.BackupFilename(""), caption) { // The generated config travels with the DB on both backup paths: an // operator restoring from the file would otherwise lose it silently. if config, err := os.ReadFile(xray.GetConfigPath()); err == nil { - t.sendDocumentWithCaption(chatID, config, "config.json", "") + t.sendBackupFile(chatID, config, "config.json", "") } if rest := strings.TrimSpace(strings.TrimPrefix(body, caption)); rest != "" { // Rare: the summary outgrew a caption. The file already landed. diff --git a/internal/web/service/tgbot/screens_test.go b/internal/web/service/tgbot/screens_test.go index 8775554ed..8212cdf6b 100644 --- a/internal/web/service/tgbot/screens_test.go +++ b/internal/web/service/tgbot/screens_test.go @@ -60,7 +60,8 @@ func recordingServer(t *testing.T, failEdit bool, overrides ...map[string]any) ( if strings.HasPrefix(string(body), "{") { // JSON payload: keep it as decoded. } else { - payload = map[string]any{} + r.Body = io.NopCloser(bytes.NewReader(body)) + payload = multipartPayload(r) } mu.Lock() calls = append(calls, apiCall{Method: method, Payload: payload}) @@ -139,6 +140,22 @@ func recordingServer(t *testing.T, failEdit bool, overrides ...map[string]any) ( } } +// multipartPayload exposes an upload's form fields as a payload; the uploaded +// document's file name is under "filename". +func multipartPayload(r *http.Request) map[string]any { + payload := map[string]any{} + if r.ParseMultipartForm(1<<22) != nil { + return payload + } + for name, values := range r.MultipartForm.Value { + payload[name] = values[0] + } + if files := r.MultipartForm.File["document"]; len(files) > 0 { + payload["filename"] = files[0].Filename + } + return payload +} + // newScreenTgbot points the package bot at a recording server and clears the // process-wide screen state the previous test may have left behind. func newScreenTgbot(t *testing.T, failEdit bool) (*Tgbot, func() []apiCall) { diff --git a/internal/web/service/tgbot/tgbot_report.go b/internal/web/service/tgbot/tgbot_report.go index 1b377896e..a37239efc 100644 --- a/internal/web/service/tgbot/tgbot_report.go +++ b/internal/web/service/tgbot/tgbot_report.go @@ -123,10 +123,10 @@ func (t *Tgbot) SendBackupToAdmins() { if dbData == nil { t.sendNotice(adminId, t.I18nBot("tgbot.messages.backupFailed")) } else { - t.sendDocumentWithCaption(adminId, dbData, dbFilename, t.backupSummary()) + t.sendBackupFile(adminId, dbData, dbFilename, t.backupSummary()) time.Sleep(500 * time.Millisecond) if configErr == nil { - t.sendDocumentWithCaption(adminId, config, "config.json", "") + t.sendBackupFile(adminId, config, "config.json", "") } } // Add delay between sends to avoid Telegram rate limits diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go index 2fe033e84..1c65523cd 100644 --- a/internal/web/service/tgbot/tgbot_router.go +++ b/internal/web/service/tgbot/tgbot_router.go @@ -1000,6 +1000,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool case "bkp", "get_backup": t.answerSilent(callbackQuery.ID) t.sendBackupScreen(chatId) + // The files landed under the menu; bring it back below them so it stays in view. + t.screenCategory(chatId, "maintenance", screenSendNew) case "ban", "get_banlogs": t.answerSilent(callbackQuery.ID) t.screenBanLogs(chatId)