Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/sub
diff options
context:
space:
mode:
authormhsanaei <ho3ein.sanaei@gmail.com>2025-09-14 02:22:42 +0300
committermhsanaei <ho3ein.sanaei@gmail.com>2025-09-14 19:56:31 +0300
commit10025ffa66c011fd756af780772260d833460795 (patch)
tree0cf2d3924f3a8c26d47c4d9fe20d2438b7c4b6fc /sub
parent5ee62b25ca9a3bf0ce683adbba5b1b64ddea074e (diff)
Subscription
Diffstat (limited to 'sub')
-rw-r--r--sub/sub.go55
-rw-r--r--sub/subController.go86
-rw-r--r--sub/subService.go203
3 files changed, 289 insertions, 55 deletions
diff --git a/sub/sub.go b/sub/sub.go
index 4f8f5672..dce57243 100644
--- a/sub/sub.go
+++ b/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() {
diff --git a/sub/subController.go b/sub/subController.go
index 3f053740..ac0c09a1 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -2,7 +2,6 @@ package sub
import (
"encoding/base64"
- "net"
"strings"
"github.com/gin-gonic/gin"
@@ -58,21 +57,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 +67,38 @@ func (a *SUBController) subs(c *gin.Context) {
result += sub + "\n"
}
- // 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)))
+ // Add headers via service
+ a.subService.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
+ a.subService.ApplyBase64ContentHeader(c, result)
+
+ // 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",
+ "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
+ }
if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@@ -96,41 +110,17 @@ 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)))
+ // Add headers via service
+ a.subService.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
-}
+// Note: host parsing and page data preparation moved to SubService
diff --git a/sub/subService.go b/sub/subService.go
index e6e25e3a..485048fd 100644
--- a/sub/subService.go
+++ b/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,184 @@ 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
+}
+
+// ApplyCommonHeaders sets standard subscription headers on the response writer.
+func (s *SubService) 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)))
+}
+
+// ApplyBase64ContentHeader adds the full subscription content as base64 header for convenience.
+func (s *SubService) ApplyBase64ContentHeader(c *gin.Context, content string) {
+ c.Writer.Header().Set("Subscription-Content-Base64", base64.StdEncoding.EncodeToString([]byte(content)))
+}