mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-10-27 02:24:40 +00:00
Compare commits
No commits in common. "bc0518391ef06d3d6b9f826085b73d1f1e35c913" and "5ee62b25ca9a3bf0ce683adbba5b1b64ddea074e" have entirely different histories.
bc0518391e
...
5ee62b25ca
30 changed files with 77 additions and 1302 deletions
|
|
@ -1 +1 @@
|
||||||
2.8.0
|
2.7.0
|
||||||
1
go.mod
1
go.mod
|
|
@ -15,7 +15,6 @@ require (
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v4 v4.25.8
|
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/valyala/fasthttp v1.65.0
|
||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.250911.0
|
github.com/xtls/xray-core v1.250911.0
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -142,8 +142,6 @@ 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/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 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
|
|
||||||
92
sub/sub.go
92
sub/sub.go
|
|
@ -3,19 +3,14 @@ package sub
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"html/template"
|
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"x-ui/config"
|
||||||
"x-ui/logger"
|
"x-ui/logger"
|
||||||
"x-ui/util/common"
|
"x-ui/util/common"
|
||||||
webpkg "x-ui/web"
|
|
||||||
"x-ui/web/locale"
|
|
||||||
"x-ui/web/middleware"
|
"x-ui/web/middleware"
|
||||||
"x-ui/web/network"
|
"x-ui/web/network"
|
||||||
"x-ui/web/service"
|
"x-ui/web/service"
|
||||||
|
|
@ -23,21 +18,6 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setEmbeddedTemplates parses and sets embedded templates on the engine
|
|
||||||
func setEmbeddedTemplates(engine *gin.Engine) error {
|
|
||||||
t, err := template.New("").Funcs(engine.FuncMap).ParseFS(
|
|
||||||
webpkg.EmbeddedHTML(),
|
|
||||||
"html/common/page.html",
|
|
||||||
"html/component/aThemeSwitch.html",
|
|
||||||
"html/subscription.html",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
engine.SetHTMLTemplate(t)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
|
|
@ -58,10 +38,13 @@ func NewServer() *Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) initRouter() (*gin.Engine, error) {
|
func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
// Always run in release mode for the subscription server
|
if config.IsDebug() {
|
||||||
gin.DefaultWriter = io.Discard
|
gin.SetMode(gin.DebugMode)
|
||||||
gin.DefaultErrorWriter = io.Discard
|
} else {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.DefaultWriter = io.Discard
|
||||||
|
gin.DefaultErrorWriter = io.Discard
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
engine := gin.Default()
|
engine := gin.Default()
|
||||||
|
|
||||||
|
|
@ -74,11 +57,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
engine.Use(middleware.DomainValidatorMiddleware(subDomain))
|
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()
|
LinksPath, err := s.settingService.GetSubPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -134,36 +112,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
SubTitle = ""
|
SubTitle = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// set per-request localizer from headers/cookies
|
|
||||||
engine.Use(locale.LocalizerMiddleware())
|
|
||||||
|
|
||||||
// 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})
|
|
||||||
|
|
||||||
// Templates: prefer embedded; fallback to disk if necessary
|
|
||||||
if err := setEmbeddedTemplates(engine); err != nil {
|
|
||||||
logger.Warning("sub: failed to parse embedded templates:", err)
|
|
||||||
if files, derr := s.getHtmlFiles(); derr == nil {
|
|
||||||
engine.LoadHTMLFiles(files...)
|
|
||||||
} else {
|
|
||||||
logger.Error("sub: no templates available (embedded parse and disk load failed)", err, derr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assets: use disk if present, fallback to embedded
|
|
||||||
if _, err := os.Stat("web/assets"); err == nil {
|
|
||||||
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
|
|
||||||
} else {
|
|
||||||
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
|
|
||||||
engine.StaticFS("/assets", http.FS(subFS))
|
|
||||||
} else {
|
|
||||||
logger.Error("sub: failed to mount embedded assets:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g := engine.Group("/")
|
g := engine.Group("/")
|
||||||
|
|
||||||
s.sub = NewSUBController(
|
s.sub = NewSUBController(
|
||||||
|
|
@ -173,30 +121,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
return engine, nil
|
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) {
|
func (s *Server) Start() (err error) {
|
||||||
// This is an anonymous function, no function name
|
// This is an anonymous function, no function name
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"x-ui/config"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
@ -59,8 +58,21 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
func (a *SUBController) subs(c *gin.Context) {
|
func (a *SUBController) subs(c *gin.Context) {
|
||||||
subId := c.Param("subid")
|
subId := c.Param("subid")
|
||||||
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
var host string
|
||||||
subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
|
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)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || len(subs) == 0 {
|
||||||
c.String(400, "Error!")
|
c.String(400, "Error!")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -69,39 +81,10 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
result += sub + "\n"
|
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, traffic, 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
|
// Add headers
|
||||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
|
c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
|
||||||
|
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
|
||||||
|
|
||||||
if a.subEncrypt {
|
if a.subEncrypt {
|
||||||
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
|
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
|
||||||
|
|
@ -113,21 +96,41 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
|
|
||||||
func (a *SUBController) subJsons(c *gin.Context) {
|
func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
subId := c.Param("subid")
|
subId := c.Param("subid")
|
||||||
_, host, _, _ := a.subService.ResolveRequest(c)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
||||||
if err != nil || len(jsonSub) == 0 {
|
if err != nil || len(jsonSub) == 0 {
|
||||||
c.String(400, "Error!")
|
c.String(400, "Error!")
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// Add headers
|
// Add headers
|
||||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
|
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)))
|
||||||
|
|
||||||
c.String(200, jsonSub)
|
c.String(200, jsonSub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
|
func getHostFromXFH(s string) (string, error) {
|
||||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
if strings.Contains(s, ":") {
|
||||||
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
realHost, _, err := net.SplitHostPort(s)
|
||||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return realHost, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,10 @@ package sub
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/goccy/go-json"
|
|
||||||
|
|
||||||
"x-ui/database"
|
"x-ui/database"
|
||||||
"x-ui/database/model"
|
"x-ui/database/model"
|
||||||
"x-ui/logger"
|
"x-ui/logger"
|
||||||
|
|
@ -19,6 +14,8 @@ import (
|
||||||
"x-ui/util/random"
|
"x-ui/util/random"
|
||||||
"x-ui/web/service"
|
"x-ui/web/service"
|
||||||
"x-ui/xray"
|
"x-ui/xray"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SubService struct {
|
type SubService struct {
|
||||||
|
|
@ -37,19 +34,19 @@ func NewSubService(showInfo bool, remarkModel string) *SubService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) {
|
func (s *SubService) GetSubs(subId string, host string) ([]string, string, error) {
|
||||||
s.address = host
|
s.address = host
|
||||||
var result []string
|
var result []string
|
||||||
|
var header string
|
||||||
var traffic xray.ClientTraffic
|
var traffic xray.ClientTraffic
|
||||||
var lastOnline int64
|
|
||||||
var clientTraffics []xray.ClientTraffic
|
var clientTraffics []xray.ClientTraffic
|
||||||
inbounds, err := s.getInboundsBySubId(subId)
|
inbounds, err := s.getInboundsBySubId(subId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, traffic, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
if len(inbounds) == 0 {
|
||||||
return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
|
return nil, "", common.NewError("No inbounds found with ", subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.datepicker, err = s.settingService.GetDatepicker()
|
s.datepicker, err = s.settingService.GetDatepicker()
|
||||||
|
|
@ -76,11 +73,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
if client.Enable && client.SubID == subId {
|
if client.Enable && client.SubID == subId {
|
||||||
link := s.getLink(inbound, client.Email)
|
link := s.getLink(inbound, client.Email)
|
||||||
result = append(result, link)
|
result = append(result, link)
|
||||||
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
|
clientTraffics = append(clientTraffics, s.getClientTraffics(inbound.ClientStats, client.Email))
|
||||||
clientTraffics = append(clientTraffics, ct)
|
|
||||||
if ct.LastOnline > lastOnline {
|
|
||||||
lastOnline = ct.LastOnline
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +100,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, lastOnline, traffic, nil
|
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||||
|
return result, header, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||||
|
|
@ -1007,142 +1001,3 @@ func searchHost(headers any) string {
|
||||||
|
|
||||||
return ""
|
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 string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
|
|
||||||
download := common.FormatTraffic(traffic.Down)
|
|
||||||
upload := common.FormatTraffic(traffic.Up)
|
|
||||||
total := "∞"
|
|
||||||
used := common.FormatTraffic(traffic.Up + traffic.Down)
|
|
||||||
remained := ""
|
|
||||||
if traffic.Total > 0 {
|
|
||||||
total = common.FormatTraffic(traffic.Total)
|
|
||||||
left := traffic.Total - (traffic.Up + traffic.Down)
|
|
||||||
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: traffic.ExpiryTime / 1000,
|
|
||||||
LastOnline: lastOnline,
|
|
||||||
Datepicker: datepicker,
|
|
||||||
DownloadByte: traffic.Down,
|
|
||||||
UploadByte: traffic.Up,
|
|
||||||
TotalByte: traffic.Total,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,125 +0,0 @@
|
||||||
(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 + '" }}'; } },
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
</template> Source IPs <a-icon type="question-circle"></a-icon>
|
</template> Source IPs <a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="ruleModal.rule.sourceIP" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
|
<a-input v-model.trim="ruleModal.rule.sourceIP"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
|
@ -19,17 +19,7 @@
|
||||||
</template> Source Port <a-icon type="question-circle"></a-icon>
|
</template> Source Port <a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="ruleModal.rule.sourcePort" placeholder="e.g. 53,443,1000-2000"></a-input>
|
<a-input v-model.trim="ruleModal.rule.sourcePort"></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>
|
||||||
<a-form-item label='Network'>
|
<a-form-item label='Network'>
|
||||||
<a-select v-model="ruleModal.rule.network" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select v-model="ruleModal.rule.network" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
|
@ -62,7 +52,7 @@
|
||||||
</template> IP <a-icon type="question-circle"></a-icon>
|
</template> IP <a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="ruleModal.rule.ip" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
|
<a-input v-model.trim="ruleModal.rule.ip"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
|
@ -72,7 +62,7 @@
|
||||||
</template> Domain <a-icon type="question-circle"></a-icon>
|
</template> Domain <a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="ruleModal.rule.domain" placeholder="e.g. google.com, geosite:cn"></a-input>
|
<a-input v-model.trim="ruleModal.rule.domain"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
|
@ -82,7 +72,7 @@
|
||||||
</template> User <a-icon type="question-circle"></a-icon>
|
</template> User <a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="ruleModal.rule.user" placeholder="e.g. email address"></a-input>
|
<a-input v-model.trim="ruleModal.rule.user"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
|
@ -92,7 +82,7 @@
|
||||||
</template> Port <a-icon type="question-circle"></a-icon>
|
</template> Port <a-icon type="question-circle"></a-icon>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<a-input v-model.trim="ruleModal.rule.port" placeholder="e.g. 53,443,1000-2000"></a-input>
|
<a-input v-model.trim="ruleModal.rule.port"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label='Inbound Tags'>
|
<a-form-item label='Inbound Tags'>
|
||||||
<a-select v-model="ruleModal.rule.inboundTag" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme">
|
<a-select v-model="ruleModal.rule.inboundTag" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
|
@ -132,7 +122,6 @@
|
||||||
ip: "",
|
ip: "",
|
||||||
port: "",
|
port: "",
|
||||||
sourcePort: "",
|
sourcePort: "",
|
||||||
vlessRoute: "",
|
|
||||||
network: "",
|
network: "",
|
||||||
sourceIP: "",
|
sourceIP: "",
|
||||||
user: "",
|
user: "",
|
||||||
|
|
@ -166,7 +155,6 @@
|
||||||
this.rule.ip = rule.ip ? rule.ip.join(',') : [];
|
this.rule.ip = rule.ip ? rule.ip.join(',') : [];
|
||||||
this.rule.port = rule.port;
|
this.rule.port = rule.port;
|
||||||
this.rule.sourcePort = rule.sourcePort;
|
this.rule.sourcePort = rule.sourcePort;
|
||||||
this.rule.vlessRoute = rule.vlessRoute;
|
|
||||||
this.rule.network = rule.network;
|
this.rule.network = rule.network;
|
||||||
this.rule.sourceIP = rule.sourceIP ? rule.sourceIP.join(',') : [];
|
this.rule.sourceIP = rule.sourceIP ? rule.sourceIP.join(',') : [];
|
||||||
this.rule.user = rule.user ? rule.user.join(',') : [];
|
this.rule.user = rule.user ? rule.user.join(',') : [];
|
||||||
|
|
@ -181,7 +169,6 @@
|
||||||
ip: "",
|
ip: "",
|
||||||
port: "",
|
port: "",
|
||||||
sourcePort: "",
|
sourcePort: "",
|
||||||
vlessRoute: "",
|
|
||||||
network: "",
|
network: "",
|
||||||
sourceIP: "",
|
sourceIP: "",
|
||||||
user: "",
|
user: "",
|
||||||
|
|
@ -223,7 +210,6 @@
|
||||||
rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
|
rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
|
||||||
rule.port = value.port;
|
rule.port = value.port;
|
||||||
rule.sourcePort = value.sourcePort;
|
rule.sourcePort = value.sourcePort;
|
||||||
rule.vlessRoute = value.vlessRoute;
|
|
||||||
rule.network = value.network;
|
rule.network = value.network;
|
||||||
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
|
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
|
||||||
rule.user = value.user.length > 0 ? value.user.split(',') : [];
|
rule.user = value.user.length > 0 ? value.user.split(',') : [];
|
||||||
|
|
|
||||||
|
|
@ -67,22 +67,18 @@
|
||||||
</template>
|
</template>
|
||||||
<template slot="info" slot-scope="text, rule, index">
|
<template slot="info" slot-scope="text, rule, index">
|
||||||
<a-popover placement="bottomRight"
|
<a-popover placement="bottomRight"
|
||||||
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
|
v-if="(rule.source+rule.sourcePort+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
|
||||||
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
||||||
<template slot="content">
|
<template slot="content">
|
||||||
<table cellpadding="2" :style="{ maxWidth: '300px' }">
|
<table cellpadding="2" :style="{ maxWidth: '300px' }">
|
||||||
<tr v-if="rule.sourceIP">
|
<tr v-if="rule.source">
|
||||||
<td>Source IP</td>
|
<td>Source</td>
|
||||||
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
|
<td><a-tag color="blue" v-for="r in rule.source.split(',')">[[ r ]]</a-tag></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="rule.sourcePort">
|
<tr v-if="rule.sourcePort">
|
||||||
<td>Source Port</td>
|
<td>Source Port</td>
|
||||||
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
|
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
|
||||||
</tr>
|
</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">
|
<tr v-if="rule.network">
|
||||||
<td>Network</td>
|
<td>Network</td>
|
||||||
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
|
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
|
||||||
|
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
{{ 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/js/util/date-util.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>
|
|
||||||
<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" .}}
|
|
||||||
|
|
@ -146,9 +146,8 @@
|
||||||
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
||||||
{
|
{
|
||||||
title: '{{ i18n "pages.xray.rules.source"}}', children: [
|
title: '{{ i18n "pages.xray.rules.source"}}', children: [
|
||||||
{ title: 'IP', dataIndex: "sourceIP", align: 'center', width: 20, ellipsis: true },
|
{ title: 'IP', dataIndex: "source", align: 'center', width: 20, ellipsis: true },
|
||||||
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, 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: [
|
title: '{{ i18n "pages.inbounds.network"}}', children: [
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package locale
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"x-ui/logger"
|
"x-ui/logger"
|
||||||
|
|
@ -79,11 +78,6 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
||||||
|
|
||||||
templateData := createTemplateData(params)
|
templateData := createTemplateData(params)
|
||||||
|
|
||||||
if localizer == nil {
|
|
||||||
// Fallback to key if localizer not ready; prevents nil panic on pages like sub
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
msg, err := localizer.Localize(&i18n.LocalizeConfig{
|
||||||
MessageID: key,
|
MessageID: key,
|
||||||
TemplateData: templateData,
|
TemplateData: templateData,
|
||||||
|
|
@ -108,15 +102,6 @@ func initTGBotLocalizer(settingService SettingService) error {
|
||||||
|
|
||||||
func LocalizerMiddleware() gin.HandlerFunc {
|
func LocalizerMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Ensure bundle is initialized so creating a Localizer won't panic
|
|
||||||
if i18nBundle == nil {
|
|
||||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
|
||||||
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
|
||||||
// Try lazy-load from disk when running sub server without InitLocalizer
|
|
||||||
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
|
|
||||||
logger.Warning("i18n lazy load failed:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var lang string
|
var lang string
|
||||||
|
|
||||||
if cookie, err := c.Request.Cookie("lang"); err == nil {
|
if cookie, err := c.Request.Cookie("lang"); err == nil {
|
||||||
|
|
@ -133,25 +118,6 @@ func LocalizerMiddleware() gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadTranslationsFromDisk attempts to load translation files from "web/translation" using the local filesystem.
|
|
||||||
func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
|
|
||||||
root := os.DirFS("web")
|
|
||||||
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
data, err := fs.ReadFile(root, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = bundle.ParseMessageFileBytes(data, path)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
||||||
err := fs.WalkDir(i18nFS, "translation",
|
err := fs.WalkDir(i18nFS, "translation",
|
||||||
func(path string, d fs.DirEntry, err error) error {
|
func(path string, d fs.DirEntry, err error) error {
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,8 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -31,7 +29,6 @@ import (
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
th "github.com/mymmrac/telego/telegohandler"
|
th "github.com/mymmrac/telego/telegohandler"
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
"github.com/skip2/go-qrcode"
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||||
)
|
)
|
||||||
|
|
@ -1358,73 +1355,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||||
case "client_commands":
|
case "client_commands":
|
||||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands"))
|
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands"))
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.commands.helpClientCommands"))
|
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":
|
case "onlines":
|
||||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines"))
|
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines"))
|
||||||
t.onlineClients(chatId)
|
t.onlineClients(chatId)
|
||||||
|
|
@ -1509,23 +1439,6 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||||
)
|
)
|
||||||
prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
|
prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
|
||||||
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
|
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":
|
case "add_client_ch_default_traffic":
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
inlineKeyboard := tu.InlineKeyboard(
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
|
|
@ -1934,13 +1847,6 @@ 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.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
|
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
|
var ReplyMarkup telego.ReplyMarkup
|
||||||
|
|
@ -2002,255 +1908,6 @@ 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) {
|
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||||
if len(replyMarkup) > 0 {
|
if len(replyMarkup) > 0 {
|
||||||
for _, adminId := range adminIds {
|
for _, adminId := range adminIds {
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
|
"emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
|
||||||
"somethingWentWrong" = "حدث خطأ ما"
|
"somethingWentWrong" = "حدث خطأ ما"
|
||||||
|
|
||||||
[subscription]
|
|
||||||
"title" = "معلومات الاشتراك"
|
|
||||||
"subId" = "معرّف الاشتراك"
|
|
||||||
"status" = "الحالة"
|
|
||||||
"downloaded" = "التنزيل"
|
|
||||||
"uploaded" = "الرفع"
|
|
||||||
"expiry" = "تاريخ الانتهاء"
|
|
||||||
"totalQuota" = "الحصة الإجمالية"
|
|
||||||
"individualLinks" = "روابط فردية"
|
|
||||||
"active" = "نشط"
|
|
||||||
"inactive" = "غير نشط"
|
|
||||||
"unlimited" = "غير محدود"
|
|
||||||
"noExpiry" = "بدون انتهاء"
|
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "الثيم"
|
"theme" = "الثيم"
|
||||||
"dark" = "داكن"
|
"dark" = "داكن"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "No added reverse proxies."
|
"emptyReverseDesc" = "No added reverse proxies."
|
||||||
"somethingWentWrong" = "Something went wrong"
|
"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]
|
[menu]
|
||||||
"theme" = "Theme"
|
"theme" = "Theme"
|
||||||
"dark" = "Dark"
|
"dark" = "Dark"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "No hay proxies inversos añadidos."
|
"emptyReverseDesc" = "No hay proxies inversos añadidos."
|
||||||
"somethingWentWrong" = "Algo salió mal"
|
"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]
|
[menu]
|
||||||
"theme" = "Tema"
|
"theme" = "Tema"
|
||||||
"dark" = "Oscuro"
|
"dark" = "Oscuro"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
|
"emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
|
||||||
"somethingWentWrong" = "مشکلی پیش آمد"
|
"somethingWentWrong" = "مشکلی پیش آمد"
|
||||||
|
|
||||||
[subscription]
|
|
||||||
"title" = "اطلاعات سابسکریپشن"
|
|
||||||
"subId" = "شناسه اشتراک"
|
|
||||||
"status" = "وضعیت"
|
|
||||||
"downloaded" = "دانلود"
|
|
||||||
"uploaded" = "آپلود"
|
|
||||||
"expiry" = "تاریخ پایان"
|
|
||||||
"totalQuota" = "حجم کلی"
|
|
||||||
"individualLinks" = "لینکهای تکی"
|
|
||||||
"active" = "فعال"
|
|
||||||
"inactive" = "غیرفعال"
|
|
||||||
"unlimited" = "نامحدود"
|
|
||||||
"noExpiry" = "بدون انقضا"
|
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "تم"
|
"theme" = "تم"
|
||||||
"dark" = "تیره"
|
"dark" = "تیره"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
|
"emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
|
||||||
"somethingWentWrong" = "Terjadi kesalahan"
|
"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]
|
[menu]
|
||||||
"theme" = "Tema"
|
"theme" = "Tema"
|
||||||
"dark" = "Gelap"
|
"dark" = "Gelap"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "追加されたリバースプロキシはありません。"
|
"emptyReverseDesc" = "追加されたリバースプロキシはありません。"
|
||||||
"somethingWentWrong" = "エラーが発生しました"
|
"somethingWentWrong" = "エラーが発生しました"
|
||||||
|
|
||||||
[subscription]
|
|
||||||
"title" = "サブスクリプション情報"
|
|
||||||
"subId" = "サブスクリプションID"
|
|
||||||
"status" = "ステータス"
|
|
||||||
"downloaded" = "ダウンロード"
|
|
||||||
"uploaded" = "アップロード"
|
|
||||||
"expiry" = "有効期限"
|
|
||||||
"totalQuota" = "合計クォータ"
|
|
||||||
"individualLinks" = "個別リンク"
|
|
||||||
"active" = "有効"
|
|
||||||
"inactive" = "無効"
|
|
||||||
"unlimited" = "無制限"
|
|
||||||
"noExpiry" = "期限なし"
|
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "テーマ"
|
"theme" = "テーマ"
|
||||||
"dark" = "ダーク"
|
"dark" = "ダーク"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "Nenhum proxy reverso adicionado."
|
"emptyReverseDesc" = "Nenhum proxy reverso adicionado."
|
||||||
"somethingWentWrong" = "Algo deu errado"
|
"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]
|
[menu]
|
||||||
"theme" = "Tema"
|
"theme" = "Tema"
|
||||||
"dark" = "Escuro"
|
"dark" = "Escuro"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "Нет добавленных реверс-прокси."
|
"emptyReverseDesc" = "Нет добавленных реверс-прокси."
|
||||||
"somethingWentWrong" = "Что-то пошло не так"
|
"somethingWentWrong" = "Что-то пошло не так"
|
||||||
|
|
||||||
[subscription]
|
|
||||||
"title" = "Информация о подписке"
|
|
||||||
"subId" = "ID подписки"
|
|
||||||
"status" = "Статус"
|
|
||||||
"downloaded" = "Загружено"
|
|
||||||
"uploaded" = "Отправлено"
|
|
||||||
"expiry" = "Срок действия"
|
|
||||||
"totalQuota" = "Общий лимит"
|
|
||||||
"individualLinks" = "Индивидуальные ссылки"
|
|
||||||
"active" = "Активна"
|
|
||||||
"inactive" = "Неактивна"
|
|
||||||
"unlimited" = "Безлимит"
|
|
||||||
"noExpiry" = "Без срока"
|
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "Тема"
|
"theme" = "Тема"
|
||||||
"dark" = "Темная"
|
"dark" = "Темная"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "Eklenmiş ters proxy yok."
|
"emptyReverseDesc" = "Eklenmiş ters proxy yok."
|
||||||
"somethingWentWrong" = "Bir şeyler yanlış gitti"
|
"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]
|
[menu]
|
||||||
"theme" = "Tema"
|
"theme" = "Tema"
|
||||||
"dark" = "Koyu"
|
"dark" = "Koyu"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "Немає доданих зворотних проксі."
|
"emptyReverseDesc" = "Немає доданих зворотних проксі."
|
||||||
"somethingWentWrong" = "Щось пішло не так"
|
"somethingWentWrong" = "Щось пішло не так"
|
||||||
|
|
||||||
[subscription]
|
|
||||||
"title" = "Інформація про підписку"
|
|
||||||
"subId" = "ID підписки"
|
|
||||||
"status" = "Статус"
|
|
||||||
"downloaded" = "Завантажено"
|
|
||||||
"uploaded" = "Відвантажено"
|
|
||||||
"expiry" = "Термін дії"
|
|
||||||
"totalQuota" = "Загальна квота"
|
|
||||||
"individualLinks" = "Окремі посилання"
|
|
||||||
"active" = "Активна"
|
|
||||||
"inactive" = "Неактивна"
|
|
||||||
"unlimited" = "Безліміт"
|
|
||||||
"noExpiry" = "Без строку"
|
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "Тема"
|
"theme" = "Тема"
|
||||||
"dark" = "Темна"
|
"dark" = "Темна"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "Không có proxy ngược nào được thêm."
|
"emptyReverseDesc" = "Không có proxy ngược nào được thêm."
|
||||||
"somethingWentWrong" = "Đã xảy ra lỗi"
|
"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]
|
[menu]
|
||||||
"theme" = "Chủ đề"
|
"theme" = "Chủ đề"
|
||||||
"dark" = "Tối"
|
"dark" = "Tối"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "未添加反向代理。"
|
"emptyReverseDesc" = "未添加反向代理。"
|
||||||
"somethingWentWrong" = "出了点问题"
|
"somethingWentWrong" = "出了点问题"
|
||||||
|
|
||||||
[subscription]
|
|
||||||
"title" = "订阅信息"
|
|
||||||
"subId" = "订阅 ID"
|
|
||||||
"status" = "状态"
|
|
||||||
"downloaded" = "已下载"
|
|
||||||
"uploaded" = "已上传"
|
|
||||||
"expiry" = "到期"
|
|
||||||
"totalQuota" = "总配额"
|
|
||||||
"individualLinks" = "单独链接"
|
|
||||||
"active" = "启用"
|
|
||||||
"inactive" = "停用"
|
|
||||||
"unlimited" = "无限制"
|
|
||||||
"noExpiry" = "无到期"
|
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "主题"
|
"theme" = "主题"
|
||||||
"dark" = "暗色"
|
"dark" = "暗色"
|
||||||
|
|
|
||||||
|
|
@ -72,20 +72,6 @@
|
||||||
"emptyReverseDesc" = "未添加反向代理。"
|
"emptyReverseDesc" = "未添加反向代理。"
|
||||||
"somethingWentWrong" = "發生錯誤"
|
"somethingWentWrong" = "發生錯誤"
|
||||||
|
|
||||||
[subscription]
|
|
||||||
"title" = "訂閱資訊"
|
|
||||||
"subId" = "訂閱 ID"
|
|
||||||
"status" = "狀態"
|
|
||||||
"downloaded" = "已下載"
|
|
||||||
"uploaded" = "已上傳"
|
|
||||||
"expiry" = "到期"
|
|
||||||
"totalQuota" = "總配額"
|
|
||||||
"individualLinks" = "個別連結"
|
|
||||||
"active" = "啟用"
|
|
||||||
"inactive" = "停用"
|
|
||||||
"unlimited" = "無限制"
|
|
||||||
"noExpiry" = "無到期"
|
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "主題"
|
"theme" = "主題"
|
||||||
"dark" = "深色"
|
"dark" = "深色"
|
||||||
|
|
|
||||||
|
|
@ -78,15 +78,6 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time {
|
||||||
return startTime
|
return startTime
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose embedded resources for reuse by other servers (e.g., sub server)
|
|
||||||
func EmbeddedHTML() embed.FS {
|
|
||||||
return htmlFS
|
|
||||||
}
|
|
||||||
|
|
||||||
func EmbeddedAssets() embed.FS {
|
|
||||||
return assetsFS
|
|
||||||
}
|
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package xray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"x-ui/logger"
|
"x-ui/logger"
|
||||||
|
|
@ -21,12 +20,6 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
|
||||||
|
|
||||||
// Convert the data to a string
|
// Convert the data to a string
|
||||||
message := strings.TrimSpace(string(m))
|
message := strings.TrimSpace(string(m))
|
||||||
msgLowerAll := strings.ToLower(message)
|
|
||||||
|
|
||||||
// Suppress noisy Windows process-kill signal that surfaces as exit status 1
|
|
||||||
if runtime.GOOS == "windows" && strings.Contains(msgLowerAll, "exit status 1") {
|
|
||||||
return len(m), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the message contains a crash
|
// Check if the message contains a crash
|
||||||
if crashRegex.MatchString(message) {
|
if crashRegex.MatchString(message) {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -225,15 +224,6 @@ func (p *process) Start() (err error) {
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// On Windows, killing the process results in "exit status 1" which isn't an error for us
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
errStr := strings.ToLower(err.Error())
|
|
||||||
if strings.Contains(errStr, "exit status 1") {
|
|
||||||
// Suppress noisy log on graceful stop
|
|
||||||
p.exitErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.Error("Failure in running xray-core:", err)
|
logger.Error("Failure in running xray-core:", err)
|
||||||
p.exitErr = err
|
p.exitErr = err
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +239,7 @@ func (p *process) Stop() error {
|
||||||
if !p.IsRunning() {
|
if !p.IsRunning() {
|
||||||
return errors.New("xray is not running")
|
return errors.New("xray is not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return p.cmd.Process.Kill()
|
return p.cmd.Process.Kill()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue