From 95336c6919e8bc44322d3317160e261b4555eda9 Mon Sep 17 00:00:00 2001 From: Mohamadhosein Moazennia Date: Fri, 20 Feb 2026 11:20:00 +0330 Subject: [PATCH] refactor(inbound): extract client mutation helpers and simplify path handling --- web/assets/js/util/index.js | 19 +- web/html/settings.html | 15 +- web/html/settings/panel/general.html | 6 +- .../settings/panel/subscription/general.html | 4 +- .../settings/panel/subscription/json.html | 4 +- web/service/inbound.go | 333 ------------------ web/service/inbound_client_mutation.go | 191 ++++++++++ web/service/inbound_client_mutation_test.go | 49 +++ 8 files changed, 273 insertions(+), 348 deletions(-) create mode 100644 web/service/inbound_client_mutation.go create mode 100644 web/service/inbound_client_mutation_test.go diff --git a/web/assets/js/util/index.js b/web/assets/js/util/index.js index e69f3341..e0eca56c 100644 --- a/web/assets/js/util/index.js +++ b/web/assets/js/util/index.js @@ -717,6 +717,23 @@ class URLBuilder { } } +class PathUtil { + static normalizePath(path) { + let normalized = path || "/"; + if (!normalized.startsWith("/")) { + normalized = `/${normalized}`; + } + if (!normalized.endsWith("/")) { + normalized = `${normalized}/`; + } + return normalized.replace(/\/+/g, "/"); + } + + static stripLeadingSlash(path) { + return (path || "").replace(/^\/+/, ""); + } +} + class LanguageManager { static supportedLanguages = [ { @@ -916,4 +933,4 @@ class IntlUtil { return formatter.format(diff, 'day'); } -} \ No newline at end of file +} diff --git a/web/html/settings.html b/web/html/settings.html index 21294da7..c05ce8a8 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -344,8 +344,7 @@ const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = this.allSetting; const newProtocol = (webCertFile || webKeyFile) ? "https:" : "http:"; - let base = webBasePath ? webBasePath.replace(/^\//, "") : ""; - if (base && !base.endsWith("/")) base += "/"; + const base = PathUtil.stripLeadingSlash(PathUtil.normalizePath(webBasePath)); if (!this.entryIsIP) { const url = new URL(window.location.href); @@ -604,20 +603,20 @@ confAlerts: { get: function () { if (!this.allSetting) return []; - var alerts = [] + const alerts = []; if (window.location.protocol !== "https:") alerts.push('{{ i18n "secAlertSSL" }}'); if (this.allSetting.webPort === 2053) alerts.push('{{ i18n "secAlertPanelPort" }}'); - panelPath = window.location.pathname.split('/').length < 4 + const panelPath = window.location.pathname.split('/').length < 4; if (panelPath && this.allSetting.webBasePath == '/') alerts.push('{{ i18n "secAlertPanelURI" }}'); if (this.allSetting.subEnable) { - subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath; + const subPath = PathUtil.normalizePath(this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath); if (subPath == '/sub/') alerts.push('{{ i18n "secAlertSubURI" }}'); } if (this.allSetting.subJsonEnable) { - subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath; + const subJsonPath = PathUtil.normalizePath(this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath); if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}'); } - return alerts + return alerts; } } }, @@ -635,4 +634,4 @@ } }); -{{ template "page/body_end" .}} \ No newline at end of file +{{ template "page/body_end" .}} diff --git a/web/html/settings/panel/general.html b/web/html/settings/panel/general.html index 6969a1b4..72050665 100644 --- a/web/html/settings/panel/general.html +++ b/web/html/settings/panel/general.html @@ -46,7 +46,9 @@ @@ -277,4 +279,4 @@ -{{end}} \ No newline at end of file +{{end}} diff --git a/web/html/settings/panel/subscription/general.html b/web/html/settings/panel/subscription/general.html index 5d83aa37..e55210fd 100644 --- a/web/html/settings/panel/subscription/general.html +++ b/web/html/settings/panel/subscription/general.html @@ -43,7 +43,7 @@ @@ -142,4 +142,4 @@ -{{end}} \ No newline at end of file +{{end}} diff --git a/web/html/settings/panel/subscription/json.html b/web/html/settings/panel/subscription/json.html index e8642305..2ce8d632 100644 --- a/web/html/settings/panel/subscription/json.html +++ b/web/html/settings/panel/subscription/json.html @@ -7,7 +7,7 @@ @@ -199,4 +199,4 @@ -{{end}} \ No newline at end of file +{{end}} diff --git a/web/service/inbound.go b/web/service/inbound.go index 101c79d9..30856495 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1416,159 +1416,6 @@ func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraff return nil, nil, common.NewError("Client Not Found In Inbound For Email:", clientEmail) } -func (s *InboundService) SetClientTelegramUserID(trafficId int, tgId int64) (bool, error) { - traffic, inbound, err := s.GetClientInboundByTrafficID(trafficId) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId) - } - - clientEmail := traffic.Email - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, 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 { - c["tgId"] = tgId - 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) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - -func (s *InboundService) checkIsEnabledByEmail(clientEmail string) (bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - clients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - isEnable := false - - for _, client := range clients { - if client.Email == clientEmail { - isEnable = client.Enable - break - } - } - - return isEnable, err -} - -func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, false, err - } - if inbound == nil { - return false, false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, false, err - } - - clientId := "" - clientOldEnabled := false - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - clientOldEnabled = oldClient.Enable - break - } - } - - if len(clientId) == 0 { - return false, false, 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, 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 { - c["enable"] = !clientOldEnabled - 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, false, err - } - inbound.Settings = string(modifiedSettings) - - needRestart, err := s.UpdateInboundClient(inbound, clientId) - if err != nil { - return false, needRestart, err - } - - return !clientOldEnabled, needRestart, nil -} - // SetClientEnableByEmail sets client enable state to desired value; returns (changed, needRestart, error) func (s *InboundService) SetClientEnableByEmail(clientEmail string, enable bool) (bool, bool, error) { current, err := s.checkIsEnabledByEmail(clientEmail) @@ -1585,186 +1432,6 @@ func (s *InboundService) SetClientEnableByEmail(clientEmail string, enable bool) return newEnabled == enable, needRestart, nil } -func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int) (bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, 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 { - c["limitIp"] = count - 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) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - -func (s *InboundService) ResetClientExpiryTimeByEmail(clientEmail string, expiry_time int64) (bool, error) { - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, 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 { - c["expiryTime"] = expiry_time - 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) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - -func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, totalGB int) (bool, error) { - if totalGB < 0 { - return false, common.NewError("totalGB must be >= 0") - } - _, inbound, err := s.GetClientInboundByEmail(clientEmail) - if err != nil { - return false, err - } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := s.GetClients(inbound) - if err != nil { - return false, err - } - - clientId := "" - - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - switch inbound.Protocol { - case "trojan": - clientId = oldClient.Password - case "shadowsocks": - clientId = oldClient.Email - default: - clientId = oldClient.ID - } - break - } - } - - if len(clientId) == 0 { - return false, 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 { - c["totalGB"] = totalGB * 1024 * 1024 * 1024 - 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) - needRestart, err := s.UpdateInboundClient(inbound, clientId) - return needRestart, err -} - func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { db := database.GetDB() diff --git a/web/service/inbound_client_mutation.go b/web/service/inbound_client_mutation.go new file mode 100644 index 00000000..39a0b049 --- /dev/null +++ b/web/service/inbound_client_mutation.go @@ -0,0 +1,191 @@ +package service + +import ( + "encoding/json" + "time" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/util/common" +) + +func (s *InboundService) resolveInboundAndClient(clientEmail string) (*model.Inbound, string, bool, error) { + _, inbound, err := s.GetClientInboundByEmail(clientEmail) + if err != nil { + return nil, "", false, err + } + if inbound == nil { + return nil, "", false, common.NewError("Inbound Not Found For Email:", clientEmail) + } + + clients, err := s.GetClients(inbound) + if err != nil { + return nil, "", false, err + } + + clientID := "" + clientEnabled := false + for _, oldClient := range clients { + if oldClient.Email != clientEmail { + continue + } + switch inbound.Protocol { + case "trojan": + clientID = oldClient.Password + case "shadowsocks": + clientID = oldClient.Email + default: + clientID = oldClient.ID + } + clientEnabled = oldClient.Enable + break + } + + if clientID == "" { + return nil, "", false, common.NewError("Client Not Found For Email:", clientEmail) + } + + return inbound, clientID, clientEnabled, nil +} + +func (s *InboundService) applySingleClientUpdate(inbound *model.Inbound, clientEmail string, mutate func(client map[string]any)) error { + var settings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + return err + } + clients, ok := settings["clients"].([]any) + if !ok { + return common.NewError("invalid clients format in inbound settings") + } + + newClients := make([]any, 0, 1) + for idx := range clients { + c, ok := clients[idx].(map[string]any) + if !ok { + continue + } + if c["email"] != clientEmail { + continue + } + mutate(c) + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, c) + break + } + + if len(newClients) == 0 { + return common.NewError("Client Not Found For Email:", clientEmail) + } + + settings["clients"] = newClients + modifiedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + inbound.Settings = string(modifiedSettings) + return nil +} + +func (s *InboundService) SetClientTelegramUserID(trafficId int, tgId int64) (bool, error) { + traffic, inbound, err := s.GetClientInboundByTrafficID(trafficId) + if err != nil { + return false, err + } + if inbound == nil { + return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId) + } + clientEmail := traffic.Email + + _, clientID, _, err := s.resolveInboundAndClient(clientEmail) + if err != nil { + return false, err + } + + if err := s.applySingleClientUpdate(inbound, clientEmail, func(client map[string]any) { + client["tgId"] = tgId + }); err != nil { + return false, err + } + + needRestart, err := s.UpdateInboundClient(inbound, clientID) + return needRestart, err +} + +func (s *InboundService) checkIsEnabledByEmail(clientEmail string) (bool, error) { + _, _, enabled, err := s.resolveInboundAndClient(clientEmail) + if err != nil { + return false, err + } + return enabled, nil +} + +func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bool, error) { + inbound, clientID, oldEnabled, err := s.resolveInboundAndClient(clientEmail) + if err != nil { + return false, false, err + } + + if err := s.applySingleClientUpdate(inbound, clientEmail, func(client map[string]any) { + client["enable"] = !oldEnabled + }); err != nil { + return false, false, err + } + + needRestart, err := s.UpdateInboundClient(inbound, clientID) + if err != nil { + return false, needRestart, err + } + + return !oldEnabled, needRestart, nil +} + +func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int) (bool, error) { + inbound, clientID, _, err := s.resolveInboundAndClient(clientEmail) + if err != nil { + return false, err + } + + if err := s.applySingleClientUpdate(inbound, clientEmail, func(client map[string]any) { + client["limitIp"] = count + }); err != nil { + return false, err + } + + needRestart, err := s.UpdateInboundClient(inbound, clientID) + return needRestart, err +} + +func (s *InboundService) ResetClientExpiryTimeByEmail(clientEmail string, expiryTime int64) (bool, error) { + inbound, clientID, _, err := s.resolveInboundAndClient(clientEmail) + if err != nil { + return false, err + } + + if err := s.applySingleClientUpdate(inbound, clientEmail, func(client map[string]any) { + client["expiryTime"] = expiryTime + }); err != nil { + return false, err + } + + needRestart, err := s.UpdateInboundClient(inbound, clientID) + return needRestart, err +} + +func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, totalGB int) (bool, error) { + if totalGB < 0 { + return false, common.NewError("totalGB must be >= 0") + } + + inbound, clientID, _, err := s.resolveInboundAndClient(clientEmail) + if err != nil { + return false, err + } + + if err := s.applySingleClientUpdate(inbound, clientEmail, func(client map[string]any) { + client["totalGB"] = totalGB * 1024 * 1024 * 1024 + }); err != nil { + return false, err + } + + needRestart, err := s.UpdateInboundClient(inbound, clientID) + return needRestart, err +} diff --git a/web/service/inbound_client_mutation_test.go b/web/service/inbound_client_mutation_test.go new file mode 100644 index 00000000..f775af2f --- /dev/null +++ b/web/service/inbound_client_mutation_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v2/database/model" +) + +func TestApplySingleClientUpdate(t *testing.T) { + svc := &InboundService{} + inbound := &model.Inbound{Settings: `{"clients":[{"email":"a@example.com","limitIp":1},{"email":"b@example.com","limitIp":2}]}`} + + err := svc.applySingleClientUpdate(inbound, "b@example.com", func(client map[string]any) { + client["limitIp"] = 9 + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var settings map[string]any + if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil { + t.Fatalf("unmarshal updated settings: %v", err) + } + clients := settings["clients"].([]any) + if len(clients) != 1 { + t.Fatalf("expected one updated client payload, got %d", len(clients)) + } + client := clients[0].(map[string]any) + if client["email"] != "b@example.com" { + t.Fatalf("unexpected updated client email: %v", client["email"]) + } + if int(client["limitIp"].(float64)) != 9 { + t.Fatalf("expected limitIp=9, got %v", client["limitIp"]) + } + if _, ok := client["updated_at"]; !ok { + t.Fatalf("expected updated_at to be set") + } +} + +func TestApplySingleClientUpdateMissingClient(t *testing.T) { + svc := &InboundService{} + inbound := &model.Inbound{Settings: `{"clients":[{"email":"a@example.com"}]}`} + + err := svc.applySingleClientUpdate(inbound, "x@example.com", func(client map[string]any) {}) + if err == nil { + t.Fatalf("expected missing client error") + } +}