mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-30 17:44:21 +00:00
feat(sub): add dynamic remark variables with Jalali date, transport, and status tokens (#5430)
* feat(sub): implement dynamic single-bracket remark variables with timezone-aware inline Jalali conversion
* Update .gitignore
* Update .gitignore
* merge: bring in origin/main commits to resolve conflict base
* fix(sub): address review issues in dynamic remark variables
- Add TIME_LEFT to unlimitedDropTokens so segments containing only
{TIME_LEFT} are dropped for unlimited clients (same as DAYS_LEFT)
- Remove dead uiSingleBraceRe variable (translateUISingleBrackets uses
a character scanner, not this regex)
- Change expireDateLabel to use time.Local instead of UTC, consistent
with jalaliExpireDateLabel
Co-authored-by: Sanaei <MHSanaei@users.noreply.github.com>
* fix
* fix
---------
Co-authored-by: MHSanaei <MHSanaei@users.noreply.github.com>
This commit is contained in:
+2
-1
@@ -43,4 +43,5 @@ system_metrics.gob
|
||||
docker-compose.override.yml
|
||||
|
||||
# Ignore .env (Environment Variables) file
|
||||
.env
|
||||
.env
|
||||
|
||||
|
||||
@@ -222,6 +222,10 @@
|
||||
"description": "Encrypt subscription responses",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"description": "Hide server settings in happ subscription (Only for Happ)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"description": "Enable JSON subscription endpoint",
|
||||
"type": "boolean"
|
||||
@@ -439,6 +443,7 @@
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subHideSettings",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
@@ -698,6 +703,10 @@
|
||||
"description": "Encrypt subscription responses",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"description": "Hide server settings in happ subscription (Only for Happ)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"description": "Enable JSON subscription endpoint",
|
||||
"type": "boolean"
|
||||
@@ -922,6 +931,7 @@
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subHideSettings",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
|
||||
@@ -163,13 +163,14 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
|
||||
}}
|
||||
}
|
||||
delete(stream, "externalProxy")
|
||||
network, _ := stream["network"].(string)
|
||||
|
||||
proxies := make([]map[string]any, 0, len(externalProxies))
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
||||
// the synthetic/legacy entry) before it becomes the proxy name.
|
||||
subReq.renderHostRemark(inbound, client, extPrxy)
|
||||
subReq.renderHostRemark(inbound, client, extPrxy, network)
|
||||
workingInbound := *inbound
|
||||
workingInbound.Listen = extPrxy["dest"].(string)
|
||||
workingInbound.Port = int(extPrxy["port"].(float64))
|
||||
@@ -214,14 +215,14 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
||||
return s.buildHysteriaProxy(subReq, inbound, client, ep)
|
||||
}
|
||||
|
||||
network, _ := stream["network"].(string)
|
||||
|
||||
proxy := map[string]any{
|
||||
"name": subReq.endpointRemark(inbound, client.Email, ep),
|
||||
"name": subReq.endpointRemark(inbound, client.Email, ep, network),
|
||||
"server": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"udp": true,
|
||||
}
|
||||
|
||||
network, _ := stream["network"].(string)
|
||||
if !s.applyTransport(proxy, network, stream) {
|
||||
return nil
|
||||
}
|
||||
@@ -298,7 +299,7 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
||||
}
|
||||
|
||||
proxy := map[string]any{
|
||||
"name": subReq.endpointRemark(inbound, client.Email, ep),
|
||||
"name": subReq.endpointRemark(inbound, client.Email, ep, "quic"),
|
||||
"type": proxyType,
|
||||
"server": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
|
||||
@@ -103,7 +103,7 @@ func (s *SubService) buildEndpointLinks(
|
||||
}
|
||||
|
||||
// buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
|
||||
func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||
func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
|
||||
var links strings.Builder
|
||||
for index, e := range eps {
|
||||
securityToApply, _ := baseObj["tls"].(string)
|
||||
@@ -111,7 +111,7 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
|
||||
securityToApply = e.ForceTls
|
||||
}
|
||||
newObj := cloneVmessShareObj(baseObj, e.ForceTls)
|
||||
newObj["ps"] = s.endpointRemark(inbound, email, e.ep)
|
||||
newObj["ps"] = s.endpointRemark(inbound, email, e.ep, transport)
|
||||
newObj["add"] = e.Address
|
||||
newObj["port"] = e.Port
|
||||
if e.ForceTls != "same" {
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
|
||||
}
|
||||
got := s.buildEndpointLinks(eps, params, "tls",
|
||||
func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
|
||||
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark) },
|
||||
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
|
||||
)
|
||||
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
|
||||
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B"
|
||||
@@ -104,7 +104,7 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
|
||||
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
|
||||
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
|
||||
}
|
||||
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user")
|
||||
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
|
||||
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
|
||||
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
|
||||
if got != want {
|
||||
|
||||
@@ -197,13 +197,15 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
|
||||
if len(eps) == 0 {
|
||||
return ""
|
||||
}
|
||||
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||
transport, _ := stream["network"].(string)
|
||||
// Clone each ep before expanding its remark template: the eps slice is
|
||||
// shared across all clients of this inbound, so the rendered (per-client)
|
||||
// remark must not leak into the next client's links.
|
||||
rendered := make([]map[string]any, len(eps))
|
||||
for i, ep := range eps {
|
||||
cp := maps.Clone(ep)
|
||||
s.renderHostRemark(inbound, client, cp)
|
||||
s.renderHostRemark(inbound, client, cp, transport)
|
||||
rendered[i] = cp
|
||||
}
|
||||
clone := *inbound
|
||||
@@ -216,12 +218,12 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
|
||||
// renderers emit it verbatim (via endpointRemark) instead of re-composing it.
|
||||
// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
|
||||
// their output stays byte-identical.
|
||||
func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any) {
|
||||
func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any, transport string) {
|
||||
if !isHostEndpoint(ep) {
|
||||
return
|
||||
}
|
||||
tmpl, _ := ep["remark"].(string)
|
||||
ep["remark"] = s.genHostRemark(inbound, client, tmpl)
|
||||
ep["remark"] = s.genHostRemark(inbound, client, tmpl, transport)
|
||||
ep["remarkFinal"] = true
|
||||
}
|
||||
|
||||
@@ -229,7 +231,7 @@ func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Clien
|
||||
// entry. A host endpoint whose template was pre-expanded by renderHostRemark
|
||||
// carries remarkFinal and is used verbatim; every other entry flows through the
|
||||
// standard genRemark composition unchanged.
|
||||
func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any) string {
|
||||
func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any, transport string) string {
|
||||
if ep != nil {
|
||||
if final, _ := ep["remarkFinal"].(bool); final {
|
||||
r, _ := ep["remark"].(string)
|
||||
@@ -240,7 +242,7 @@ func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map
|
||||
if ep != nil {
|
||||
extra, _ = ep["remark"].(string)
|
||||
}
|
||||
return s.genRemark(inbound, email, extra)
|
||||
return s.genRemark(inbound, email, extra, transport)
|
||||
}
|
||||
|
||||
// applyEndpointHostPath overrides the transport host header / path for a host
|
||||
|
||||
@@ -174,12 +174,13 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
}
|
||||
|
||||
delete(stream, "externalProxy")
|
||||
network, _ := stream["network"].(string)
|
||||
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
||||
// the synthetic/legacy entry) before it's used as the config remark.
|
||||
subReq.renderHostRemark(inbound, client, extPrxy)
|
||||
subReq.renderHostRemark(inbound, client, extPrxy, network)
|
||||
inbound.Listen = extPrxy["dest"].(string)
|
||||
inbound.Port = int(extPrxy["port"].(float64))
|
||||
newStream := cloneStreamForExternalProxy(stream)
|
||||
@@ -220,8 +221,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
newConfigJson := make(map[string]any)
|
||||
maps.Copy(newConfigJson, s.configJson)
|
||||
|
||||
transport, _ := newStream["network"].(string)
|
||||
newConfigJson["outbounds"] = newOutbounds
|
||||
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy)
|
||||
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy, transport)
|
||||
|
||||
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
||||
newJsonArray = append(newJsonArray, newConfig)
|
||||
|
||||
+216
-8
@@ -1,6 +1,7 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -23,6 +24,7 @@ type remarkContext struct {
|
||||
stats xray.ClientTraffic
|
||||
inbound *model.Inbound
|
||||
hostRemark string
|
||||
transport string
|
||||
}
|
||||
|
||||
// configName is the display name for a link: always the inbound's own remark.
|
||||
@@ -50,6 +52,54 @@ var unlimitedDropTokens = map[string]bool{
|
||||
"TRAFFIC_LEFT": true,
|
||||
"TRAFFIC_TOTAL": true,
|
||||
"DAYS_LEFT": true,
|
||||
"TIME_LEFT": true,
|
||||
}
|
||||
|
||||
// uiTokenMap translates user-friendly single-brace tokens (used in the frontend
|
||||
// Remark/Host Name fields) to their internal double-brace equivalents. Tokens
|
||||
// not present in this map are left untouched.
|
||||
var uiTokenMap = map[string]string{
|
||||
"EMAIL": "EMAIL",
|
||||
"DATA_USAGE": "TRAFFIC_USED",
|
||||
"DATA_LEFT": "TRAFFIC_LEFT",
|
||||
"DATA_LIMIT": "TRAFFIC_TOTAL",
|
||||
"DAYS_LEFT": "DAYS_LEFT",
|
||||
"EXPIRE_DATE": "EXPIRE_DATE",
|
||||
"JALALI_EXPIRE_DATE": "JALALI_EXPIRE_DATE",
|
||||
"TIME_LEFT": "TIME_LEFT",
|
||||
"STATUS_EMOJI": "STATUS_EMOJI",
|
||||
"USAGE_PERCENTAGE": "USAGE_PERCENTAGE",
|
||||
"PROTOCOL": "PROTOCOL",
|
||||
"TRANSPORT": "TRANSPORT",
|
||||
}
|
||||
|
||||
// translateUISingleBrackets converts user-friendly single-brace tokens to the
|
||||
// internal double-brace format before regex expansion. Only {TOKEN} patterns
|
||||
// that are NOT part of {{TOKEN}} are translated. Unknown tokens stay as-is.
|
||||
func translateUISingleBrackets(template string) string {
|
||||
var result strings.Builder
|
||||
i := 0
|
||||
for i < len(template) {
|
||||
if template[i] == '{' && (i == 0 || template[i-1] != '{') {
|
||||
j := i + 1
|
||||
for j < len(template) && template[j] != '}' {
|
||||
j++
|
||||
}
|
||||
if j < len(template) && template[j] == '}' {
|
||||
token := template[i+1 : j]
|
||||
if internal, ok := uiTokenMap[token]; ok {
|
||||
result.WriteString("{{")
|
||||
result.WriteString(internal)
|
||||
result.WriteString("}}")
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
result.WriteByte(template[i])
|
||||
i++
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
|
||||
@@ -58,6 +108,7 @@ var unlimitedDropTokens = map[string]bool{
|
||||
// or expiry (∞) drops out whole — decoration and separator included — so an
|
||||
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
|
||||
func expandRemarkVars(template string, ctx remarkContext) string {
|
||||
template = translateUISingleBrackets(template)
|
||||
if !strings.Contains(template, "{{") {
|
||||
return template
|
||||
}
|
||||
@@ -164,6 +215,21 @@ func remarkVarValue(token string, ctx remarkContext) string {
|
||||
return strconv.Itoa(c.Reset)
|
||||
}
|
||||
return ""
|
||||
case "STATUS_EMOJI":
|
||||
return statusEmoji(st)
|
||||
case "USAGE_PERCENTAGE":
|
||||
return usagePercentage(st)
|
||||
case "PROTOCOL":
|
||||
if ctx.inbound != nil {
|
||||
return strings.ToUpper(string(ctx.inbound.Protocol))
|
||||
}
|
||||
return ""
|
||||
case "TRANSPORT":
|
||||
return ctx.transport
|
||||
case "TIME_LEFT":
|
||||
return timeLeftLabel(st.ExpiryTime)
|
||||
case "JALALI_EXPIRE_DATE":
|
||||
return jalaliExpireDateLabel(st.ExpiryTime)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -202,13 +268,13 @@ func daysLeftLabel(expiryMs int64) string {
|
||||
return strconv.FormatInt(days, 10)
|
||||
}
|
||||
|
||||
// expireDateLabel renders a fixed expiry as YYYY-MM-DD (UTC). Unlimited and
|
||||
// delayed-start (no fixed calendar date yet) expiries yield "".
|
||||
// expireDateLabel renders a fixed expiry as YYYY-MM-DD (local time). Unlimited
|
||||
// and delayed-start (no fixed calendar date yet) expiries yield "".
|
||||
func expireDateLabel(expiryMs int64) string {
|
||||
if expiryMs <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(expiryMs/1000, 0).UTC().Format("2006-01-02")
|
||||
return time.Unix(expiryMs/1000, 0).In(time.Local).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func max64(a, b int64) int64 {
|
||||
@@ -218,6 +284,145 @@ func max64(a, b int64) int64 {
|
||||
return b
|
||||
}
|
||||
|
||||
// statusEmoji maps clientStatus to a single emoji character.
|
||||
func statusEmoji(st xray.ClientTraffic) string {
|
||||
switch clientStatus(st) {
|
||||
case "active":
|
||||
return "✅"
|
||||
case "expired":
|
||||
return "⏳"
|
||||
case "depleted":
|
||||
return "🚫"
|
||||
case "disabled":
|
||||
return "🚫"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3%").
|
||||
// Returns "" when the client has no traffic limit.
|
||||
func usagePercentage(st xray.ClientTraffic) string {
|
||||
if st.Total <= 0 {
|
||||
return ""
|
||||
}
|
||||
used := st.Up + st.Down
|
||||
pct := float64(used) / float64(st.Total) * 100
|
||||
if pct > 100 {
|
||||
pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
|
||||
}
|
||||
return fmt.Sprintf("%.1f%%", pct)
|
||||
}
|
||||
|
||||
// timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
|
||||
// are zero). Returns "∞" for unlimited and "0" when past expiry.
|
||||
func timeLeftLabel(expiryMs int64) string {
|
||||
if expiryMs == 0 {
|
||||
return unlimitedMark
|
||||
}
|
||||
exp := expiryMs / 1000
|
||||
var secs int64
|
||||
if exp > 0 {
|
||||
secs = exp - time.Now().Unix()
|
||||
} else {
|
||||
secs = -exp
|
||||
}
|
||||
if secs <= 0 {
|
||||
return "0"
|
||||
}
|
||||
days := secs / 86400
|
||||
hours := (secs % 86400) / 3600
|
||||
mins := (secs % 3600) / 60
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
}
|
||||
return fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
|
||||
// jalaliExpireDateLabel converts a Gregorian expiry timestamp to Jalali
|
||||
// (Persian/Solar Hijri) date format "YYYY/MM/DD". Returns "" for unlimited
|
||||
// or delayed-start expiries.
|
||||
func jalaliExpireDateLabel(expiryMs int64) string {
|
||||
if expiryMs <= 0 {
|
||||
return ""
|
||||
}
|
||||
t := time.Unix(expiryMs/1000, 0).In(time.Local)
|
||||
y, m, d := gregorianToJalali(t.Year(), int(t.Month()), t.Day())
|
||||
return fmt.Sprintf("%d/%02d/%02d", y, m, d)
|
||||
}
|
||||
|
||||
// gregorianToJalali converts a Gregorian date to Jalali (Solar Hijri) date.
|
||||
// Uses a reference-date approach: counts days from a known reference point
|
||||
// (2024-01-01 = 1402-10-11 JAL) and walks the Jalali calendar forward/backward.
|
||||
func gregorianToJalali(gy, gm, gd int) (jy, jm, jd int) {
|
||||
// Compute Julian Day Number for the input Gregorian date
|
||||
a := (14 - gm) / 12
|
||||
y := gy + 4800 - a
|
||||
m := gm + 12*a - 3
|
||||
jdn := gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
|
||||
|
||||
// Reference: 2024-01-01 = JDN 2460311 = 1402-10-11 JAL
|
||||
refJDN := 2460311
|
||||
days := int64(jdn - refJDN)
|
||||
jy, jm, jd = 1402, 10, 11
|
||||
|
||||
// Walk forward
|
||||
for days > 0 {
|
||||
remaining := int64(jalaliMonthDays(jy, jm) - jd + 1)
|
||||
if days < remaining {
|
||||
jd += int(days)
|
||||
return
|
||||
}
|
||||
days -= remaining
|
||||
jm++
|
||||
if jm > 12 {
|
||||
jm = 1
|
||||
jy++
|
||||
}
|
||||
jd = 1
|
||||
}
|
||||
// Walk backward
|
||||
for days < 0 {
|
||||
jd += int(days)
|
||||
for jd < 1 {
|
||||
jm--
|
||||
if jm < 1 {
|
||||
jm = 12
|
||||
jy--
|
||||
}
|
||||
jd += jalaliMonthDays(jy, jm)
|
||||
}
|
||||
days = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func jalaliMonthDays(y, m int) int {
|
||||
if m <= 6 {
|
||||
return 31
|
||||
}
|
||||
if m <= 11 {
|
||||
return 30
|
||||
}
|
||||
if isJalaliLeap(y) {
|
||||
return 30
|
||||
}
|
||||
return 29
|
||||
}
|
||||
|
||||
// isJalaliLeap reports whether the given Jalali year is a leap year.
|
||||
// The leap pattern repeats every 33 years with 8 leap years.
|
||||
func isJalaliLeap(y int) bool {
|
||||
switch y % 33 {
|
||||
case 1, 5, 9, 13, 17, 22, 26, 30:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// statsForClient returns the client's live traffic record, or a minimal one
|
||||
// synthesized from the client (enable/expiry/total) when no live stats exist —
|
||||
// so expiry/total/status tokens still resolve on links that have no counters yet.
|
||||
@@ -261,6 +466,7 @@ var usageInfoTokens = []string{
|
||||
"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
|
||||
"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
|
||||
"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
|
||||
"STATUS_EMOJI", "USAGE_PERCENTAGE", "TIME_LEFT", "JALALI_EXPIRE_DATE",
|
||||
}
|
||||
|
||||
// nameOnlyTemplate returns template with the trailing per-client info part
|
||||
@@ -287,25 +493,27 @@ func nameOnlyTemplate(template string) string {
|
||||
// name-only template for every link thereafter — so the info shows once. Only
|
||||
// called in the subscription-body context (displays bypass the template).
|
||||
func (s *SubService) effectiveTemplate(email string) string {
|
||||
translated := translateUISingleBrackets(s.remarkTemplate)
|
||||
if s.usageShown == nil {
|
||||
s.usageShown = map[string]bool{}
|
||||
}
|
||||
if s.usageShown[email] {
|
||||
return nameOnlyTemplate(s.remarkTemplate)
|
||||
return nameOnlyTemplate(translated)
|
||||
}
|
||||
s.usageShown[email] = true
|
||||
return s.remarkTemplate
|
||||
return translated
|
||||
}
|
||||
|
||||
// genTemplatedRemark expands the remark template for one client. hostRemark is
|
||||
// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
|
||||
// token only and never substitutes the inbound remark as the config name.
|
||||
func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
|
||||
func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
|
||||
ctx := remarkContext{
|
||||
client: client,
|
||||
stats: s.statsForClient(inbound, client),
|
||||
inbound: inbound,
|
||||
hostRemark: hostRemark,
|
||||
transport: transport,
|
||||
}
|
||||
tmpl := s.effectiveTemplate(client.Email)
|
||||
// Fall back to the config name when the template is empty or expands to
|
||||
@@ -320,9 +528,9 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
|
||||
// config name is always the inbound's own remark; the host's remark is surfaced
|
||||
// only through the {{HOST}} token. In the subscription body the rest of the
|
||||
// remark template still applies; displays show just the config name.
|
||||
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
|
||||
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
|
||||
if !s.subscriptionBody {
|
||||
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
|
||||
}
|
||||
return s.genTemplatedRemark(inbound, client, hostRemark)
|
||||
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ func TestExpandRemarkVars(t *testing.T) {
|
||||
|
||||
cases := []struct{ tmpl, want string }{
|
||||
{"{{EMAIL}}", "john@example.com"},
|
||||
{"{{USERNAME}}", "john@example.com"},
|
||||
{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
|
||||
{"{{HOST}}", ""}, // no host remark in ctx → empty
|
||||
{"{{ID}}", client.ID},
|
||||
@@ -169,10 +168,10 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
|
||||
// never substitutes it (it is reachable only through {{HOST}}).
|
||||
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("") // no template → config name only
|
||||
if got := s.genHostRemark(inbound, client, "Relay"); got != "DE" {
|
||||
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
|
||||
t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
|
||||
}
|
||||
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
|
||||
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
|
||||
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
|
||||
}
|
||||
}
|
||||
@@ -182,17 +181,17 @@ func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
|
||||
func TestGenHostRemark_GlobalTemplate(t *testing.T) {
|
||||
// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE | 80.00GB | 10d" {
|
||||
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE | 80.00GB | 10d" {
|
||||
t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
|
||||
}
|
||||
// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
|
||||
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
|
||||
if got := s2.genHostRemark(inbound2, client2, "CDN"); got != "DE|CDN|80.00GB" {
|
||||
if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE|CDN|80.00GB" {
|
||||
t.Fatalf("global template (inbound + host) = %q, want %q", got, "DE|CDN|80.00GB")
|
||||
}
|
||||
// {{HOST}} is the host's own remark even when the inbound has one of its own.
|
||||
s3, inbound3, client3 := hostRemarkService("{{HOST}}")
|
||||
if got := s3.genHostRemark(inbound3, client3, "CDN"); got != "CDN" {
|
||||
if got := s3.genHostRemark(inbound3, client3, "CDN", ""); got != "CDN" {
|
||||
t.Fatalf("{{HOST}} token = %q, want CDN", got)
|
||||
}
|
||||
}
|
||||
@@ -201,7 +200,7 @@ func TestGenHostRemark_GlobalTemplate(t *testing.T) {
|
||||
// legacy externalProxy remark passed as extra.
|
||||
func TestGenRemark_GlobalTemplate(t *testing.T) {
|
||||
s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
|
||||
got := s.genRemark(inbound, "john@example.com", "")
|
||||
got := s.genRemark(inbound, "john@example.com", "", "")
|
||||
if got != "john@example.com | 80.00GB" {
|
||||
t.Fatalf("global template (non-host) = %q", got)
|
||||
}
|
||||
@@ -210,7 +209,7 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
|
||||
// With no template, genRemark composes the fallback model and adds no suffix.
|
||||
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
|
||||
s, inbound, _ := hostRemarkService("")
|
||||
got := s.genRemark(inbound, "john@example.com", "Relay")
|
||||
got := s.genRemark(inbound, "john@example.com", "Relay", "")
|
||||
if got != "DE-Relay" {
|
||||
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
|
||||
}
|
||||
@@ -220,8 +219,8 @@ func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
|
||||
// link of the request; later links show the name-only template.
|
||||
func TestUsageOnFirstLinkOnly(t *testing.T) {
|
||||
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
||||
first := s.genHostRemark(inbound, client, "")
|
||||
second := s.genHostRemark(inbound, client, "")
|
||||
first := s.genHostRemark(inbound, client, "", "")
|
||||
second := s.genHostRemark(inbound, client, "", "")
|
||||
if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
|
||||
t.Fatalf("first link should carry usage: %q", first)
|
||||
}
|
||||
@@ -241,15 +240,15 @@ func TestRemarkInDisplayContext(t *testing.T) {
|
||||
s.subscriptionBody = false
|
||||
// A host link in a display shows only the config name — the inbound's remark,
|
||||
// with no per-client email or usage info and the host remark ignored.
|
||||
if got := s.genHostRemark(inbound, client, "CDN"); got != "DE" {
|
||||
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
|
||||
t.Fatalf("display host link = %q, want config name %q", got, "DE")
|
||||
}
|
||||
// With no host remark, the config name is likewise the inbound's own remark.
|
||||
if got := s.genHostRemark(inbound, client, ""); got != "DE" {
|
||||
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
|
||||
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
|
||||
}
|
||||
// genRemark (non-host) likewise drops the template in display context.
|
||||
if got := s.genRemark(inbound, client.Email, ""); got != "DE" {
|
||||
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
|
||||
t.Fatalf("display genRemark = %q, want %q", got, "DE")
|
||||
}
|
||||
}
|
||||
@@ -294,9 +293,176 @@ func TestStatsForClient_CrossInboundFallback(t *testing.T) {
|
||||
func TestGenHostRemark_PerClient(t *testing.T) {
|
||||
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
|
||||
inbound := &model.Inbound{}
|
||||
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
|
||||
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
|
||||
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
|
||||
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
|
||||
if a != "alice@x" || b != "bob@x" {
|
||||
t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusEmoji(t *testing.T) {
|
||||
cases := []struct {
|
||||
stats xray.ClientTraffic
|
||||
want string
|
||||
}{
|
||||
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: gb}, "✅"},
|
||||
{xray.ClientTraffic{Enable: true, Total: 10 * gb, Up: 10 * gb, Down: 1}, "🚫"},
|
||||
{xray.ClientTraffic{Enable: false}, "🚫"},
|
||||
{xray.ClientTraffic{Enable: true, ExpiryTime: 1000}, "⏳"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := statusEmoji(c.stats); got != c.want {
|
||||
t.Errorf("statusEmoji(%+v) = %q, want %q", c.stats, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsagePercentage(t *testing.T) {
|
||||
if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
|
||||
t.Errorf("usagePercentage 50%% = %q", got)
|
||||
}
|
||||
if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
|
||||
t.Errorf("usagePercentage unlimited = %q, want empty", got)
|
||||
}
|
||||
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
|
||||
t.Errorf("usagePercentage 100%% = %q", got)
|
||||
}
|
||||
// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
|
||||
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
|
||||
t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeLeftLabel(t *testing.T) {
|
||||
if got := timeLeftLabel(0); got != "∞" {
|
||||
t.Errorf("timeLeftLabel(0) = %q, want ∞", got)
|
||||
}
|
||||
// Delayed-start: negative expiry = duration in ms. 1000ms = 1 second = "0m".
|
||||
if got := timeLeftLabel(-1000); got != "0m" {
|
||||
t.Errorf("timeLeftLabel(-1000) = %q, want 0m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGregorianToJalali(t *testing.T) {
|
||||
cases := []struct {
|
||||
gy, gm, gd int
|
||||
jy, jm, jd int
|
||||
}{
|
||||
{2024, 1, 1, 1402, 10, 11},
|
||||
{2000, 3, 20, 1379, 1, 1},
|
||||
{1979, 2, 11, 1357, 11, 22},
|
||||
}
|
||||
for _, c := range cases {
|
||||
jy, jm, jd := gregorianToJalali(c.gy, c.gm, c.gd)
|
||||
if jy != c.jy || jm != c.jm || jd != c.jd {
|
||||
t.Errorf("gregorianToJalali(%d,%d,%d) = (%d,%d,%d), want (%d,%d,%d)",
|
||||
c.gy, c.gm, c.gd, jy, jm, jd, c.jy, c.jm, c.jd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJalaliExpireDateLabel(t *testing.T) {
|
||||
if got := jalaliExpireDateLabel(0); got != "" {
|
||||
t.Errorf("jalaliExpireDateLabel(0) = %q, want empty", got)
|
||||
}
|
||||
if got := jalaliExpireDateLabel(-1000); got != "" {
|
||||
t.Errorf("jalaliExpireDateLabel(-1000) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandNewTokensInTemplate(t *testing.T) {
|
||||
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
|
||||
client := model.Client{Email: "alice@test.com", ID: "abc-123"}
|
||||
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
|
||||
ctx := remarkContext{
|
||||
client: client,
|
||||
stats: stats,
|
||||
inbound: inbound,
|
||||
transport: "ws",
|
||||
}
|
||||
|
||||
cases := []struct{ tmpl, want string }{
|
||||
{"{{STATUS_EMOJI}}", "✅"},
|
||||
{"{{USAGE_PERCENTAGE}}", "50.0%"},
|
||||
{"{{PROTOCOL}}", "VLESS"},
|
||||
{"{{TRANSPORT}}", "ws"},
|
||||
{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
|
||||
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateUISingleBrackets(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"{EMAIL}", "{{EMAIL}}"},
|
||||
{"{DATA_LEFT}", "{{TRAFFIC_LEFT}}"},
|
||||
{"{DATA_LEFT} of {DATA_LIMIT}", "{{TRAFFIC_LEFT}} of {{TRAFFIC_TOTAL}}"},
|
||||
{"{STATUS_EMOJI} {INBOUND}", "{{STATUS_EMOJI}} {INBOUND}"},
|
||||
{"{UNKNOWN_TOKEN}", "{UNKNOWN_TOKEN}"},
|
||||
{"no braces", "no braces"},
|
||||
{"{{TRAFFIC_LEFT}}", "{{TRAFFIC_LEFT}}"},
|
||||
{"{username}", "{username}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := translateUISingleBrackets(c.in); got != c.want {
|
||||
t.Errorf("translateUISingleBrackets(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
|
||||
inbound := &model.Inbound{Remark: "DE", Protocol: "vless"}
|
||||
stats := xray.ClientTraffic{Enable: true, Total: 100 * gb, Up: 50 * gb, Down: 0}
|
||||
ctx := remarkContext{
|
||||
client: model.Client{Email: "alice@test.com"},
|
||||
stats: stats,
|
||||
inbound: inbound,
|
||||
transport: "ws",
|
||||
}
|
||||
cases := []struct{ tmpl, want string }{
|
||||
{"{EMAIL}", "alice@test.com"},
|
||||
{"{DATA_LEFT}", "50.00GB"},
|
||||
{"{DATA_USAGE}", "50.00GB"},
|
||||
{"{DATA_LIMIT}", "100.00GB"},
|
||||
{"{STATUS_EMOJI}", "✅"},
|
||||
{"{USAGE_PERCENTAGE}", "50.0%"},
|
||||
{"{PROTOCOL}", "VLESS"},
|
||||
{"{TRANSPORT}", "ws"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {
|
||||
t.Errorf("expandRemarkVars(%q) = %q, want %q", c.tmpl, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
|
||||
s := &SubService{
|
||||
remarkTemplate: "{STATUS_EMOJI} {{INBOUND}}|📊{{TRAFFIC_LEFT}}",
|
||||
subscriptionBody: true,
|
||||
usageShown: map[string]bool{},
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Remark: "DE",
|
||||
ClientStats: []xray.ClientTraffic{{
|
||||
Email: "alice@x",
|
||||
Enable: true,
|
||||
Total: 100 * gb,
|
||||
Up: 20 * gb,
|
||||
Down: 10 * gb,
|
||||
}},
|
||||
}
|
||||
client := model.Client{Email: "alice@x"}
|
||||
first := s.genTemplatedRemark(inbound, client, "", "ws")
|
||||
s.usageShown["alice@x"] = true
|
||||
second := s.genTemplatedRemark(inbound, client, "", "ws")
|
||||
if !strings.Contains(first, "📊") {
|
||||
t.Fatalf("first link should carry usage: %q", first)
|
||||
}
|
||||
if strings.Contains(second, "📊") {
|
||||
t.Fatalf("second link must not carry usage: %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-15
@@ -532,10 +532,10 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
externalProxies, _ := stream["externalProxy"].([]any)
|
||||
|
||||
if len(externalProxies) > 0 {
|
||||
return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email)
|
||||
return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email, network)
|
||||
}
|
||||
|
||||
obj["ps"] = s.genRemark(inbound, email, "")
|
||||
obj["ps"] = s.genRemark(inbound, email, "", network)
|
||||
return buildVmessLink(obj)
|
||||
}
|
||||
|
||||
@@ -619,13 +619,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
|
||||
},
|
||||
func(ep map[string]any) string {
|
||||
return s.endpointRemark(inbound, email, ep)
|
||||
return s.endpointRemark(inbound, email, ep, streamNetwork)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
|
||||
}
|
||||
|
||||
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
|
||||
@@ -670,13 +670,13 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
|
||||
},
|
||||
func(ep map[string]any) string {
|
||||
return s.endpointRemark(inbound, email, ep)
|
||||
return s.endpointRemark(inbound, email, ep, streamNetwork)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("trojan://%s@%s", password, joinHostPort(address, port))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
|
||||
}
|
||||
|
||||
// encodeUserinfo percent-encodes a userinfo (password/auth) value so it
|
||||
@@ -763,13 +763,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
|
||||
},
|
||||
func(ep map[string]any) string {
|
||||
return s.endpointRemark(inbound, email, ep)
|
||||
return s.endpointRemark(inbound, email, ep, streamNetwork)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
|
||||
}
|
||||
|
||||
func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string {
|
||||
@@ -872,7 +872,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
applyExternalProxyHysteriaParams(ep, epParams)
|
||||
|
||||
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
|
||||
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep)))
|
||||
links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "quic")))
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
@@ -883,7 +883,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
params["mport"] = hopPorts
|
||||
}
|
||||
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
|
||||
return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", "quic"))
|
||||
}
|
||||
|
||||
// hysteriaHopPorts returns the configured Hysteria2 UDP port-hopping range
|
||||
@@ -1494,14 +1494,15 @@ func joinAnyStrings(items []any) string {
|
||||
|
||||
// buildVmessExternalProxyLinks is a thin adapter: it maps the legacy
|
||||
// externalProxy entries to []ShareEndpoint and renders them through the unified
|
||||
// endpoint path. Kept so genVmessLink's call site is unchanged.
|
||||
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||
// endpoint path. Kept as a thin shim over the unified endpoint builder so
|
||||
// genVmessLink keeps calling one helper (now threading transport through).
|
||||
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string, transport string) string {
|
||||
eps := make([]ShareEndpoint, 0, len(externalProxies))
|
||||
for _, externalProxy := range externalProxies {
|
||||
ep, _ := externalProxy.(map[string]any)
|
||||
eps = append(eps, externalProxyToEndpoint(ep))
|
||||
}
|
||||
return s.buildEndpointVmessLinks(eps, baseObj, inbound, email)
|
||||
return s.buildEndpointVmessLinks(eps, baseObj, inbound, email, transport)
|
||||
}
|
||||
|
||||
// buildLinkWithParams appends ?query and #fragment to a pre-built
|
||||
@@ -1588,9 +1589,9 @@ func cloneStringMap(source map[string]string) map[string]string {
|
||||
// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
|
||||
// remark template takes over; otherwise (and in every display context) the
|
||||
// remark is just the config name (inbound remark, then extra).
|
||||
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
|
||||
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
|
||||
if s.remarkTemplate != "" && s.subscriptionBody {
|
||||
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra)
|
||||
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
|
||||
}
|
||||
// Sub info page + panel link/QR displays: just the config name (no template,
|
||||
// so no per-client email/usage leaks into the shown remark).
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestGenRemarkOmitsNodeName(t *testing.T) {
|
||||
nodesByID: map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
|
||||
}
|
||||
ib := &model.Inbound{Remark: "vless-tcp", NodeID: &nodeID}
|
||||
if got := s.genRemark(ib, "", ""); got != "vless-tcp" {
|
||||
if got := s.genRemark(ib, "", "", ""); got != "vless-tcp" {
|
||||
t.Fatalf("remark = %q, want %q (node name must not leak into client-visible remarks)", got, "vless-tcp")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user