fix(sub): restore the subscription info page for browser visits

Revert 43bc9153 and its follow-up 338822ab. The copy-only notice replaced the
themed sub page for every browser request, so mobile users got a bare "This is
a subscription link" screen instead of their traffic, expiry and links — and it
left serveSubPage plus the custom-theme renderer as dead code.
This commit is contained in:
Sanaei
2026-08-15 22:11:21 +02:00
parent b4e4478699
commit f22df49a71
16 changed files with 99 additions and 339 deletions
+82 -105
View File
@@ -1,10 +1,12 @@
package sub
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
stdhtml "html"
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
@@ -16,8 +18,6 @@ import (
"unicode"
"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/web/service"
@@ -296,18 +296,23 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
}
}
// maybeServeSubPage validates the subscription and renders a copy-only page.
// The full page embeds share links and must never handle browser navigation.
// 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).
func (a *SUBController) maybeServeSubPage(c *gin.Context) bool {
explicit := explicitSubPageRequest(c)
if !explicit && !a.isBrowserSubscriptionRequest(c) {
accept := c.GetHeader("Accept")
wantsHTML := strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html")
if !wantsHTML {
return false
}
_, ok := a.buildSubPageData(c)
page, ok := a.buildSubPageData(c)
if !ok {
return true
}
a.serveSubscriptionCopyPage(c)
a.serveSubPage(c, page.BasePath, page)
return true
}
@@ -491,108 +496,80 @@ func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp
return regexp.MustCompile(defaultPattern)
}
// 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")
}
func (a *SUBController) isBrowserSubscriptionRequest(c *gin.Context) bool {
accept := strings.ToLower(c.GetHeader("Accept"))
if strings.Contains(accept, "text/html") {
return true
}
fetchDest := strings.ToLower(c.GetHeader("Sec-Fetch-Dest"))
fetchMode := strings.ToLower(c.GetHeader("Sec-Fetch-Mode"))
if fetchDest == "document" || fetchMode == "navigate" {
return true
}
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",
// 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
}
for _, client := range vpnClients {
if strings.Contains(ua, client) {
return false
body = readBody
}
// 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/`))
}
subData := a.subPageContext(page)
// 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
}
}
return true
}
return false
}
func (a *SUBController) serveSubscriptionCopyPage(c *gin.Context) {
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)
setNoCacheHeaders(c)
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>
* { box-sizing: border-box; }
html { min-height: 100%; background: #050505; }
body { margin: 0; width: 100%; min-height: 100vh; min-height: 100dvh; padding: 24px; overflow-x: hidden; display: grid; place-items: center; background: #050505; color: #f2f2f2; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; text-align: center; }
main { width: 100%; max-width: 520px; min-width: 0; padding: 32px; overflow-wrap: anywhere; }
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; }
@media (max-width: 480px) { body { padding: 16px; } main { padding: 24px 8px; } }
</style>
</head>
<body>
<main dir="auto">
<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()
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
}
// subPageContext builds the shared view-model map: the template context for