fix(sub): serve a copy-only page when a subscription URL is opened in a browser (#6183)

* fix(sub): show copy-only page for browser subscription visits

Browser navigation to /sub previously rendered the normal subscription page, which exposed subscription material in page data or raw base64 depending on request headers. Keep VPN clients on the raw subscription body, but classify browser document requests and return a neutral static copy-only HTML page with no embedded share links or page data.

This preserves the C1 LimitIP parser fix in the same master candidate while avoiding a DE rollback of the browser subscription UX.

* fix(sub): keep the themed page for an explicit html request

Only implicit browser navigation is downgraded to the copy-only page. An
operator who appends html=1 or view=html already holds the URL, so the
themed subscription page keeps rendering for them and serveSubPage stays
in use.

* fix(sub): keep browser pages copy-only
This commit is contained in:
n0ctal
2026-08-15 21:18:03 +05:00
committed by GitHub
parent dafd3c0e64
commit 43bc915397
16 changed files with 312 additions and 96 deletions
+100 -79
View File
@@ -1,12 +1,10 @@
package sub
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
stdhtml "html"
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
@@ -18,6 +16,8 @@ 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,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 {
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
}
page, ok := a.buildSubPageData(c)
_, ok := a.buildSubPageData(c)
if !ok {
return true
}
a.serveSubPage(c, page.BasePath, page)
a.serveSubscriptionCopyPage(c)
return true
}
@@ -496,80 +491,106 @@ func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp
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
// 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",
}
body = readBody
for _, client := range vpnClients {
if strings.Contains(ua, client) {
return false
}
}
return true
}
return false
}
// 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) serveSubscriptionCopyPage(c *gin.Context) {
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>
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))
}
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
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
}
}
}
subDataJSON, err := json.Marshal(subData)
if err != nil {
subDataJSON = []byte("{}")
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]
}
// 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)
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
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
+133
View File
@@ -0,0 +1,133 @@
package sub
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
func TestIsBrowserSubscriptionRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
accept string
ua string
dest string
mode string
query string
want bool
}{
{name: "explicit html query is not implicit navigation", query: "?html=1", want: false},
{name: "html accept", accept: "text/html,application/xhtml+xml", want: true},
{name: "browser navigation with wildcard accept", accept: "*/*", ua: "Mozilla/5.0 Safari/605.1.15", dest: "document", mode: "navigate", want: true},
{name: "browser ua fallback", accept: "*/*", ua: "Mozilla/5.0 Chrome/126.0.0.0", want: true},
{name: "vpn client wildcard", accept: "*/*", ua: "Incy/3.3.0", want: false},
{name: "vpn client with mozilla token", accept: "*/*", ua: "Mozilla/5.0 Incy/3.3.0", want: false},
{name: "plain client", accept: "*/*", ua: "Go-http-client/2.0", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req := httptest.NewRequest(http.MethodGet, "/sub/abc"+tt.query, nil)
if tt.accept != "" {
req.Header.Set("Accept", tt.accept)
}
if tt.ua != "" {
req.Header.Set("User-Agent", tt.ua)
}
if tt.dest != "" {
req.Header.Set("Sec-Fetch-Dest", tt.dest)
}
if tt.mode != "" {
req.Header.Set("Sec-Fetch-Mode", tt.mode)
}
c.Request = req
if got := (&SUBController{}).isBrowserSubscriptionRequest(c); got != tt.want {
t.Fatalf("isBrowserSubscriptionRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestBrowserClassificationHonorsConfiguredFormatMatchers(t *testing.T) {
cases := []struct {
name string
new func() *SUBController
}{
{"clash", func() *SUBController {
return &SUBController{subClashAutoDetect: true, clashEnabled: true, clashUserAgent: regexp.MustCompile(`Custom-Client`)}
}},
{"json", func() *SUBController {
return &SUBController{jsonAutoDetect: true, jsonEnabled: true, jsonUserAgent: regexp.MustCompile(`Custom-Client`)}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
c.Request.Header.Set("User-Agent", "Mozilla/5.0 Custom-Client/1.0")
if tc.new().isBrowserSubscriptionRequest(c) {
t.Fatal("configured subscription client was classified as a browser")
}
})
}
}
func TestSubscriptionCopyPageUsesRequestLocale(t *testing.T) {
bundle := i18n.NewBundle(language.English)
for id, text := range map[string]string{
"subCopyPageTitle": "Titre localisé",
"subCopyPageHeading": "En-tête localisé",
"subCopyPageInstructions": "Instructions localisées",
} {
bundle.AddMessages(language.French, &i18n.Message{ID: id, Other: text})
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
c.Request.Header.Set("Accept-Language", "fr-FR")
c.Set("localizer", i18n.NewLocalizer(bundle, "fr-FR"))
(&SUBController{}).serveSubscriptionCopyPage(c)
if body := w.Body.String(); !strings.Contains(body, `<html lang="fr-FR">`) ||
!strings.Contains(body, "Titre localisé") || !strings.Contains(body, "Instructions localisées") {
t.Fatalf("copy page was not localized from the request: %s", body)
}
}
func TestExplicitSubPageRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
query string
want bool
}{
{name: "html=1", query: "?html=1", want: true},
{name: "view=html", query: "?view=HTML", want: true},
{name: "no query", query: "", want: false},
{name: "unrelated query", query: "?format=info", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc"+tt.query, nil)
if got := explicitSubPageRequest(c); got != tt.want {
t.Fatalf("explicitSubPageRequest() = %v, want %v", got, tt.want)
}
})
}
}
+27 -4
View File
@@ -118,10 +118,33 @@ func TestSubInfoEndpoint_HTMLPageStillWinsWithoutFormatParam(t *testing.T) {
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Fatalf("Content-Type = %q, want text/html for a browser request", ct)
}
if !strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
t.Fatal("browser request must still get the SPA page with injected page data")
if strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
t.Fatal("copy-only browser page must not embed subscription page data")
}
if !strings.Contains(w.Body.String(), `"isOnline":false`) {
t.Fatalf("injected page data must carry isOnline; body=%s", w.Body.String())
if !strings.Contains(w.Body.String(), "This is a subscription link") {
t.Fatalf("browser request did not get the copy-only page; body=%s", w.Body.String())
}
}
func TestExplicitHTMLRequestUsesCopyOnlyPage(t *testing.T) {
gin.SetMode(gin.TestMode)
initSubDB(t)
seedInfoEndpointSub(t, "explicit-html", "explicit@x")
oldDistFS := distFS
distFS = testDistFS
t.Cleanup(func() { distFS = oldDistFS })
router := gin.New()
NewSUBController(router.Group("/"))
req := httptest.NewRequest(http.MethodGet, "/sub/explicit-html?html=1", nil)
req.Host = "sub.example.com"
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if strings.Contains(w.Body.String(), "__SUB_PAGE_DATA__") {
t.Fatal("explicit HTML request exposed subscription page data")
}
}