Ver Fonte

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
n0ctal há 2 horas atrás
pai
commit
43bc915397

+ 100 - 79
internal/sub/controller.go

@@ -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
-		}
-		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)
-	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

+ 133 - 0
internal/sub/controller_browser_test.go

@@ -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
internal/sub/info_endpoint_test.go

@@ -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")
 	}
 }

+ 4 - 1
internal/web/translation/ar-EG.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "فشل",
     "statusDown": "غير متصل",
     "statusUp": "متصل"
-  }
+  },
+  "subCopyPageTitle": "رابط الاشتراك",
+  "subCopyPageHeading": "هذا رابط اشتراك",
+  "subCopyPageInstructions": "لا حاجة لفتحه في المتصفح. انسخ عنوان هذه الصفحة والصقه في التطبيق."
 }

+ 4 - 1
internal/web/translation/en-US.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "FAILED",
     "statusDown": "DOWN",
     "statusUp": "UP"
-  }
+  },
+  "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."
 }

+ 4 - 1
internal/web/translation/es-ES.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "FALLIDO",
     "statusDown": "CAÍDO",
     "statusUp": "ACTIVO"
-  }
+  },
+  "subCopyPageTitle": "Enlace de suscripción",
+  "subCopyPageHeading": "Este es un enlace de suscripción",
+  "subCopyPageInstructions": "No necesita abrirlo en el navegador. Copie la dirección de esta página y péguela en la aplicación."
 }

+ 4 - 1
internal/web/translation/fa-IR.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "ناموفق",
     "statusDown": "قطع",
     "statusUp": "وصل"
-  }
+  },
+  "subCopyPageTitle": "پیوند اشتراک",
+  "subCopyPageHeading": "این یک پیوند اشتراک است",
+  "subCopyPageInstructions": "نیازی نیست آن را در مرورگر باز کنید. نشانی این صفحه را کپی و در برنامه جای‌گذاری کنید."
 }

+ 4 - 1
internal/web/translation/id-ID.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "GAGAL",
     "statusDown": "MATI",
     "statusUp": "AKTIF"
-  }
+  },
+  "subCopyPageTitle": "Tautan langganan",
+  "subCopyPageHeading": "Ini adalah tautan langganan",
+  "subCopyPageInstructions": "Anda tidak perlu membukanya di browser. Salin alamat halaman ini dan tempelkan ke aplikasi."
 }

+ 4 - 1
internal/web/translation/ja-JP.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "失敗",
     "statusDown": "ダウン",
     "statusUp": "アップ"
-  }
+  },
+  "subCopyPageTitle": "サブスクリプションリンク",
+  "subCopyPageHeading": "これはサブスクリプションリンクです",
+  "subCopyPageInstructions": "ブラウザーで開く必要はありません。このページのアドレスをコピーしてアプリに貼り付けてください。"
 }

+ 4 - 1
internal/web/translation/pt-BR.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "FALHOU",
     "statusDown": "INATIVO",
     "statusUp": "ATIVO"
-  }
+  },
+  "subCopyPageTitle": "Link de assinatura",
+  "subCopyPageHeading": "Este é um link de assinatura",
+  "subCopyPageInstructions": "Não é necessário abri-lo no navegador. Copie o endereço desta página e cole-o no aplicativo."
 }

+ 4 - 1
internal/web/translation/ru-RU.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "НЕУДАЧНО",
     "statusDown": "НЕДОСТУПЕН",
     "statusUp": "РАБОТАЕТ"
-  }
+  },
+  "subCopyPageTitle": "Ссылка подписки",
+  "subCopyPageHeading": "Это ссылка подписки",
+  "subCopyPageInstructions": "Открывать её в браузере не нужно. Скопируйте адрес этой страницы и вставьте его в приложение."
 }

+ 4 - 1
internal/web/translation/tr-TR.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "BAŞARISIZ",
     "statusDown": "ÇEVRİMDIŞI",
     "statusUp": "ÇEVRİMİÇİ"
-  }
+  },
+  "subCopyPageTitle": "Abonelik bağlantısı",
+  "subCopyPageHeading": "Bu bir abonelik bağlantısıdır",
+  "subCopyPageInstructions": "Tarayıcıda açmanız gerekmez. Bu sayfanın adresini kopyalayıp uygulamaya yapıştırın."
 }

+ 4 - 1
internal/web/translation/uk-UA.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "НЕВДАЛО",
     "statusDown": "НЕДОСТУПНО",
     "statusUp": "ДОСТУПНО"
-  }
+  },
+  "subCopyPageTitle": "Посилання підписки",
+  "subCopyPageHeading": "Це посилання підписки",
+  "subCopyPageInstructions": "Відкривати його в браузері не потрібно. Скопіюйте адресу цієї сторінки та вставте її в застосунок."
 }

+ 4 - 1
internal/web/translation/vi-VN.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "THẤT BẠI",
     "statusDown": "NGỪNG HOẠT ĐỘNG",
     "statusUp": "HOẠT ĐỘNG"
-  }
+  },
+  "subCopyPageTitle": "Liên kết đăng ký",
+  "subCopyPageHeading": "Đây là liên kết đăng ký",
+  "subCopyPageInstructions": "Bạn không cần mở liên kết trong trình duyệt. Hãy sao chép địa chỉ trang này và dán vào ứng dụng."
 }

+ 4 - 1
internal/web/translation/zh-CN.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "失败",
     "statusDown": "断开",
     "statusUp": "恢复"
-  }
+  },
+  "subCopyPageTitle": "订阅链接",
+  "subCopyPageHeading": "这是一个订阅链接",
+  "subCopyPageInstructions": "无需在浏览器中打开。请复制此页面地址并粘贴到应用中。"
 }

+ 4 - 1
internal/web/translation/zh-TW.json

@@ -2075,5 +2075,8 @@
     "statusFailed": "失敗",
     "statusDown": "中斷",
     "statusUp": "恢復"
-  }
+  },
+  "subCopyPageTitle": "訂閱連結",
+  "subCopyPageHeading": "這是訂閱連結",
+  "subCopyPageInstructions": "無需在瀏覽器中開啟。請複製此頁面位址並貼到應用程式中。"
 }