diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index c89e42c8..e7c8f92a 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -481,7 +481,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string { params.set('security', 'none'); } - const url = new URL(`trojan://${clientPassword}@${address}:${port}`); + const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); url.hash = encodeURIComponent(remark); return url.toString(); diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index c7c7989a..e9d2b0a2 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -61,6 +61,17 @@ function trimEmail(remark: string, email: string): string { .trim(); } +// Decode a base64 string as UTF-8. atob() returns a binary string where +// each char holds one raw byte (Latin-1 interpretation), which mangles +// any multi-byte UTF-8 sequence in the payload — most commonly the +// emoji decorations the panel embeds in remarks (📊, ⏳). +function base64DecodeUtf8(b64: string): string { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder('utf-8').decode(bytes); +} + function parseLinkMeta(link: string): { protocol: string; remark: string } { const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link); const scheme = schemeMatch?.[1]?.toLowerCase() ?? ''; @@ -79,7 +90,7 @@ function parseLinkMeta(link: string): { protocol: string; remark: string } { if (scheme === 'vmess') { try { const body = link.slice('vmess://'.length).split('#')[0]; - const json = JSON.parse(atob(body)) as { ps?: unknown }; + const json = JSON.parse(base64DecodeUtf8(body)) as { ps?: unknown }; if (typeof json?.ps === 'string') remark = json.ps; } catch { /* fall through to fragment parsing */ } } diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index 2cef8cd7..4aecd42e 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -102,6 +102,17 @@ function isPostQuantumLink(link: string): boolean { return false; } +// Decode a base64 string as UTF-8. atob() returns a binary string where +// each char holds one raw byte (Latin-1 interpretation), which mangles +// any multi-byte UTF-8 sequence in the payload — most commonly the +// emoji decorations the panel embeds in remarks (📊, ⏳). +function base64DecodeUtf8(b64: string): string { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder('utf-8').decode(bytes); +} + function parseLinkMeta(link: string, idx: number): { protocol: string; remark: string } { const fallback = `Link ${idx + 1}`; if (!link) return { protocol: 'LINK', remark: fallback }; @@ -122,7 +133,7 @@ function parseLinkMeta(link: string, idx: number): { protocol: string; remark: s if (scheme === 'vmess') { try { const body = link.slice('vmess://'.length).split('#')[0]; - const json = JSON.parse(atob(body)) as { ps?: unknown }; + const json = JSON.parse(base64DecodeUtf8(body)) as { ps?: unknown }; if (typeof json?.ps === 'string') remark = json.ps; } catch { /* fall through */ } } diff --git a/sub/subClashService.go b/sub/subClashService.go index 829bcd34..de2e22a8 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -4,7 +4,6 @@ import ( "fmt" "maps" "strings" - "time" "github.com/goccy/go-json" yaml "github.com/goccy/go-yaml" @@ -12,7 +11,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/web/service" - "github.com/mhsanaei/3x-ui/v3/xray" ) type SubClashService struct { @@ -38,8 +36,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e return "", "", err } - var traffic xray.ClientTraffic - var clientTraffics []xray.ClientTraffic var proxies []map[string]any seenEmails := make(map[string]struct{}) @@ -54,7 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e s.SubService.projectThroughFallbackMaster(inbound) for _, client := range clients { if client.SubID == subId { - _, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email) + seenEmails[client.Email] = struct{}{} proxies = append(proxies, s.getProxies(inbound, client, host)...) } } @@ -64,27 +60,11 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e return "", "", nil } - now := time.Now().UnixMilli() - for index, clientTraffic := range clientTraffics { - if index == 0 { - traffic.Up = clientTraffic.Up - traffic.Down = clientTraffic.Down - traffic.Total = clientTraffic.Total - traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) - } else { - traffic.Up += clientTraffic.Up - traffic.Down += clientTraffic.Down - if traffic.Total == 0 || clientTraffic.Total == 0 { - traffic.Total = 0 - } else { - traffic.Total += clientTraffic.Total - } - normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) - if normalized != traffic.ExpiryTime { - traffic.ExpiryTime = 0 - } - } + emails := make([]string, 0, len(seenEmails)) + for e := range seenEmails { + emails = append(emails, e) } + traffic, _ := s.SubService.AggregateTrafficByEmails(emails) proxyNames := make([]string, 0, len(proxies)+1) for _, proxy := range proxies { diff --git a/sub/subJsonService.go b/sub/subJsonService.go index bde3e12b..e0a89955 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -6,14 +6,12 @@ import ( "fmt" "maps" "strings" - "time" "github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/util/json_util" "github.com/mhsanaei/3x-ui/v3/util/random" "github.com/mhsanaei/3x-ui/v3/web/service" - "github.com/mhsanaei/3x-ui/v3/xray" ) //go:embed default.json @@ -97,8 +95,6 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err } var header string - var traffic xray.ClientTraffic - var clientTraffics []xray.ClientTraffic var configArray []json_util.RawMessage seenEmails := make(map[string]struct{}) @@ -115,7 +111,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err for _, client := range clients { if client.SubID == subId { - _, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email) + seenEmails[client.Email] = struct{}{} configArray = append(configArray, s.getConfig(inbound, client, host)...) } } @@ -125,28 +121,11 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err return "", "", nil } - // Prepare statistics - now := time.Now().UnixMilli() - for index, clientTraffic := range clientTraffics { - if index == 0 { - traffic.Up = clientTraffic.Up - traffic.Down = clientTraffic.Down - traffic.Total = clientTraffic.Total - traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) - } else { - traffic.Up += clientTraffic.Up - traffic.Down += clientTraffic.Down - if traffic.Total == 0 || clientTraffic.Total == 0 { - traffic.Total = 0 - } else { - traffic.Total += clientTraffic.Total - } - normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) - if normalized != traffic.ExpiryTime { - traffic.ExpiryTime = 0 - } - } + emails := make([]string, 0, len(seenEmails)) + for e := range seenEmails { + emails = append(emails, e) } + traffic, _ := s.SubService.AggregateTrafficByEmails(emails) // Combile outbounds var finalJson []byte diff --git a/sub/subService.go b/sub/subService.go index d417e557..e6697156 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -62,9 +62,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int var result []string var emails []string var traffic xray.ClientTraffic - var lastOnline int64 var hasEnabledClient bool - var clientTraffics []xray.ClientTraffic inbounds, err := s.getInboundsBySubId(subId) if err != nil { return nil, nil, 0, traffic, err @@ -101,40 +99,68 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int } result = append(result, s.GetLink(inbound, client.Email)) emails = append(emails, client.Email) - var ct xray.ClientTraffic - ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email) - if ct.LastOnline > lastOnline { - lastOnline = ct.LastOnline - } + seenEmails[client.Email] = struct{}{} } } } - now := time.Now().UnixMilli() - for index, clientTraffic := range clientTraffics { - if index == 0 { - traffic.Up = clientTraffic.Up - traffic.Down = clientTraffic.Down - traffic.Total = clientTraffic.Total - traffic.ExpiryTime = subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) - } else { - traffic.Up += clientTraffic.Up - traffic.Down += clientTraffic.Down - if traffic.Total == 0 || clientTraffic.Total == 0 { - traffic.Total = 0 - } else { - traffic.Total += clientTraffic.Total - } - normalized := subscriptionExpiryFromClient(now, clientTraffic.ExpiryTime) - if normalized != traffic.ExpiryTime { - traffic.ExpiryTime = 0 - } - } + uniqueEmails := make([]string, 0, len(seenEmails)) + for e := range seenEmails { + uniqueEmails = append(uniqueEmails, e) } + traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails) traffic.Enable = hasEnabledClient return result, emails, lastOnline, traffic, nil } +// AggregateTrafficByEmails resolves traffic for every email in one +// query and folds the rows into a single ClientTraffic + lastOnline. +// xray.ClientTraffic.Email is globally unique, so a multi-inbound +// client's single row is attached to exactly one inbound — iterating +// per-inbound ClientStats would miss it on the others. Used by GetSubs, +// SubClashService.GetClash, and SubJsonService.GetJson to keep the +// sub-info header consistent across all three formats. +func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraffic, int64) { + var agg xray.ClientTraffic + var lastOnline int64 + if len(emails) == 0 { + return agg, 0 + } + var rows []xray.ClientTraffic + if err := database.GetDB(). + Model(&xray.ClientTraffic{}). + Where("email IN ?", emails). + Find(&rows).Error; err != nil { + logger.Warning("SubService - AggregateTrafficByEmails: load by email:", err) + return agg, 0 + } + now := time.Now().UnixMilli() + for i, ct := range rows { + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } + if i == 0 { + agg.Up = ct.Up + agg.Down = ct.Down + agg.Total = ct.Total + agg.ExpiryTime = subscriptionExpiryFromClient(now, ct.ExpiryTime) + continue + } + agg.Up += ct.Up + agg.Down += ct.Down + if agg.Total == 0 || ct.Total == 0 { + agg.Total = 0 + } else { + agg.Total += ct.Total + } + normalized := subscriptionExpiryFromClient(now, ct.ExpiryTime) + if normalized != agg.ExpiryTime { + agg.ExpiryTime = 0 + } + } + return agg, lastOnline +} + func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 { if expiryTime > 0 { return expiryTime @@ -163,28 +189,6 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) return inbounds, nil } -// appendUniqueTraffic resolves the traffic stats for email and appends them -// to acc only the first time email is seen. Shared-email mode lets one -// client_traffics row underpin several inbounds, so without dedupe its -// quota and usage would be counted once per inbound. -func (s *SubService) appendUniqueTraffic(seen map[string]struct{}, acc []xray.ClientTraffic, stats []xray.ClientTraffic, email string) (xray.ClientTraffic, []xray.ClientTraffic) { - ct := s.getClientTraffics(stats, email) - if _, dup := seen[email]; !dup { - seen[email] = struct{}{} - acc = append(acc, ct) - } - return ct, acc -} - -func (s *SubService) getClientTraffics(traffics []xray.ClientTraffic, email string) xray.ClientTraffic { - for _, traffic := range traffics { - if traffic.Email == email { - return traffic - } - } - return xray.ClientTraffic{} -} - // projectThroughFallbackMaster mutates the inbound in place so its // Listen/Port/StreamSettings reflect the externally reachable master // when applicable. Covers both fallback mechanisms: @@ -396,7 +400,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string stream := unmarshalStreamSettings(inbound.StreamSettings) clients, _ := s.inboundService.GetClients(inbound) clientIndex := findClientIndex(clients, email) - password := clients[clientIndex].Password + password := encodeUserinfo(clients[clientIndex].Password) port := inbound.Port streamNetwork := stream["network"].(string) params := make(map[string]string) @@ -439,6 +443,17 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } +// encodeUserinfo percent-encodes a userinfo (password/auth) value so it +// can be safely embedded in a `scheme://@host:port` URL. RFC 3986 +// allows `=` in userinfo as a sub-delim, but several Trojan and Hysteria +// clients reject share-links where the password contains literal `/` +// or `=` (notably the common base64-with-padding shape produced by the +// panel). Encode them too — this matches encodeURIComponent() on the +// frontend and round-trips cleanly through net/url's parser. +func encodeUserinfo(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} + func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Shadowsocks { return "" @@ -507,7 +522,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin break } } - auth := clients[clientIndex].Auth + auth := encodeUserinfo(clients[clientIndex].Auth) params := make(map[string]string) params["security"] = "tls"