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 @@
{{ i18n "pages.settings.panelUrlPath"}}
{{ i18n "pages.settings.panelUrlPathDesc"}}
-
+
@@ -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 @@
{ p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subPath)"
+ @blur="allSetting.subPath = PathUtil.normalizePath(allSetting.subPath)"
placeholder="/sub/">
@@ -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 @@
{ p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subJsonPath)"
+ @blur="allSetting.subJsonPath = PathUtil.normalizePath(allSetting.subJsonPath)"
placeholder="/json/">
@@ -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")
+ }
+}