diff --git a/internal/web/service/client_identity_normalized_test.go b/internal/web/service/client_identity_normalized_test.go index 5b1906715..57b17e9d7 100644 --- a/internal/web/service/client_identity_normalized_test.go +++ b/internal/web/service/client_identity_normalized_test.go @@ -4,8 +4,11 @@ import ( "strings" "testing" + "gorm.io/gorm" + "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) @@ -243,3 +246,155 @@ func settingsSubID(t *testing.T, inboundId int, email string) string { t.Fatalf("%q not found in settings", email) return "" } + +func attachEmailToInbound(t *testing.T, db *gorm.DB, tag, email string) { + t.Helper() + id := nodeInboundID(t, db, tag) + clients, err := (&ClientService{}).ListForInbound(db, id) + if err != nil { + t.Fatalf("list clients of %q: %v", tag, err) + } + clients = append(clients, model.Client{Email: email, Enable: true}) + if err := (&ClientService{}).SyncInbound(db, id, clients); err != nil { + t.Fatalf("attach %q to %q: %v", email, tag, err) + } +} + +func countTrafficRows(t *testing.T, db *gorm.DB, email string) int64 { + t.Helper() + var rows int64 + if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil { + t.Fatalf("count rows of %q: %v", email, err) + } + return rows +} + +// SQLite's LOWER() folds ASCII only, so an email with a non-ASCII capital must +// still be recognised as attached to a sibling inbound. +func TestEmailUsedByOtherInbounds_NonASCIICapital(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-a", 41001) + createNodeInbound(t, db, 1, "n1-b", 41002) + svc := &InboundService{} + + emails := []string{"Ivan", "Иван", "Ömer", "ÉMILE"} + for _, email := range emails { + attachEmailToInbound(t, db, "n1-a", email) + attachEmailToInbound(t, db, "n1-b", email) + } + except := nodeInboundID(t, db, "n1-a") + + shared, err := svc.emailsUsedByOtherInbounds(db, emails, except) + if err != nil { + t.Fatalf("emailsUsedByOtherInbounds: %v", err) + } + for _, email := range emails { + used, err := svc.emailUsedByOtherInbounds(db, email, except) + if err != nil { + t.Fatalf("emailUsedByOtherInbounds(%q): %v", email, err) + } + if !used { + t.Errorf("emailUsedByOtherInbounds(%q) = false, the client is attached to n1-b", email) + } + if !shared[strings.ToLower(email)] { + t.Errorf("emailsUsedByOtherInbounds misses %q, the client is attached to n1-b", email) + } + } +} + +// The traffic row holds usage, quota and expiry for every inbound of the client: +// a node dropping its inbound must not take it from the panel's own inbound. +func TestNodeInboundSweep_KeepsSharedRowOfNonASCIICapitalEmail(t *testing.T) { + db := initTrafficTestDB(t) + own := &model.Inbound{UserId: 1, Tag: "own-in", Enable: true, Port: 41000, Protocol: model.VLESS} + if err := db.Create(own).Error; err != nil { + t.Fatalf("create own inbound: %v", err) + } + createNodeInbound(t, db, 1, "n1-a", 41001) + createNodeInbound(t, db, 1, "n1-b", 41002) + svc := &InboundService{} + + for _, email := range []string{"Ivan", "Иван"} { + client := model.Client{Email: email, Enable: true, TotalGB: 1 << 30} + attachEmailToInbound(t, db, "own-in", email) + attachEmailToInbound(t, db, "n1-a", email) + if err := svc.AddClientStat(db, nodeInboundID(t, db, "n1-a"), &client); err != nil { + t.Fatalf("AddClientStat(%q): %v", email, err) + } + } + + snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{Tag: "n1-b"}}} + if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + + for _, email := range []string{"Ivan", "Иван"} { + if rows := countTrafficRows(t, db, email); rows != 1 { + t.Errorf("%q is still attached to own-in but has %d client_traffics rows, want 1", email, rows) + } + } +} + +// The duplicate-email check folds in Go as well: neither the stored spelling nor +// a case variant of it may be claimed under another subId. +func TestAddInboundClientRejectsTakenNonASCIICapitalEmail(t *testing.T) { + for _, email := range []string{"Иван", "иван", "ИВАН"} { + t.Run(email, func(t *testing.T) { + setupBulkDB(t) + cs := &ClientService{} + is := &InboundService{} + + seed := []model.Client{{ID: "id-ivan", Email: "Иван", Enable: true, SubID: "s-ivan"}} + owner := mkInbound(t, 21161, model.VLESS, clientsSettings(t, seed)) + if err := cs.SyncInbound(nil, owner.Id, seed); err != nil { + t.Fatalf("seed SyncInbound: %v", err) + } + target := mkInbound(t, 21162, model.VLESS, `{"clients": []}`) + + add := []model.Client{{ID: "id-other", Email: email, Enable: true, SubID: "s-other"}} + _, err := cs.AddInboundClient(is, &model.Inbound{Id: target.Id, Settings: clientsSettings(t, add)}) + if err == nil { + t.Fatalf("%q was accepted although Иван is taken", email) + } + if !strings.Contains(err.Error(), "Duplicate email") { + t.Errorf("error = %q, want it to mention Duplicate email", err) + } + }) + } +} + +// Scoped to one inbound, a depleted row goes only with the email's last inbound; +// a row that is not shared goes even when LOWER() cannot fold its email. +func TestDelDepletedClients_NonASCIICapitalEmail(t *testing.T) { + setupBulkDB(t) + cs := &ClientService{} + is := &InboundService{} + db := database.GetDB() + + shared := model.Client{ID: "id-ivan", Email: "Иван", Enable: true, SubID: "s-ivan"} + single := model.Client{ID: "id-omer", Email: "Ömer", Enable: true, SubID: "s-omer"} + keep := model.Client{ID: "id-keep", Email: "keep@x", Enable: true, SubID: "s-keep"} + ibA := mkInbound(t, 21171, model.VLESS, clientsSettings(t, []model.Client{shared, single, keep})) + ibB := mkInbound(t, 21172, model.VLESS, clientsSettings(t, []model.Client{shared})) + if err := cs.SyncInbound(nil, ibA.Id, []model.Client{shared, single, keep}); err != nil { + t.Fatalf("seed A: %v", err) + } + if err := cs.SyncInbound(nil, ibB.Id, []model.Client{shared}); err != nil { + t.Fatalf("seed B: %v", err) + } + for _, email := range []string{"Иван", "Ömer"} { + if err := db.Create(&xray.ClientTraffic{InboundId: ibA.Id, Email: email, Enable: true, Up: 100, Total: 100}).Error; err != nil { + t.Fatalf("seed traffic of %q: %v", email, err) + } + } + + if err := is.DelDepletedClients(ibA.Id); err != nil { + t.Fatalf("DelDepletedClients: %v", err) + } + + for email, want := range map[string]int64{"Иван": 1, "Ömer": 0} { + if rows := countTrafficRows(t, db, email); rows != want { + t.Errorf("%q has %d client_traffics rows, want %d", email, rows, want) + } + } +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 940fedbab..9a6d4e1ab 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -616,29 +616,20 @@ func (s *InboundService) GetAllEmails() ([]string, error) { // checked. One clients row owns an email's identity, so the answer no longer // needs a scan of every inbound's settings JSON (#6252). func (s *InboundService) emailSubIDsForClients(clients []model.Client) (map[string]string, error) { - want := make(map[string]struct{}, len(clients)) + emails := make([]string, 0, len(clients)) for i := range clients { - if email := strings.ToLower(strings.TrimSpace(clients[i].Email)); email != "" { - want[email] = struct{}{} - } - } - result := make(map[string]string, len(want)) - if len(want) == 0 { - return result, nil - } - lowered := make([]string, 0, len(want)) - for email := range want { - lowered = append(lowered, email) + emails = append(emails, clients[i].Email) } + result := make(map[string]string, len(emails)) db := database.GetDB() - for _, batch := range chunkStrings(lowered, sqlInChunk) { + for _, batch := range chunkStrings(emailSpellings(emails), sqlInChunk) { var rows []struct { Email string SubID string `gorm:"column:sub_id"` } err := db.Model(&model.ClientRecord{}). Select("email, sub_id"). - Where("LOWER(email) IN ?", batch). + Where("LOWER(email) IN ? OR email IN ?", batch, batch). Scan(&rows).Error if err != nil { return nil, err diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index 010440de4..c8b26f60d 100644 --- a/internal/web/service/inbound_clients.go +++ b/internal/web/service/inbound_clients.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "time" + "unicode" "github.com/google/uuid" @@ -123,17 +124,42 @@ func (s *InboundService) backfillClientStats(db *gorm.DB, inbounds []*model.Inbo return clientsByInbound } +// emailSpellings returns the arguments a "LOWER(col) IN ? OR col IN ?" match +// needs: SQLite's LOWER() folds ASCII only, so non-ASCII capitals get every case. +func emailSpellings(emails []string) []string { + seen := make(map[string]struct{}, len(emails)) + out := make([]string, 0, len(emails)) + for _, e := range emails { + e = strings.TrimSpace(e) + if e == "" { + continue + } + variants := []string{strings.ToLower(e)} + if strings.ContainsFunc(e, func(r rune) bool { return r > unicode.MaxASCII }) { + variants = caseVariants(e) + } + for _, v := range variants { + if _, dup := seen[v]; !dup { + seen[v] = struct{}{} + out = append(out, v) + } + } + } + return out +} + // emailUsedByOtherInbounds reports whether email lives in any inbound other than // exceptInboundId. Inside a transaction pass tx: a pool query there starves the pool. func (s *InboundService) emailUsedByOtherInbounds(db *gorm.DB, email string, exceptInboundId int) (bool, error) { - if email == "" { + spellings := emailSpellings([]string{email}) + if len(spellings) == 0 { return false, nil } var count int64 err := db.Table("client_inbounds"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). - Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) = ?", - exceptInboundId, strings.ToLower(strings.TrimSpace(email))). + Where("client_inbounds.inbound_id != ? AND (LOWER(clients.email) IN ? OR clients.email IN ?)", + exceptInboundId, spellings, spellings). Count(&count).Error if err != nil { return false, err @@ -141,34 +167,22 @@ func (s *InboundService) emailUsedByOtherInbounds(db *gorm.DB, email string, exc return count > 0, nil } +// emailsUsedByOtherInbounds is emailUsedByOtherInbounds for a batch; the result +// is keyed by the lower-cased email. func (s *InboundService) emailsUsedByOtherInbounds(db *gorm.DB, emails []string, exceptInboundId int) (map[string]bool, error) { shared := make(map[string]bool, len(emails)) - want := make(map[string]struct{}, len(emails)) - for _, e := range emails { - e = strings.ToLower(strings.TrimSpace(e)) - if e != "" { - want[e] = struct{}{} - } - } - if len(want) == 0 { - return shared, nil - } - lowered := make([]string, 0, len(want)) - for e := range want { - lowered = append(lowered, e) - } - for _, batch := range chunkStrings(lowered, sqlInChunk) { + for _, batch := range chunkStrings(emailSpellings(emails), sqlInChunk) { var rows []struct{ Email string } err := db.Table("client_inbounds"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). - Select("DISTINCT LOWER(clients.email) AS email"). - Where("client_inbounds.inbound_id != ? AND LOWER(clients.email) IN ?", exceptInboundId, batch). + Select("DISTINCT clients.email AS email"). + Where("client_inbounds.inbound_id != ? AND (LOWER(clients.email) IN ? OR clients.email IN ?)", exceptInboundId, batch, batch). Scan(&rows).Error if err != nil { return nil, err } for _, r := range rows { - shared[r.Email] = true + shared[strings.ToLower(r.Email)] = true } } return shared, nil diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index ef6e96564..9fd889c4e 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -1012,33 +1012,38 @@ func (s *InboundService) DelDepletedClients(id int) (err error) { if id < 0 { return tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error } - emails := make([]string, 0, len(depletedEmails)) - for e := range depletedEmails { - emails = append(emails, e) + rowEmails := make([]string, 0, len(depletedRows)) + for _, r := range depletedRows { + if r.Email != "" { + rowEmails = append(rowEmails, r.Email) + } } - var stillReferenced []string emailExpr := database.JSONFieldText("client.value", "email") stillQuery := fmt.Sprintf( - "SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?", + "SELECT DISTINCT %s %s WHERE LOWER(%s) IN ? OR %s IN ?", emailExpr, database.JSONClientsFromInbound(), emailExpr, + emailExpr, ) - if err := tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil { - return err + stillSet := make(map[string]struct{}, len(rowEmails)) + for _, batch := range chunkStrings(emailSpellings(rowEmails), sqlInChunk) { + var stillReferenced []string + if err := tx.Raw(stillQuery, batch, batch).Scan(&stillReferenced).Error; err != nil { + return err + } + for _, e := range stillReferenced { + stillSet[strings.ToLower(e)] = struct{}{} + } } - stillSet := make(map[string]struct{}, len(stillReferenced)) - for _, e := range stillReferenced { - stillSet[e] = struct{}{} - } - toDelete := make([]string, 0, len(emails)) - for _, e := range emails { - if _, kept := stillSet[e]; !kept { + toDelete := make([]string, 0, len(rowEmails)) + for _, e := range rowEmails { + if _, kept := stillSet[strings.ToLower(e)]; !kept { toDelete = append(toDelete, e) } } - if len(toDelete) > 0 { - if err := tx.Where("LOWER(email) IN ?", toDelete).Delete(xray.ClientTraffic{}).Error; err != nil { + for _, batch := range chunkStrings(emailSpellings(toDelete), sqlInChunk) { + if err := tx.Where("LOWER(email) IN ? OR email IN ?", batch, batch).Delete(xray.ClientTraffic{}).Error; err != nil { return err } }