mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-17 16:50:58 +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
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Ignore .env (Environment Variables) file
|
# Ignore .env (Environment Variables) file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,10 @@
|
|||||||
"description": "Encrypt subscription responses",
|
"description": "Encrypt subscription responses",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subHideSettings": {
|
||||||
|
"description": "Hide server settings in happ subscription (Only for Happ)",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subJsonEnable": {
|
"subJsonEnable": {
|
||||||
"description": "Enable JSON subscription endpoint",
|
"description": "Enable JSON subscription endpoint",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -439,6 +443,7 @@
|
|||||||
"subEnable",
|
"subEnable",
|
||||||
"subEnableRouting",
|
"subEnableRouting",
|
||||||
"subEncrypt",
|
"subEncrypt",
|
||||||
|
"subHideSettings",
|
||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
@@ -698,6 +703,10 @@
|
|||||||
"description": "Encrypt subscription responses",
|
"description": "Encrypt subscription responses",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"subHideSettings": {
|
||||||
|
"description": "Hide server settings in happ subscription (Only for Happ)",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"subJsonEnable": {
|
"subJsonEnable": {
|
||||||
"description": "Enable JSON subscription endpoint",
|
"description": "Enable JSON subscription endpoint",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -922,6 +931,7 @@
|
|||||||
"subEnable",
|
"subEnable",
|
||||||
"subEnableRouting",
|
"subEnableRouting",
|
||||||
"subEncrypt",
|
"subEncrypt",
|
||||||
|
"subHideSettings",
|
||||||
"subJsonEnable",
|
"subJsonEnable",
|
||||||
"subJsonFinalMask",
|
"subJsonFinalMask",
|
||||||
"subJsonMux",
|
"subJsonMux",
|
||||||
|
|||||||
@@ -163,13 +163,14 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
delete(stream, "externalProxy")
|
delete(stream, "externalProxy")
|
||||||
|
network, _ := stream["network"].(string)
|
||||||
|
|
||||||
proxies := make([]map[string]any, 0, len(externalProxies))
|
proxies := make([]map[string]any, 0, len(externalProxies))
|
||||||
for _, ep := range externalProxies {
|
for _, ep := range externalProxies {
|
||||||
extPrxy := ep.(map[string]any)
|
extPrxy := ep.(map[string]any)
|
||||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
// Expand the host's {{VAR}} remark template for this client (no-op for
|
||||||
// the synthetic/legacy entry) before it becomes the proxy name.
|
// the synthetic/legacy entry) before it becomes the proxy name.
|
||||||
subReq.renderHostRemark(inbound, client, extPrxy)
|
subReq.renderHostRemark(inbound, client, extPrxy, network)
|
||||||
workingInbound := *inbound
|
workingInbound := *inbound
|
||||||
workingInbound.Listen = extPrxy["dest"].(string)
|
workingInbound.Listen = extPrxy["dest"].(string)
|
||||||
workingInbound.Port = int(extPrxy["port"].(float64))
|
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)
|
return s.buildHysteriaProxy(subReq, inbound, client, ep)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
network, _ := stream["network"].(string)
|
||||||
|
|
||||||
proxy := map[string]any{
|
proxy := map[string]any{
|
||||||
"name": subReq.endpointRemark(inbound, client.Email, ep),
|
"name": subReq.endpointRemark(inbound, client.Email, ep, network),
|
||||||
"server": inbound.Listen,
|
"server": inbound.Listen,
|
||||||
"port": inbound.Port,
|
"port": inbound.Port,
|
||||||
"udp": true,
|
"udp": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
network, _ := stream["network"].(string)
|
|
||||||
if !s.applyTransport(proxy, network, stream) {
|
if !s.applyTransport(proxy, network, stream) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -298,7 +299,7 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
proxy := map[string]any{
|
proxy := map[string]any{
|
||||||
"name": subReq.endpointRemark(inbound, client.Email, ep),
|
"name": subReq.endpointRemark(inbound, client.Email, ep, "quic"),
|
||||||
"type": proxyType,
|
"type": proxyType,
|
||||||
"server": inbound.Listen,
|
"server": inbound.Listen,
|
||||||
"port": inbound.Port,
|
"port": inbound.Port,
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func (s *SubService) buildEndpointLinks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildEndpointVmessLinks renders one VMess base64-JSON link per endpoint.
|
// 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
|
var links strings.Builder
|
||||||
for index, e := range eps {
|
for index, e := range eps {
|
||||||
securityToApply, _ := baseObj["tls"].(string)
|
securityToApply, _ := baseObj["tls"].(string)
|
||||||
@@ -111,7 +111,7 @@ func (s *SubService) buildEndpointVmessLinks(eps []ShareEndpoint, baseObj map[st
|
|||||||
securityToApply = e.ForceTls
|
securityToApply = e.ForceTls
|
||||||
}
|
}
|
||||||
newObj := cloneVmessShareObj(baseObj, 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["add"] = e.Address
|
||||||
newObj["port"] = e.Port
|
newObj["port"] = e.Port
|
||||||
if e.ForceTls != "same" {
|
if e.ForceTls != "same" {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
got := s.buildEndpointLinks(eps, params, "tls",
|
got := s.buildEndpointLinks(eps, params, "tls",
|
||||||
func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
|
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" +
|
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"
|
"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": "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"}),
|
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" +
|
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
|
||||||
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
|
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
|
||||||
if got != want {
|
if got != want {
|
||||||
|
|||||||
@@ -197,13 +197,15 @@ func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client,
|
|||||||
if len(eps) == 0 {
|
if len(eps) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
stream := unmarshalStreamSettings(inbound.StreamSettings)
|
||||||
|
transport, _ := stream["network"].(string)
|
||||||
// Clone each ep before expanding its remark template: the eps slice is
|
// Clone each ep before expanding its remark template: the eps slice is
|
||||||
// shared across all clients of this inbound, so the rendered (per-client)
|
// shared across all clients of this inbound, so the rendered (per-client)
|
||||||
// remark must not leak into the next client's links.
|
// remark must not leak into the next client's links.
|
||||||
rendered := make([]map[string]any, len(eps))
|
rendered := make([]map[string]any, len(eps))
|
||||||
for i, ep := range eps {
|
for i, ep := range eps {
|
||||||
cp := maps.Clone(ep)
|
cp := maps.Clone(ep)
|
||||||
s.renderHostRemark(inbound, client, cp)
|
s.renderHostRemark(inbound, client, cp, transport)
|
||||||
rendered[i] = cp
|
rendered[i] = cp
|
||||||
}
|
}
|
||||||
clone := *inbound
|
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.
|
// renderers emit it verbatim (via endpointRemark) instead of re-composing it.
|
||||||
// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
|
// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
|
||||||
// their output stays byte-identical.
|
// 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) {
|
if !isHostEndpoint(ep) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmpl, _ := ep["remark"].(string)
|
tmpl, _ := ep["remark"].(string)
|
||||||
ep["remark"] = s.genHostRemark(inbound, client, tmpl)
|
ep["remark"] = s.genHostRemark(inbound, client, tmpl, transport)
|
||||||
ep["remarkFinal"] = true
|
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
|
// entry. A host endpoint whose template was pre-expanded by renderHostRemark
|
||||||
// carries remarkFinal and is used verbatim; every other entry flows through the
|
// carries remarkFinal and is used verbatim; every other entry flows through the
|
||||||
// standard genRemark composition unchanged.
|
// 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 ep != nil {
|
||||||
if final, _ := ep["remarkFinal"].(bool); final {
|
if final, _ := ep["remarkFinal"].(bool); final {
|
||||||
r, _ := ep["remark"].(string)
|
r, _ := ep["remark"].(string)
|
||||||
@@ -240,7 +242,7 @@ func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map
|
|||||||
if ep != nil {
|
if ep != nil {
|
||||||
extra, _ = ep["remark"].(string)
|
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
|
// 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")
|
delete(stream, "externalProxy")
|
||||||
|
network, _ := stream["network"].(string)
|
||||||
|
|
||||||
for _, ep := range externalProxies {
|
for _, ep := range externalProxies {
|
||||||
extPrxy := ep.(map[string]any)
|
extPrxy := ep.(map[string]any)
|
||||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
// 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.
|
// 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.Listen = extPrxy["dest"].(string)
|
||||||
inbound.Port = int(extPrxy["port"].(float64))
|
inbound.Port = int(extPrxy["port"].(float64))
|
||||||
newStream := cloneStreamForExternalProxy(stream)
|
newStream := cloneStreamForExternalProxy(stream)
|
||||||
@@ -220,8 +221,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
|||||||
newConfigJson := make(map[string]any)
|
newConfigJson := make(map[string]any)
|
||||||
maps.Copy(newConfigJson, s.configJson)
|
maps.Copy(newConfigJson, s.configJson)
|
||||||
|
|
||||||
|
transport, _ := newStream["network"].(string)
|
||||||
newConfigJson["outbounds"] = newOutbounds
|
newConfigJson["outbounds"] = newOutbounds
|
||||||
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy)
|
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy, transport)
|
||||||
|
|
||||||
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
||||||
newJsonArray = append(newJsonArray, newConfig)
|
newJsonArray = append(newJsonArray, newConfig)
|
||||||
|
|||||||
+216
-8
@@ -1,6 +1,7 @@
|
|||||||
package sub
|
package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,6 +24,7 @@ type remarkContext struct {
|
|||||||
stats xray.ClientTraffic
|
stats xray.ClientTraffic
|
||||||
inbound *model.Inbound
|
inbound *model.Inbound
|
||||||
hostRemark string
|
hostRemark string
|
||||||
|
transport string
|
||||||
}
|
}
|
||||||
|
|
||||||
// configName is the display name for a link: always the inbound's own remark.
|
// 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_LEFT": true,
|
||||||
"TRAFFIC_TOTAL": true,
|
"TRAFFIC_TOTAL": true,
|
||||||
"DAYS_LEFT": 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
|
// 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
|
// or expiry (∞) drops out whole — decoration and separator included — so an
|
||||||
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
|
// unlimited client gets "host" instead of "host|📊∞|⏳∞D".
|
||||||
func expandRemarkVars(template string, ctx remarkContext) string {
|
func expandRemarkVars(template string, ctx remarkContext) string {
|
||||||
|
template = translateUISingleBrackets(template)
|
||||||
if !strings.Contains(template, "{{") {
|
if !strings.Contains(template, "{{") {
|
||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
@@ -164,6 +215,21 @@ func remarkVarValue(token string, ctx remarkContext) string {
|
|||||||
return strconv.Itoa(c.Reset)
|
return strconv.Itoa(c.Reset)
|
||||||
}
|
}
|
||||||
return ""
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -202,13 +268,13 @@ func daysLeftLabel(expiryMs int64) string {
|
|||||||
return strconv.FormatInt(days, 10)
|
return strconv.FormatInt(days, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// expireDateLabel renders a fixed expiry as YYYY-MM-DD (UTC). Unlimited and
|
// expireDateLabel renders a fixed expiry as YYYY-MM-DD (local time). Unlimited
|
||||||
// delayed-start (no fixed calendar date yet) expiries yield "".
|
// and delayed-start (no fixed calendar date yet) expiries yield "".
|
||||||
func expireDateLabel(expiryMs int64) string {
|
func expireDateLabel(expiryMs int64) string {
|
||||||
if expiryMs <= 0 {
|
if expiryMs <= 0 {
|
||||||
return ""
|
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 {
|
func max64(a, b int64) int64 {
|
||||||
@@ -218,6 +284,145 @@ func max64(a, b int64) int64 {
|
|||||||
return b
|
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
|
// statsForClient returns the client's live traffic record, or a minimal one
|
||||||
// synthesized from the client (enable/expiry/total) when no live stats exist —
|
// 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.
|
// 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", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
|
||||||
"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
|
"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
|
||||||
"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
|
"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
|
// 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
|
// name-only template for every link thereafter — so the info shows once. Only
|
||||||
// called in the subscription-body context (displays bypass the template).
|
// called in the subscription-body context (displays bypass the template).
|
||||||
func (s *SubService) effectiveTemplate(email string) string {
|
func (s *SubService) effectiveTemplate(email string) string {
|
||||||
|
translated := translateUISingleBrackets(s.remarkTemplate)
|
||||||
if s.usageShown == nil {
|
if s.usageShown == nil {
|
||||||
s.usageShown = map[string]bool{}
|
s.usageShown = map[string]bool{}
|
||||||
}
|
}
|
||||||
if s.usageShown[email] {
|
if s.usageShown[email] {
|
||||||
return nameOnlyTemplate(s.remarkTemplate)
|
return nameOnlyTemplate(translated)
|
||||||
}
|
}
|
||||||
s.usageShown[email] = true
|
s.usageShown[email] = true
|
||||||
return s.remarkTemplate
|
return translated
|
||||||
}
|
}
|
||||||
|
|
||||||
// genTemplatedRemark expands the remark template for one client. hostRemark is
|
// genTemplatedRemark expands the remark template for one client. hostRemark is
|
||||||
// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
|
// 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.
|
// 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{
|
ctx := remarkContext{
|
||||||
client: client,
|
client: client,
|
||||||
stats: s.statsForClient(inbound, client),
|
stats: s.statsForClient(inbound, client),
|
||||||
inbound: inbound,
|
inbound: inbound,
|
||||||
hostRemark: hostRemark,
|
hostRemark: hostRemark,
|
||||||
|
transport: transport,
|
||||||
}
|
}
|
||||||
tmpl := s.effectiveTemplate(client.Email)
|
tmpl := s.effectiveTemplate(client.Email)
|
||||||
// Fall back to the config name when the template is empty or expands to
|
// 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
|
// 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
|
// only through the {{HOST}} token. In the subscription body the rest of the
|
||||||
// remark template still applies; displays show just the config name.
|
// 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 {
|
if !s.subscriptionBody {
|
||||||
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
|
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 }{
|
cases := []struct{ tmpl, want string }{
|
||||||
{"{{EMAIL}}", "john@example.com"},
|
{"{{EMAIL}}", "john@example.com"},
|
||||||
{"{{USERNAME}}", "john@example.com"},
|
|
||||||
{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
|
{"{{INBOUND}}", "Germany"}, // no host remark in ctx → inbound remark
|
||||||
{"{{HOST}}", ""}, // no host remark in ctx → empty
|
{"{{HOST}}", ""}, // no host remark in ctx → empty
|
||||||
{"{{ID}}", client.ID},
|
{"{{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}}).
|
// never substitutes it (it is reachable only through {{HOST}}).
|
||||||
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
|
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
|
||||||
s, inbound, client := hostRemarkService("") // no template → config name only
|
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")
|
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")
|
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) {
|
func TestGenHostRemark_GlobalTemplate(t *testing.T) {
|
||||||
// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
|
// {{INBOUND}} resolves to the inbound remark regardless of the host remark.
|
||||||
s, inbound, client := hostRemarkService("{{INBOUND}} | {{TRAFFIC_LEFT}} | {{DAYS_LEFT}}d")
|
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)
|
t.Fatalf("global template ({{INBOUND}} = inbound) = %q", got)
|
||||||
}
|
}
|
||||||
// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
|
// {{INBOUND}} and {{HOST}} side by side show both, distinctly (#5443).
|
||||||
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}|{{HOST}}|{{TRAFFIC_LEFT}}")
|
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")
|
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.
|
// {{HOST}} is the host's own remark even when the inbound has one of its own.
|
||||||
s3, inbound3, client3 := hostRemarkService("{{HOST}}")
|
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)
|
t.Fatalf("{{HOST}} token = %q, want CDN", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,7 +200,7 @@ func TestGenHostRemark_GlobalTemplate(t *testing.T) {
|
|||||||
// legacy externalProxy remark passed as extra.
|
// legacy externalProxy remark passed as extra.
|
||||||
func TestGenRemark_GlobalTemplate(t *testing.T) {
|
func TestGenRemark_GlobalTemplate(t *testing.T) {
|
||||||
s, inbound, _ := hostRemarkService("{{EMAIL}} | {{TRAFFIC_LEFT}}")
|
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" {
|
if got != "john@example.com | 80.00GB" {
|
||||||
t.Fatalf("global template (non-host) = %q", got)
|
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.
|
// With no template, genRemark composes the fallback model and adds no suffix.
|
||||||
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
|
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
|
||||||
s, inbound, _ := hostRemarkService("")
|
s, inbound, _ := hostRemarkService("")
|
||||||
got := s.genRemark(inbound, "john@example.com", "Relay")
|
got := s.genRemark(inbound, "john@example.com", "Relay", "")
|
||||||
if got != "DE-Relay" {
|
if got != "DE-Relay" {
|
||||||
t.Fatalf("genRemark = %q, want %q (no suffix)", 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.
|
// link of the request; later links show the name-only template.
|
||||||
func TestUsageOnFirstLinkOnly(t *testing.T) {
|
func TestUsageOnFirstLinkOnly(t *testing.T) {
|
||||||
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
|
||||||
first := s.genHostRemark(inbound, client, "")
|
first := s.genHostRemark(inbound, client, "", "")
|
||||||
second := s.genHostRemark(inbound, client, "")
|
second := s.genHostRemark(inbound, client, "", "")
|
||||||
if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
|
if !strings.Contains(first, "📊") || !strings.Contains(first, "80.00GB") {
|
||||||
t.Fatalf("first link should carry usage: %q", first)
|
t.Fatalf("first link should carry usage: %q", first)
|
||||||
}
|
}
|
||||||
@@ -241,15 +240,15 @@ func TestRemarkInDisplayContext(t *testing.T) {
|
|||||||
s.subscriptionBody = false
|
s.subscriptionBody = false
|
||||||
// A host link in a display shows only the config name — the inbound's remark,
|
// 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.
|
// 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")
|
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.
|
// 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")
|
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
|
||||||
}
|
}
|
||||||
// genRemark (non-host) likewise drops the template in display context.
|
// 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")
|
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) {
|
func TestGenHostRemark_PerClient(t *testing.T) {
|
||||||
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
|
s := &SubService{remarkTemplate: "{{EMAIL}}", subscriptionBody: true}
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "")
|
a := s.genHostRemark(inbound, model.Client{Email: "alice@x"}, "", "")
|
||||||
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "")
|
b := s.genHostRemark(inbound, model.Client{Email: "bob@x"}, "", "")
|
||||||
if a != "alice@x" || b != "bob@x" {
|
if a != "alice@x" || b != "bob@x" {
|
||||||
t.Fatalf("per-client expansion failed: a=%q b=%q", a, b)
|
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)
|
externalProxies, _ := stream["externalProxy"].([]any)
|
||||||
|
|
||||||
if len(externalProxies) > 0 {
|
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)
|
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))
|
return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
|
||||||
},
|
},
|
||||||
func(ep map[string]any) string {
|
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))
|
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 {
|
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))
|
return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
|
||||||
},
|
},
|
||||||
func(ep map[string]any) string {
|
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))
|
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
|
// 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))
|
return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
|
||||||
},
|
},
|
||||||
func(ep map[string]any) string {
|
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))
|
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 {
|
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)
|
applyExternalProxyHysteriaParams(ep, epParams)
|
||||||
|
|
||||||
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
|
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")
|
return strings.Join(links, "\n")
|
||||||
}
|
}
|
||||||
@@ -883,7 +883,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
|||||||
params["mport"] = hopPorts
|
params["mport"] = hopPorts
|
||||||
}
|
}
|
||||||
link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
|
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
|
// 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
|
// buildVmessExternalProxyLinks is a thin adapter: it maps the legacy
|
||||||
// externalProxy entries to []ShareEndpoint and renders them through the unified
|
// externalProxy entries to []ShareEndpoint and renders them through the unified
|
||||||
// endpoint path. Kept so genVmessLink's call site is unchanged.
|
// endpoint path. Kept as a thin shim over the unified endpoint builder so
|
||||||
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
// 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))
|
eps := make([]ShareEndpoint, 0, len(externalProxies))
|
||||||
for _, externalProxy := range externalProxies {
|
for _, externalProxy := range externalProxies {
|
||||||
ep, _ := externalProxy.(map[string]any)
|
ep, _ := externalProxy.(map[string]any)
|
||||||
eps = append(eps, externalProxyToEndpoint(ep))
|
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
|
// 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
|
// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
|
||||||
// remark template takes over; otherwise (and in every display context) the
|
// remark template takes over; otherwise (and in every display context) the
|
||||||
// remark is just the config name (inbound remark, then extra).
|
// 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 {
|
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,
|
// Sub info page + panel link/QR displays: just the config name (no template,
|
||||||
// so no per-client email/usage leaks into the shown remark).
|
// 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"}},
|
nodesByID: map[int]*model.Node{7: {Id: 7, Name: "Berlin", Address: "node7.example.com"}},
|
||||||
}
|
}
|
||||||
ib := &model.Inbound{Remark: "vless-tcp", NodeID: &nodeID}
|
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")
|
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