浏览代码

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.
Sanaei 7 小时之前
父节点
当前提交
f22df49a71

+ 79 - 102
internal/sub/controller.go

@@ -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
+// 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
 	}
 
-	fetchDest := strings.ToLower(c.GetHeader("Sec-Fetch-Dest"))
-	fetchMode := strings.ToLower(c.GetHeader("Sec-Fetch-Mode"))
-	if fetchDest == "document" || fetchMode == "navigate" {
-		return true
+	// 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/`))
 	}
 
-	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
-}
+	subData := a.subPageContext(page)
 
-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>
-		* { 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
+	// 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
 			}
 		}
 	}
-	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()
+	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)
+	c.Data(http.StatusOK, "text/html; charset=utf-8", out)
 }
 
 // subPageContext builds the shared view-model map: the template context for

+ 0 - 155
internal/sub/controller_browser_test.go

@@ -1,155 +0,0 @@
-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 TestSubscriptionCopyPageIsMobileSafe(t *testing.T) {
-	w := httptest.NewRecorder()
-	c, _ := gin.CreateTestContext(w)
-	c.Request = httptest.NewRequest(http.MethodGet, "/sub/abc", nil)
-
-	(&SUBController{}).serveSubscriptionCopyPage(c)
-	body := w.Body.String()
-	for _, required := range []string{
-		`<meta name="viewport" content="width=device-width, initial-scale=1">`,
-		`* { box-sizing: border-box; }`,
-		`min-height: 100dvh`,
-		`overflow-x: hidden`,
-		`overflow-wrap: anywhere`,
-		`@media (max-width: 480px)`,
-		`<main dir="auto">`,
-	} {
-		if !strings.Contains(body, required) {
-			t.Fatalf("copy page is missing mobile layout constraint %q", required)
-		}
-	}
-}
-
-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)
-			}
-		})
-	}
-}

+ 4 - 27
internal/sub/info_endpoint_test.go

@@ -118,33 +118,10 @@ 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("copy-only browser page must not embed subscription page data")
+	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(), "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")
+	if !strings.Contains(w.Body.String(), `"isOnline":false`) {
+		t.Fatalf("injected page data must carry isOnline; body=%s", w.Body.String())
 	}
 }

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

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

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

@@ -2075,8 +2075,5 @@
     "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."
+  }
 }

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

@@ -2075,8 +2075,5 @@
     "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."
+  }
 }

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

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

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

@@ -2075,8 +2075,5 @@
     "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."
+  }
 }

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

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

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

@@ -2075,8 +2075,5 @@
     "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."
+  }
 }

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

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

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

@@ -2075,8 +2075,5 @@
     "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."
+  }
 }

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

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

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

@@ -2075,8 +2075,5 @@
     "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."
+  }
 }

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

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

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

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