mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-06 04:44:21 +00:00
599 lines
17 KiB
Go
599 lines
17 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_]+)\}\}`)
|
|
|
|
// 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".
|
|
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 "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
|
|
}
|
|
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}
|
|
}
|
|
|
|
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)
|
|
|
|
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, "|")
|
|
}
|
|
|
|
func filterRemarkSegment(seg string, remove map[string]bool) string {
|
|
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
|
|
hasRemove := false
|
|
for _, loc := range locs {
|
|
if remove[seg[loc[2]:loc[3]]] {
|
|
hasRemove = true
|
|
break
|
|
}
|
|
}
|
|
if !hasRemove {
|
|
return strings.TrimSpace(seg)
|
|
}
|
|
runs := make([]string, 0, 2)
|
|
runStart, leftRemoved := 0, false
|
|
for _, loc := range locs {
|
|
if !remove[seg[loc[2]:loc[3]]] {
|
|
continue
|
|
}
|
|
runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
|
|
runStart, leftRemoved = loc[1], true
|
|
}
|
|
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
|
|
return strings.Join(runs, " ")
|
|
}
|
|
|
|
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
|
|
locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
|
|
if len(locs) == 0 {
|
|
return runs
|
|
}
|
|
start, end := 0, len(run)
|
|
if leftRemoved {
|
|
start = locs[0][0]
|
|
}
|
|
if rightRemoved {
|
|
end = locs[len(locs)-1][1]
|
|
}
|
|
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] {
|
|
return filterRemarkTemplate(translated, usageInfoTokens)
|
|
}
|
|
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)
|
|
}
|