mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-09-19 08:23:03 +00:00
Merge pull request #3466 from MHSanaei/Subscription
Subscription,tgbot,rule
This commit is contained in:
commit
6d41320ed7
26 changed files with 4091 additions and 68 deletions
1
go.mod
1
go.mod
|
@ -15,6 +15,7 @@ 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,6 +142,8 @@ github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771 h1:emzAzMZ1
|
||||||
github.com/seiflotfy/cuckoofilter v0.0.0-20240715131351-a2f2c23f1771/go.mod h1:bR6DqgcAl1zTcOX8/pE2Qkj9XO00eCNqmKb7lXP8EAg=
|
github.com/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=
|
||||||
|
|
55
sub/sub.go
55
sub/sub.go
|
@ -6,11 +6,14 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"x-ui/config"
|
"x-ui/config"
|
||||||
"x-ui/logger"
|
"x-ui/logger"
|
||||||
"x-ui/util/common"
|
"x-ui/util/common"
|
||||||
|
"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"
|
||||||
|
@ -57,6 +60,11 @@ 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
|
||||||
|
@ -112,6 +120,29 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
SubTitle = ""
|
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("/")
|
g := engine.Group("/")
|
||||||
|
|
||||||
s.sub = NewSUBController(
|
s.sub = NewSUBController(
|
||||||
|
@ -121,6 +152,30 @@ 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,8 +2,8 @@ package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"x-ui/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
@ -58,21 +58,8 @@ 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")
|
||||||
var host string
|
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
||||||
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
|
subs, header, lastOnline, err := a.subService.GetSubs(subId, host)
|
||||||
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 {
|
||||||
|
@ -81,10 +68,38 @@ 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, header, lastOnline, subs, subURL, subJsonURL)
|
||||||
|
c.HTML(200, "subscription.html", gin.H{
|
||||||
|
"title": "subscription.title",
|
||||||
|
"cur_ver": config.GetVersion(),
|
||||||
|
"host": page.Host,
|
||||||
|
"base_path": page.BasePath,
|
||||||
|
"sId": page.SId,
|
||||||
|
"download": page.Download,
|
||||||
|
"upload": page.Upload,
|
||||||
|
"total": page.Total,
|
||||||
|
"used": page.Used,
|
||||||
|
"remained": page.Remained,
|
||||||
|
"expire": page.Expire,
|
||||||
|
"lastOnline": page.LastOnline,
|
||||||
|
"datepicker": page.Datepicker,
|
||||||
|
"downloadByte": page.DownloadByte,
|
||||||
|
"uploadByte": page.UploadByte,
|
||||||
|
"totalByte": page.TotalByte,
|
||||||
|
"subUrl": page.SubUrl,
|
||||||
|
"subJsonUrl": page.SubJsonUrl,
|
||||||
|
"result": page.Result,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Add headers
|
// Add headers
|
||||||
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)))
|
||||||
|
@ -96,41 +111,21 @@ 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")
|
||||||
var host string
|
_, host, _, _ := a.subService.ResolveRequest(c)
|
||||||
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
|
||||||
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)))
|
|
||||||
|
|
||||||
c.String(200, jsonSub)
|
c.String(200, jsonSub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHostFromXFH(s string) (string, error) {
|
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
|
||||||
if strings.Contains(s, ":") {
|
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||||
realHost, _, err := net.SplitHostPort(s)
|
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
||||||
if err != nil {
|
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return realHost, nil
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,15 @@ 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"
|
||||||
|
@ -14,8 +19,6 @@ 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 {
|
||||||
|
@ -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
|
s.address = host
|
||||||
var result []string
|
var result []string
|
||||||
var header 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, "", err
|
return nil, "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
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()
|
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 {
|
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)
|
||||||
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)
|
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) {
|
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
|
||||||
|
@ -1001,3 +1009,172 @@ 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, 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
|
||||||
|
}
|
||||||
|
|
2836
web/assets/css/custom.min.css
vendored
2836
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
125
web/assets/js/subscription.js
Normal file
125
web/assets/js/subscription.js
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
(function () {
|
||||||
|
// Vue app for Subscription page
|
||||||
|
const el = document.getElementById('subscription-data');
|
||||||
|
if (!el) return;
|
||||||
|
const textarea = document.getElementById('subscription-links');
|
||||||
|
const rawLinks = (textarea?.value || '').split('\n').filter(Boolean);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
sId: el.getAttribute('data-sid') || '',
|
||||||
|
subUrl: el.getAttribute('data-sub-url') || '',
|
||||||
|
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||||
|
download: el.getAttribute('data-download') || '',
|
||||||
|
upload: el.getAttribute('data-upload') || '',
|
||||||
|
used: el.getAttribute('data-used') || '',
|
||||||
|
total: el.getAttribute('data-total') || '',
|
||||||
|
remained: el.getAttribute('data-remained') || '',
|
||||||
|
expireMs: (parseInt(el.getAttribute('data-expire') || '0', 10) || 0) * 1000,
|
||||||
|
lastOnlineMs: (parseInt(el.getAttribute('data-lastonline') || '0', 10) || 0),
|
||||||
|
downloadByte: parseInt(el.getAttribute('data-downloadbyte') || '0', 10) || 0,
|
||||||
|
uploadByte: parseInt(el.getAttribute('data-uploadbyte') || '0', 10) || 0,
|
||||||
|
totalByte: parseInt(el.getAttribute('data-totalbyte') || '0', 10) || 0,
|
||||||
|
datepicker: el.getAttribute('data-datepicker') || 'gregorian',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize lastOnline to milliseconds if it looks like seconds
|
||||||
|
if (data.lastOnlineMs && data.lastOnlineMs < 10_000_000_000) {
|
||||||
|
data.lastOnlineMs *= 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLink(item) {
|
||||||
|
return (
|
||||||
|
Vue.h('a-list-item', {}, [
|
||||||
|
Vue.h('a-space', { props: { size: 'small' } }, [
|
||||||
|
Vue.h('a-button', { props: { size: 'small' }, on: { click: () => copy(item) } }, [Vue.h('a-icon', { props: { type: 'copy' } })]),
|
||||||
|
Vue.h('span', { class: 'break-all' }, item)
|
||||||
|
])
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(text) {
|
||||||
|
ClipboardManager.copyText(text).then(ok => {
|
||||||
|
const messageType = ok ? 'success' : 'error';
|
||||||
|
Vue.prototype.$message[messageType](ok ? 'Copied' : 'Copy failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(url) {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawQR(value) {
|
||||||
|
try { new QRious({ element: document.getElementById('qrcode'), value, size: 220 }); } catch (e) { console.warn(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract a human label (email/ps) from different link types
|
||||||
|
function linkName(link, idx) {
|
||||||
|
try {
|
||||||
|
if (link.startsWith('vmess://')) {
|
||||||
|
const json = JSON.parse(atob(link.replace('vmess://', '')));
|
||||||
|
if (json.ps) return json.ps;
|
||||||
|
if (json.add && json.id) return json.add; // fallback host
|
||||||
|
} else if (link.startsWith('vless://') || link.startsWith('trojan://')) {
|
||||||
|
// vless://<id>@host:port?...#name
|
||||||
|
const hashIdx = link.indexOf('#');
|
||||||
|
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
||||||
|
// email sometimes in query params like sni or remark
|
||||||
|
const qIdx = link.indexOf('?');
|
||||||
|
if (qIdx !== -1) {
|
||||||
|
const qs = new URL('http://x/?' + link.substring(qIdx + 1, hashIdx !== -1 ? hashIdx : undefined)).searchParams;
|
||||||
|
if (qs.get('remark')) return qs.get('remark');
|
||||||
|
if (qs.get('email')) return qs.get('email');
|
||||||
|
}
|
||||||
|
// else take user@host
|
||||||
|
const at = link.indexOf('@');
|
||||||
|
const protSep = link.indexOf('://');
|
||||||
|
if (at !== -1 && protSep !== -1) return link.substring(protSep + 3, at);
|
||||||
|
} else if (link.startsWith('ss://')) {
|
||||||
|
// shadowsocks: label often after #
|
||||||
|
const hashIdx = link.indexOf('#');
|
||||||
|
if (hashIdx !== -1) return decodeURIComponent(link.substring(hashIdx + 1));
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore and fallback */ }
|
||||||
|
return 'Link ' + (idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Vue({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
el: '#app',
|
||||||
|
data: {
|
||||||
|
themeSwitcher,
|
||||||
|
app: data,
|
||||||
|
links: rawLinks,
|
||||||
|
lang: '',
|
||||||
|
viewportWidth: (typeof window !== 'undefined' ? window.innerWidth : 1024),
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.lang = LanguageManager.getLanguage();
|
||||||
|
// Discover subJsonUrl if provided via template bootstrap
|
||||||
|
const tpl = document.getElementById('subscription-data');
|
||||||
|
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
||||||
|
if (sj) this.app.subJsonUrl = sj;
|
||||||
|
drawQR(this.app.subUrl);
|
||||||
|
// Draw second QR if available
|
||||||
|
try { new QRious({ element: document.getElementById('qrcode-subjson'), value: this.app.subJsonUrl || '', size: 220 }); } catch (e) { /* ignore */ }
|
||||||
|
// Track viewport width for responsive behavior
|
||||||
|
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||||
|
window.addEventListener('resize', this._onResize);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isMobile() { return this.viewportWidth < 576; },
|
||||||
|
isUnlimited() { return !this.app.totalByte; },
|
||||||
|
isActive() {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
|
||||||
|
const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
|
||||||
|
return expiryOk && trafficOk;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: { renderLink, copy, open, linkName, i18nLabel(key) { return '{{ i18n "' + key + '" }}'; } },
|
||||||
|
});
|
||||||
|
})();
|
|
@ -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"></a-input>
|
<a-input v-model.trim="ruleModal.rule.sourceIP" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
@ -19,7 +19,17 @@
|
||||||
</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"></a-input>
|
<a-input v-model.trim="ruleModal.rule.sourcePort" placeholder="e.g. 53,443,1000-2000"></a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<template slot="label">
|
||||||
|
<a-tooltip>
|
||||||
|
<template slot="title">
|
||||||
|
<span>{{ i18n "pages.xray.rules.useComma" }}</span>
|
||||||
|
</template> VLESS Route <a-icon type="question-circle"></a-icon>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
<a-input v-model.trim="ruleModal.rule.vlessRoute" placeholder="e.g. 53,443,1000-2000"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<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">
|
||||||
|
@ -52,7 +62,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"></a-input>
|
<a-input v-model.trim="ruleModal.rule.ip" placeholder="e.g. 0.0.0.0/8, fc00::/7, geoip:ir"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
@ -62,7 +72,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"></a-input>
|
<a-input v-model.trim="ruleModal.rule.domain" placeholder="e.g. google.com, geosite:cn"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
@ -72,7 +82,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"></a-input>
|
<a-input v-model.trim="ruleModal.rule.user" placeholder="e.g. email address"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<template slot="label">
|
<template slot="label">
|
||||||
|
@ -82,7 +92,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"></a-input>
|
<a-input v-model.trim="ruleModal.rule.port" placeholder="e.g. 53,443,1000-2000"></a-input>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<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">
|
||||||
|
@ -122,6 +132,7 @@
|
||||||
ip: "",
|
ip: "",
|
||||||
port: "",
|
port: "",
|
||||||
sourcePort: "",
|
sourcePort: "",
|
||||||
|
vlessRoute: "",
|
||||||
network: "",
|
network: "",
|
||||||
sourceIP: "",
|
sourceIP: "",
|
||||||
user: "",
|
user: "",
|
||||||
|
@ -155,6 +166,7 @@
|
||||||
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(',') : [];
|
||||||
|
@ -169,6 +181,7 @@
|
||||||
ip: "",
|
ip: "",
|
||||||
port: "",
|
port: "",
|
||||||
sourcePort: "",
|
sourcePort: "",
|
||||||
|
vlessRoute: "",
|
||||||
network: "",
|
network: "",
|
||||||
sourceIP: "",
|
sourceIP: "",
|
||||||
user: "",
|
user: "",
|
||||||
|
@ -210,6 +223,7 @@
|
||||||
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,18 +67,22 @@
|
||||||
</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.source+rule.sourcePort+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
|
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
|
||||||
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
|
: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.source">
|
<tr v-if="rule.sourceIP">
|
||||||
<td>Source</td>
|
<td>Source IP</td>
|
||||||
<td><a-tag color="blue" v-for="r in rule.source.split(',')">[[ r ]]</a-tag></td>
|
<td><a-tag color="blue" v-for="r in rule.sourceIP.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>
|
||||||
|
|
274
web/html/subscription.html
Normal file
274
web/html/subscription.html
Normal file
|
@ -0,0 +1,274 @@
|
||||||
|
{{ template "page/head_start" .}}
|
||||||
|
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
|
||||||
|
<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script>
|
||||||
|
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
|
||||||
|
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
|
||||||
|
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
|
||||||
|
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
||||||
|
{{ template "page/head_end" .}}
|
||||||
|
|
||||||
|
{{ template "page/body_start" .}}
|
||||||
|
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme">
|
||||||
|
<a-layout-content class="p-2">
|
||||||
|
<a-row type="flex" justify="center" class="mt-2">
|
||||||
|
<a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
|
||||||
|
<a-card hoverable class="subscription-card">
|
||||||
|
<template #title>
|
||||||
|
<a-space>
|
||||||
|
<span>{{ i18n "subscription.title" }}</span>
|
||||||
|
<a-tag>{{ .sId }}</a-tag>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-popover
|
||||||
|
:overlay-class-name="themeSwitcher.currentTheme"
|
||||||
|
title='{{ i18n "menu.settings" }}'
|
||||||
|
placement="bottomRight" trigger="click">
|
||||||
|
<template #content>
|
||||||
|
<a-space direction="vertical" :size="10">
|
||||||
|
<a-theme-switch-login></a-theme-switch-login>
|
||||||
|
<span>{{ i18n "pages.settings.language"
|
||||||
|
}}</span>
|
||||||
|
<a-select ref="selectLang" class="w-100"
|
||||||
|
v-model="lang"
|
||||||
|
@change="LanguageManager.setLanguage(lang)"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option :value="l.value"
|
||||||
|
label="English"
|
||||||
|
v-for="l in LanguageManager.supportedLanguages"
|
||||||
|
:key="l.value">
|
||||||
|
<span role="img"
|
||||||
|
:aria-label="l.name"
|
||||||
|
v-text="l.icon"></span>
|
||||||
|
<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,8 +146,9 @@
|
||||||
{ 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: "source", align: 'center', width: 20, ellipsis: true },
|
{ title: 'IP', dataIndex: "sourceIP", align: 'center', width: 20, ellipsis: true },
|
||||||
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, ellipsis: true }]
|
{ title: '{{ 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: [
|
||||||
|
|
|
@ -48,6 +48,22 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitLocalizerFS allows initializing i18n from any fs.FS (e.g., disk), rooted at a directory containing a "translation" folder
|
||||||
|
func InitLocalizerFS(fsys fs.FS, settingService SettingService) error {
|
||||||
|
// set default bundle to english
|
||||||
|
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||||
|
i18nBundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
|
||||||
|
|
||||||
|
if err := parseTranslationFiles(fsys, i18nBundle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := initTGBotLocalizer(settingService); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func createTemplateData(params []string, seperator ...string) map[string]any {
|
func createTemplateData(params []string, seperator ...string) map[string]any {
|
||||||
var sep string = "=="
|
var sep string = "=="
|
||||||
if len(seperator) > 0 {
|
if len(seperator) > 0 {
|
||||||
|
@ -118,8 +134,8 @@ func LocalizerMiddleware() gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
func parseTranslationFiles(fsys fs.FS, i18nBundle *i18n.Bundle) error {
|
||||||
err := fs.WalkDir(i18nFS, "translation",
|
err := fs.WalkDir(fsys, "translation",
|
||||||
func(path string, d fs.DirEntry, err error) error {
|
func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -129,7 +145,7 @@ func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := i18nFS.ReadFile(path)
|
data, err := fs.ReadFile(fsys, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,10 @@ 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"
|
||||||
|
@ -29,6 +31,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
@ -1355,6 +1358,73 @@ 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)
|
||||||
|
@ -1439,6 +1509,23 @@ 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(
|
||||||
|
@ -1847,6 +1934,13 @@ func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.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
|
||||||
|
@ -1908,6 +2002,255 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
|
||||||
|
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
||||||
|
// Resolve subId from client email
|
||||||
|
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||||
|
_ = traffic
|
||||||
|
if err != nil || client == nil {
|
||||||
|
return "", "", errors.New("client not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather settings to construct absolute URLs
|
||||||
|
subDomain, _ := t.settingService.GetSubDomain()
|
||||||
|
subPort, _ := t.settingService.GetSubPort()
|
||||||
|
subPath, _ := t.settingService.GetSubPath()
|
||||||
|
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
||||||
|
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
||||||
|
subCertFile, _ := t.settingService.GetSubCertFile()
|
||||||
|
|
||||||
|
tls := (subKeyFile != "" && subCertFile != "")
|
||||||
|
scheme := "http"
|
||||||
|
if tls {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks
|
||||||
|
if subDomain == "" {
|
||||||
|
// try panel domain, otherwise OS hostname
|
||||||
|
if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
|
||||||
|
subDomain = d
|
||||||
|
} else if hostname != "" {
|
||||||
|
subDomain = hostname
|
||||||
|
} else {
|
||||||
|
subDomain = "localhost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host := subDomain
|
||||||
|
if (subPort == 443 && tls) || (subPort == 80 && !tls) {
|
||||||
|
// standard ports: no port in host
|
||||||
|
} else {
|
||||||
|
host = fmt.Sprintf("%s:%d", subDomain, subPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure paths
|
||||||
|
if !strings.HasPrefix(subPath, "/") {
|
||||||
|
subPath = "/" + subPath
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(subPath, "/") {
|
||||||
|
subPath = subPath + "/"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(subJsonPath, "/") {
|
||||||
|
subJsonPath = "/" + subJsonPath
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(subJsonPath, "/") {
|
||||||
|
subJsonPath = subJsonPath + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
||||||
|
subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
||||||
|
return subURL, subJsonURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
||||||
|
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||||
|
if err != nil {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "Subscription URL:\r\n<code>" + subURL + "</code>\r\n\r\n" +
|
||||||
|
"JSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||||
|
inlineKeyboard := tu.InlineKeyboard(
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links " + email)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
|
||||||
|
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
|
||||||
|
// Build the HTML sub page URL; we'll call it with header Accept to get raw content
|
||||||
|
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||||
|
if err != nil {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to fetch raw subscription links. Prefer plain text response.
|
||||||
|
req, err := http.NewRequest("GET", subURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Force plain text to avoid HTML page; controller respects Accept header
|
||||||
|
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||||
|
|
||||||
|
// Use default client with reasonable timeout via context
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If service is configured to encode (Base64), decode it
|
||||||
|
encoded, _ := t.settingService.GetSubEncrypt()
|
||||||
|
var content string
|
||||||
|
if encoded {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
// fallback to raw text
|
||||||
|
content = string(bodyBytes)
|
||||||
|
} else {
|
||||||
|
content = string(decoded)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = string(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize line endings and trim
|
||||||
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||||
|
var cleaned []string
|
||||||
|
for _, l := range lines {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if l != "" {
|
||||||
|
cleaned = append(cleaned, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleaned) == 0 {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send in chunks to respect message length; use monospace formatting
|
||||||
|
const maxPerMessage = 50
|
||||||
|
for i := 0; i < len(cleaned); i += maxPerMessage {
|
||||||
|
j := i + maxPerMessage
|
||||||
|
if j > len(cleaned) {
|
||||||
|
j = len(cleaned)
|
||||||
|
}
|
||||||
|
chunk := cleaned[i:j]
|
||||||
|
msg := t.I18nBot("subscription.individualLinks") + ":\r\n"
|
||||||
|
for _, link := range chunk {
|
||||||
|
// wrap each link in <code>
|
||||||
|
msg += "<code>" + link + "</code>\r\n"
|
||||||
|
}
|
||||||
|
t.SendMsgToTgbot(chatId, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
|
||||||
|
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||||
|
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||||
|
if err != nil {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create QR PNG bytes from content
|
||||||
|
createQR := func(content string, size int) ([]byte, error) {
|
||||||
|
if size <= 0 {
|
||||||
|
size = 256
|
||||||
|
}
|
||||||
|
return qrcode.Encode(content, qrcode.Medium, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inform user
|
||||||
|
t.SendMsgToTgbot(chatId, "QRCode"+":")
|
||||||
|
|
||||||
|
// Send sub URL QR (filename: sub.png)
|
||||||
|
if png, err := createQR(subURL, 320); err == nil {
|
||||||
|
document := tu.Document(
|
||||||
|
tu.ID(chatId),
|
||||||
|
tu.FileFromBytes(png, "sub.png"),
|
||||||
|
)
|
||||||
|
_, _ = bot.SendDocument(context.Background(), document)
|
||||||
|
} else {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send JSON URL QR (filename: subjson.png)
|
||||||
|
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||||
|
document := tu.Document(
|
||||||
|
tu.ID(chatId),
|
||||||
|
tu.FileFromBytes(png, "subjson.png"),
|
||||||
|
)
|
||||||
|
_, _ = bot.SendDocument(context.Background(), document)
|
||||||
|
} else {
|
||||||
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also generate a few individual links' QRs (first up to 5)
|
||||||
|
subPageURL := subURL
|
||||||
|
req, err := http.NewRequest("GET", subPageURL, nil)
|
||||||
|
if err == nil {
|
||||||
|
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
if resp, err := http.DefaultClient.Do(req); err == nil {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
encoded, _ := t.settingService.GetSubEncrypt()
|
||||||
|
var content string
|
||||||
|
if encoded {
|
||||||
|
if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
||||||
|
content = string(dec)
|
||||||
|
} else {
|
||||||
|
content = string(body)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = string(body)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||||
|
var cleaned []string
|
||||||
|
for _, l := range lines {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if l != "" {
|
||||||
|
cleaned = append(cleaned, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleaned) > 0 {
|
||||||
|
max := min(len(cleaned), 5)
|
||||||
|
for i := range max {
|
||||||
|
if png, err := createQR(cleaned[i], 320); err == nil {
|
||||||
|
// Use the email as filename for individual link QR
|
||||||
|
filename := email + ".png"
|
||||||
|
document := tu.Document(
|
||||||
|
tu.ID(chatId),
|
||||||
|
tu.FileFromBytes(png, filename),
|
||||||
|
)
|
||||||
|
_, _ = bot.SendDocument(context.Background(), document)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
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,6 +72,20 @@
|
||||||
"emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
|
"emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
|
||||||
"somethingWentWrong" = "حدث خطأ ما"
|
"somethingWentWrong" = "حدث خطأ ما"
|
||||||
|
|
||||||
|
[subscription]
|
||||||
|
"title" = "معلومات الاشتراك"
|
||||||
|
"subId" = "معرّف الاشتراك"
|
||||||
|
"status" = "الحالة"
|
||||||
|
"downloaded" = "التنزيل"
|
||||||
|
"uploaded" = "الرفع"
|
||||||
|
"expiry" = "تاريخ الانتهاء"
|
||||||
|
"totalQuota" = "الحصة الإجمالية"
|
||||||
|
"individualLinks" = "روابط فردية"
|
||||||
|
"active" = "نشط"
|
||||||
|
"inactive" = "غير نشط"
|
||||||
|
"unlimited" = "غير محدود"
|
||||||
|
"noExpiry" = "بدون انتهاء"
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "الثيم"
|
"theme" = "الثيم"
|
||||||
"dark" = "داكن"
|
"dark" = "داكن"
|
||||||
|
|
|
@ -72,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
|
"emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
|
||||||
"somethingWentWrong" = "مشکلی پیش آمد"
|
"somethingWentWrong" = "مشکلی پیش آمد"
|
||||||
|
|
||||||
|
[subscription]
|
||||||
|
"title" = "اطلاعات سابسکریپشن"
|
||||||
|
"subId" = "شناسه اشتراک"
|
||||||
|
"status" = "وضعیت"
|
||||||
|
"downloaded" = "دانلود"
|
||||||
|
"uploaded" = "آپلود"
|
||||||
|
"expiry" = "تاریخ پایان"
|
||||||
|
"totalQuota" = "حجم کلی"
|
||||||
|
"individualLinks" = "لینکهای تکی"
|
||||||
|
"active" = "فعال"
|
||||||
|
"inactive" = "غیرفعال"
|
||||||
|
"unlimited" = "نامحدود"
|
||||||
|
"noExpiry" = "بدون انقضا"
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "تم"
|
"theme" = "تم"
|
||||||
"dark" = "تیره"
|
"dark" = "تیره"
|
||||||
|
|
|
@ -72,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"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,6 +72,20 @@
|
||||||
"emptyReverseDesc" = "未添加反向代理。"
|
"emptyReverseDesc" = "未添加反向代理。"
|
||||||
"somethingWentWrong" = "發生錯誤"
|
"somethingWentWrong" = "發生錯誤"
|
||||||
|
|
||||||
|
[subscription]
|
||||||
|
"title" = "訂閱資訊"
|
||||||
|
"subId" = "訂閱 ID"
|
||||||
|
"status" = "狀態"
|
||||||
|
"downloaded" = "已下載"
|
||||||
|
"uploaded" = "已上傳"
|
||||||
|
"expiry" = "到期"
|
||||||
|
"totalQuota" = "總配額"
|
||||||
|
"individualLinks" = "個別連結"
|
||||||
|
"active" = "啟用"
|
||||||
|
"inactive" = "停用"
|
||||||
|
"unlimited" = "無限制"
|
||||||
|
"noExpiry" = "無到期"
|
||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
"theme" = "主題"
|
"theme" = "主題"
|
||||||
"dark" = "深色"
|
"dark" = "深色"
|
||||||
|
|
Loading…
Reference in a new issue