refactor(subscription): unify URL building and server listener bootstrap

This commit is contained in:
Mohamadhosein Moazennia 2026-02-20 11:19:54 +03:30
parent f3ac4bef4c
commit 0de971fbef
10 changed files with 596 additions and 375 deletions

33
serverutil/listener.go Normal file
View file

@ -0,0 +1,33 @@
package serverutil
import (
"crypto/tls"
"net"
"github.com/mhsanaei/3x-ui/v2/web/network"
)
// TLSWrapResult captures listener wrapping outcome.
type TLSWrapResult struct {
Listener net.Listener
HTTPS bool
CertErr error
}
// WrapListenerWithOptionalTLS wraps listener with auto HTTPS + TLS when cert/key are valid.
// If cert loading fails, it returns the original listener and the certificate error.
func WrapListenerWithOptionalTLS(listener net.Listener, certFile, keyFile string) TLSWrapResult {
if certFile == "" || keyFile == "" {
return TLSWrapResult{Listener: listener, HTTPS: false}
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return TLSWrapResult{Listener: listener, HTTPS: false, CertErr: err}
}
config := &tls.Config{Certificates: []tls.Certificate{cert}}
wrapped := network.NewAutoHttpsListener(listener)
wrapped = tls.NewListener(wrapped, config)
return TLSWrapResult{Listener: wrapped, HTTPS: true}
}

View file

@ -4,7 +4,6 @@ package sub
import (
"context"
"crypto/tls"
"html/template"
"io"
"io/fs"
@ -16,11 +15,11 @@ import (
"strings"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/serverutil"
"github.com/mhsanaei/3x-ui/v2/util/common"
webpkg "github.com/mhsanaei/3x-ui/v2/web"
"github.com/mhsanaei/3x-ui/v2/web/locale"
"github.com/mhsanaei/3x-ui/v2/web/middleware"
"github.com/mhsanaei/3x-ui/v2/web/network"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/gin-gonic/gin"
@ -330,21 +329,14 @@ func (s *Server) Start() (err error) {
if err != nil {
return err
}
if certFile != "" || keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
c := &tls.Config{
Certificates: []tls.Certificate{cert},
}
listener = network.NewAutoHttpsListener(listener)
listener = tls.NewListener(listener, c)
logger.Info("Sub server running HTTPS on", listener.Addr())
} else {
logger.Error("Error loading certificates:", err)
logger.Info("Sub server running HTTP on", listener.Addr())
}
wrapped := serverutil.WrapListenerWithOptionalTLS(listener, certFile, keyFile)
if wrapped.HTTPS {
listener = wrapped.Listener
logger.Info("Sub server running HTTPS on", listener.Addr())
} else {
if wrapped.CertErr != nil {
logger.Error("Error loading certificates:", wrapped.CertErr)
}
logger.Info("Sub server running HTTP on", listener.Addr())
}
s.listener = listener

View file

@ -1081,70 +1081,44 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
// It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
// Input validation
if subId == "" {
return "", ""
}
// Get configured URIs first (highest priority)
configuredSubURI, _ := s.settingService.GetSubURI()
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
// Determine base scheme and host (cached to avoid duplicate calls)
var baseScheme, baseHostWithPort string
if configuredSubURI == "" || configuredSubJsonURI == "" {
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
}
// Build subscription URL
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
// Build JSON subscription URL
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
return subURL, subJsonURL
}
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
func (s *SubService) getBaseSchemeAndHost(requestScheme, requestHostWithPort string) (string, string) {
subDomain, err := s.settingService.GetSubDomain()
if err != nil || subDomain == "" {
return requestScheme, requestHostWithPort
}
// Get port and TLS settings
configuredSubJSONURI, _ := s.settingService.GetSubJsonURI()
subDomain, _ := s.settingService.GetSubDomain()
subPort, _ := s.settingService.GetSubPort()
subKeyFile, _ := s.settingService.GetSubKeyFile()
subCertFile, _ := s.settingService.GetSubCertFile()
subJSONEnabled, _ := s.settingService.GetSubJsonEnable()
// Determine scheme from TLS configuration
scheme := "http"
if subKeyFile != "" && subCertFile != "" {
scheme = "https"
subURL, subJsonURL, err := service.BuildSubscriptionURLs(service.SubscriptionURLInput{
SubID: subId,
ConfiguredSubURI: configuredSubURI,
ConfiguredSubJSONURI: configuredSubJSONURI,
SubDomain: subDomain,
SubPort: subPort,
SubPath: subPath,
SubJSONPath: subJsonPath,
SubKeyFile: subKeyFile,
SubCertFile: subCertFile,
RequestScheme: requestSchemeOrDefault(scheme),
RequestHostWithPort: hostWithPort,
JSONEnabled: subJSONEnabled,
})
if err != nil {
return "", ""
}
// Build host:port, always include port for clarity
hostWithPort := fmt.Sprintf("%s:%d", subDomain, subPort)
return scheme, hostWithPort
return subURL, subJsonURL
}
// buildSingleURL constructs a single URL using configured URI or base components
func (s *SubService) buildSingleURL(configuredURI, baseScheme, baseHostWithPort, basePath, subId string) string {
if configuredURI != "" {
return s.joinPathWithID(configuredURI, subId)
func requestSchemeOrDefault(scheme string) string {
if scheme == "" {
return "http"
}
baseURL := fmt.Sprintf("%s://%s", baseScheme, baseHostWithPort)
return s.joinPathWithID(baseURL+basePath, subId)
}
// joinPathWithID safely joins a base path with a subscription ID
func (s *SubService) joinPathWithID(basePath, subId string) string {
if strings.HasSuffix(basePath, "/") {
return basePath + subId
}
return basePath + "/" + subId
return scheme
}
// BuildPageData parses header and prepares the template view model.

31
sub/sub_smoke_test.go Normal file
View file

@ -0,0 +1,31 @@
package sub
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v2/database"
)
func TestSubRouterSmoke(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "sub-smoke.db")
if err := database.InitDB(dbPath); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
defer func() { _ = database.CloseDB() }()
s := NewServer()
engine, err := s.initRouter()
if err != nil {
t.Fatalf("initRouter failed: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub/non-existent-id", nil)
engine.ServeHTTP(rec, req)
if rec.Code == http.StatusNotFound {
t.Fatalf("expected configured sub route to exist, got %d", rec.Code)
}
}

View file

@ -0,0 +1,100 @@
package service
import (
"fmt"
"strings"
)
// SubscriptionURLInput contains all required inputs for URL generation.
type SubscriptionURLInput struct {
SubID string
ConfiguredSubURI string
ConfiguredSubJSONURI string
SubDomain string
SubPort int
SubPath string
SubJSONPath string
SubKeyFile string
SubCertFile string
RequestScheme string
RequestHostWithPort string
JSONEnabled bool
}
func normalizeSubscriptionPath(path string) string {
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if !strings.HasSuffix(path, "/") {
path += "/"
}
return path
}
func normalizeConfiguredURI(uri string) string {
if uri == "" {
return ""
}
if strings.HasSuffix(uri, "/") {
return uri
}
return uri + "/"
}
func resolveBaseSchemeHost(in SubscriptionURLInput) (scheme string, host string) {
if in.SubDomain != "" {
scheme = "http"
if in.SubKeyFile != "" && in.SubCertFile != "" {
scheme = "https"
}
host = in.SubDomain
if !((in.SubPort == 443 && scheme == "https") || (in.SubPort == 80 && scheme == "http")) {
host = fmt.Sprintf("%s:%d", in.SubDomain, in.SubPort)
}
return scheme, host
}
scheme = in.RequestScheme
if scheme == "" {
scheme = "http"
}
host = in.RequestHostWithPort
if host == "" {
host = "localhost"
}
return scheme, host
}
// BuildSubscriptionURLs computes canonical subscription URLs used by both sub and tgbot flows.
func BuildSubscriptionURLs(in SubscriptionURLInput) (subURL string, subJSONURL string, err error) {
if in.SubID == "" {
return "", "", fmt.Errorf("sub id is required")
}
if uri := normalizeConfiguredURI(in.ConfiguredSubURI); uri != "" {
subURL = uri + in.SubID
} else {
scheme, host := resolveBaseSchemeHost(in)
subPath := normalizeSubscriptionPath(in.SubPath)
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, in.SubID)
}
if !in.JSONEnabled {
return subURL, "", nil
}
if uri := normalizeConfiguredURI(in.ConfiguredSubJSONURI); uri != "" {
subJSONURL = uri + in.SubID
} else {
scheme, host := resolveBaseSchemeHost(in)
subJSONPath := normalizeSubscriptionPath(in.SubJSONPath)
subJSONURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJSONPath, in.SubID)
}
return subURL, subJSONURL, nil
}

View file

@ -0,0 +1,63 @@
package service
import "testing"
func TestBuildSubscriptionURLsConfiguredURIs(t *testing.T) {
sub, subJSON, err := BuildSubscriptionURLs(SubscriptionURLInput{
SubID: "abc123",
ConfiguredSubURI: "https://sub.example.com/s/",
ConfiguredSubJSONURI: "https://sub.example.com/j",
JSONEnabled: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub != "https://sub.example.com/s/abc123" {
t.Fatalf("unexpected sub url: %s", sub)
}
if subJSON != "https://sub.example.com/j/abc123" {
t.Fatalf("unexpected sub json url: %s", subJSON)
}
}
func TestBuildSubscriptionURLsDerivedFromSubDomain(t *testing.T) {
sub, subJSON, err := BuildSubscriptionURLs(SubscriptionURLInput{
SubID: "sid",
SubDomain: "sub.example.com",
SubPort: 443,
SubPath: "sub",
SubJSONPath: "/json/",
SubKeyFile: "/tmp/key.pem",
SubCertFile: "/tmp/cert.pem",
JSONEnabled: true,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub != "https://sub.example.com/sub/sid" {
t.Fatalf("unexpected sub url: %s", sub)
}
if subJSON != "https://sub.example.com/json/sid" {
t.Fatalf("unexpected sub json url: %s", subJSON)
}
}
func TestBuildSubscriptionURLsFallsBackToRequestHost(t *testing.T) {
sub, subJSON, err := BuildSubscriptionURLs(SubscriptionURLInput{
SubID: "sid",
RequestScheme: "https",
RequestHostWithPort: "panel.example.com:8443",
SubPath: "/sub/",
SubJSONPath: "/json/",
JSONEnabled: false,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sub != "https://panel.example.com:8443/sub/sid" {
t.Fatalf("unexpected sub url: %s", sub)
}
if subJSON != "" {
t.Fatalf("expected empty json url when disabled, got: %s", subJSON)
}
}

View file

@ -8,7 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
@ -33,7 +32,6 @@ 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"
)
@ -2321,293 +2319,6 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R
}
}
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
// Resolve subId from client email
traffic, client, err := t.inboundService.GetClientByEmail(email)
_ = traffic
if err != nil || client == nil {
return "", "", errors.New("client not found")
}
// Gather settings to construct absolute URLs
subURI, _ := t.settingService.GetSubURI()
subJsonURI, _ := t.settingService.GetSubJsonURI()
subDomain, _ := t.settingService.GetSubDomain()
subPort, _ := t.settingService.GetSubPort()
subPath, _ := t.settingService.GetSubPath()
subJsonPath, _ := t.settingService.GetSubJsonPath()
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
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 + "/"
}
var subURL string
var subJsonURL string
// If pre-configured URIs are available, use them directly
if subURI != "" {
if !strings.HasSuffix(subURI, "/") {
subURI = subURI + "/"
}
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
} else {
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
}
if subJsonURI != "" {
if !strings.HasSuffix(subJsonURI, "/") {
subJsonURI = subJsonURI + "/"
}
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
} else {
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
}
if !subJsonEnable {
subJsonURL = ""
}
return subURL, subJsonURL, nil
}
// sendClientSubLinks sends the subscription links for the client to the chat.
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>"
if subJsonURL != "" {
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
}
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_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 optimized client with connection pooling
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := optimizedHTTPClient.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) when available
if subJsonURL != "" {
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 := optimizedHTTPClient.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)
// Reduced delay for better performance
if i < max-1 { // Only delay between documents, not after the last one
time.Sleep(50 * time.Millisecond)
}
}
}
}
}
}
}
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
if len(replyMarkup) > 0 {

View file

@ -0,0 +1,261 @@
package service
import (
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"strings"
"time"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/skip2/go-qrcode"
)
// 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
subURI, _ := t.settingService.GetSubURI()
subJsonURI, _ := t.settingService.GetSubJsonURI()
subDomain, _ := t.settingService.GetSubDomain()
subPort, _ := t.settingService.GetSubPort()
subPath, _ := t.settingService.GetSubPath()
subJsonPath, _ := t.settingService.GetSubJsonPath()
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
subKeyFile, _ := t.settingService.GetSubKeyFile()
subCertFile, _ := t.settingService.GetSubCertFile()
// 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"
}
}
return BuildSubscriptionURLs(SubscriptionURLInput{
SubID: client.SubID,
ConfiguredSubURI: subURI,
ConfiguredSubJSONURI: subJsonURI,
SubDomain: subDomain,
SubPort: subPort,
SubPath: subPath,
SubJSONPath: subJsonPath,
SubKeyFile: subKeyFile,
SubCertFile: subCertFile,
JSONEnabled: subJsonEnable,
})
}
// sendClientSubLinks sends the subscription links for the client to the chat.
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>"
if subJsonURL != "" {
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
}
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
),
)
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
}
// sendClientIndividualLinks fetches 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 optimized client with connection pooling
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := optimizedHTTPClient.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 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) when available
if subJsonURL != "" {
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 := optimizedHTTPClient.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 {
filename := email + ".png"
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, filename),
)
_, _ = bot.SendDocument(context.Background(), document)
if i < max-1 {
time.Sleep(50 * time.Millisecond)
}
}
}
}
}
}
}

View file

@ -4,7 +4,6 @@ package web
import (
"context"
"crypto/tls"
"embed"
"html/template"
"io"
@ -18,12 +17,12 @@ import (
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/serverutil"
"github.com/mhsanaei/3x-ui/v2/util/common"
"github.com/mhsanaei/3x-ui/v2/web/controller"
"github.com/mhsanaei/3x-ui/v2/web/job"
"github.com/mhsanaei/3x-ui/v2/web/locale"
"github.com/mhsanaei/3x-ui/v2/web/middleware"
"github.com/mhsanaei/3x-ui/v2/web/network"
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket"
@ -414,20 +413,14 @@ func (s *Server) Start() (err error) {
if err != nil {
return err
}
if certFile != "" || keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err == nil {
c := &tls.Config{
Certificates: []tls.Certificate{cert},
}
listener = network.NewAutoHttpsListener(listener)
listener = tls.NewListener(listener, c)
logger.Info("Web server running HTTPS on", listener.Addr())
} else {
logger.Error("Error loading certificates:", err)
logger.Info("Web server running HTTP on", listener.Addr())
}
wrapped := serverutil.WrapListenerWithOptionalTLS(listener, certFile, keyFile)
if wrapped.HTTPS {
listener = wrapped.Listener
logger.Info("Web server running HTTPS on", listener.Addr())
} else {
if wrapped.CertErr != nil {
logger.Error("Error loading certificates:", wrapped.CertErr)
}
logger.Info("Web server running HTTP on", listener.Addr())
}
s.listener = listener

63
web/web_smoke_test.go Normal file
View file

@ -0,0 +1,63 @@
package web
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/web/global"
"github.com/robfig/cron/v3"
)
func TestRouterSmokeAuthAndCoreRoutes(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "web-smoke.db")
if err := database.InitDB(dbPath); err != nil {
t.Fatalf("InitDB failed: %v", err)
}
defer func() {
_ = database.CloseDB()
}()
s := NewServer()
s.cron = cron.New(cron.WithSeconds())
global.SetWebServer(s)
engine, err := s.initRouter()
if err != nil {
t.Fatalf("initRouter failed: %v", err)
}
// Login page should be reachable.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected GET / to return 200, got %d", rec.Code)
}
// Unauthenticated API request should be hidden as 404.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil)
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected unauthenticated API to return 404, got %d", rec.Code)
}
// Panel root requires auth and should redirect.
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/panel/", nil)
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("expected unauthenticated panel route to redirect, got %d", rec.Code)
}
for _, path := range []string{"/panel/inbounds", "/panel/settings", "/panel/xray"} {
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, path, nil)
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("expected unauthenticated %s to redirect, got %d", path, rec.Code)
}
}
}