mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 16:20:59 +00:00
fix(sub): restore the subscription info page for browser visits
Revert43bc9153and its follow-up338822ab. 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.
This commit is contained in:
+82
-105
@@ -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 `<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/`))
|
||||
}
|
||||
|
||||
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(`<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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,8 +2075,5 @@
|
||||
"statusFailed": "فشل",
|
||||
"statusDown": "غير متصل",
|
||||
"statusUp": "متصل"
|
||||
},
|
||||
"subCopyPageTitle": "رابط الاشتراك",
|
||||
"subCopyPageHeading": "هذا رابط اشتراك",
|
||||
"subCopyPageInstructions": "لا حاجة لفتحه في المتصفح. انسخ عنوان هذه الصفحة والصقه في التطبيق."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,8 +2075,5 @@
|
||||
"statusFailed": "ناموفق",
|
||||
"statusDown": "قطع",
|
||||
"statusUp": "وصل"
|
||||
},
|
||||
"subCopyPageTitle": "پیوند اشتراک",
|
||||
"subCopyPageHeading": "این یک پیوند اشتراک است",
|
||||
"subCopyPageInstructions": "نیازی نیست آن را در مرورگر باز کنید. نشانی این صفحه را کپی و در برنامه جایگذاری کنید."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,8 +2075,5 @@
|
||||
"statusFailed": "失敗",
|
||||
"statusDown": "ダウン",
|
||||
"statusUp": "アップ"
|
||||
},
|
||||
"subCopyPageTitle": "サブスクリプションリンク",
|
||||
"subCopyPageHeading": "これはサブスクリプションリンクです",
|
||||
"subCopyPageInstructions": "ブラウザーで開く必要はありません。このページのアドレスをコピーしてアプリに貼り付けてください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,8 +2075,5 @@
|
||||
"statusFailed": "НЕУДАЧНО",
|
||||
"statusDown": "НЕДОСТУПЕН",
|
||||
"statusUp": "РАБОТАЕТ"
|
||||
},
|
||||
"subCopyPageTitle": "Ссылка подписки",
|
||||
"subCopyPageHeading": "Это ссылка подписки",
|
||||
"subCopyPageInstructions": "Открывать её в браузере не нужно. Скопируйте адрес этой страницы и вставьте его в приложение."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,8 +2075,5 @@
|
||||
"statusFailed": "НЕВДАЛО",
|
||||
"statusDown": "НЕДОСТУПНО",
|
||||
"statusUp": "ДОСТУПНО"
|
||||
},
|
||||
"subCopyPageTitle": "Посилання підписки",
|
||||
"subCopyPageHeading": "Це посилання підписки",
|
||||
"subCopyPageInstructions": "Відкривати його в браузері не потрібно. Скопіюйте адресу цієї сторінки та вставте її в застосунок."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,8 +2075,5 @@
|
||||
"statusFailed": "失败",
|
||||
"statusDown": "断开",
|
||||
"statusUp": "恢复"
|
||||
},
|
||||
"subCopyPageTitle": "订阅链接",
|
||||
"subCopyPageHeading": "这是一个订阅链接",
|
||||
"subCopyPageInstructions": "无需在浏览器中打开。请复制此页面地址并粘贴到应用中。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,8 +2075,5 @@
|
||||
"statusFailed": "失敗",
|
||||
"statusDown": "中斷",
|
||||
"statusUp": "恢復"
|
||||
},
|
||||
"subCopyPageTitle": "訂閱連結",
|
||||
"subCopyPageHeading": "這是訂閱連結",
|
||||
"subCopyPageInstructions": "無需在瀏覽器中開啟。請複製此頁面位址並貼到應用程式中。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user