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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
@ -16,11 +15,11 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"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/util/common"
|
||||||
webpkg "github.com/mhsanaei/3x-ui/v2/web"
|
webpkg "github.com/mhsanaei/3x-ui/v2/web"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/locale"
|
"github.com/mhsanaei/3x-ui/v2/web/locale"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
"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/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
@ -330,21 +329,14 @@ func (s *Server) Start() (err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
wrapped := serverutil.WrapListenerWithOptionalTLS(listener, certFile, keyFile)
|
||||||
if certFile != "" || keyFile != "" {
|
if wrapped.HTTPS {
|
||||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
listener = wrapped.Listener
|
||||||
if err == nil {
|
logger.Info("Sub server running HTTPS on", listener.Addr())
|
||||||
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())
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
if wrapped.CertErr != nil {
|
||||||
|
logger.Error("Error loading certificates:", wrapped.CertErr)
|
||||||
|
}
|
||||||
logger.Info("Sub server running HTTP on", listener.Addr())
|
logger.Info("Sub server running HTTP on", listener.Addr())
|
||||||
}
|
}
|
||||||
s.listener = listener
|
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.
|
// 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.
|
// 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) {
|
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()
|
configuredSubURI, _ := s.settingService.GetSubURI()
|
||||||
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
configuredSubJSONURI, _ := s.settingService.GetSubJsonURI()
|
||||||
|
subDomain, _ := s.settingService.GetSubDomain()
|
||||||
// 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
|
|
||||||
subPort, _ := s.settingService.GetSubPort()
|
subPort, _ := s.settingService.GetSubPort()
|
||||||
subKeyFile, _ := s.settingService.GetSubKeyFile()
|
subKeyFile, _ := s.settingService.GetSubKeyFile()
|
||||||
subCertFile, _ := s.settingService.GetSubCertFile()
|
subCertFile, _ := s.settingService.GetSubCertFile()
|
||||||
|
subJSONEnabled, _ := s.settingService.GetSubJsonEnable()
|
||||||
|
|
||||||
// Determine scheme from TLS configuration
|
subURL, subJsonURL, err := service.BuildSubscriptionURLs(service.SubscriptionURLInput{
|
||||||
scheme := "http"
|
SubID: subId,
|
||||||
if subKeyFile != "" && subCertFile != "" {
|
|
||||||
scheme = "https"
|
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 "", ""
|
||||||
}
|
}
|
||||||
|
return subURL, subJsonURL
|
||||||
// Build host:port, always include port for clarity
|
|
||||||
hostWithPort := fmt.Sprintf("%s:%d", subDomain, subPort)
|
|
||||||
|
|
||||||
return scheme, hostWithPort
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSingleURL constructs a single URL using configured URI or base components
|
func requestSchemeOrDefault(scheme string) string {
|
||||||
func (s *SubService) buildSingleURL(configuredURI, baseScheme, baseHostWithPort, basePath, subId string) string {
|
if scheme == "" {
|
||||||
if configuredURI != "" {
|
return "http"
|
||||||
return s.joinPathWithID(configuredURI, subId)
|
|
||||||
}
|
}
|
||||||
|
return scheme
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPageData parses header and prepares the template view model.
|
// 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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -33,7 +32,6 @@ import (
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
th "github.com/mymmrac/telego/telegohandler"
|
th "github.com/mymmrac/telego/telegohandler"
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
tu "github.com/mymmrac/telego/telegoutil"
|
||||||
"github.com/skip2/go-qrcode"
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
"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.
|
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
|
||||||
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||||
if len(replyMarkup) > 0 {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
|
||||||
"embed"
|
"embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -18,12 +17,12 @@ import (
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/config"
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"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/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/controller"
|
"github.com/mhsanaei/3x-ui/v2/web/controller"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/job"
|
"github.com/mhsanaei/3x-ui/v2/web/job"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/locale"
|
"github.com/mhsanaei/3x-ui/v2/web/locale"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/middleware"
|
"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/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
"github.com/mhsanaei/3x-ui/v2/web/websocket"
|
||||||
|
|
||||||
|
|
@ -414,20 +413,14 @@ func (s *Server) Start() (err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if certFile != "" || keyFile != "" {
|
wrapped := serverutil.WrapListenerWithOptionalTLS(listener, certFile, keyFile)
|
||||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
if wrapped.HTTPS {
|
||||||
if err == nil {
|
listener = wrapped.Listener
|
||||||
c := &tls.Config{
|
logger.Info("Web server running HTTPS on", listener.Addr())
|
||||||
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())
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
if wrapped.CertErr != nil {
|
||||||
|
logger.Error("Error loading certificates:", wrapped.CertErr)
|
||||||
|
}
|
||||||
logger.Info("Web server running HTTP on", listener.Addr())
|
logger.Info("Web server running HTTP on", listener.Addr())
|
||||||
}
|
}
|
||||||
s.listener = listener
|
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