mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
fix(sub): multi-inbound traffic + trojan/hysteria userinfo + utf-8 vmess remark
Three bugs surfaced by the new SubPage and the recent client-record refactor: - xray.ClientTraffic.Email is globally unique, so a multi-inbound client has exactly one traffic row attached to whichever inbound claimed it. Iterating inbound.ClientStats per inbound dedup-locked the first lookup to zero for clients that lived under any other inbound, so the SubPage info table read 0 B for all the multi- inbound subs. Replaced appendUniqueTraffic with a single AggregateTrafficByEmails(emails) helper that runs one WHERE email IN (?) over xray.ClientTraffic and folds the rows. GetSubs / SubClashService.GetClash / SubJsonService.GetJson all share it. - Trojan and Hysteria share-links embedded the raw password/auth into the userinfo (scheme://<value>@host) without percent-encoding, so passwords containing `/` or `=` (e.g., base64-with-padding) broke popular trojan clients with parse errors. Added encodeUserinfo() that wraps url.QueryEscape and rewrites the `+` (space) back to `%20` for parity with encodeURIComponent on the frontend; applied to trojan.password and hysteria.auth. Same fix on the frontend's genTrojanLink. - VMess link remarks ride inside a base64-encoded JSON payload, but the SubPage / ClientInfoModal parser used JSON.parse(atob(body)), which treats the binary string as Latin-1 and shreds any multi-byte UTF-8 sequence. Most visible on the emoji decorations (genRemark appends 📊/⏳), so a remark like `test-1.00GB📊` rendered as `test-1.00GBð…`. Routed through Uint8Array + TextDecoder('utf-8') so multi-byte codepoints survive.
This commit is contained in:
parent
6c279d48fd
commit
a038ad6135
6 changed files with 101 additions and 105 deletions
|
|
@ -481,7 +481,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
||||||
params.set('security', 'none');
|
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);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,17 @@ function trimEmail(remark: string, email: string): string {
|
||||||
.trim();
|
.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 } {
|
function parseLinkMeta(link: string): { protocol: string; remark: string } {
|
||||||
const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
|
const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link);
|
||||||
const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
|
const scheme = schemeMatch?.[1]?.toLowerCase() ?? '';
|
||||||
|
|
@ -79,7 +90,7 @@ function parseLinkMeta(link: string): { protocol: string; remark: string } {
|
||||||
if (scheme === 'vmess') {
|
if (scheme === 'vmess') {
|
||||||
try {
|
try {
|
||||||
const body = link.slice('vmess://'.length).split('#')[0];
|
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;
|
if (typeof json?.ps === 'string') remark = json.ps;
|
||||||
} catch { /* fall through to fragment parsing */ }
|
} catch { /* fall through to fragment parsing */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,17 @@ function isPostQuantumLink(link: string): boolean {
|
||||||
return false;
|
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 } {
|
function parseLinkMeta(link: string, idx: number): { protocol: string; remark: string } {
|
||||||
const fallback = `Link ${idx + 1}`;
|
const fallback = `Link ${idx + 1}`;
|
||||||
if (!link) return { protocol: 'LINK', remark: fallback };
|
if (!link) return { protocol: 'LINK', remark: fallback };
|
||||||
|
|
@ -122,7 +133,7 @@ function parseLinkMeta(link: string, idx: number): { protocol: string; remark: s
|
||||||
if (scheme === 'vmess') {
|
if (scheme === 'vmess') {
|
||||||
try {
|
try {
|
||||||
const body = link.slice('vmess://'.length).split('#')[0];
|
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;
|
if (typeof json?.ps === 'string') remark = json.ps;
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
yaml "github.com/goccy/go-yaml"
|
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/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SubClashService struct {
|
type SubClashService struct {
|
||||||
|
|
@ -38,8 +36,6 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var traffic xray.ClientTraffic
|
|
||||||
var clientTraffics []xray.ClientTraffic
|
|
||||||
var proxies []map[string]any
|
var proxies []map[string]any
|
||||||
|
|
||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
|
|
@ -54,7 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
||||||
s.SubService.projectThroughFallbackMaster(inbound)
|
s.SubService.projectThroughFallbackMaster(inbound)
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
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)...)
|
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
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
emails := make([]string, 0, len(seenEmails))
|
||||||
for index, clientTraffic := range clientTraffics {
|
for e := range seenEmails {
|
||||||
if index == 0 {
|
emails = append(emails, e)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
|
||||||
|
|
||||||
proxyNames := make([]string, 0, len(proxies)+1)
|
proxyNames := make([]string, 0, len(proxies)+1)
|
||||||
for _, proxy := range proxies {
|
for _, proxy := range proxies {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,12 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/random"
|
"github.com/mhsanaei/3x-ui/v3/util/random"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed default.json
|
//go:embed default.json
|
||||||
|
|
@ -97,8 +95,6 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var header string
|
var header string
|
||||||
var traffic xray.ClientTraffic
|
|
||||||
var clientTraffics []xray.ClientTraffic
|
|
||||||
var configArray []json_util.RawMessage
|
var configArray []json_util.RawMessage
|
||||||
|
|
||||||
seenEmails := make(map[string]struct{})
|
seenEmails := make(map[string]struct{})
|
||||||
|
|
@ -115,7 +111,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
|
||||||
|
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.SubID == subId {
|
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)...)
|
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
|
return "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare statistics
|
emails := make([]string, 0, len(seenEmails))
|
||||||
now := time.Now().UnixMilli()
|
for e := range seenEmails {
|
||||||
for index, clientTraffic := range clientTraffics {
|
emails = append(emails, e)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
|
||||||
|
|
||||||
// Combile outbounds
|
// Combile outbounds
|
||||||
var finalJson []byte
|
var finalJson []byte
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
|
||||||
var result []string
|
var result []string
|
||||||
var emails []string
|
var emails []string
|
||||||
var traffic xray.ClientTraffic
|
var traffic xray.ClientTraffic
|
||||||
var lastOnline int64
|
|
||||||
var hasEnabledClient bool
|
var hasEnabledClient bool
|
||||||
var clientTraffics []xray.ClientTraffic
|
|
||||||
inbounds, err := s.getInboundsBySubId(subId)
|
inbounds, err := s.getInboundsBySubId(subId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, 0, traffic, err
|
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))
|
result = append(result, s.GetLink(inbound, client.Email))
|
||||||
emails = append(emails, client.Email)
|
emails = append(emails, client.Email)
|
||||||
var ct xray.ClientTraffic
|
seenEmails[client.Email] = struct{}{}
|
||||||
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
|
||||||
if ct.LastOnline > lastOnline {
|
|
||||||
lastOnline = ct.LastOnline
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
uniqueEmails := make([]string, 0, len(seenEmails))
|
||||||
for index, clientTraffic := range clientTraffics {
|
for e := range seenEmails {
|
||||||
if index == 0 {
|
uniqueEmails = append(uniqueEmails, e)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
traffic, lastOnline := s.AggregateTrafficByEmails(uniqueEmails)
|
||||||
traffic.Enable = hasEnabledClient
|
traffic.Enable = hasEnabledClient
|
||||||
return result, emails, lastOnline, traffic, nil
|
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 {
|
func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
|
||||||
if expiryTime > 0 {
|
if expiryTime > 0 {
|
||||||
return expiryTime
|
return expiryTime
|
||||||
|
|
@ -163,28 +189,6 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
||||||
return inbounds, nil
|
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
|
// projectThroughFallbackMaster mutates the inbound in place so its
|
||||||
// Listen/Port/StreamSettings reflect the externally reachable master
|
// Listen/Port/StreamSettings reflect the externally reachable master
|
||||||
// when applicable. Covers both fallback mechanisms:
|
// when applicable. Covers both fallback mechanisms:
|
||||||
|
|
@ -396,7 +400,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||||
clients, _ := s.inboundService.GetClients(inbound)
|
clients, _ := s.inboundService.GetClients(inbound)
|
||||||
clientIndex := findClientIndex(clients, email)
|
clientIndex := findClientIndex(clients, email)
|
||||||
password := clients[clientIndex].Password
|
password := encodeUserinfo(clients[clientIndex].Password)
|
||||||
port := inbound.Port
|
port := inbound.Port
|
||||||
streamNetwork := stream["network"].(string)
|
streamNetwork := stream["network"].(string)
|
||||||
params := make(map[string]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, ""))
|
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://<value>@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 {
|
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
|
||||||
if inbound.Protocol != model.Shadowsocks {
|
if inbound.Protocol != model.Shadowsocks {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -507,7 +522,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auth := clients[clientIndex].Auth
|
auth := encodeUserinfo(clients[clientIndex].Auth)
|
||||||
params := make(map[string]string)
|
params := make(map[string]string)
|
||||||
|
|
||||||
params["security"] = "tls"
|
params["security"] = "tls"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue