Compare commits

...

9 commits

Author SHA1 Message Date
Alireza Ahmadi
bc0518391e sub template enhancements
Some checks failed
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
Release 3X-UI / Build for Windows (push) Has been cancelled
2025-09-14 23:08:09 +02:00
mhsanaei
5408a2f82c
v2.8.0 2025-09-14 22:09:36 +02:00
mhsanaei
c8d71ea748
minify css 2025-09-14 20:05:15 +02:00
mhsanaei
46de886b53
windows: error filter 2025-09-14 20:04:12 +02:00
Sanaei
6d41320ed7
Merge pull request #3466 from MHSanaei/Subscription
Subscription,tgbot,rule
2025-09-14 20:03:32 +02:00
mhsanaei
bf9d2e6aeb
rule: Vless Route 2025-09-14 19:53:05 +02:00
mhsanaei
ed96fa090b
tgbot: subscription,qrcode, link 2025-09-14 19:51:57 +02:00
Alireza Ahmadi
3ac1d7f546 enhancements 2025-09-14 19:44:26 +02:00
mhsanaei
10025ffa66
Subscription 2025-09-14 18:56:31 +02:00
30 changed files with 1302 additions and 77 deletions

View file

@ -1 +1 @@
2.7.0
2.8.0

1
go.mod
View file

@ -15,6 +15,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4
github.com/robfig/cron/v3 v3.0.1
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/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.250911.0

2
go.sum
View file

@ -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/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/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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

View file

@ -3,14 +3,19 @@ package sub
import (
"context"
"crypto/tls"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"x-ui/config"
"x-ui/logger"
"x-ui/util/common"
webpkg "x-ui/web"
"x-ui/web/locale"
"x-ui/web/middleware"
"x-ui/web/network"
"x-ui/web/service"
@ -18,6 +23,21 @@ import (
"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 {
httpServer *http.Server
listener net.Listener
@ -38,13 +58,10 @@ func NewServer() *Server {
}
func (s *Server) initRouter() (*gin.Engine, error) {
if config.IsDebug() {
gin.SetMode(gin.DebugMode)
} else {
gin.DefaultWriter = io.Discard
gin.DefaultErrorWriter = io.Discard
gin.SetMode(gin.ReleaseMode)
}
// Always run in release mode for the subscription server
gin.DefaultWriter = io.Discard
gin.DefaultErrorWriter = io.Discard
gin.SetMode(gin.ReleaseMode)
engine := gin.Default()
@ -57,6 +74,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
engine.Use(middleware.DomainValidatorMiddleware(subDomain))
}
// Provide base_path in context for templates
engine.Use(func(c *gin.Context) {
c.Set("base_path", "/")
})
LinksPath, err := s.settingService.GetSubPath()
if err != nil {
return nil, err
@ -112,6 +134,36 @@ func (s *Server) initRouter() (*gin.Engine, error) {
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("/")
s.sub = NewSUBController(
@ -121,6 +173,30 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return engine, nil
}
// getHtmlFiles loads templates from local folder (used in debug mode)
func (s *Server) getHtmlFiles() ([]string, error) {
dir, _ := os.Getwd()
files := []string{}
// common layout
common := filepath.Join(dir, "web", "html", "common", "page.html")
if _, err := os.Stat(common); err == nil {
files = append(files, common)
}
// components used
theme := filepath.Join(dir, "web", "html", "component", "aThemeSwitch.html")
if _, err := os.Stat(theme); err == nil {
files = append(files, theme)
}
// page itself
page := filepath.Join(dir, "web", "html", "subscription.html")
if _, err := os.Stat(page); err == nil {
files = append(files, page)
} else {
return nil, err
}
return files, nil
}
func (s *Server) Start() (err error) {
// This is an anonymous function, no function name
defer func() {

View file

@ -2,8 +2,9 @@ package sub
import (
"encoding/base64"
"net"
"fmt"
"strings"
"x-ui/config"
"github.com/gin-gonic/gin"
)
@ -58,21 +59,8 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
func (a *SUBController) subs(c *gin.Context) {
subId := c.Param("subid")
var host string
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
}
if host == "" {
var err error
host, _, err = net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
}
subs, header, err := a.subService.GetSubs(subId, host)
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
if err != nil || len(subs) == 0 {
c.String(400, "Error!")
} else {
@ -81,10 +69,39 @@ func (a *SUBController) subs(c *gin.Context) {
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
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)))
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
@ -96,41 +113,21 @@ func (a *SUBController) subs(c *gin.Context) {
func (a *SUBController) subJsons(c *gin.Context) {
subId := c.Param("subid")
var host string
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
}
if host == "" {
var err error
host, _, err = net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
}
_, host, _, _ := a.subService.ResolveRequest(c)
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
if err != nil || len(jsonSub) == 0 {
c.String(400, "Error!")
} else {
// Add headers
c.Writer.Header().Set("Subscription-Userinfo", header)
c.Writer.Header().Set("Profile-Update-Interval", a.updateInterval)
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(a.subTitle)))
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle)
c.String(200, jsonSub)
}
}
func getHostFromXFH(s string) (string, error) {
if strings.Contains(s, ":") {
realHost, _, err := net.SplitHostPort(s)
if err != nil {
return "", err
}
return realHost, nil
}
return s, nil
func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) {
c.Writer.Header().Set("Subscription-Userinfo", header)
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
}

View file

@ -3,10 +3,15 @@ package sub
import (
"encoding/base64"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"x-ui/database"
"x-ui/database/model"
"x-ui/logger"
@ -14,8 +19,6 @@ import (
"x-ui/util/random"
"x-ui/web/service"
"x-ui/xray"
"github.com/goccy/go-json"
)
type SubService struct {
@ -34,19 +37,19 @@ 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, int64, xray.ClientTraffic, error) {
s.address = host
var result []string
var header string
var traffic xray.ClientTraffic
var lastOnline int64
var clientTraffics []xray.ClientTraffic
inbounds, err := s.getInboundsBySubId(subId)
if err != nil {
return nil, "", err
return nil, 0, traffic, err
}
if len(inbounds) == 0 {
return nil, "", common.NewError("No inbounds found with ", subId)
return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
}
s.datepicker, err = s.settingService.GetDatepicker()
@ -73,7 +76,11 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
if client.Enable && client.SubID == subId {
link := s.getLink(inbound, client.Email)
result = append(result, link)
clientTraffics = append(clientTraffics, s.getClientTraffics(inbound.ClientStats, client.Email))
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
clientTraffics = append(clientTraffics, ct)
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline
}
}
}
}
@ -100,8 +107,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, string, error
}
}
}
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
return result, header, nil
return result, lastOnline, traffic, nil
}
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
@ -1001,3 +1007,142 @@ func searchHost(headers any) string {
return ""
}
// PageData is a view model for subscription.html
type PageData struct {
Host string
BasePath string
SId string
Download string
Upload string
Total string
Used string
Remained string
Expire int64
LastOnline int64
Datepicker string
DownloadByte int64
UploadByte int64
TotalByte int64
SubUrl string
SubJsonUrl string
Result []string
}
// ResolveRequest extracts scheme and host info from request/headers consistently.
func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, hostWithPort string, hostHeader string) {
// scheme
scheme = "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
scheme = "https"
}
// base host (no port)
if h, err := getHostFromXFH(c.GetHeader("X-Forwarded-Host")); err == nil && h != "" {
host = h
}
if host == "" {
host = c.GetHeader("X-Real-IP")
}
if host == "" {
var err error
host, _, err = net.SplitHostPort(c.Request.Host)
if err != nil {
host = c.Request.Host
}
}
// host:port for URLs
hostWithPort = c.GetHeader("X-Forwarded-Host")
if hostWithPort == "" {
hostWithPort = c.Request.Host
}
if hostWithPort == "" {
hostWithPort = host
}
// header display host
hostHeader = c.GetHeader("X-Forwarded-Host")
if hostHeader == "" {
hostHeader = c.GetHeader("X-Real-IP")
}
if hostHeader == "" {
hostHeader = host
}
return
}
// BuildURLs constructs absolute subscription and json URLs.
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
if strings.HasSuffix(subPath, "/") {
subURL = scheme + "://" + hostWithPort + subPath + subId
} else {
subURL = scheme + "://" + hostWithPort + strings.TrimRight(subPath, "/") + "/" + subId
}
if strings.HasSuffix(subJsonPath, "/") {
subJsonURL = scheme + "://" + hostWithPort + subJsonPath + subId
} else {
subJsonURL = scheme + "://" + hostWithPort + strings.TrimRight(subJsonPath, "/") + "/" + subId
}
return
}
// BuildPageData parses header and prepares the template view model.
func (s *SubService) BuildPageData(subId 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
}

File diff suppressed because one or more lines are too long

View 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 + '" }}'; } },
});
})();

View file

@ -9,7 +9,7 @@
</template> Source IPs <a-icon type="question-circle"></a-icon>
</a-tooltip>
</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>
<template slot="label">
@ -19,7 +19,17 @@
</template> Source Port <a-icon type="question-circle"></a-icon>
</a-tooltip>
</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 label='Network'>
<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>
</a-tooltip>
</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>
<template slot="label">
@ -62,7 +72,7 @@
</template> Domain <a-icon type="question-circle"></a-icon>
</a-tooltip>
</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>
<template slot="label">
@ -72,7 +82,7 @@
</template> User <a-icon type="question-circle"></a-icon>
</a-tooltip>
</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>
<template slot="label">
@ -82,7 +92,7 @@
</template> Port <a-icon type="question-circle"></a-icon>
</a-tooltip>
</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 label='Inbound Tags'>
<a-select v-model="ruleModal.rule.inboundTag" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme">
@ -122,6 +132,7 @@
ip: "",
port: "",
sourcePort: "",
vlessRoute: "",
network: "",
sourceIP: "",
user: "",
@ -155,6 +166,7 @@
this.rule.ip = rule.ip ? rule.ip.join(',') : [];
this.rule.port = rule.port;
this.rule.sourcePort = rule.sourcePort;
this.rule.vlessRoute = rule.vlessRoute;
this.rule.network = rule.network;
this.rule.sourceIP = rule.sourceIP ? rule.sourceIP.join(',') : [];
this.rule.user = rule.user ? rule.user.join(',') : [];
@ -169,6 +181,7 @@
ip: "",
port: "",
sourcePort: "",
vlessRoute: "",
network: "",
sourceIP: "",
user: "",
@ -210,6 +223,7 @@
rule.ip = value.ip.length > 0 ? value.ip.split(',') : [];
rule.port = value.port;
rule.sourcePort = value.sourcePort;
rule.vlessRoute = value.vlessRoute;
rule.network = value.network;
rule.sourceIP = value.sourceIP.length > 0 ? value.sourceIP.split(',') : [];
rule.user = value.user.length > 0 ? value.user.split(',') : [];

View file

@ -67,18 +67,22 @@
</template>
<template slot="info" slot-scope="text, rule, index">
<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">
<template slot="content">
<table cellpadding="2" :style="{ maxWidth: '300px' }">
<tr v-if="rule.source">
<td>Source</td>
<td><a-tag color="blue" v-for="r in rule.source.split(',')">[[ r ]]</a-tag></td>
<tr v-if="rule.sourceIP">
<td>Source IP</td>
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.sourcePort">
<td>Source Port</td>
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
</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">
<td>Network</td>
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>

275
web/html/subscription.html Normal file
View file

@ -0,0 +1,275 @@
{{ 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>
&nbsp;&nbsp;<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" .}}

View file

@ -146,8 +146,9 @@
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
{
title: '{{ i18n "pages.xray.rules.source"}}', children: [
{ title: 'IP', dataIndex: "source", align: 'center', width: 20, ellipsis: true },
{ title: '{{ i18n "pages.inbounds.port" }}', dataIndex: 'sourcePort', align: 'center', width: 10, 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: 'VLESS Route', dataIndex: 'vlessRoute', align: 'center', width: 15, ellipsis: true }]
},
{
title: '{{ i18n "pages.inbounds.network"}}', children: [

View file

@ -3,6 +3,7 @@ package locale
import (
"embed"
"io/fs"
"os"
"strings"
"x-ui/logger"
@ -78,6 +79,11 @@ func I18n(i18nType I18nType, key string, params ...string) string {
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{
MessageID: key,
TemplateData: templateData,
@ -102,6 +108,15 @@ func initTGBotLocalizer(settingService SettingService) error {
func LocalizerMiddleware() gin.HandlerFunc {
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
if cookie, err := c.Request.Cookie("lang"); err == nil {
@ -118,6 +133,25 @@ 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 {
err := fs.WalkDir(i18nFS, "translation",
func(path string, d fs.DirEntry, err error) error {

View file

@ -7,8 +7,10 @@ import (
"encoding/base64"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"os"
"regexp"
@ -29,6 +31,7 @@ import (
"github.com/mymmrac/telego"
th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/skip2/go-qrcode"
"github.com/valyala/fasthttp"
"github.com/valyala/fasthttp/fasthttpproxy"
)
@ -1355,6 +1358,73 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
case "client_commands":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.commands"))
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":
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.onlines"))
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)
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":
inlineKeyboard := tu.InlineKeyboard(
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.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
@ -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) {
if len(replyMarkup) > 0 {
for _, adminId := range adminIds {

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "مفيش بروكسي عكسي مضاف."
"somethingWentWrong" = "حدث خطأ ما"
[subscription]
"title" = "معلومات الاشتراك"
"subId" = "معرّف الاشتراك"
"status" = "الحالة"
"downloaded" = "التنزيل"
"uploaded" = "الرفع"
"expiry" = "تاريخ الانتهاء"
"totalQuota" = "الحصة الإجمالية"
"individualLinks" = "روابط فردية"
"active" = "نشط"
"inactive" = "غير نشط"
"unlimited" = "غير محدود"
"noExpiry" = "بدون انتهاء"
[menu]
"theme" = "الثيم"
"dark" = "داكن"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "No added reverse proxies."
"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]
"theme" = "Theme"
"dark" = "Dark"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "No hay proxies inversos añadidos."
"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]
"theme" = "Tema"
"dark" = "Oscuro"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "هیچ پروکسی معکوس اضافه نشده است."
"somethingWentWrong" = "مشکلی پیش آمد"
[subscription]
"title" = "اطلاعات سابسکریپشن"
"subId" = "شناسه اشتراک"
"status" = "وضعیت"
"downloaded" = "دانلود"
"uploaded" = "آپلود"
"expiry" = "تاریخ پایان"
"totalQuota" = "حجم کلی"
"individualLinks" = "لینک‌های تکی"
"active" = "فعال"
"inactive" = "غیرفعال"
"unlimited" = "نامحدود"
"noExpiry" = "بدون انقضا"
[menu]
"theme" = "تم"
"dark" = "تیره"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Tidak ada proxy terbalik yang ditambahkan."
"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]
"theme" = "Tema"
"dark" = "Gelap"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "追加されたリバースプロキシはありません。"
"somethingWentWrong" = "エラーが発生しました"
[subscription]
"title" = "サブスクリプション情報"
"subId" = "サブスクリプションID"
"status" = "ステータス"
"downloaded" = "ダウンロード"
"uploaded" = "アップロード"
"expiry" = "有効期限"
"totalQuota" = "合計クォータ"
"individualLinks" = "個別リンク"
"active" = "有効"
"inactive" = "無効"
"unlimited" = "無制限"
"noExpiry" = "期限なし"
[menu]
"theme" = "テーマ"
"dark" = "ダーク"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Nenhum proxy reverso adicionado."
"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]
"theme" = "Tema"
"dark" = "Escuro"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Нет добавленных реверс-прокси."
"somethingWentWrong" = "Что-то пошло не так"
[subscription]
"title" = "Информация о подписке"
"subId" = "ID подписки"
"status" = "Статус"
"downloaded" = "Загружено"
"uploaded" = "Отправлено"
"expiry" = "Срок действия"
"totalQuota" = "Общий лимит"
"individualLinks" = "Индивидуальные ссылки"
"active" = "Активна"
"inactive" = "Неактивна"
"unlimited" = "Безлимит"
"noExpiry" = "Без срока"
[menu]
"theme" = "Тема"
"dark" = "Темная"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Eklenmiş ters proxy yok."
"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]
"theme" = "Tema"
"dark" = "Koyu"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Немає доданих зворотних проксі."
"somethingWentWrong" = "Щось пішло не так"
[subscription]
"title" = "Інформація про підписку"
"subId" = "ID підписки"
"status" = "Статус"
"downloaded" = "Завантажено"
"uploaded" = "Відвантажено"
"expiry" = "Термін дії"
"totalQuota" = "Загальна квота"
"individualLinks" = "Окремі посилання"
"active" = "Активна"
"inactive" = "Неактивна"
"unlimited" = "Безліміт"
"noExpiry" = "Без строку"
[menu]
"theme" = "Тема"
"dark" = "Темна"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "Không có proxy ngược nào được thêm."
"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]
"theme" = "Chủ đề"
"dark" = "Tối"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "未添加反向代理。"
"somethingWentWrong" = "出了点问题"
[subscription]
"title" = "订阅信息"
"subId" = "订阅 ID"
"status" = "状态"
"downloaded" = "已下载"
"uploaded" = "已上传"
"expiry" = "到期"
"totalQuota" = "总配额"
"individualLinks" = "单独链接"
"active" = "启用"
"inactive" = "停用"
"unlimited" = "无限制"
"noExpiry" = "无到期"
[menu]
"theme" = "主题"
"dark" = "暗色"

View file

@ -72,6 +72,20 @@
"emptyReverseDesc" = "未添加反向代理。"
"somethingWentWrong" = "發生錯誤"
[subscription]
"title" = "訂閱資訊"
"subId" = "訂閱 ID"
"status" = "狀態"
"downloaded" = "已下載"
"uploaded" = "已上傳"
"expiry" = "到期"
"totalQuota" = "總配額"
"individualLinks" = "個別連結"
"active" = "啟用"
"inactive" = "停用"
"unlimited" = "無限制"
"noExpiry" = "無到期"
[menu]
"theme" = "主題"
"dark" = "深色"

View file

@ -78,6 +78,15 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time {
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 {
httpServer *http.Server
listener net.Listener

View file

@ -2,6 +2,7 @@ package xray
import (
"regexp"
"runtime"
"strings"
"x-ui/logger"
@ -20,6 +21,12 @@ func (lw *LogWriter) Write(m []byte) (n int, err error) {
// Convert the data to a string
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
if crashRegex.MatchString(message) {

View file

@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"time"
@ -224,6 +225,15 @@ func (p *process) Start() (err error) {
go func() {
err := cmd.Run()
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)
p.exitErr = err
}
@ -239,7 +249,7 @@ func (p *process) Stop() error {
if !p.IsRunning() {
return errors.New("xray is not running")
}
if runtime.GOOS == "windows" {
return p.cmd.Process.Kill()
} else {