From 10025ffa66c011fd756af780772260d833460795 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 14 Sep 2025 01:22:42 +0200 Subject: Subscription --- sub/sub.go | 55 ++++++++++++++ sub/subController.go | 86 ++++++++++------------ sub/subService.go | 203 +++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 289 insertions(+), 55 deletions(-) (limited to 'sub') 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))) +} -- cgit v1.2.3