Subscription

This commit is contained in:
mhsanaei
2025-09-14 01:22:42 +02:00
parent 5ee62b25ca
commit 10025ffa66
20 changed files with 3722 additions and 59 deletions

View File

@@ -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() {

View File

@@ -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

View File

@@ -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)))
}