diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 15b22d2dd..828eafc66 100644 --- a/internal/sub/controller.go +++ b/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 +// 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 `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 := ` + + + + + + {{TITLE}} + + + +
+

{{HEADING}}

+

{{INSTRUCTIONS}}

+
+ +` + 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(``) - out := bytes.Replace(body, []byte(""), 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 diff --git a/internal/sub/controller_browser_test.go b/internal/sub/controller_browser_test.go new file mode 100644 index 000000000..a86474869 --- /dev/null +++ b/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, ``) || + !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) + } + }) + } +} diff --git a/internal/sub/info_endpoint_test.go b/internal/sub/info_endpoint_test.go index a8b0ec96d..d623de1b0 100644 --- a/internal/sub/info_endpoint_test.go +++ b/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") } } diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index d957c21bc..7c9b460c9 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2075,5 +2075,8 @@ "statusFailed": "فشل", "statusDown": "غير متصل", "statusUp": "متصل" - } + }, + "subCopyPageTitle": "رابط الاشتراك", + "subCopyPageHeading": "هذا رابط اشتراك", + "subCopyPageInstructions": "لا حاجة لفتحه في المتصفح. انسخ عنوان هذه الصفحة والصقه في التطبيق." } diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 2226d0392..2920e2e48 100644 --- a/internal/web/translation/en-US.json +++ b/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." } diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 6ed53b0f6..01d8cf697 100644 --- a/internal/web/translation/es-ES.json +++ b/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." } diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 4d6304fb3..c5b68d9ff 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2075,5 +2075,8 @@ "statusFailed": "ناموفق", "statusDown": "قطع", "statusUp": "وصل" - } + }, + "subCopyPageTitle": "پیوند اشتراک", + "subCopyPageHeading": "این یک پیوند اشتراک است", + "subCopyPageInstructions": "نیازی نیست آن را در مرورگر باز کنید. نشانی این صفحه را کپی و در برنامه جای‌گذاری کنید." } diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 3f02eb861..550d93532 100644 --- a/internal/web/translation/id-ID.json +++ b/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." } diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index dd57c7aa2..008d259ff 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2075,5 +2075,8 @@ "statusFailed": "失敗", "statusDown": "ダウン", "statusUp": "アップ" - } + }, + "subCopyPageTitle": "サブスクリプションリンク", + "subCopyPageHeading": "これはサブスクリプションリンクです", + "subCopyPageInstructions": "ブラウザーで開く必要はありません。このページのアドレスをコピーしてアプリに貼り付けてください。" } diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index a338ba4a0..e32af8e68 100644 --- a/internal/web/translation/pt-BR.json +++ b/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." } diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ca849826b..a6c717847 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2075,5 +2075,8 @@ "statusFailed": "НЕУДАЧНО", "statusDown": "НЕДОСТУПЕН", "statusUp": "РАБОТАЕТ" - } + }, + "subCopyPageTitle": "Ссылка подписки", + "subCopyPageHeading": "Это ссылка подписки", + "subCopyPageInstructions": "Открывать её в браузере не нужно. Скопируйте адрес этой страницы и вставьте его в приложение." } diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 38367064c..f46e047b8 100644 --- a/internal/web/translation/tr-TR.json +++ b/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." } diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index f0209566e..675d19421 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2075,5 +2075,8 @@ "statusFailed": "НЕВДАЛО", "statusDown": "НЕДОСТУПНО", "statusUp": "ДОСТУПНО" - } + }, + "subCopyPageTitle": "Посилання підписки", + "subCopyPageHeading": "Це посилання підписки", + "subCopyPageInstructions": "Відкривати його в браузері не потрібно. Скопіюйте адресу цієї сторінки та вставте її в застосунок." } diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 998476745..3aa1f04e5 100644 --- a/internal/web/translation/vi-VN.json +++ b/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." } diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 3a81d3e8e..bbe2a9847 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2075,5 +2075,8 @@ "statusFailed": "失败", "statusDown": "断开", "statusUp": "恢复" - } + }, + "subCopyPageTitle": "订阅链接", + "subCopyPageHeading": "这是一个订阅链接", + "subCopyPageInstructions": "无需在浏览器中打开。请复制此页面地址并粘贴到应用中。" } diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 430d20dd3..3202ab2ec 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2075,5 +2075,8 @@ "statusFailed": "失敗", "statusDown": "中斷", "statusUp": "恢復" - } + }, + "subCopyPageTitle": "訂閱連結", + "subCopyPageHeading": "這是訂閱連結", + "subCopyPageInstructions": "無需在瀏覽器中開啟。請複製此頁面位址並貼到應用程式中。" }