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