diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index f1142d87f..67b6faebe 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -1351,6 +1351,12 @@ func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, traf } func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) { + // The client's own row answers without loading and parsing its whole + // inbound; legacy clients never normalized into `clients` fall through. + if rec, rErr := s.GetRecordByEmail(nil, clientEmail); rErr == nil && rec != nil { + return rec.Enable, nil + } + _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) if err != nil { return false, err diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 9a6d4e1ab..a0296cb8f 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1802,6 +1802,23 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) { return inbound, nil } +// GetInboundRemarks returns the remarks of the given inbounds keyed by id +// without loading their settings; ids that do not exist are absent. +func (s *InboundService) GetInboundRemarks(ids []int) (map[int]string, error) { + db := database.GetDB() + remarks := make(map[int]string, len(ids)) + for _, batch := range chunkInts(ids, sqlInChunk) { + var rows []model.Inbound + if err := db.Select("id", "remark").Where("id IN ?", batch).Find(&rows).Error; err != nil { + return nil, err + } + for i := range rows { + remarks[rows[i].Id] = rows[i].Remark + } + } + return remarks, nil +} + func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) { db := database.GetDB() inbound := &model.Inbound{} diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go index e11e2ce77..fb8f367ea 100644 --- a/internal/web/service/tgbot/tgbot_client.go +++ b/internal/web/service/tgbot/tgbot_client.go @@ -86,14 +86,10 @@ func (t *Tgbot) describeAttachedInbounds(ids []int) string { if len(ids) == 0 { return "" } + remarks, _ := t.inboundService.GetInboundRemarks(ids) parts := make([]string, 0, len(ids)) for _, id := range ids { - ib, err := t.inboundService.GetInbound(id) - if err != nil || ib == nil { - parts = append(parts, fmt.Sprintf("#%d", id)) - continue - } - label := ib.Remark + label := remarks[id] if label == "" { label = fmt.Sprintf("#%d", id) } diff --git a/internal/web/service/tgbot/tgbot_review_fixes_test.go b/internal/web/service/tgbot/tgbot_review_fixes_test.go index f3c1c1ef3..75dfe3fce 100644 --- a/internal/web/service/tgbot/tgbot_review_fixes_test.go +++ b/internal/web/service/tgbot/tgbot_review_fixes_test.go @@ -2,12 +2,20 @@ package tgbot import ( "encoding/json" + "path/filepath" + "strconv" "strings" "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" + "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mymmrac/telego" + "gorm.io/gorm" ) // A login success must not fold into the failure card of the same IP: the @@ -272,3 +280,57 @@ func TestUsageEmailArgumentDoesNotLeakAnotherClient(t *testing.T) { t.Fatalf("a client's /usage leaked another client:\n%s\n%s", body, markup) } } + +// Listing an account's clients used to load their whole inbound, settings and +// all, several times per client, so the list cost its length times the inbound's. +func TestClientUsageDoesNotLoadTheInboundPerClient(t *testing.T) { + sent := recordingBotServer(t) + dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db")) + db := database.GetDB() + + const owned = 40 + clients := make([]model.Client, 0, owned) + for i := range owned { + n := strconv.Itoa(i) + clients = append(clients, model.Client{ID: "uuid-" + n, Email: "owned-" + n, SubID: "sub-" + n, TgID: ownerTgID, Enable: true}) + } + settings, err := json.Marshal(map[string]any{"clients": clients}) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + inbound := &model.Inbound{UserId: 1, Tag: "in-443", Remark: "shared-inbound", Enable: true, Port: 443, Protocol: model.VLESS, Settings: string(settings)} + if err := db.Create(inbound).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + if err := (&service.ClientService{}).SyncInbound(nil, inbound.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + for i := range clients { + if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: clients[i].Email, Enable: true}).Error; err != nil { + t.Fatalf("seed traffic: %v", err) + } + } + + origRunning := isRunning + t.Cleanup(func() { isRunning = origRunning }) + isRunning = true + + wholeRows := 0 + if err := db.Callback().Query().After("gorm:query").Register("test:whole_inbound_rows", func(tx *gorm.DB) { + if tx.Statement.Table == "inbounds" && len(tx.Statement.Selects) == 0 { + wholeRows++ + } + }); err != nil { + t.Fatalf("register query counter: %v", err) + } + t.Cleanup(func() { _ = db.Callback().Query().Remove("test:whole_inbound_rows") }) + + (&Tgbot{}).getClientUsage(ownerTgID, ownerTgID) + + if got := strings.Join(sent(), "\n"); !strings.Contains(got, "shared-inbound") { + t.Fatalf("no client listed with its inbound: %q", got) + } + if wholeRows >= owned { + t.Fatalf("the inbound row was loaded %d times for %d clients", wholeRows, owned) + } +}