From 605e90dbf08ec3641ab88bbe9fe8967ea545755f Mon Sep 17 00:00:00 2001 From: wahh3b-lgtm Date: Sun, 21 Jun 2026 03:30:27 +0330 Subject: [PATCH] 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 * fix * fix --------- Co-authored-by: MHSanaei --- .gitignore | 3 +- frontend/public/openapi.json | 10 ++ internal/sub/clash_service.go | 11 +- internal/sub/endpoint.go | 4 +- internal/sub/endpoint_test.go | 4 +- internal/sub/host_sub.go | 12 +- internal/sub/json_service.go | 6 +- internal/sub/remark_vars.go | 224 +++++++++++++++++++++++++++++-- internal/sub/remark_vars_test.go | 196 ++++++++++++++++++++++++--- internal/sub/service.go | 31 ++--- internal/sub/service_test.go | 2 +- 11 files changed, 447 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index 6fef50ece..0991d4f30 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ system_metrics.gob docker-compose.override.yml # Ignore .env (Environment Variables) file -.env \ No newline at end of file +.env + diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 03593c73d..9ecf7edae 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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", diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index d9cbec68a..2e165e55f 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -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, diff --git a/internal/sub/endpoint.go b/internal/sub/endpoint.go index 338763722..311f9d0cc 100644 --- a/internal/sub/endpoint.go +++ b/internal/sub/endpoint.go @@ -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" { diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index 9b42e935e..b420982ce 100644 --- a/internal/sub/endpoint_test.go +++ b/internal/sub/endpoint_test.go @@ -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 { diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 4cea2cd3f..4fe2c4170 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -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 diff --git a/internal/sub/json_service.go b/internal/sub/json_service.go index 72fb3d1f9..c5681cebc 100644 --- a/internal/sub/json_service.go +++ b/internal/sub/json_service.go @@ -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) diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index 1ba475a7b..29eac0f8a 100644 --- a/internal/sub/remark_vars.go +++ b/internal/sub/remark_vars.go @@ -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) } diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go index 42845f1ad..6c8c43a23 100644 --- a/internal/sub/remark_vars_test.go +++ b/internal/sub/remark_vars_test.go @@ -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) + } +} diff --git a/internal/sub/service.go b/internal/sub/service.go index 67010d111..6cb9650bf 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -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). diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index b860e0449..aeea02c40 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -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") } }