mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
fix(client): apply per-field client edits to every inbound of the email (#5039)
applyClientFieldByEmail patched only the first inbound that the client_traffics row pointed at. For a multi-inbound client the sibling inbounds kept the old expiryTime/totalGB/limitIp in their settings JSON, and the next SyncInbound over a stale sibling reverted the edit in the normalized records — the Telegram bot's expiry change appeared to apply and then sprang back. Patch the field on every inbound linked to the email, falling back to the legacy single-inbound lookup for clients that were never normalized.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestResetClientExpiryTimeByEmail_MultiInbound reproduces #5039: a client
|
||||
// attached to several inbounds had its expiry patched only on the first
|
||||
// inbound's JSON, so the stale siblings reverted the change on the next sync.
|
||||
func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
const email = "multi@example.com"
|
||||
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c111"
|
||||
const oldExpiry = int64(1700000000000)
|
||||
const newExpiry = int64(1800000000000)
|
||||
|
||||
clientJSON := func(expiry int64) string {
|
||||
b, _ := json.Marshal(map[string]any{"clients": []map[string]any{{
|
||||
"email": email, "id": uid, "enable": true, "expiryTime": expiry, "subId": "sub-multi-1",
|
||||
}}})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
first := &model.Inbound{Tag: "vless-a", Enable: true, Port: 50001, Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON(oldExpiry)}
|
||||
second := &model.Inbound{Tag: "vless-b", Enable: true, Port: 50002, Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON(oldExpiry)}
|
||||
for _, ib := range []*model.Inbound{first, second} {
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
clientSvc := ClientService{}
|
||||
inboundSvc := InboundService{}
|
||||
for _, ib := range []*model.Inbound{first, second} {
|
||||
clients, err := inboundSvc.GetClients(ib)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
|
||||
}
|
||||
if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := clientSvc.ResetClientExpiryTimeByEmail(&inboundSvc, email, newExpiry); err != nil {
|
||||
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
|
||||
}
|
||||
|
||||
for _, ib := range []*model.Inbound{first, second} {
|
||||
fresh, err := inboundSvc.GetInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
|
||||
}
|
||||
clients, err := inboundSvc.GetClients(fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
|
||||
}
|
||||
if len(clients) != 1 || clients[0].ExpiryTime != newExpiry {
|
||||
t.Errorf("inbound %s settings expiry = %d, want %d (#5039)", ib.Tag, clients[0].ExpiryTime, newExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := clientSvc.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordByEmail: %v", err)
|
||||
}
|
||||
if rec.ExpiryTime != newExpiry {
|
||||
t.Errorf("client record expiry = %d, want %d", rec.ExpiryTime, newExpiry)
|
||||
}
|
||||
}
|
||||
@@ -949,54 +949,78 @@ func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clien
|
||||
// the matched client — that is the input contract UpdateInboundClient expects
|
||||
// (clients[0] is the new data; clientEmail locates the row to replace). It
|
||||
// backs the single-field by-email setters below.
|
||||
// applyClientFieldByEmail mutates a client field on every inbound the email is
|
||||
// attached to. A multi-inbound client is one logical identity: patching only
|
||||
// the first inbound's JSON would leave the siblings stale, and the next
|
||||
// SyncInbound over a stale sibling would revert the edit in the normalized
|
||||
// records (#5039).
|
||||
func (s *ClientService) applyClientFieldByEmail(inboundSvc *InboundService, clientEmail string, mutate func(c map[string]any)) (bool, error) {
|
||||
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
|
||||
inboundIds, err := s.GetInboundIdsForEmail(database.GetDB(), clientEmail)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if inbound == nil {
|
||||
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
|
||||
}
|
||||
|
||||
oldClients, err := inboundSvc.GetClients(inbound)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, oldClient := range oldClients {
|
||||
if oldClient.Email == clientEmail {
|
||||
found = true
|
||||
break
|
||||
if len(inboundIds) == 0 {
|
||||
// Legacy fallback for clients that only live in the inbound JSON and
|
||||
// were never normalized into client_inbounds.
|
||||
_, inbound, gErr := inboundSvc.GetClientInboundByEmail(clientEmail)
|
||||
if gErr != nil {
|
||||
return false, gErr
|
||||
}
|
||||
if inbound == nil {
|
||||
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
|
||||
}
|
||||
inboundIds = []int{inbound.Id}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
found := false
|
||||
for _, ibId := range inboundIds {
|
||||
inbound, gErr := inboundSvc.GetInbound(ibId)
|
||||
if gErr != nil {
|
||||
return needRestart, gErr
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
if uErr := json.Unmarshal([]byte(inbound.Settings), &settings); uErr != nil {
|
||||
return needRestart, uErr
|
||||
}
|
||||
clients, _ := settings["clients"].([]any)
|
||||
// UpdateInboundClient expects a single-client payload, so keep only the
|
||||
// matching entry in the scratch copy; it splices the result back into
|
||||
// the inbound's full client list itself.
|
||||
var newClients []any
|
||||
for client_index := range clients {
|
||||
c, ok := clients[client_index].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if c["email"] == clientEmail {
|
||||
mutate(c)
|
||||
c["updated_at"] = time.Now().Unix() * 1000
|
||||
newClients = append(newClients, any(c))
|
||||
}
|
||||
}
|
||||
if len(newClients) == 0 {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
settings["clients"] = newClients
|
||||
modifiedSettings, mErr := json.MarshalIndent(settings, "", " ")
|
||||
if mErr != nil {
|
||||
return needRestart, mErr
|
||||
}
|
||||
inbound.Settings = string(modifiedSettings)
|
||||
nr, uErr := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
|
||||
if uErr != nil {
|
||||
return needRestart, uErr
|
||||
}
|
||||
needRestart = needRestart || nr
|
||||
}
|
||||
|
||||
if !found {
|
||||
return false, common.NewError("Client Not Found For Email:", clientEmail)
|
||||
return needRestart, common.NewError("Client Not Found For Email:", clientEmail)
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
err = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
clients := settings["clients"].([]any)
|
||||
var newClients []any
|
||||
for client_index := range clients {
|
||||
c := clients[client_index].(map[string]any)
|
||||
if c["email"] == clientEmail {
|
||||
mutate(c)
|
||||
c["updated_at"] = time.Now().Unix() * 1000
|
||||
newClients = append(newClients, any(c))
|
||||
}
|
||||
}
|
||||
settings["clients"] = newClients
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inbound.Settings = string(modifiedSettings)
|
||||
return s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {
|
||||
|
||||
Reference in New Issue
Block a user