remark_vars.go 10 KB

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