remark_vars.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. package sub
  2. import (
  3. "regexp"
  4. "strconv"
  5. "strings"
  6. "time"
  7. "unicode"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  9. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  10. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  11. )
  12. // remarkContext carries the per-client data a remark template can interpolate.
  13. // stats holds the live traffic record when one exists; when it doesn't, the
  14. // caller synthesizes a minimal one from the client so expiry/total/status tokens
  15. // still resolve. hostRemark is the host endpoint's own remark; it backs the
  16. // {{HOST}} token only — it never substitutes the inbound's remark as the config
  17. // name (use {{INBOUND}} and {{HOST}} side by side to show both).
  18. type remarkContext struct {
  19. client model.Client
  20. stats xray.ClientTraffic
  21. inbound *model.Inbound
  22. hostRemark string
  23. }
  24. // configName is the display name for a link: always the inbound's own remark.
  25. // The host endpoint's remark is surfaced only through the {{HOST}} token.
  26. func (ctx remarkContext) configName() string {
  27. if ctx.inbound != nil {
  28. return ctx.inbound.Remark
  29. }
  30. return ""
  31. }
  32. // remarkVarRe matches a {{TOKEN}} placeholder. Tokens are uppercase letters and
  33. // underscores only, so ordinary braces in a remark are left untouched.
  34. var remarkVarRe = regexp.MustCompile(`\{\{([A-Z_]+)\}\}`)
  35. // unlimitedMark is the value the human-readable quota/expiry tokens render when
  36. // the client has no limit. A segment built only around such a token carries no
  37. // information, so it is dropped rather than printed as "∞" (see expandRemarkVars).
  38. const unlimitedMark = "∞"
  39. // unlimitedDropTokens are the tokens that render unlimitedMark for an unlimited
  40. // client. A "|"-separated segment whose only value comes from one of these is
  41. // dropped whole when unlimited, so the operator never sees "📊∞|⏳∞D".
  42. var unlimitedDropTokens = map[string]bool{
  43. "TRAFFIC_LEFT": true,
  44. "TRAFFIC_TOTAL": true,
  45. "DAYS_LEFT": true,
  46. }
  47. // expandRemarkVars substitutes every {{TOKEN}} in template with its per-client
  48. // value. Unknown tokens resolve to "" (never the literal text). The template is
  49. // split on "|" into segments: a segment whose only value is an unlimited quota
  50. // or expiry (∞) drops out whole — decoration and separator included — so an
  51. // unlimited client gets "host" instead of "host|📊∞|⏳∞D".
  52. func expandRemarkVars(template string, ctx remarkContext) string {
  53. if !strings.Contains(template, "{{") {
  54. return template
  55. }
  56. segments := strings.Split(template, "|")
  57. kept := make([]string, 0, len(segments))
  58. for _, seg := range segments {
  59. if out, drop := expandSegment(seg, ctx); !drop {
  60. kept = append(kept, out)
  61. }
  62. }
  63. return strings.Join(kept, "|")
  64. }
  65. // expandSegment expands one "|" segment and reports whether it should be dropped.
  66. // It drops only when the segment carries an unlimited (∞) quota/expiry token and
  67. // no other token in it resolves to a non-empty value — so a segment mixing, say,
  68. // {{EMAIL}} with {{TRAFFIC_LEFT}} is always kept.
  69. func expandSegment(seg string, ctx remarkContext) (string, bool) {
  70. hasUnlimited, hasOtherValue := false, false
  71. out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
  72. token := m[2 : len(m)-2]
  73. val := remarkVarValue(token, ctx)
  74. switch {
  75. case unlimitedDropTokens[token] && val == unlimitedMark:
  76. hasUnlimited = true
  77. case val != "":
  78. hasOtherValue = true
  79. }
  80. return val
  81. })
  82. return out, hasUnlimited && !hasOtherValue
  83. }
  84. func remarkVarValue(token string, ctx remarkContext) string {
  85. c := ctx.client
  86. st := ctx.stats
  87. used := st.Up + st.Down
  88. switch token {
  89. case "EMAIL", "USERNAME":
  90. return c.Email
  91. case "INBOUND":
  92. return ctx.configName()
  93. case "HOST":
  94. return ctx.hostRemark
  95. case "ID":
  96. return c.ID
  97. case "SHORT_ID":
  98. if len(c.ID) >= 8 {
  99. return c.ID[:8]
  100. }
  101. return c.ID
  102. case "TELEGRAM_ID":
  103. if c.TgID != 0 {
  104. return strconv.FormatInt(c.TgID, 10)
  105. }
  106. return ""
  107. case "SUB_ID":
  108. return c.SubID
  109. case "COMMENT":
  110. return c.Comment
  111. case "STATUS":
  112. return clientStatus(st)
  113. case "DAYS_LEFT":
  114. return daysLeftLabel(st.ExpiryTime)
  115. case "EXPIRE_DATE":
  116. return expireDateLabel(st.ExpiryTime)
  117. case "EXPIRE_UNIX":
  118. if st.ExpiryTime <= 0 {
  119. return "0"
  120. }
  121. return strconv.FormatInt(st.ExpiryTime/1000, 10)
  122. case "CREATED_UNIX":
  123. if c.CreatedAt == 0 {
  124. return ""
  125. }
  126. return strconv.FormatInt(c.CreatedAt/1000, 10)
  127. case "TRAFFIC_USED":
  128. return common.FormatTraffic(used)
  129. case "TRAFFIC_LEFT":
  130. if st.Total <= 0 {
  131. return unlimitedMark
  132. }
  133. return common.FormatTraffic(max64(st.Total-used, 0))
  134. case "TRAFFIC_TOTAL":
  135. if st.Total <= 0 {
  136. return unlimitedMark
  137. }
  138. return common.FormatTraffic(st.Total)
  139. case "TRAFFIC_USED_BYTES":
  140. return strconv.FormatInt(used, 10)
  141. case "TRAFFIC_LEFT_BYTES":
  142. if st.Total <= 0 {
  143. return "0"
  144. }
  145. return strconv.FormatInt(max64(st.Total-used, 0), 10)
  146. case "TRAFFIC_TOTAL_BYTES":
  147. return strconv.FormatInt(st.Total, 10)
  148. case "UP":
  149. return common.FormatTraffic(st.Up)
  150. case "DOWN":
  151. return common.FormatTraffic(st.Down)
  152. case "RESET_DAYS":
  153. if c.Reset > 0 {
  154. return strconv.Itoa(c.Reset)
  155. }
  156. return ""
  157. }
  158. return ""
  159. }
  160. // clientStatus collapses enable/expiry/quota into a single word.
  161. func clientStatus(st xray.ClientTraffic) string {
  162. if !st.Enable {
  163. return "disabled"
  164. }
  165. if st.ExpiryTime > 0 && st.ExpiryTime/1000 < time.Now().Unix() {
  166. return "expired"
  167. }
  168. if st.Total > 0 && st.Up+st.Down >= st.Total {
  169. return "depleted"
  170. }
  171. return "active"
  172. }
  173. // daysLeftLabel is the whole-days form of remainingTimeLabel: "∞" for unlimited,
  174. // "0" once past expiry.
  175. func daysLeftLabel(expiryMs int64) string {
  176. if expiryMs == 0 {
  177. return unlimitedMark
  178. }
  179. exp := expiryMs / 1000
  180. var secs int64
  181. if exp > 0 {
  182. secs = exp - time.Now().Unix()
  183. } else {
  184. secs = -exp // delayed-start: value is the duration itself
  185. }
  186. days := secs / 86400
  187. if days < 0 {
  188. return "0"
  189. }
  190. return strconv.FormatInt(days, 10)
  191. }
  192. // expireDateLabel renders a fixed expiry as YYYY-MM-DD (UTC). Unlimited and
  193. // delayed-start (no fixed calendar date yet) expiries yield "".
  194. func expireDateLabel(expiryMs int64) string {
  195. if expiryMs <= 0 {
  196. return ""
  197. }
  198. return time.Unix(expiryMs/1000, 0).UTC().Format("2006-01-02")
  199. }
  200. func max64(a, b int64) int64 {
  201. if a > b {
  202. return a
  203. }
  204. return b
  205. }
  206. // statsForClient returns the client's live traffic record, or a minimal one
  207. // synthesized from the client (enable/expiry/total) when no live stats exist —
  208. // so expiry/total/status tokens still resolve on links that have no counters yet.
  209. func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client) xray.ClientTraffic {
  210. if stats, ok := s.findClientStats(inbound, client.Email); ok {
  211. return stats
  212. }
  213. // client_traffics.email is globally unique, so a client shared across several
  214. // inbounds of one subscription has a single traffic row owned by exactly one
  215. // inbound. On every other inbound's link findClientStats misses; fall back to
  216. // the per-request map built from all the subscription's inbounds so
  217. // {{TRAFFIC_*}} reflect real usage instead of the full quota (#5443).
  218. if stats, ok := s.statsByEmail[client.Email]; ok {
  219. return stats
  220. }
  221. return xray.ClientTraffic{
  222. Enable: client.Enable,
  223. ExpiryTime: client.ExpiryTime,
  224. Total: client.TotalGB,
  225. }
  226. }
  227. // lookupClient resolves the full client (TgID, SubID, comment, …) for an email,
  228. // needed when a global remark template references client-only tokens. Falls back
  229. // to an email-only client if not found.
  230. func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Client {
  231. clients, _ := s.inboundService.GetClients(inbound)
  232. for _, c := range clients {
  233. if c.Email == email {
  234. return c
  235. }
  236. }
  237. return model.Client{Email: email}
  238. }
  239. // usageInfoTokens are the per-client status tokens. On every link of a
  240. // subscription except the client's first, these (and the decoration leading
  241. // into them) are dropped, so the traffic/expiry info shows once instead of on
  242. // every server.
  243. var usageInfoTokens = []string{
  244. "TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
  245. "TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
  246. "UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
  247. }
  248. // nameOnlyTemplate returns template with the trailing per-client info part
  249. // removed: everything from the first usage token (and the decoration — emojis,
  250. // spaces, separators — leading into it) onward is dropped, leaving the config
  251. // name. Returns "" when the template is info-only.
  252. func nameOnlyTemplate(template string) string {
  253. idx := -1
  254. for _, tok := range usageInfoTokens {
  255. if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
  256. idx = i
  257. }
  258. }
  259. if idx < 0 {
  260. return template
  261. }
  262. return strings.TrimRightFunc(template[:idx], func(r rune) bool {
  263. return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
  264. })
  265. }
  266. // effectiveTemplate picks which template to expand for one body link: the full
  267. // template (with the per-client info) for a client's first link, and the
  268. // name-only template for every link thereafter — so the info shows once. Only
  269. // called in the subscription-body context (displays bypass the template).
  270. func (s *SubService) effectiveTemplate(email string) string {
  271. if s.usageShown == nil {
  272. s.usageShown = map[string]bool{}
  273. }
  274. if s.usageShown[email] {
  275. return nameOnlyTemplate(s.remarkTemplate)
  276. }
  277. s.usageShown[email] = true
  278. return s.remarkTemplate
  279. }
  280. // genTemplatedRemark expands the remark template for one client. hostRemark is
  281. // the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
  282. // token only and never substitutes the inbound remark as the config name.
  283. func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
  284. ctx := remarkContext{
  285. client: client,
  286. stats: s.statsForClient(inbound, client),
  287. inbound: inbound,
  288. hostRemark: hostRemark,
  289. }
  290. tmpl := s.effectiveTemplate(client.Email)
  291. // Fall back to the config name when the template is empty or expands to
  292. // nothing (e.g. an all-unlimited template whose only segments dropped out).
  293. if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
  294. return out
  295. }
  296. return ctx.configName()
  297. }
  298. // genHostRemark builds one host endpoint's remark for a specific client. The
  299. // config name is always the inbound's own remark; the host's remark is surfaced
  300. // only through the {{HOST}} token. In the subscription body the rest of the
  301. // remark template still applies; displays show just the config name.
  302. func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string) string {
  303. if !s.subscriptionBody {
  304. return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
  305. }
  306. return s.genTemplatedRemark(inbound, client, hostRemark)
  307. }