Files
3x-ui/internal/sub/remark_vars.go
T
Tosd e862d81c60 fix(sub): omit hyphen for empty remark variables (#6101)
* fix(sub): omit hyphen for empty remark variables

The default INBOUND-EMAIL template left a leading hyphen when an inbound had no remark after display remarks became template-driven in b0c1156dd. Treat a hyphen between adjacent variables as their separator and drop it when it would lead the output or when the value after it is empty, so an empty variable in the middle of a template still leaves a single separator between its neighbours. Literal leading hyphens written into the template are preserved.

* fix(sub): elide the remark separator after leading decoration

The separator between two adjacent tokens was kept as soon as any text had
reached the segment, so a template opening with decoration still rendered
"🌐-john" for an inbound with no remark. Track whether a token has produced
a value rather than testing the accumulated output, so the hyphen is elided
for any prefix that carries no token value of its own, and the builder is no
longer rescanned once per token.
2026-07-28 23:02:00 +02:00

669 lines
20 KiB
Go

package sub
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// remarkContext carries the per-client data a remark template can interpolate.
// stats holds the live traffic record when one exists; when it doesn't, the
// caller synthesizes a minimal one from the client so expiry/total/status tokens
// still resolve. hostRemark is the host endpoint's own remark; it backs the
// {{HOST}} token only — it never substitutes the inbound's remark as the config
// name (use {{INBOUND}} and {{HOST}} side by side to show both).
type remarkContext struct {
client model.Client
stats xray.ClientTraffic
inbound *model.Inbound
hostRemark string
transport string
security string
}
// configName is the display name for a link: always the inbound's own remark.
// The host endpoint's remark is surfaced only through the {{HOST}} token.
func (ctx remarkContext) configName() string {
if ctx.inbound != nil {
return ctx.inbound.Remark
}
return ""
}
// remarkVarRe matches a {{TOKEN}} placeholder. Tokens are uppercase letters and
// underscores only, so ordinary braces in a remark are left untouched.
var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
// remarkToken is one {{TOKEN}} occurrence: its name and the byte range it spans
// in the segment it was found in.
type remarkToken struct {
name string
start int
end int
}
// remarkTokens locates every {{TOKEN}} in seg. Both the template-level filter and
// the value-level expansion walk a segment through this, so they share one notion
// of where a token begins and ends and what the literal text between two of them is.
func remarkTokens(seg string) []remarkToken {
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
tokens := make([]remarkToken, len(locs))
for i, loc := range locs {
tokens[i] = remarkToken{name: seg[loc[2]:loc[3]], start: loc[0], end: loc[1]}
}
return tokens
}
// unlimitedMark is the value the human-readable quota/expiry tokens render when
// the client has no limit. A segment built only around such a token carries no
// information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
const unlimitedMark = "∞"
// unlimitedDropTokens are the tokens that render unlimitedMark for an unlimited
// client. A "|"-separated segment whose only value comes from one of these is
// dropped whole when unlimited, so the operator never sees "📊∞|⏳∞D".
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",
"SECURITY": "SECURITY",
}
// 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
// value. Unknown tokens resolve to "" (never the literal text). The template is
// split on "|" into segments: a segment whose only value is an unlimited quota
// or expiry (∞) drops out whole — decoration and separator included — so an
// unlimited client gets "host" instead of "host|📊∞|⏳∞D". Inside a surviving
// segment expandSegment also elides a hyphen separator an empty token would
// leave dangling.
func expandRemarkVars(template string, ctx remarkContext) string {
template = translateUISingleBrackets(template)
if !strings.Contains(template, "{{") {
return template
}
segments := strings.Split(template, "|")
kept := make([]string, 0, len(segments))
for _, seg := range segments {
if out, drop := expandSegment(seg, ctx); !drop {
kept = append(kept, out)
}
}
return strings.Join(kept, "|")
}
// expandSegment expands one "|" segment and reports whether it should be dropped.
// A segment that contains tokens is dropped when none of them resolve to a real
// value — whether because they render the unlimited (∞) mark or the empty string
// — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
// say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
// tokens) is always kept.
//
// A hyphen standing alone between two adjacent tokens is treated as their
// separator and elided when no token before it has produced a value yet or when
// the token after it resolves to nothing. "{{INBOUND}}-{{EMAIL}}" gives "john"
// for an inbound with no remark, "🌐{{INBOUND}}-{{EMAIL}}" gives "🌐john" so
// leading decoration does not keep the separator alive, and
// "{{EMAIL}}-{{INBOUND}}-{{EMAIL}}" keeps a single separator when the middle
// token is empty. A hyphen anywhere else in the segment is literal text and is
// kept as written.
func expandSegment(seg string, ctx remarkContext) (string, bool) {
tokens := remarkTokens(seg)
hasToken, hasOtherValue := len(tokens) > 0, false
values := make([]string, len(tokens))
for i, tok := range tokens {
val := remarkVarValue(tok.name, ctx)
values[i] = val
if val != "" && (!unlimitedDropTokens[tok.name] || val != unlimitedMark) {
hasOtherValue = true
}
}
var result strings.Builder
start, wroteValue := 0, false
for i, tok := range tokens {
result.WriteString(seg[start:tok.start])
result.WriteString(values[i])
wroteValue = wroteValue || values[i] != ""
start = tok.end
if i+1 < len(tokens) {
between := seg[start:tokens[i+1].start]
if strings.TrimSpace(between) == "-" && (!wroteValue || values[i+1] == "") {
start = tokens[i+1].start
}
}
}
result.WriteString(seg[start:])
return result.String(), hasToken && !hasOtherValue
}
func remarkVarValue(token string, ctx remarkContext) string {
c := ctx.client
st := ctx.stats
used := st.Up + st.Down
switch token {
case "EMAIL", "USERNAME":
return c.Email
case "INBOUND":
return ctx.configName()
case "HOST":
return ctx.hostRemark
case "ID":
return c.ID
case "SHORT_ID":
if len(c.ID) >= 8 {
return c.ID[:8]
}
return c.ID
case "TELEGRAM_ID":
if c.TgID != 0 {
return strconv.FormatInt(c.TgID, 10)
}
return ""
case "SUB_ID":
return c.SubID
case "COMMENT":
return c.Comment
case "STATUS":
return clientStatus(st)
case "DAYS_LEFT":
return daysLeftLabel(st.ExpiryTime)
case "EXPIRE_DATE":
return expireDateLabel(st.ExpiryTime)
case "EXPIRE_UNIX":
if st.ExpiryTime <= 0 {
return "0"
}
return strconv.FormatInt(st.ExpiryTime/1000, 10)
case "CREATED_UNIX":
if c.CreatedAt == 0 {
return ""
}
return strconv.FormatInt(c.CreatedAt/1000, 10)
case "TRAFFIC_USED":
return common.FormatTraffic(used)
case "TRAFFIC_LEFT":
if st.Total <= 0 {
return unlimitedMark
}
return common.FormatTraffic(max64(st.Total-used, 0))
case "TRAFFIC_TOTAL":
if st.Total <= 0 {
return unlimitedMark
}
return common.FormatTraffic(st.Total)
case "TRAFFIC_USED_BYTES":
return strconv.FormatInt(used, 10)
case "TRAFFIC_LEFT_BYTES":
if st.Total <= 0 {
return "0"
}
return strconv.FormatInt(max64(st.Total-used, 0), 10)
case "TRAFFIC_TOTAL_BYTES":
return strconv.FormatInt(st.Total, 10)
case "UP":
return common.FormatTraffic(st.Up)
case "DOWN":
return common.FormatTraffic(st.Down)
case "RESET_DAYS":
if c.Reset > 0 {
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 "SECURITY":
return strings.ToUpper(ctx.security)
case "TIME_LEFT":
return timeLeftLabel(st.ExpiryTime)
case "JALALI_EXPIRE_DATE":
return jalaliExpireDateLabel(st.ExpiryTime)
}
return ""
}
// clientStatus collapses enable/expiry/quota into a single word.
func clientStatus(st xray.ClientTraffic) string {
if !st.Enable {
return "disabled"
}
if st.ExpiryTime > 0 && st.ExpiryTime/1000 < time.Now().Unix() {
return "expired"
}
if st.Total > 0 && st.Up+st.Down >= st.Total {
return "depleted"
}
return "active"
}
// daysLeftLabel is the whole-days form of remainingTimeLabel: "∞" for unlimited,
// "0" once past expiry.
func daysLeftLabel(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 // delayed-start: value is the duration itself
}
days := secs / 86400
if days < 0 {
return "0"
}
return strconv.FormatInt(days, 10)
}
// 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).In(time.Local).Format("2006-01-02")
}
func max64(a, b int64) int64 {
if a > b {
return a
}
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.
func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client) xray.ClientTraffic {
if stats, ok := s.findClientStats(inbound, client.Email); ok {
return stats
}
// client_traffics.email is globally unique, so a client shared across several
// inbounds of one subscription has a single traffic row owned by exactly one
// inbound. On every other inbound's link findClientStats misses; fall back to
// the per-request map built from all the subscription's inbounds so
// {{TRAFFIC_*}} reflect real usage instead of the full quota (#5443).
if stats, ok := s.statsByEmail[client.Email]; ok {
return stats
}
// Both in-memory paths key off client_traffics.inbound_id, which goes stale
// when an inbound is deleted and recreated, orphaning the row from every
// loaded inbound. Fall back to a direct lookup by the globally-unique email
// so usage still resolves for clients predating that recreation (#5567).
if stats, ok := s.statsByEmailFromDB(client.Email); ok {
return stats
}
return xray.ClientTraffic{
Enable: client.Enable,
ExpiryTime: client.ExpiryTime,
Total: client.TotalGB,
}
}
// lookupClient resolves the full client (TgID, SubID, comment, …) for an email,
// needed when a global remark template references client-only tokens. Falls back
// to an email-only client if not found.
func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
if c, ok := s.clientForLink(inbound, email); ok {
return c
}
return model.Client{Email: email}
}
var usageInfoTokens = map[string]bool{
"TRAFFIC_USED": true, "TRAFFIC_LEFT": true, "TRAFFIC_TOTAL": true,
"TRAFFIC_USED_BYTES": true, "TRAFFIC_LEFT_BYTES": true, "TRAFFIC_TOTAL_BYTES": true,
"UP": true, "DOWN": true, "DAYS_LEFT": true, "EXPIRE_DATE": true, "EXPIRE_UNIX": true,
"STATUS": true, "STATUS_EMOJI": true, "USAGE_PERCENTAGE": true, "TIME_LEFT": true,
"JALALI_EXPIRE_DATE": true,
}
var connectionTokens = map[string]bool{
"PROTOCOL": true,
"TRANSPORT": true,
"SECURITY": true,
}
var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens)
// firstLinkOnlyBodyTokens are stripped from every subscription-body link after a
// client's first one: the usage/info tokens plus the per-client EMAIL/USERNAME
// identity. A client app needs the email once, so repeating it on every link of
// the same subscription is noise — show it on the first link only, like traffic.
var firstLinkOnlyBodyTokens = mergeTokenSets(usageInfoTokens, map[string]bool{
"EMAIL": true,
"USERNAME": true,
})
func mergeTokenSets(sets ...map[string]bool) map[string]bool {
out := make(map[string]bool)
for _, set := range sets {
for tok := range set {
out[tok] = true
}
}
return out
}
func filterRemarkTemplate(template string, remove map[string]bool) string {
segments := strings.Split(template, "|")
kept := make([]string, 0, len(segments))
for _, seg := range segments {
if out := filterRemarkSegment(seg, remove); out != "" {
kept = append(kept, out)
}
}
return strings.Join(kept, "|")
}
// filterRemarkSegment drops whole token categories from one segment while it is
// still a template, before any value is known. Literal text touching a removed
// token goes with it and the surviving runs rejoin with a space, so filtering the
// usage tokens out of "{{EMAIL}} 📊{{TRAFFIC_LEFT}}" leaves "{{EMAIL}}". This is
// the template-level counterpart to expandSegment, which works one layer later on
// tokens that survive here but resolve to an empty value.
func filterRemarkSegment(seg string, remove map[string]bool) string {
tokens := remarkTokens(seg)
hasRemove := false
for _, tok := range tokens {
if remove[tok.name] {
hasRemove = true
break
}
}
if !hasRemove {
return strings.TrimSpace(seg)
}
runs := make([]string, 0, 2)
runStart, leftRemoved := 0, false
for _, tok := range tokens {
if !remove[tok.name] {
continue
}
runs = appendKeptRun(runs, seg[runStart:tok.start], leftRemoved, true)
runStart, leftRemoved = tok.end, true
}
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
return strings.Join(runs, " ")
}
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
tokens := remarkTokens(run)
if len(tokens) == 0 {
return runs
}
start, end := 0, len(run)
if leftRemoved {
start = tokens[0].start
}
if rightRemoved {
end = tokens[len(tokens)-1].end
}
if frag := strings.TrimSpace(run[start:end]); frag != "" {
runs = append(runs, frag)
}
return runs
}
func (s *SubService) effectiveTemplate(email string) string {
translated := translateUISingleBrackets(s.remarkTemplate)
if s.usageShown == nil {
s.usageShown = map[string]bool{}
}
if s.usageShown[email] {
remove := firstLinkOnlyBodyTokens
if s.showIdentityOnAllLinks {
remove = usageInfoTokens
}
return filterRemarkTemplate(translated, remove)
}
s.usageShown[email] = true
return translated
}
func inboundSecurity(inbound *model.Inbound) string {
if inbound == nil {
return ""
}
stream := unmarshalStreamSettings(inbound.StreamSettings)
security, _ := stream["security"].(string)
return security
}
// 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, transport string) string {
ctx := remarkContext{
client: client,
stats: s.statsForClient(inbound, client),
inbound: inbound,
hostRemark: hostRemark,
transport: transport,
security: inboundSecurity(inbound),
}
var tmpl string
if s.subscriptionBody {
tmpl = s.effectiveTemplate(client.Email)
} else {
tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
}
if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
return out
}
return ctx.configName()
}
// genHostRemark builds one host endpoint's remark for a specific client. With a
// remark template set it is template-driven (body shows the full template on the
// first link and the name-only part thereafter; displays render the name-only
// part). With no template it falls back to inbound, host and email joined by "-".
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
if s.remarkTemplate != "" {
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
}
return fallbackRemark(inbound.Remark, hostRemark, client.Email)
}