mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-11 23:26:07 +00:00
b0c1156dd6
Unify remark generation around the Remark Template. Display contexts (Clients-page QR/Info modals and the HTML sub info page) now render the template name-only client/identity part instead of a hardcoded fallback; the subscription body keeps the full template on a client first link and name-only thereafter. The default template gains the email token so the client email shows by default again (#5532). BuildPageData now splits each multi-link entry (one link per host of an inbound) into a separate row, so the sub page no longer collapses several host links onto a single mangled line. QR captions on the Clients QR modal and the sub page reuse the link fragment remark.
540 lines
16 KiB
Go
540 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 render name-only directly).
|
|
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,
|
|
}
|
|
var tmpl string
|
|
if s.subscriptionBody {
|
|
tmpl = s.effectiveTemplate(client.Email)
|
|
} else {
|
|
tmpl = nameOnlyTemplate(translateUISingleBrackets(s.remarkTemplate))
|
|
}
|
|
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)
|
|
}
|