1
0
Эх сурвалжийг харах

Merge pull request #3466 from MHSanaei/Subscription

Subscription,tgbot,rule
Sanaei 23 цаг өмнө
parent
commit
6d41320ed7

+ 1 - 0
go.mod

@@ -15,6 +15,7 @@ require (
 	github.com/pelletier/go-toml/v2 v2.2.4
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/shirou/gopsutil/v4 v4.25.8
+	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.65.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xtls/xray-core v1.250911.0

+ 2 - 0
go.sum

@@ -142,6 +142,8 @@ github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1
 github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg=
 github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
 github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

+ 55 - 0
sub/sub.go

@@ -6,11 +6,14 @@ import (
 	"io"
 	"net"
 	"net/http"
+	"os"
+	"path/filepath"
 	"strconv"
 
 	"x-ui/config"
 	"x-ui/logger"
 	"x-ui/util/common"
+	"x-ui/web/locale"
 	"x-ui/web/middleware"
 	"x-ui/web/network"
 	"x-ui/web/service"
@@ -57,6 +60,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		engine.Use(middleware.DomainValidatorMiddleware(subDomain))
 	}
 
+	// Provide base_path in context for templates
+	engine.Use(func(c *gin.Context) {
+		c.Set("base_path", "/")
+	})
+
 	LinksPath, err := s.settingService.GetSubPath()
 	if err != nil {
 		return nil, err
@@ -112,6 +120,29 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubTitle = ""
 	}
 
+	// init i18n for sub server using disk FS so templates can use {{ i18n }}
+	// Root FS is project root; translation files are under web/translation
+	if err := locale.InitLocalizerFS(os.DirFS("web"), &s.settingService); err != nil {
+		logger.Warning("sub: i18n init failed:", err)
+	}
+	// set per-request localizer from headers/cookies
+	engine.Use(locale.LocalizerMiddleware())
+
+	// load HTML templates needed for subscription page (common layout + page + component + subscription)
+	if files, err := s.getHtmlFiles(); err != nil {
+		logger.Warning("sub: getHtmlFiles failed:", err)
+	} else {
+		// register i18n function similar to web server
+		i18nWebFunc := func(key string, params ...string) string {
+			return locale.I18n(locale.Web, key, params...)
+		}
+		engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc})
+		engine.LoadHTMLFiles(files...)
+	}
+
+	// serve assets from web/assets to use shared JS/CSS like other pages
+	engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
+
 	g := engine.Group("/")
 
 	s.sub = NewSUBController(
@@ -121,6 +152,30 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	return engine, nil
 }
 
+// getHtmlFiles loads templates from local folder (used in debug mode)
+func (s *Server) getHtmlFiles() ([]string, error) {
+	dir, _ := os.Getwd()
+	files := []string{}
+	// common layout
+	common := filepath.Join(dir, "web", "html", "common", "page.html")
+	if _, err := os.Stat(common); err == nil {
+		files = append(files, common)
+	}
+	// components used
+	theme := filepath.Join(dir, "web", "html", "component", "aThemeSwitch.html")
+	if _, err := os.Stat(theme); err == nil {
+		files = append(files, theme)
+	}
+	// page itself
+	page := filepath.Join(dir, "web", "html", "subscription.html")
+	if _, err := os.Stat(page); err == nil {
+		files = append(files, page)
+	} else {
+		return nil, err
+	}
+	return files, nil
+}
+
 func (s *Server) Start() (err error) {
 	// This is an anonymous function, no function name
 	defer func() {

+ 40 - 45
sub/subController.go

@@ -2,8 +2,8 @@ package sub
 
 import (
 	"encoding/base64"
-	"net"
 	"strings"
+	"x-ui/config"
 
 	"github.com/gin-gonic/gin"
 )
@@ -58,21 +58,8 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
 
 func (a *SUBController) subs(c *gin.Context) {
 	subId := c.Param("subid")
-	var host string
-	if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
-		host = h
-	}
-	if host == "" {
-		host = c.GetHeader("X-Real-IP")
-	}
-	if host == "" {
-		var err error
-		host, _, err = net.SplitHostPort(c.Request.Host)
-		if err != nil {
-			host = c.Request.Host
-		}
-	}
-	subs, header, err := a.subService.GetSubs(subId, host)
+	scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
+	subs, header, lastOnline, err := a.subService.GetSubs(subId, host)
 	if err != nil || len(subs) == 0 {
 		c.String(400, "Error!")
 	} else {
@@ -81,10 +68,38 @@ func (a *SUBController) subs(c *gin.Context) {
 			result += sub + "\n"
 		}
 
+		// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
+		accept := c.GetHeader("Accept")
+		if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
+			// Build page data in service
+			subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
+			page := a.subService.BuildPageData(subId, hostHeader, header, lastOnline, subs, subURL, subJsonURL)
+			c.HTML(200, "subscription.html", gin.H{
+				"title":        "subscription.title",
+				"cur_ver":      config.GetVersion(),
+				"host":         page.Host,
+				"base_path":    page.BasePath,
+				"sId":          page.SId,
+				"download":     page.Download,
+				"upload":       page.Upload,
+				"total":        page.Total,
+				"used":         page.Used,
+				"remained":     page.Remained,
+				"expire":       page.Expire,
+				"lastOnline":   page.LastOnline,
+				"datepicker":   page.Datepicker,
+				"downloadByte": page.DownloadByte,
+				"uploadByte":   page.UploadByte,
+				"totalByte":    page.TotalByte,
+				"subUrl":       page.SubUrl,
+				"subJsonUrl":   page.SubJsonUrl,
+				"result":       page.Result,
+			})
+			return
+		}
+
 		// Add headers
-		c.Writer.Header().Set("Subscription-Userinfo", header)
-		c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
-		c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
+		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
 
 		if a.subEncrypt {
 			c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@@ -96,41 +111,21 @@ func (a *SUBController) subs(c *gin.Context) {
 
 func (a *SUBController) subJsons(c *gin.Context) {
 	subId := c.Param("subid")
-	var host string
-	if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
-		host = h
-	}
-	if host == "" {
-		host = c.GetHeader("X-Real-IP")
-	}
-	if host == "" {
-		var err error
-		host, _, err = net.SplitHostPort(c.Request.Host)
-		if err != nil {
-			host = c.Request.Host
-		}
-	}
+	_, host, _, _ := a.subService.ResolveRequest(c)
 	jsonSub, header, err := a.subJsonService.GetJson(subId, host)
 	if err != nil || len(jsonSub) == 0 {
 		c.String(400, "Error!")
 	} else {
 
 		// Add headers
-		c.Writer.Header().Set("Subscription-Userinfo", header)
-		c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
-		c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
+		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
 
 		c.String(200, jsonSub)
 	}
 }
 
-func getHostFromXFH(s string) (string, error) {
-	if strings.Contains(s, ":") {
-		realHost, _, err := net.SplitHostPort(s)
-		if err != nil {
-			return "", err
-		}
-		return realHost, nil
-	}
-	return s, nil
+func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
+	c.Writer.Header().Set("Subscription-Userinfo", header)
+	c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
+	c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
 }

+ 184 - 7
sub/subService.go

@@ -3,10 +3,15 @@ package sub
 import (
 	"encoding/base64"
 	"fmt"
+	"net"
 	"net/url"
+	"strconv"
 	"strings"
 	"time"
 
+	"github.com/gin-gonic/gin"
+	"github.com/goccy/go-json"
+
 	"x-ui/database"
 	"x-ui/database/model"
 	"x-ui/logger"
@@ -14,8 +19,6 @@ import (
 	"x-ui/util/random"
 	"x-ui/web/service"
 	"x-ui/xray"
-
-	"github.com/goccy/go-json"
 )
 
 type SubService struct {
@@ -34,19 +37,20 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
 	}
 }
 
-func (s *SubService) GetSubs(subId string, host string) ([]string, string, error) {
+func (s *SubService) GetSubs(subId string, host string) ([]string, string, int64, error) {
 	s.address = host
 	var result []string
 	var header string
 	var traffic xray.ClientTraffic
+	var lastOnline int64
 	var clientTraffics []xray.ClientTraffic
 	inbounds, err := s.getInboundsBySubId(subId)
 	if err != nil {
-		return nil, "", err
+		return nil, "", 0, err
 	}
 
 	if len(inbounds) == 0 {
-		return nil, "", common.NewError("No inbounds found with ", subId)
+		return nil, "", 0, common.NewError("No inbounds found with ", subId)
 	}
 
 	s.datepicker, err = s.settingService.GetDatepicker()
@@ -73,7 +77,11 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
 			if client.Enable && client.SubID == subId {
 				link := s.getLink(inbound, client.Email)
 				result = append(result, link)
-				clientTraffics = append(clientTraffics, s.getClientTraffics(inbound.ClientStats, client.Email))
+				ct := s.getClientTraffics(inbound.ClientStats, client.Email)
+				clientTraffics = append(clientTraffics, ct)
+				if ct.LastOnline > lastOnline {
+					lastOnline = ct.LastOnline
+				}
 			}
 		}
 	}
@@ -101,7 +109,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
 		}
 	}
 	header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
-	return result, header, nil
+	return result, header, lastOnline, nil
 }
 
 func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
@@ -1001,3 +1009,172 @@ func searchHost(headers any) string {
 
 	return ""
 }
+
+// PageData is a view model for subscription.html
+type PageData struct {
+	Host         string
+	BasePath     string
+	SId          string
+	Download     string
+	Upload       string
+	Total        string
+	Used         string
+	Remained     string
+	Expire       int64
+	LastOnline   int64
+	Datepicker   string
+	DownloadByte int64
+	UploadByte   int64
+	TotalByte    int64
+	SubUrl       string
+	SubJsonUrl   string
+	Result       []string
+}
+
+// ResolveRequest extracts scheme and host info from request/headers consistently.
+func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
+	// scheme
+	scheme = "http"
+	if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
+		scheme = "https"
+	}
+
+	// base host (no port)
+	if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
+		host = h
+	}
+	if host == "" {
+		host = c.GetHeader("X-Real-IP")
+	}
+	if host == "" {
+		var err error
+		host, _, err = net.SplitHostPort(c.Request.Host)
+		if err != nil {
+			host = c.Request.Host
+		}
+	}
+
+	// host:port for URLs
+	hostWithPort = c.GetHeader("X-Forwarded-Host")
+	if hostWithPort == "" {
+		hostWithPort = c.Request.Host
+	}
+	if hostWithPort == "" {
+		hostWithPort = host
+	}
+
+	// header display host
+	hostHeader = c.GetHeader("X-Forwarded-Host")
+	if hostHeader == "" {
+		hostHeader = c.GetHeader("X-Real-IP")
+	}
+	if hostHeader == "" {
+		hostHeader = host
+	}
+	return
+}
+
+// BuildURLs constructs absolute subscription and json URLs.
+func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
+	if strings.HasSuffix(subPath, "/") {
+		subURL = scheme + "://" + hostWithPort + subPath + subId
+	} else {
+		subURL = scheme + "://" + hostWithPort + strings.TrimRight(subPath, "/") + "/" + subId
+	}
+	if strings.HasSuffix(subJsonPath, "/") {
+		subJsonURL = scheme + "://" + hostWithPort + subJsonPath + subId
+	} else {
+		subJsonURL = scheme + "://" + hostWithPort + strings.TrimRight(subJsonPath, "/") + "/" + subId
+	}
+	return
+}
+
+// BuildPageData parses header and prepares the template view model.
+func (s *SubService) BuildPageData(subId, hostHeader, header string, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
+	// Parse header values
+	var uploadByte, downloadByte, totalByte, expire int64
+	parts := strings.Split(header, ";")
+	for _, p := range parts {
+		kv := strings.Split(strings.TrimSpace(p), "=")
+		if len(kv) != 2 {
+			continue
+		}
+		key := strings.ToLower(strings.TrimSpace(kv[0]))
+		val := strings.TrimSpace(kv[1])
+		switch key {
+		case "upload":
+			if v, err := parseInt64(val); err == nil {
+				uploadByte = v
+			}
+		case "download":
+			if v, err := parseInt64(val); err == nil {
+				downloadByte = v
+			}
+		case "total":
+			if v, err := parseInt64(val); err == nil {
+				totalByte = v
+			}
+		case "expire":
+			if v, err := parseInt64(val); err == nil {
+				expire = v
+			}
+		}
+	}
+
+	download := common.FormatTraffic(downloadByte)
+	upload := common.FormatTraffic(uploadByte)
+	total := "∞"
+	used := common.FormatTraffic(uploadByte + downloadByte)
+	remained := ""
+	if totalByte > 0 {
+		total = common.FormatTraffic(totalByte)
+		left := totalByte - (uploadByte + downloadByte)
+		if left < 0 {
+			left = 0
+		}
+		remained = common.FormatTraffic(left)
+	}
+
+	datepicker := s.datepicker
+	if datepicker == "" {
+		datepicker = "gregorian"
+	}
+
+	return PageData{
+		Host:         hostHeader,
+		BasePath:     "/",
+		SId:          subId,
+		Download:     download,
+		Upload:       upload,
+		Total:        total,
+		Used:         used,
+		Remained:     remained,
+		Expire:       expire,
+		LastOnline:   lastOnline,
+		Datepicker:   datepicker,
+		DownloadByte: downloadByte,
+		UploadByte:   uploadByte,
+		TotalByte:    totalByte,
+		SubUrl:       subURL,
+		SubJsonUrl:   subJsonURL,
+		Result:       subs,
+	}
+}
+
+func getHostFromXFH(s string) (string, error) {
+	if strings.Contains(s, ":") {
+		realHost, _, err := net.SplitHostPort(s)
+		if err != nil {
+			return "", err
+		}
+		return realHost, nil
+	}
+	return s, nil
+}
+
+func parseInt64(s string) (int64, error) {
+	// handle potential quotes
+	s = strings.Trim(s, "\"'")
+	n, err := strconv.ParseInt(s, 10, 64)
+	return n, err
+}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
web/assets/css/custom.min.css


+ 125 - 0
web/assets/js/subscription.js

@@ -0,0 +1,125 @@
+(function () {
+  // Vue app for Subscription page
+  const el = document.getElementById('subscription-data');
+  if (!el) return;
+  const textarea = document.getElementById('subscription-links');
+  const rawLinks = (textarea?.value || '').split('\n').filter(Boolean);
+
+  const data = {
+    sId: el.getAttribute('data-sid') || '',
+    subUrl: el.getAttribute('data-sub-url') || '',
+    subJsonUrl: el.getAttribute('data-subjson-url') || '',
+    download: el.getAttribute('data-download') || '',
+    upload: el.getAttribute('data-upload') || '',
+    used: el.getAttribute('data-used') || '',
+    total: el.getAttribute('data-total') || '',
+    remained: el.getAttribute('data-remained') || '',
+    expireMs: (parseInt(el.getAttribute('data-expire') || '0', 10) || 0) * 1000,
+    lastOnlineMs: (parseInt(el.getAttribute('data-lastonline') || '0', 10) || 0),
+    downloadByte: parseInt(el.getAttribute('data-downloadbyte') || '0', 10) || 0,
+    uploadByte: parseInt(el.getAttribute('data-uploadbyte') || '0', 10) || 0,
+    totalByte: parseInt(el.getAttribute('data-totalbyte') || '0', 10) || 0,
+    datepicker: el.getAttribute('data-datepicker') || 'gregorian',
+  };
+
+  // Normalize lastOnline to milliseconds if it looks like seconds
+  if (data.lastOnlineMs && data.lastOnlineMs < 10_000_000_000) {
+    data.lastOnlineMs *= 1000;
+  }
+
+  function renderLink(item) {
+    return (
+      Vue.h('a-list-item', {}, [
+        Vue.h('a-space', { props: { size: 'small' } }, [
+          Vue.h('a-button', { props: { size: 'small' }, on: { click: () => copy(item) } }, [Vue.h('a-icon', { props: { type: 'copy' } })]),
+          Vue.h('span', { class: 'break-all' }, item)
+        ])
+      ])
+    );
+  }
+
+  function copy(text) {
+    ClipboardManager.copyText(text).then(ok => {
+      const messageType = ok ? 'success' : 'error';
+      Vue.prototype.$message[messageType](ok ? 'Copied' : 'Copy failed');
+    });
+  }
+
+  function open(url) {
+    window.location.href = url;
+  }
+
+  function drawQR(value) {
+    try { new QRious({ element: document.getElementById('qrcode'), value, size: 220 }); } catch (e) { console.warn(e); }
+  }
+
+  // Try to extract a human label (email/ps) from different link types
+  function linkName(link, idx) {
+    try {
+      if (link.startsWith('vmess://')) {
+        const json = JSON.parse(atob(link.replace('vmess://', '')));
+        if (json.ps) return json.ps;
+        if (json.add && json.id) return json.add; // fallback host
+      } else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
+        // vless://<id>@host:port?...#name
+        const hashIdx = link.indexOf('#');
+        if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
+        // email sometimes in query params like sni or remark
+        const qIdx = link.indexOf('?');
+        if (qIdx !== -1) {
+          const qs = new URL('http://x/?' + link.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : undefined)).searchParams;
+          if (qs.get('remark')) return qs.get('remark');
+          if (qs.get('email')) return qs.get('email');
+        }
+        // else take user@host
+        const at = link.indexOf('@');
+        const protSep = link.indexOf('://');
+        if (at !== -1 && protSep !== -1) return link.substring(protSep + 3, at);
+      } else if (link.startsWith('ss://')) {
+        // shadowsocks: label often after #
+        const hashIdx = link.indexOf('#');
+        if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
+      }
+    } catch (e) { /* ignore and fallback */ }
+    return 'Link ' + (idx + 1);
+  }
+
+  const app = new Vue({
+    delimiters: ['[[', ']]'],
+    el: '#app',
+    data: {
+      themeSwitcher,
+      app: data,
+      links: rawLinks,
+      lang: '',
+      viewportWidth: (typeof window !== 'undefined' ? window.innerWidth : 1024),
+    },
+    async mounted() {
+      this.lang = LanguageManager.getLanguage();
+      // Discover subJsonUrl if provided via template bootstrap
+      const tpl = document.getElementById('subscription-data');
+      const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
+      if (sj) this.app.subJsonUrl = sj;
+      drawQR(this.app.subUrl);
+      // Draw second QR if available
+      try { new QRious({ element: document.getElementById('qrcode-subjson'), value: this.app.subJsonUrl || '', size: 220 }); } catch (e) { /* ignore */ }
+      // Track viewport width for responsive behavior
+      this._onResize = () => { this.viewportWidth = window.innerWidth; };
+      window.addEventListener('resize', this._onResize);
+    },
+    beforeDestroy() {
+      if (this._onResize) window.removeEventListener('resize', this._onResize);
+    },
+    computed: {
+      isMobile() { return this.viewportWidth < 576; },
+      isUnlimited() { return !this.app.totalByte; },
+      isActive() {
+        const now = Date.now();
+        const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
+        const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
+        return expiryOk && trafficOk;
+      },
+    },
+    methods: { renderLink, copy, open, linkName, i18nLabel(key) { return '{{ i18n "' + key + '" }}'; } },
+  });
+})();

+ 20 - 6
web/html/modals/xray_rule_modal.html

@@ -9,7 +9,7 @@
           </template> Source IPs <a-icon type="question-circle"></a-icon>
         </a-tooltip>
       </template>
-      <a-input v-model.trim="ruleModal.rule.sourceIP"></a-input>
+      <a-input v-model.trim="ruleModal.rule.sourceIP" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
     </a-form-item>
     <a-form-item>
       <template slot="label">
@@ -19,7 +19,17 @@
           </template> Source Port <a-icon type="question-circle"></a-icon>
         </a-tooltip>
       </template>
-      <a-input v-model.trim="ruleModal.rule.sourcePort"></a-input>
+      <a-input v-model.trim="ruleModal.rule.sourcePort" placeholder="e.g. 53,443,1000-2000"></a-input>
+    </a-form-item>
+    <a-form-item>
+      <template slot="label">
+        <a-tooltip>
+          <template slot="title">
+            <span>{{ i18n "pages.xray.rules.useComma" }}</span>
+          </template> VLESS Route <a-icon type="question-circle"></a-icon>
+        </a-tooltip>
+      </template>
+      <a-input v-model.trim="ruleModal.rule.vlessRoute" placeholder="e.g. 53,443,1000-2000"></a-input>
     </a-form-item>
     <a-form-item label='Network'>
       <a-select v-model="ruleModal.rule.network" :dropdown-class-name="themeSwitcher.currentTheme">
@@ -52,7 +62,7 @@
           </template> IP <a-icon type="question-circle"></a-icon>
         </a-tooltip>
       </template>
-      <a-input v-model.trim="ruleModal.rule.ip"></a-input>
+      <a-input v-model.trim="ruleModal.rule.ip" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
     </a-form-item>
     <a-form-item>
       <template slot="label">
@@ -62,7 +72,7 @@
           </template> Domain <a-icon type="question-circle"></a-icon>
         </a-tooltip>
       </template>
-      <a-input v-model.trim="ruleModal.rule.domain"></a-input>
+      <a-input v-model.trim="ruleModal.rule.domain" placeholder="e.g. google.com, geosite:cn"></a-input>
     </a-form-item>
     <a-form-item>
       <template slot="label">
@@ -72,7 +82,7 @@
           </template> User <a-icon type="question-circle"></a-icon>
         </a-tooltip>
       </template>
-      <a-input v-model.trim="ruleModal.rule.user"></a-input>
+      <a-input v-model.trim="ruleModal.rule.user" placeholder="e.g. email address"></a-input>
     </a-form-item>
     <a-form-item>
       <template slot="label">
@@ -82,7 +92,7 @@
           </template> Port <a-icon type="question-circle"></a-icon>
         </a-tooltip>
       </template>
-      <a-input v-model.trim="ruleModal.rule.port"></a-input>
+      <a-input v-model.trim="ruleModal.rule.port" placeholder="e.g. 53,443,1000-2000"></a-input>
     </a-form-item>
     <a-form-item label='Inbound Tags'>
       <a-select v-model="ruleModal.rule.inboundTag" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme">
@@ -122,6 +132,7 @@
       ip: "",
       port: "",
       sourcePort: "",
+      vlessRoute: "",
       network: "",
       sourceIP: "",
       user: "",
@@ -155,6 +166,7 @@
         this.rule.ip = rule.ip ? rule.ip.join(',') : [];
         this.rule.port = rule.port;
         this.rule.sourcePort = rule.sourcePort;
+        this.rule.vlessRoute = rule.vlessRoute;
         this.rule.network = rule.network;
         this.rule.sourceIP = rule.sourceIP ? rule.sourceIP.join(',') : [];
         this.rule.user = rule.user ? rule.user.join(',') : [];
@@ -169,6 +181,7 @@
           ip: "",
           port: "",
           sourcePort: "",
+          vlessRoute: "",
           network: "",
           sourceIP: "",
           user: "",
@@ -210,6 +223,7 @@
       rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
       rule.port = value.port;
       rule.sourcePort = value.sourcePort;
+      rule.vlessRoute = value.vlessRoute;
       rule.network = value.network;
       rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
       rule.user = value.user.length > 0 ? value.user.split(',') : [];

+ 8 - 4
web/html/settings/xray/routing.html

@@ -67,18 +67,22 @@
         </template>
         <template slot="info" slot-scope="text, rule, index">
             <a-popover placement="bottomRight"
-                v-if="(rule.source+rule.sourcePort+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
+                v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
                 :overlay-class-name="themeSwitcher.currentTheme" trigger="click">
                 <template slot="content">
                     <table cellpadding="2" :style="{ maxWidth: '300px' }">
-                        <tr v-if="rule.source">
-                            <td>Source</td>
-                            <td><a-tag color="blue" v-for="r in rule.source.split(',')">[[ r ]]</a-tag></td>
+                        <tr v-if="rule.sourceIP">
+                            <td>Source IP</td>
+                            <td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
                         </tr>
                         <tr v-if="rule.sourcePort">
                             <td>Source Port</td>
                             <td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
                         </tr>
+                        <tr v-if="rule.vlessRoute">
+                            <td>VLESS Route</td>
+                            <td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
+                        </tr>
                         <tr v-if="rule.network">
                             <td>Network</td>
                             <td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>

+ 274 - 0
web/html/subscription.html

@@ -0,0 +1,274 @@
+{{ template "page/head_start" .}}
+<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
+<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
+<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
+<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
+<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
+<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
+{{ template "page/head_end" .}}
+
+{{ template "page/body_start" .}}
+<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
+    <a-layout-content class="p-2">
+        <a-row type="flex" justify="center" class="mt-2">
+            <a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
+                <a-card hoverable class="subscription-card">
+                    <template #title>
+                        <a-space>
+                            <span>{{ i18n "subscription.title" }}</span>
+                            <a-tag>{{ .sId }}</a-tag>
+                        </a-space>
+                    </template>
+                    <template #extra>
+                        <a-popover
+                            :overlay-class-name="themeSwitcher.currentTheme"
+                            title='{{ i18n "menu.settings" }}'
+                            placement="bottomRight" trigger="click">
+                            <template #content>
+                                <a-space direction="vertical" :size="10">
+                                    <a-theme-switch-login></a-theme-switch-login>
+                                    <span>{{ i18n "pages.settings.language"
+                                        }}</span>
+                                    <a-select ref="selectLang" class="w-100"
+                                        v-model="lang"
+                                        @change="LanguageManager.setLanguage(lang)"
+                                        :dropdown-class-name="themeSwitcher.currentTheme">
+                                        <a-select-option :value="l.value"
+                                            label="English"
+                                            v-for="l in LanguageManager.supportedLanguages"
+                                            :key="l.value">
+                                            <span role="img"
+                                                :aria-label="l.name"
+                                                v-text="l.icon"></span>
+                                            &nbsp;&nbsp;<span
+                                                v-text="l.name"></span>
+                                        </a-select-option>
+                                    </a-select>
+                                </a-space>
+                            </template>
+                            <a-button shape="circle" icon="setting"></a-button>
+                        </a-popover>
+                    </template>
+
+                    <a-form layout="vertical">
+                        <a-form-item>
+                            <a-space direction="vertical" align="center">
+                                <a-row type="flex" :gutter="[8,8]"
+                                    justify="center" style="width:100%">
+                                    <a-col :xs="24" :sm="12"
+                                        style="text-align:center;">
+                                        <tr-qr-box class="qr-box">
+                                            <a-tag color="purple"
+                                                class="qr-tag">
+                                                <span>{{ i18n
+                                                    "pages.settings.subSettings"}}</span>
+                                            </a-tag>
+                                            <tr-qr-bg class="qr-bg-sub">
+                                                <tr-qr-bg-inner
+                                                    class="qr-bg-sub-inner">
+                                                    <canvas id="qrcode"
+                                                        class="qr-cv"
+                                                        title='{{ i18n "copy" }}'
+                                                        @click="copy(app.subUrl)"></canvas>
+                                                </tr-qr-bg-inner>
+                                            </tr-qr-bg>
+                                        </tr-qr-box>
+                                    </a-col>
+                                    <a-col :xs="24" :sm="12"
+                                        style="text-align:center;">
+                                        <tr-qr-box class="qr-box">
+                                            <a-tag color="purple"
+                                                class="qr-tag">
+                                                <span>{{ i18n
+                                                    "pages.settings.subSettings"}}
+                                                    Json</span>
+                                            </a-tag>
+                                            <tr-qr-bg class="qr-bg-sub">
+                                                <tr-qr-bg-inner
+                                                    class="qr-bg-sub-inner">
+                                                    <canvas id="qrcode-subjson"
+                                                        class="qr-cv"
+                                                        title='{{ i18n "copy" }}'
+                                                        @click="copy(app.subJsonUrl)"></canvas>
+                                                </tr-qr-bg-inner>
+                                            </tr-qr-bg>
+                                        </tr-qr-box>
+                                    </a-col>
+                                </a-row>
+                            </a-space>
+                        </a-form-item>
+
+                        <a-form-item>
+                            <a-descriptions bordered :column="1" size="small">
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.subId" }}'>[[
+                                    app.sId
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.status" }}'>
+                                    <template v-if="isUnlimited">
+                                        <a-tag color="purple">{{ i18n
+                                            "subscription.unlimited" }}</a-tag>
+                                    </template>
+                                    <template v-else>
+                                        <a-tag
+                                            :color="isActive ? 'green' : 'red'">[[
+                                            isActive ? '{{ i18n
+                                            "subscription.active" }}' : '{{ i18n
+                                            "subscription.inactive" }}'
+                                            ]]</a-tag>
+                                    </template>
+                                </a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.downloaded" }}'>[[
+                                    app.download
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.uploaded" }}'>[[
+                                    app.upload
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "usage" }}'>[[ app.used
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.totalQuota" }}'>[[
+                                    app.total
+                                    ]]</a-descriptions-item>
+                                <a-descriptions-item v-if="app.totalByte > 0"
+                                    label='{{ i18n "remained" }}'>[[
+                                    app.remained ]]</a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "lastOnline" }}'>
+                                    <template v-if="app.lastOnlineMs > 0">
+                                        <template
+                                            v-if="app.datepicker === 'gregorian'">
+                                            [[
+                                            DateUtil.formatMillis(app.lastOnlineMs)
+                                            ]]
+                                        </template>
+                                        <template v-else>
+                                            [[
+                                            DateUtil.convertToJalalian(moment(app.lastOnlineMs))
+                                            ]]
+                                        </template>
+                                    </template>
+                                    <template v-else>
+                                        <span>-</span>
+                                    </template>
+                                </a-descriptions-item>
+                                <a-descriptions-item
+                                    label='{{ i18n "subscription.expiry" }}'>
+                                    <template v-if="app.expireMs === 0">
+                                        {{ i18n "subscription.noExpiry" }}
+                                    </template>
+                                    <template v-else>
+                                        <template
+                                            v-if="app.datepicker === 'gregorian'">
+                                            [[
+                                            DateUtil.formatMillis(app.expireMs)
+                                            ]]
+                                        </template>
+                                        <template v-else>
+                                            [[
+                                            DateUtil.convertToJalalian(moment(app.expireMs))
+                                            ]]
+                                        </template>
+                                    </template>
+                                </a-descriptions-item>
+                            </a-descriptions>
+                        </a-form-item>
+                    </a-form>
+
+                    <br />
+                    <a-list bordered>
+                        <a-list-item v-for="(link, idx) in links" :key="link">
+                            <div style="width:100%; text-align:center;">
+                                <a-button type="primary" :block="isMobile"
+                                    @click="copy(link)">[[ linkName(link, idx)
+                                    ]]</a-button>
+                            </div>
+                        </a-list-item>
+                    </a-list>
+                    <br />
+
+                    <a-form layout="vertical">
+                        <a-form-item>
+                            <a-row type="flex" justify="center" :gutter="[8,8]"
+                                style="width:100%">
+                                <a-col :xs="24" :sm="12"
+                                    style="text-align:center;">
+                                    <!-- Android dropdown -->
+                                    <a-dropdown :trigger="['click']">
+                                        <a-button :block="isMobile"
+                                            :style="{ marginTop: isMobile ? '6px' : 0 }"
+                                            size="large" type="primary">
+                                            Android <a-icon type="down" />
+                                        </a-button>
+                                        <a-menu slot="overlay"
+                                            :class="themeSwitcher.currentTheme">
+                                            <a-menu-item key="android-v2box"
+                                                @click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
+                                            <a-menu-item key="android-v2rayng"
+                                                @click="open('v2rayng://install-config?url=' + encodeURIComponent(app.subUrl))">V2RayNG</a-menu-item>
+                                            <a-menu-item key="android-singbox"
+                                                @click="copy(app.subUrl)">Sing-box</a-menu-item>
+                                            <a-menu-item key="android-v2raytun"
+                                                @click="copy(app.subUrl)">V2RayTun</a-menu-item>
+                                            <a-menu-item key="android-npvtunnel"
+                                                @click="copy(app.subUrl)">NPV
+                                                Tunnel</a-menu-item>
+                                        </a-menu>
+                                    </a-dropdown>
+                                </a-col>
+                                <a-col :xs="24" :sm="12"
+                                    style="text-align:center;">
+                                    <!-- iOS dropdown -->
+                                    <a-dropdown :trigger="['click']">
+                                        <a-button :block="isMobile"
+                                            :style="{ marginTop: isMobile ? '6px' : 0 }"
+                                            size="large" type="primary">
+                                            iOS <a-icon type="down" />
+                                        </a-button>
+                                        <a-menu slot="overlay"
+                                            :class="themeSwitcher.currentTheme">
+                                            <a-menu-item key="ios-shadowrocket"
+                                                @click="open('shadowrocket://add/subscription?url=' + encodeURIComponent(app.subUrl) + '&remark=' + encodeURIComponent(app.sId))">Shadowrocket</a-menu-item>
+                                            <a-menu-item key="ios-v2box"
+                                                @click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
+                                            <a-menu-item key="ios-streisand"
+                                                @click="open('streisand://import/' + encodeURIComponent(app.subUrl))">Streisand</a-menu-item>
+                                            <a-menu-item key="ios-v2raytun"
+                                                @click="copy(app.subUrl)">V2RayTun</a-menu-item>
+                                            <a-menu-item key="ios-npvtunnel"
+                                                @click="copy(app.subUrl)">NPV
+                                                Tunnel</a-menu-item>
+                                        </a-menu>
+                                    </a-dropdown>
+                                </a-col>
+                            </a-row>
+                        </a-form-item>
+                    </a-form>
+                </a-card>
+            </a-col>
+        </a-row>
+    </a-layout-content>
+</a-layout>
+
+<!-- Bootstrap data for external JS -->
+<template id="subscription-data" data-sid="{{ .sId }}"
+    data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
+    data-download="{{ .download }}"
+    data-upload="{{ .upload }}" data-used="{{ .used }}"
+    data-total="{{ .total }}" data-remained="{{ .remained }}"
+    data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
+    data-downloadbyte="{{ .downloadByte }}"
+    data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
+    data-datepicker="{{ .datepicker }}"></template>
+<textarea id="subscription-links"
+    style="display:none">{{ range .result }}{{ . }}
+{{ end }}</textarea>
+
+{{template "component/aThemeSwitch" .}}
+<script src="{{ .base_path }}assets/js/subscription.js?{{ .cur_ver }}"></script>
+
+{{ template "page/body_end" .}}

+ 3 - 2
web/html/xray.html

@@ -146,8 +146,9 @@
     { title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
     {
       title: '{{ i18n "pages.xray.rules.source"}}', children: [
-        { title: 'IP', dataIndex: "source", align: 'center', width: 20, ellipsis: true },
-        { title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true }]
+        { title: 'IP', dataIndex: "sourceIP", align: 'center', width: 20, ellipsis: true },
+        { title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true },
+        { title: 'VLESS Route', dataIndex: 'vlessRoute', align: 'center', width: 15, ellipsis: true }]
     },
     {
       title: '{{ i18n "pages.inbounds.network"}}', children: [

+ 19 - 3
web/locale/locale.go

@@ -48,6 +48,22 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
 	return nil
 }
 
+// InitLocalizerFS allows initializing i18n from any fs.FS (e.g., disk), rooted at a directory containing a "translation" folder
+func InitLocalizerFS(fsys fs.FS, settingService SettingService) error {
+	// set default bundle to english
+	i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
+	i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
+
+	if err := parseTranslationFiles(fsys, i18nBundle); err != nil {
+		return err
+	}
+
+	if err := initTGBotLocalizer(settingService); err != nil {
+		return err
+	}
+	return nil
+}
+
 func createTemplateData(params []string, seperator ...string) map[string]any {
 	var sep string = "=="
 	if len(seperator) > 0 {
@@ -118,8 +134,8 @@ func LocalizerMiddleware() gin.HandlerFunc {
 	}
 }
 
-func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
-	err := fs.WalkDir(i18nFS, "translation",
+func parseTranslationFiles(fsys fs.FS, i18nBundle *i18n.Bundle) error {
+	err := fs.WalkDir(fsys, "translation",
 		func(path string, d fs.DirEntry, err error) error {
 			if err != nil {
 				return err
@@ -129,7 +145,7 @@ func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
 				return nil
 			}
 
-			data, err := i18nFS.ReadFile(path)
+			data, err := fs.ReadFile(fsys, path)
 			if err != nil {
 				return err
 			}

+ 343 - 0
web/service/tgbot.go

@@ -7,8 +7,10 @@ import (
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"io"
 	"math/big"
 	"net"
+	"net/http"
 	"net/url"
 	"os"
 	"regexp"
@@ -29,6 +31,7 @@ import (
 	"github.com/mymmrac/telego"
 	th "github.com/mymmrac/telego/telegohandler"
 	tu "github.com/mymmrac/telego/telegoutil"
+	"github.com/skip2/go-qrcode"
 	"github.com/valyala/fasthttp"
 	"github.com/valyala/fasthttp/fasthttpproxy"
 )
@@ -1355,6 +1358,73 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 	case "client_commands":
 		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands"))
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands"))
+	case "client_sub_links":
+		// show user's own clients to choose one for sub links
+		tgUserID := callbackQuery.From.ID
+		traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
+		if err != nil {
+			// fallback to message
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+			return
+		}
+		if len(traffics) == 0 {
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
+			return
+		}
+		var buttons []telego.InlineKeyboardButton
+		for _, tr := range traffics {
+			buttons = append(buttons, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_sub_links "+tr.Email)))
+		}
+		cols := 1
+		if len(buttons) >= 6 {
+			cols = 2
+		}
+		keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard)
+	case "client_individual_links":
+		// show user's clients to choose for individual links
+		tgUserID := callbackQuery.From.ID
+		traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
+		if err != nil {
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+			return
+		}
+		if len(traffics) == 0 {
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
+			return
+		}
+		var buttons2 []telego.InlineKeyboardButton
+		for _, tr := range traffics {
+			buttons2 = append(buttons2, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_individual_links "+tr.Email)))
+		}
+		cols2 := 1
+		if len(buttons2) >= 6 {
+			cols2 = 2
+		}
+		keyboard2 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols2, buttons2...))
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard2)
+	case "client_qr_links":
+		// show user's clients to choose for QR codes
+		tgUserID := callbackQuery.From.ID
+		traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
+		if err != nil {
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOccurred")+"\r\n"+err.Error())
+			return
+		}
+		if len(traffics) == 0 {
+			t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
+			return
+		}
+		var buttons3 []telego.InlineKeyboardButton
+		for _, tr := range traffics {
+			buttons3 = append(buttons3, tu.InlineKeyboardButton(tr.Email).WithCallbackData(t.encodeQuery("client_qr_links "+tr.Email)))
+		}
+		cols3 := 1
+		if len(buttons3) >= 6 {
+			cols3 = 2
+		}
+		keyboard3 := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols3, buttons3...))
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.pleaseChoose"), keyboard3)
 	case "onlines":
 		t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines"))
 		t.onlineClients(chatId)
@@ -1439,6 +1509,23 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
 		)
 		prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
 		t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
+	default:
+		// dynamic callbacks
+		if strings.HasPrefix(callbackQuery.Data, "client_sub_links ") {
+			email := strings.TrimPrefix(callbackQuery.Data, "client_sub_links ")
+			t.sendClientSubLinks(chatId, email)
+			return
+		}
+		if strings.HasPrefix(callbackQuery.Data, "client_individual_links ") {
+			email := strings.TrimPrefix(callbackQuery.Data, "client_individual_links ")
+			t.sendClientIndividualLinks(chatId, email)
+			return
+		}
+		if strings.HasPrefix(callbackQuery.Data, "client_qr_links ") {
+			email := strings.TrimPrefix(callbackQuery.Data, "client_qr_links ")
+			t.sendClientQRLinks(chatId, email)
+			return
+		}
 	case "add_client_ch_default_traffic":
 		inlineKeyboard := tu.InlineKeyboard(
 			tu.InlineKeyboardRow(
@@ -1847,6 +1934,13 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
 			tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
 		),
+		tu.InlineKeyboardRow(
+			tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
+			tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
+		),
+		tu.InlineKeyboardRow(
+			tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
+		),
 	)
 
 	var ReplyMarkup telego.ReplyMarkup
@@ -1908,6 +2002,255 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
 	}
 }
 
+// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
+func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
+	// Resolve subId from client email
+	traffic, client, err := t.inboundService.GetClientByEmail(email)
+	_ = traffic
+	if err != nil || client == nil {
+		return "", "", errors.New("client not found")
+	}
+
+	// Gather settings to construct absolute URLs
+	subDomain, _ := t.settingService.GetSubDomain()
+	subPort, _ := t.settingService.GetSubPort()
+	subPath, _ := t.settingService.GetSubPath()
+	subJsonPath, _ := t.settingService.GetSubJsonPath()
+	subKeyFile, _ := t.settingService.GetSubKeyFile()
+	subCertFile, _ := t.settingService.GetSubCertFile()
+
+	tls := (subKeyFile != "" && subCertFile != "")
+	scheme := "http"
+	if tls {
+		scheme = "https"
+	}
+
+	// Fallbacks
+	if subDomain == "" {
+		// try panel domain, otherwise OS hostname
+		if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
+			subDomain = d
+		} else if hostname != "" {
+			subDomain = hostname
+		} else {
+			subDomain = "localhost"
+		}
+	}
+
+	host := subDomain
+	if (subPort == 443 && tls) || (subPort == 80 && !tls) {
+		// standard ports: no port in host
+	} else {
+		host = fmt.Sprintf("%s:%d", subDomain, subPort)
+	}
+
+	// Ensure paths
+	if !strings.HasPrefix(subPath, "/") {
+		subPath = "/" + subPath
+	}
+	if !strings.HasSuffix(subPath, "/") {
+		subPath = subPath + "/"
+	}
+	if !strings.HasPrefix(subJsonPath, "/") {
+		subJsonPath = "/" + subJsonPath
+	}
+	if !strings.HasSuffix(subJsonPath, "/") {
+		subJsonPath = subJsonPath + "/"
+	}
+
+	subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
+	subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
+	return subURL, subJsonURL, nil
+}
+
+func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
+	subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
+	if err != nil {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+		return
+	}
+	msg := "Subscription URL:\r\n<code>" + subURL + "</code>\r\n\r\n" +
+		"JSON URL:\r\n<code>" + subJsonURL + "</code>"
+	inlineKeyboard := tu.InlineKeyboard(
+		tu.InlineKeyboardRow(
+			tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links " + email)),
+		),
+	)
+	t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
+}
+
+// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
+func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
+	// Build the HTML sub page URL; we'll call it with header Accept to get raw content
+	subURL, _, err := t.buildSubscriptionURLs(email)
+	if err != nil {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+		return
+	}
+
+	// Try to fetch raw subscription links. Prefer plain text response.
+	req, err := http.NewRequest("GET", subURL, nil)
+	if err != nil {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+		return
+	}
+	// Force plain text to avoid HTML page; controller respects Accept header
+	req.Header.Set("Accept", "text/plain, */*;q=0.1")
+
+	// Use default client with reasonable timeout via context
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	req = req.WithContext(ctx)
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+		return
+	}
+	defer resp.Body.Close()
+
+	bodyBytes, err := io.ReadAll(resp.Body)
+	if err != nil {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+		return
+	}
+
+	// If service is configured to encode (Base64), decode it
+	encoded, _ := t.settingService.GetSubEncrypt()
+	var content string
+	if encoded {
+		decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
+		if err != nil {
+			// fallback to raw text
+			content = string(bodyBytes)
+		} else {
+			content = string(decoded)
+		}
+	} else {
+		content = string(bodyBytes)
+	}
+
+	// Normalize line endings and trim
+	lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
+	var cleaned []string
+	for _, l := range lines {
+		l = strings.TrimSpace(l)
+		if l != "" {
+			cleaned = append(cleaned, l)
+		}
+	}
+	if len(cleaned) == 0 {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
+		return
+	}
+
+	// Send in chunks to respect message length; use monospace formatting
+	const maxPerMessage = 50
+	for i := 0; i < len(cleaned); i += maxPerMessage {
+		j := i + maxPerMessage
+		if j > len(cleaned) {
+			j = len(cleaned)
+		}
+		chunk := cleaned[i:j]
+		msg := t.I18nBot("subscription.individualLinks") + ":\r\n"
+		for _, link := range chunk {
+			// wrap each link in <code>
+			msg += "<code>" + link + "</code>\r\n"
+		}
+		t.SendMsgToTgbot(chatId, msg)
+	}
+}
+
+// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
+func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
+	subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
+	if err != nil {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+		return
+	}
+
+	// Helper to create QR PNG bytes from content
+	createQR := func(content string, size int) ([]byte, error) {
+		if size <= 0 {
+			size = 256
+		}
+		return qrcode.Encode(content, qrcode.Medium, size)
+	}
+
+	// Inform user
+	t.SendMsgToTgbot(chatId, "QRCode"+":")
+
+	// Send sub URL QR (filename: sub.png)
+	if png, err := createQR(subURL, 320); err == nil {
+		document := tu.Document(
+			tu.ID(chatId),
+			tu.FileFromBytes(png, "sub.png"),
+		)
+		_, _ = bot.SendDocument(context.Background(), document)
+	} else {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+	}
+
+	// Send JSON URL QR (filename: subjson.png)
+	if png, err := createQR(subJsonURL, 320); err == nil {
+		document := tu.Document(
+			tu.ID(chatId),
+			tu.FileFromBytes(png, "subjson.png"),
+		)
+		_, _ = bot.SendDocument(context.Background(), document)
+	} else {
+		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
+	}
+
+	// Also generate a few individual links' QRs (first up to 5)
+	subPageURL := subURL
+	req, err := http.NewRequest("GET", subPageURL, nil)
+	if err == nil {
+		req.Header.Set("Accept", "text/plain, */*;q=0.1")
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		req = req.WithContext(ctx)
+		if resp, err := http.DefaultClient.Do(req); err == nil {
+			body, _ := io.ReadAll(resp.Body)
+			_ = resp.Body.Close()
+			encoded, _ := t.settingService.GetSubEncrypt()
+			var content string
+			if encoded {
+				if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
+					content = string(dec)
+				} else {
+					content = string(body)
+				}
+			} else {
+				content = string(body)
+			}
+			lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
+			var cleaned []string
+			for _, l := range lines {
+				l = strings.TrimSpace(l)
+				if l != "" {
+					cleaned = append(cleaned, l)
+				}
+			}
+			if len(cleaned) > 0 {
+				max := min(len(cleaned), 5)
+				for i := range max {
+					if png, err := createQR(cleaned[i], 320); err == nil {
+						// Use the email as filename for individual link QR
+						filename := email + ".png"
+						document := tu.Document(
+							tu.ID(chatId),
+							tu.FileFromBytes(png, filename),
+						)
+						_, _ = bot.SendDocument(context.Background(), document)
+						time.Sleep(200 * time.Millisecond)
+					}
+				}
+			}
+		}
+	}
+}
+
 func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
 	if len(replyMarkup) > 0 {
 		for _, adminId := range adminIds {

+ 14 - 0
web/translation/translate.ar_EG.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
 "somethingWentWrong" = "حدث خطأ ما"
 
+[subscription]
+"title" = "معلومات الاشتراك"
+"subId" = "معرّف الاشتراك"
+"status" = "الحالة"
+"downloaded" = "التنزيل"
+"uploaded" = "الرفع"
+"expiry" = "تاريخ الانتهاء"
+"totalQuota" = "الحصة الإجمالية"
+"individualLinks" = "روابط فردية"
+"active" = "نشط"
+"inactive" = "غير نشط"
+"unlimited" = "غير محدود"
+"noExpiry" = "بدون انتهاء"
+
 [menu]
 "theme" = "الثيم"
 "dark" = "داكن"

+ 14 - 0
web/translation/translate.en_US.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "No added reverse proxies."
 "somethingWentWrong" = "Something went wrong"
 
+[subscription]
+"title" = "Subscription info"
+"subId" = "Subscription ID"
+"status" = "Status"
+"downloaded" = "Downloaded"
+"uploaded" = "Uploaded"
+"expiry" = "Expiry"
+"totalQuota" = "Total quota"
+"individualLinks" = "Individual links"
+"active" = "Active"
+"inactive" = "Inactive"
+"unlimited" = "Unlimited"
+"noExpiry" = "No expiry"
+
 [menu]
 "theme" = "Theme"
 "dark" = "Dark"

+ 14 - 0
web/translation/translate.es_ES.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "No hay proxies inversos añadidos."
 "somethingWentWrong" = "Algo salió mal"
 
+[subscription]
+"title" = "Información de suscripción"
+"subId" = "ID de suscripción"
+"status" = "Estado"
+"downloaded" = "Descargado"
+"uploaded" = "Subido"
+"expiry" = "Caducidad"
+"totalQuota" = "Cuota total"
+"individualLinks" = "Enlaces individuales"
+"active" = "Activo"
+"inactive" = "Inactivo"
+"unlimited" = "Ilimitado"
+"noExpiry" = "Sin caducidad"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Oscuro"

+ 14 - 0
web/translation/translate.fa_IR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
 "somethingWentWrong" = "مشکلی پیش آمد"
 
+[subscription]
+"title" = "اطلاعات سابسکریپشن"
+"subId" = "شناسه اشتراک"
+"status" = "وضعیت"
+"downloaded" = "دانلود"
+"uploaded" = "آپلود"
+"expiry" = "تاریخ پایان"
+"totalQuota" = "حجم کلی"
+"individualLinks" = "لینک‌های تکی"
+"active" = "فعال"
+"inactive" = "غیرفعال"
+"unlimited" = "نامحدود"
+"noExpiry" = "بدون انقضا"
+
 [menu]
 "theme" = "تم"
 "dark" = "تیره"

+ 14 - 0
web/translation/translate.id_ID.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
 "somethingWentWrong" = "Terjadi kesalahan"
 
+[subscription]
+"title" = "Info langganan"
+"subId" = "ID langganan"
+"status" = "Status"
+"downloaded" = "Diunduh"
+"uploaded" = "Diunggah"
+"expiry" = "Kedaluwarsa"
+"totalQuota" = "Kuota total"
+"individualLinks" = "Tautan individual"
+"active" = "Aktif"
+"inactive" = "Nonaktif"
+"unlimited" = "Tanpa batas"
+"noExpiry" = "Tanpa kedaluwarsa"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Gelap"

+ 14 - 0
web/translation/translate.ja_JP.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "追加されたリバースプロキシはありません。"
 "somethingWentWrong" = "エラーが発生しました"
 
+[subscription]
+"title" = "サブスクリプション情報"
+"subId" = "サブスクリプションID"
+"status" = "ステータス"
+"downloaded" = "ダウンロード"
+"uploaded" = "アップロード"
+"expiry" = "有効期限"
+"totalQuota" = "合計クォータ"
+"individualLinks" = "個別リンク"
+"active" = "有効"
+"inactive" = "無効"
+"unlimited" = "無制限"
+"noExpiry" = "期限なし"
+
 [menu]
 "theme" = "テーマ"
 "dark" = "ダーク"

+ 14 - 0
web/translation/translate.pt_BR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Nenhum proxy reverso adicionado."
 "somethingWentWrong" = "Algo deu errado"
 
+[subscription]
+"title" = "Informações da assinatura"
+"subId" = "ID da assinatura"
+"status" = "Status"
+"downloaded" = "Baixado"
+"uploaded" = "Enviado"
+"expiry" = "Validade"
+"totalQuota" = "Cota total"
+"individualLinks" = "Links individuais"
+"active" = "Ativo"
+"inactive" = "Inativo"
+"unlimited" = "Ilimitado"
+"noExpiry" = "Sem validade"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Escuro"

+ 14 - 0
web/translation/translate.ru_RU.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Нет добавленных реверс-прокси."
 "somethingWentWrong" = "Что-то пошло не так"
 
+[subscription]
+"title" = "Информация о подписке"
+"subId" = "ID подписки"
+"status" = "Статус"
+"downloaded" = "Загружено"
+"uploaded" = "Отправлено"
+"expiry" = "Срок действия"
+"totalQuota" = "Общий лимит"
+"individualLinks" = "Индивидуальные ссылки"
+"active" = "Активна"
+"inactive" = "Неактивна"
+"unlimited" = "Безлимит"
+"noExpiry" = "Без срока"
+
 [menu]
 "theme" = "Тема"
 "dark" = "Темная"

+ 14 - 0
web/translation/translate.tr_TR.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Eklenmiş ters proxy yok."
 "somethingWentWrong" = "Bir şeyler yanlış gitti"
 
+[subscription]
+"title" = "Abonelik Bilgisi"
+"subId" = "Abonelik Kimliği"
+"status" = "Durum"
+"downloaded" = "İndirilen"
+"uploaded" = "Yüklenen"
+"expiry" = "Son Kullanma"
+"totalQuota" = "Toplam Kota"
+"individualLinks" = "Bireysel Bağlantılar"
+"active" = "Aktif"
+"inactive" = "Pasif"
+"unlimited" = "Sınırsız"
+"noExpiry" = "Süresiz"
+
 [menu]
 "theme" = "Tema"
 "dark" = "Koyu"

+ 14 - 0
web/translation/translate.uk_UA.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Немає доданих зворотних проксі."
 "somethingWentWrong" = "Щось пішло не так"
 
+[subscription]
+"title" = "Інформація про підписку"
+"subId" = "ID підписки"
+"status" = "Статус"
+"downloaded" = "Завантажено"
+"uploaded" = "Відвантажено"
+"expiry" = "Термін дії"
+"totalQuota" = "Загальна квота"
+"individualLinks" = "Окремі посилання"
+"active" = "Активна"
+"inactive" = "Неактивна"
+"unlimited" = "Безліміт"
+"noExpiry" = "Без строку"
+
 [menu]
 "theme" = "Тема"
 "dark" = "Темна"

+ 14 - 0
web/translation/translate.vi_VN.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "Không có proxy ngược nào được thêm."
 "somethingWentWrong" = "Đã xảy ra lỗi"
 
+[subscription]
+"title" = "Thông tin đăng ký"
+"subId" = "ID đăng ký"
+"status" = "Trạng thái"
+"downloaded" = "Đã tải xuống"
+"uploaded" = "Đã tải lên"
+"expiry" = "Hết hạn"
+"totalQuota" = "Tổng hạn mức"
+"individualLinks" = "Liên kết riêng lẻ"
+"active" = "Hoạt động"
+"inactive" = "Không hoạt động"
+"unlimited" = "Không giới hạn"
+"noExpiry" = "Không hết hạn"
+
 [menu]
 "theme" = "Chủ đề"
 "dark" = "Tối"

+ 14 - 0
web/translation/translate.zh_CN.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "未添加反向代理。"
 "somethingWentWrong" = "出了点问题"
 
+[subscription]
+"title" = "订阅信息"
+"subId" = "订阅 ID"
+"status" = "状态"
+"downloaded" = "已下载"
+"uploaded" = "已上传"
+"expiry" = "到期"
+"totalQuota" = "总配额"
+"individualLinks" = "单独链接"
+"active" = "启用"
+"inactive" = "停用"
+"unlimited" = "无限制"
+"noExpiry" = "无到期"
+
 [menu]
 "theme" = "主题"
 "dark" = "暗色"

+ 14 - 0
web/translation/translate.zh_TW.toml

@@ -72,6 +72,20 @@
 "emptyReverseDesc" = "未添加反向代理。"
 "somethingWentWrong" = "發生錯誤"
 
+[subscription]
+"title" = "訂閱資訊"
+"subId" = "訂閱 ID"
+"status" = "狀態"
+"downloaded" = "已下載"
+"uploaded" = "已上傳"
+"expiry" = "到期"
+"totalQuota" = "總配額"
+"individualLinks" = "個別連結"
+"active" = "啟用"
+"inactive" = "停用"
+"unlimited" = "無限制"
+"noExpiry" = "無到期"
+
 [menu]
 "theme" = "主題"
 "dark" = "深色"

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно