|
@@ -1,12 +1,10 @@
|
|
|
package sub
|
|
package sub
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
- "bytes"
|
|
|
|
|
"encoding/base64"
|
|
"encoding/base64"
|
|
|
- "encoding/json"
|
|
|
|
|
"fmt"
|
|
"fmt"
|
|
|
|
|
+ stdhtml "html"
|
|
|
"html/template"
|
|
"html/template"
|
|
|
- "io/fs"
|
|
|
|
|
"net/http"
|
|
"net/http"
|
|
|
"net/url"
|
|
"net/url"
|
|
|
"os"
|
|
"os"
|
|
@@ -18,6 +16,8 @@ import (
|
|
|
"unicode"
|
|
"unicode"
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
+ "github.com/nicksnyder/go-i18n/v2/i18n"
|
|
|
|
|
+ "golang.org/x/text/language"
|
|
|
|
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
@@ -296,23 +296,18 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// maybeServeSubPage renders the HTML info page when the request comes from a
|
|
|
|
|
-// browser (Accept: text/html) or explicitly asks for it (?html=1 or ?view=html).
|
|
|
|
|
-// It reports whether the request was handled. The remark template's per-client
|
|
|
|
|
-// info is for the content a client app imports — the raw subscription body. A
|
|
|
|
|
-// browser viewing the HTML info page gets clean, name-only remarks (usage is
|
|
|
|
|
-// shown in the page summary).
|
|
|
|
|
|
|
+// maybeServeSubPage validates the subscription and renders a copy-only page.
|
|
|
|
|
+// The full page embeds share links and must never handle browser navigation.
|
|
|
func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
|
|
func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
|
|
|
- accept := c.GetHeader("Accept")
|
|
|
|
|
- wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
|
|
|
|
|
- if !wantsHTML {
|
|
|
|
|
|
|
+ explicit := explicitSubPageRequest(c)
|
|
|
|
|
+ if !explicit && !a.isBrowserSubscriptionRequest(c) {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
- page, ok := a.buildSubPageData(c)
|
|
|
|
|
|
|
+ _, ok := a.buildSubPageData(c)
|
|
|
if !ok {
|
|
if !ok {
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
|
- a.serveSubPage(c, page.BasePath, page)
|
|
|
|
|
|
|
+ a.serveSubscriptionCopyPage(c)
|
|
|
return true
|
|
return true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -496,80 +491,106 @@ func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp
|
|
|
return regexp.MustCompile(defaultPattern)
|
|
return regexp.MustCompile(defaultPattern)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// serveSubPage renders internal/web/dist/subpage.html for the current subscription
|
|
|
|
|
-// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
|
|
|
|
|
-// we inject that here, along with window.X_UI_BASE_PATH so the
|
|
|
|
|
-// page's static asset references resolve correctly when the panel runs
|
|
|
|
|
-// behind a URL prefix.
|
|
|
|
|
-func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
|
|
|
|
|
- var body []byte
|
|
|
|
|
- if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
|
|
|
|
|
- body = diskBody
|
|
|
|
|
- } else {
|
|
|
|
|
- readBody, err := fs.ReadFile(distFS, "dist/subpage.html")
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- c.String(http.StatusInternalServerError, "missing embedded subpage")
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
- body = readBody
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// explicitSubPageRequest reports whether the caller explicitly asked for HTML.
|
|
|
|
|
+func explicitSubPageRequest(c *gin.Context) bool {
|
|
|
|
|
+ return c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- // Vite emits absolute asset URLs (`/assets/...`); when the panel is
|
|
|
|
|
- // installed under a custom URL prefix, rewrite them so the bundle
|
|
|
|
|
- // loads from `<basePath>assets/...` where the static handler is
|
|
|
|
|
- // actually mounted.
|
|
|
|
|
- if basePath != "/" && basePath != "" {
|
|
|
|
|
- body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
|
|
|
|
- body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
|
|
|
|
|
|
+func (a *SUBController) isBrowserSubscriptionRequest(c *gin.Context) bool {
|
|
|
|
|
+ accept := strings.ToLower(c.GetHeader("Accept"))
|
|
|
|
|
+ if strings.Contains(accept, "text/html") {
|
|
|
|
|
+ return true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- subData := a.subPageContext(page)
|
|
|
|
|
|
|
+ fetchDest := strings.ToLower(c.GetHeader("Sec-Fetch-Dest"))
|
|
|
|
|
+ fetchMode := strings.ToLower(c.GetHeader("Sec-Fetch-Mode"))
|
|
|
|
|
+ if fetchDest == "document" || fetchMode == "navigate" {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // When an admin has configured a custom subscription theme, render it
|
|
|
|
|
- // instead of the default SPA. We render into a buffer first so a template
|
|
|
|
|
- // that fails mid-execution can't leave a partially-written (corrupt)
|
|
|
|
|
- // response — on any error we log and fall through to the default page.
|
|
|
|
|
- if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
|
|
|
|
|
- if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
|
|
|
|
|
- logger.Error("sub: custom template parse failed, using default page:", err)
|
|
|
|
|
- } else if tmpl == nil {
|
|
|
|
|
- logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
|
|
|
|
|
- } else {
|
|
|
|
|
- var buf bytes.Buffer
|
|
|
|
|
- if execErr := tmpl.Execute(&buf, subData); execErr != nil {
|
|
|
|
|
- logger.Error("sub: custom template execution failed, using default page:", execErr)
|
|
|
|
|
- } else {
|
|
|
|
|
- setNoCacheHeaders(c)
|
|
|
|
|
- c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ rawUA := c.GetHeader("User-Agent")
|
|
|
|
|
+ ua := strings.ToLower(rawUA)
|
|
|
|
|
+ if rawUA == "" {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, rawUA, a.clashUserAgent) ||
|
|
|
|
|
+ shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, rawUA, a.jsonUserAgent) {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+ if strings.Contains(ua, "mozilla/") {
|
|
|
|
|
+ vpnClients := []string{
|
|
|
|
|
+ "clash", "mihomo", "sing-box", "v2ray", "xray", "hiddify",
|
|
|
|
|
+ "nekobox", "shadowrocket", "streisand", "v2box", "incy", "happ",
|
|
|
|
|
+ }
|
|
|
|
|
+ for _, client := range vpnClients {
|
|
|
|
|
+ if strings.Contains(ua, client) {
|
|
|
|
|
+ return false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ return true
|
|
|
}
|
|
}
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- subDataJSON, err := json.Marshal(subData)
|
|
|
|
|
- if err != nil {
|
|
|
|
|
- subDataJSON = []byte("{}")
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Defense-in-depth string-escape for the basePath embed — admin-
|
|
|
|
|
- // controlled but cheap to harden.
|
|
|
|
|
- jsEscape := strings.NewReplacer(
|
|
|
|
|
- `\`, `\\`,
|
|
|
|
|
- `"`, `\"`,
|
|
|
|
|
- "\n", `\n`,
|
|
|
|
|
- "\r", `\r`,
|
|
|
|
|
- "<", `<`,
|
|
|
|
|
- ">", `>`,
|
|
|
|
|
- "&", `&`,
|
|
|
|
|
- )
|
|
|
|
|
- escapedBase := jsEscape.Replace(basePath)
|
|
|
|
|
-
|
|
|
|
|
- inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
|
|
|
|
|
- `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
|
|
|
|
- out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
|
|
|
|
-
|
|
|
|
|
|
|
+func (a *SUBController) serveSubscriptionCopyPage(c *gin.Context) {
|
|
|
setNoCacheHeaders(c)
|
|
setNoCacheHeaders(c)
|
|
|
- c.Data(http.StatusOK, "text/html; charset=utf-8", out)
|
|
|
|
|
|
|
+ title := localizeRequest(c, "subCopyPageTitle")
|
|
|
|
|
+ heading := localizeRequest(c, "subCopyPageHeading")
|
|
|
|
|
+ instructions := localizeRequest(c, "subCopyPageInstructions")
|
|
|
|
|
+ lang := requestLanguage(c)
|
|
|
|
|
+ page := `<!doctype html>
|
|
|
|
|
+<html lang="{{LANG}}">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="utf-8">
|
|
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
+ <meta name="robots" content="noindex,nofollow">
|
|
|
|
|
+ <title>{{TITLE}}</title>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ html, body { margin: 0; min-height: 100%; background: #050505; color: #f2f2f2; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
|
|
|
+ body { min-height: 100vh; display: flex; align-items: center; justify-content: center; text-align: center; }
|
|
|
|
|
+ main { max-width: 520px; padding: 32px; }
|
|
|
|
|
+ h1 { margin: 0 0 14px; font-size: 24px; font-weight: 650; letter-spacing: -0.02em; }
|
|
|
|
|
+ p { margin: 0; color: #b8b8b8; font-size: 16px; line-height: 1.55; }
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <main>
|
|
|
|
|
+ <h1>{{HEADING}}</h1>
|
|
|
|
|
+ <p>{{INSTRUCTIONS}}</p>
|
|
|
|
|
+ </main>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>`
|
|
|
|
|
+ page = strings.NewReplacer(
|
|
|
|
|
+ "{{LANG}}", stdhtml.EscapeString(lang),
|
|
|
|
|
+ "{{TITLE}}", stdhtml.EscapeString(title),
|
|
|
|
|
+ "{{HEADING}}", stdhtml.EscapeString(heading),
|
|
|
|
|
+ "{{INSTRUCTIONS}}", stdhtml.EscapeString(instructions),
|
|
|
|
|
+ ).Replace(page)
|
|
|
|
|
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func localizeRequest(c *gin.Context, key string) string {
|
|
|
|
|
+ if value, ok := c.Get("localizer"); ok {
|
|
|
|
|
+ if localizer, ok := value.(*i18n.Localizer); ok {
|
|
|
|
|
+ if msg, err := localizer.Localize(&i18n.LocalizeConfig{MessageID: key}); err == nil {
|
|
|
|
|
+ return msg
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ fallbacks := map[string]string{
|
|
|
|
|
+ "subCopyPageTitle": "Subscription link",
|
|
|
|
|
+ "subCopyPageHeading": "This is a subscription link",
|
|
|
|
|
+ "subCopyPageInstructions": "You do not need to open it in a browser. Copy this page address and paste it into the app.",
|
|
|
|
|
+ }
|
|
|
|
|
+ return fallbacks[key]
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func requestLanguage(c *gin.Context) string {
|
|
|
|
|
+ tag, _, _ := language.ParseAcceptLanguage(c.GetHeader("Accept-Language"))
|
|
|
|
|
+ if len(tag) == 0 {
|
|
|
|
|
+ return "en-US"
|
|
|
|
|
+ }
|
|
|
|
|
+ return tag[0].String()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// subPageContext builds the shared view-model map: the template context for
|
|
// subPageContext builds the shared view-model map: the template context for
|