Files
3x-ui/internal/sub/remark_vars.go
T
wahh3b-lgtm 605e90dbf0 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>
2026-06-21 02:00:27 +02:00

537 lines
16 KiB
Go

package sub
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"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
}
// 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_]+)\}\}`)
// 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",
}
// 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".
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.
// It drops only when the segment carries an unlimited (∞) quota/expiry token and
// no other token in it resolves to a non-empty value — so a segment mixing, say,
// {{EMAIL}} with {{TRAFFIC_LEFT}} is always kept.
func expandSegment(seg string, ctx remarkContext) (string, bool) {
hasUnlimited, hasOtherValue := false, false
out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
token := m[2 : len(m)-2]
val := remarkVarValue(token, ctx)
switch {
case unlimitedDropTokens[token] && val == unlimitedMark:
hasUnlimited = true
case val != "":
hasOtherValue = true
}
return val
})
return out, hasUnlimited && !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 "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
}
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 {
clients, _ := s.inboundService.GetClients(inbound)
for _, c := range clients {
if c.Email == email {
return c
}
}
return model.Client{Email: email}
}
// usageInfoTokens are the per-client status tokens. On every link of a
// subscription except the client's first, these (and the decoration leading
// into them) are dropped, so the traffic/expiry info shows once instead of on
// every server.
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
// removed: everything from the first usage token (and the decoration — emojis,
// spaces, separators — leading into it) onward is dropped, leaving the config
// name. Returns "" when the template is info-only.
func nameOnlyTemplate(template string) string {
idx := -1
for _, tok := range usageInfoTokens {
if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
idx = i
}
}
if idx < 0 {
return template
}
return strings.TrimRightFunc(template[:idx], func(r rune) bool {
return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// effectiveTemplate picks which template to expand for one body link: the full
// template (with the per-client info) for a client's first link, and the
// 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(translated)
}
s.usageShown[email] = true
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, 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
// nothing (e.g. an all-unlimited template whose only segments dropped out).
if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
return out
}
return ctx.configName()
}
// genHostRemark builds one host endpoint's remark for a specific client. The
// 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, transport string) string {
if !s.subscriptionBody {
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
}
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
}