From 0de971fbef4590919534b33fae0661a42e9c2eb4 Mon Sep 17 00:00:00 2001 From: Mohamadhosein Moazennia Date: Fri, 20 Feb 2026 11:19:54 +0330 Subject: [PATCH] refactor(subscription): unify URL building and server listener bootstrap --- serverutil/listener.go | 33 +++ sub/sub.go | 24 +-- sub/subService.go | 84 +++----- sub/sub_smoke_test.go | 31 +++ web/service/subscription_urls.go | 100 +++++++++ web/service/subscription_urls_test.go | 63 ++++++ web/service/tgbot.go | 289 -------------------------- web/service/tgbot_subscription.go | 261 +++++++++++++++++++++++ web/web.go | 23 +- web/web_smoke_test.go | 63 ++++++ 10 files changed, 596 insertions(+), 375 deletions(-) create mode 100644 serverutil/listener.go create mode 100644 sub/sub_smoke_test.go create mode 100644 web/service/subscription_urls.go create mode 100644 web/service/subscription_urls_test.go create mode 100644 web/service/tgbot_subscription.go create mode 100644 web/web_smoke_test.go 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) + } + } +}