From f22df49a712815ebd24cc94acc554a69abd04d7c Mon Sep 17 00:00:00 2001 From: Sanaei Date: Sat, 15 Aug 2026 22:11:21 +0200 Subject: [PATCH] fix(sub): restore the subscription info page for browser visits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/sub/controller.go | 187 +++++++++++------------- internal/sub/controller_browser_test.go | 155 -------------------- 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, 99 insertions(+), 339 deletions(-) delete mode 100644 internal/sub/controller_browser_test.go diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 587a02c77..15b22d2dd 100644 --- a/internal/sub/controller.go +++ b/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 - } - - 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", +// 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 } - for _, client := range vpnClients { - if strings.Contains(ua, client) { - return false + body = readBody + } + + // 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/`)) + } + + 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 } } - return true } - return false -} -func (a *SUBController) serveSubscriptionCopyPage(c *gin.Context) { + 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(``) + out := bytes.Replace(body, []byte(""), inject, 1) + 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)) -} - -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() + c.Data(http.StatusOK, "text/html; charset=utf-8", out) } // 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 deleted file mode 100644 index 42f589cf6..000000000 --- a/internal/sub/controller_browser_test.go +++ /dev/null @@ -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, ``) || - !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{ - ``, - `* { box-sizing: border-box; }`, - `min-height: 100dvh`, - `overflow-x: hidden`, - `overflow-wrap: anywhere`, - `@media (max-width: 480px)`, - `
`, - } { - 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) - } - }) - } -} diff --git a/internal/sub/info_endpoint_test.go b/internal/sub/info_endpoint_test.go index d623de1b0..a8b0ec96d 100644 --- a/internal/sub/info_endpoint_test.go +++ b/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()) } } diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 7c9b460c9..d957c21bc 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2075,8 +2075,5 @@ "statusFailed": "فشل", "statusDown": "غير متصل", "statusUp": "متصل" - }, - "subCopyPageTitle": "رابط الاشتراك", - "subCopyPageHeading": "هذا رابط اشتراك", - "subCopyPageInstructions": "لا حاجة لفتحه في المتصفح. انسخ عنوان هذه الصفحة والصقه في التطبيق." + } } diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 2920e2e48..2226d0392 100644 --- a/internal/web/translation/en-US.json +++ b/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." + } } diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 01d8cf697..6ed53b0f6 100644 --- a/internal/web/translation/es-ES.json +++ b/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." + } } diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index c5b68d9ff..4d6304fb3 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2075,8 +2075,5 @@ "statusFailed": "ناموفق", "statusDown": "قطع", "statusUp": "وصل" - }, - "subCopyPageTitle": "پیوند اشتراک", - "subCopyPageHeading": "این یک پیوند اشتراک است", - "subCopyPageInstructions": "نیازی نیست آن را در مرورگر باز کنید. نشانی این صفحه را کپی و در برنامه جای‌گذاری کنید." + } } diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 550d93532..3f02eb861 100644 --- a/internal/web/translation/id-ID.json +++ b/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." + } } diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 008d259ff..dd57c7aa2 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2075,8 +2075,5 @@ "statusFailed": "失敗", "statusDown": "ダウン", "statusUp": "アップ" - }, - "subCopyPageTitle": "サブスクリプションリンク", - "subCopyPageHeading": "これはサブスクリプションリンクです", - "subCopyPageInstructions": "ブラウザーで開く必要はありません。このページのアドレスをコピーしてアプリに貼り付けてください。" + } } diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e32af8e68..a338ba4a0 100644 --- a/internal/web/translation/pt-BR.json +++ b/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." + } } diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index a6c717847..ca849826b 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2075,8 +2075,5 @@ "statusFailed": "НЕУДАЧНО", "statusDown": "НЕДОСТУПЕН", "statusUp": "РАБОТАЕТ" - }, - "subCopyPageTitle": "Ссылка подписки", - "subCopyPageHeading": "Это ссылка подписки", - "subCopyPageInstructions": "Открывать её в браузере не нужно. Скопируйте адрес этой страницы и вставьте его в приложение." + } } diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index f46e047b8..38367064c 100644 --- a/internal/web/translation/tr-TR.json +++ b/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." + } } diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 675d19421..f0209566e 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2075,8 +2075,5 @@ "statusFailed": "НЕВДАЛО", "statusDown": "НЕДОСТУПНО", "statusUp": "ДОСТУПНО" - }, - "subCopyPageTitle": "Посилання підписки", - "subCopyPageHeading": "Це посилання підписки", - "subCopyPageInstructions": "Відкривати його в браузері не потрібно. Скопіюйте адресу цієї сторінки та вставте її в застосунок." + } } diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 3aa1f04e5..998476745 100644 --- a/internal/web/translation/vi-VN.json +++ b/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." + } } diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index bbe2a9847..3a81d3e8e 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2075,8 +2075,5 @@ "statusFailed": "失败", "statusDown": "断开", "statusUp": "恢复" - }, - "subCopyPageTitle": "订阅链接", - "subCopyPageHeading": "这是一个订阅链接", - "subCopyPageInstructions": "无需在浏览器中打开。请复制此页面地址并粘贴到应用中。" + } } diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 3202ab2ec..430d20dd3 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2075,8 +2075,5 @@ "statusFailed": "失敗", "statusDown": "中斷", "statusUp": "恢復" - }, - "subCopyPageTitle": "訂閱連結", - "subCopyPageHeading": "這是訂閱連結", - "subCopyPageInstructions": "無需在瀏覽器中開啟。請複製此頁面位址並貼到應用程式中。" + } }