diff --git a/serverutil/listener.go b/serverutil/listener.go
new file mode 100644
index 00000000..3f505234
--- /dev/null
+++ b/serverutil/listener.go
@@ -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}
+}
diff --git a/sub/sub.go b/sub/sub.go
index 1dcd9601..83da70db 100644
--- a/sub/sub.go
+++ b/sub/sub.go
@@ -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
diff --git a/sub/subService.go b/sub/subService.go
index 818f193b..37923f6a 100644
--- a/sub/subService.go
+++ b/sub/subService.go
@@ -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.
diff --git a/sub/sub_smoke_test.go b/sub/sub_smoke_test.go
new file mode 100644
index 00000000..1fc1faea
--- /dev/null
+++ b/sub/sub_smoke_test.go
@@ -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)
+ }
+}
diff --git a/web/service/subscription_urls.go b/web/service/subscription_urls.go
new file mode 100644
index 00000000..96c4ed40
--- /dev/null
+++ b/web/service/subscription_urls.go
@@ -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
+}
diff --git a/web/service/subscription_urls_test.go b/web/service/subscription_urls_test.go
new file mode 100644
index 00000000..2c2fdd99
--- /dev/null
+++ b/web/service/subscription_urls_test.go
@@ -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)
+ }
+}
diff --git a/web/service/tgbot.go b/web/service/tgbot.go
index 3ff80b40..6f52648e 100644
--- a/web/service/tgbot.go
+++ b/web/service/tgbot.go
@@ -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" + subURL + ""
- if subJsonURL != "" {
- msg += "\r\n\r\nJSON URL:\r\n" + subJsonURL + ""
- }
- 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
- msg += "" + link + "\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 {
diff --git a/web/service/tgbot_subscription.go b/web/service/tgbot_subscription.go
new file mode 100644
index 00000000..91426dcb
--- /dev/null
+++ b/web/service/tgbot_subscription.go
@@ -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" + subURL + ""
+ if subJsonURL != "" {
+ msg += "\r\n\r\nJSON URL:\r\n" + subJsonURL + ""
+ }
+ 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
+ msg += "" + link + "\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)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/web/web.go b/web/web.go
index 300572a3..cb04b8b4 100644
--- a/web/web.go
+++ b/web/web.go
@@ -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
diff --git a/web/web_smoke_test.go b/web/web_smoke_test.go
new file mode 100644
index 00000000..7497a8de
--- /dev/null
+++ b/web/web_smoke_test.go
@@ -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)
+ }
+ }
+}