From 43bc915397bdab40dccef15126d87df638231222 Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Sat, 15 Aug 2026 21:18:03 +0500 Subject: [PATCH] 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 --- internal/sub/controller.go | 179 +++++++++++++----------- internal/sub/controller_browser_test.go | 133 ++++++++++++++++++ internal/sub/info_endpoint_test.go | 31 +++- internal/web/translation/ar-EG.json | 5 +- internal/web/translation/en-US.json | 5 +- internal/web/translation/es-ES.json | 5 +- internal/web/translation/fa-IR.json | 5 +- internal/web/translation/id-ID.json | 5 +- internal/web/translation/ja-JP.json | 5 +- internal/web/translation/pt-BR.json | 5 +- internal/web/translation/ru-RU.json | 5 +- internal/web/translation/tr-TR.json | 5 +- internal/web/translation/uk-UA.json | 5 +- internal/web/translation/vi-VN.json | 5 +- internal/web/translation/zh-CN.json | 5 +- internal/web/translation/zh-TW.json | 5 +- 16 files changed, 312 insertions(+), 96 deletions(-) create mode 100644 internal/sub/controller_browser_test.go 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": "無需在瀏覽器中開啟。請複製此頁面位址並貼到應用程式中。" }