mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-27 20:53:01 +00:00
refactor(subscription): unify URL building and server listener bootstrap
This commit is contained in:
parent
f3ac4bef4c
commit
0de971fbef
10 changed files with 596 additions and 375 deletions
33
serverutil/listener.go
Normal file
33
serverutil/listener.go
Normal 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}
|
||||
}
|
||||
24
sub/sub.go
24
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
|
||||
|
|
|
|||
|
|
@ -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
31
sub/sub_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
100
web/service/subscription_urls.go
Normal file
100
web/service/subscription_urls.go
Normal 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
|
||||
}
|
||||
63
web/service/subscription_urls_test.go
Normal file
63
web/service/subscription_urls_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
261
web/service/tgbot_subscription.go
Normal file
261
web/service/tgbot_subscription.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
web/web.go
23
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
|
||||
|
|
|
|||
63
web/web_smoke_test.go
Normal file
63
web/web_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue