diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d2cd03500..fae0f5ff2 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -243,6 +243,10 @@ "description": "Subscription support URL", "type": "string" }, + "subThemeDir": { + "description": "Absolute path to a folder containing a custom subscription page template", + "type": "string" + }, "subTitle": { "description": "Subscription title", "type": "string" @@ -404,6 +408,7 @@ "subRoutingRules", "subShowInfo", "subSupportUrl", + "subThemeDir", "subTitle", "subURI", "subUpdates", @@ -666,6 +671,10 @@ "description": "Subscription support URL", "type": "string" }, + "subThemeDir": { + "description": "Absolute path to a folder containing a custom subscription page template", + "type": "string" + }, "subTitle": { "description": "Subscription title", "type": "string" @@ -833,6 +842,7 @@ "subRoutingRules", "subShowInfo", "subSupportUrl", + "subThemeDir", "subTitle", "subURI", "subUpdates", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 17bed5c68..9576da449 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -56,6 +56,7 @@ export const EXAMPLES: Record = { "subRoutingRules": "", "subShowInfo": false, "subSupportUrl": "", + "subThemeDir": "", "subTitle": "", "subURI": "", "subUpdates": 0, @@ -143,6 +144,7 @@ export const EXAMPLES: Record = { "subRoutingRules": "", "subShowInfo": false, "subSupportUrl": "", + "subThemeDir": "", "subTitle": "", "subURI": "", "subUpdates": 0, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 77207c750..95ea0f021 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -217,6 +217,10 @@ export const SCHEMAS: Record = { "description": "Subscription support URL", "type": "string" }, + "subThemeDir": { + "description": "Absolute path to a folder containing a custom subscription page template", + "type": "string" + }, "subTitle": { "description": "Subscription title", "type": "string" @@ -378,6 +382,7 @@ export const SCHEMAS: Record = { "subRoutingRules", "subShowInfo", "subSupportUrl", + "subThemeDir", "subTitle", "subURI", "subUpdates", @@ -640,6 +645,10 @@ export const SCHEMAS: Record = { "description": "Subscription support URL", "type": "string" }, + "subThemeDir": { + "description": "Absolute path to a folder containing a custom subscription page template", + "type": "string" + }, "subTitle": { "description": "Subscription title", "type": "string" @@ -807,6 +816,7 @@ export const SCHEMAS: Record = { "subRoutingRules", "subShowInfo", "subSupportUrl", + "subThemeDir", "subTitle", "subURI", "subUpdates", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index a4adf2366..c5e7c0550 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -61,6 +61,7 @@ export interface AllSetting { subRoutingRules: string; subShowInfo: boolean; subSupportUrl: string; + subThemeDir: string; subTitle: string; subURI: string; subUpdates: number; @@ -149,6 +150,7 @@ export interface AllSettingView { subRoutingRules: string; subShowInfo: boolean; subSupportUrl: string; + subThemeDir: string; subTitle: string; subURI: string; subUpdates: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 296207768..87a672116 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -71,6 +71,7 @@ export const AllSettingSchema = z.object({ subRoutingRules: z.string(), subShowInfo: z.boolean(), subSupportUrl: z.string(), + subThemeDir: z.string(), subTitle: z.string(), subURI: z.string(), subUpdates: z.number().int().min(0).max(525600), @@ -160,6 +161,7 @@ export const AllSettingViewSchema = z.object({ subRoutingRules: z.string(), subShowInfo: z.boolean(), subSupportUrl: z.string(), + subThemeDir: z.string(), subTitle: z.string(), subURI: z.string(), subUpdates: z.number().int().min(0).max(525600), diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index 047dcca62..f2d199070 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -60,6 +60,7 @@ export class AllSetting { subJsonMux = ''; subJsonRules = ''; subJsonFinalMask = ''; + subThemeDir = ''; timeLocation = 'Local'; diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx index 88b6e1608..550dff935 100644 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -157,6 +157,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su onChange={(e) => updateSetting({ subAnnounce: e.target.value })} /> + + updateSetting({ subThemeDir: e.target.value })} /> + + Happ diff --git a/sub/subController.go b/sub/subController.go index 70a02dc68..5e71f2dd4 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -5,15 +5,19 @@ import ( "encoding/base64" "encoding/json" "fmt" + "html/template" "net/http" "net/url" "os" + "path/filepath" "strconv" "strings" - - "github.com/mhsanaei/3x-ui/v3/web/service" + "sync" + "time" "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v3/logger" + "github.com/mhsanaei/3x-ui/v3/web/service" ) // writeSubError translates a service-layer result into an HTTP response. @@ -28,6 +32,14 @@ func writeSubError(c *gin.Context, err error) { c.Status(http.StatusInternalServerError) } +// cachedSubTemplate holds a parsed custom subscription template together with +// the modification time of the file it was parsed from, so the cache can be +// invalidated when an admin edits the template on disk. +type cachedSubTemplate struct { + tmpl *template.Template + modTime time.Time +} + // SUBController handles HTTP requests for subscription links and JSON configurations. type SUBController struct { subTitle string @@ -48,6 +60,9 @@ type SUBController struct { subJsonService *SubJsonService subClashService *SubClashService settingService service.SettingService + + subTemplateMu sync.RWMutex + subTemplateCache map[string]*cachedSubTemplate } // NewSUBController creates a new subscription controller with the given configuration. @@ -93,6 +108,8 @@ func NewSUBController( subService: sub, subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub), subClashService: NewSubClashService(clashEnableRouting, clashRules, sub), + + subTemplateCache: map[string]*cachedSubTemplate{}, } a.initRouter(g) return a @@ -202,25 +219,49 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD } subData := map[string]any{ - "sId": page.SId, - "enabled": page.Enabled, - "download": page.Download, - "upload": page.Upload, - "total": page.Total, - "used": page.Used, - "remained": page.Remained, - "expire": page.Expire, - "lastOnline": page.LastOnline, - "downloadByte": page.DownloadByte, - "uploadByte": page.UploadByte, - "totalByte": page.TotalByte, - "subUrl": page.SubUrl, - "subJsonUrl": page.SubJsonUrl, - "subClashUrl": page.SubClashUrl, - "links": page.Result, - "emails": page.Emails, - "datepicker": datepicker, + "sId": page.SId, + "enabled": page.Enabled, + "download": page.Download, + "upload": page.Upload, + "total": page.Total, + "used": page.Used, + "remained": page.Remained, + "expire": page.Expire, + "lastOnline": page.LastOnline, + "downloadByte": page.DownloadByte, + "uploadByte": page.UploadByte, + "totalByte": page.TotalByte, + "subUrl": page.SubUrl, + "subJsonUrl": page.SubJsonUrl, + "subClashUrl": page.SubClashUrl, + "subTitle": page.SubTitle, + "subSupportUrl": page.SubSupportUrl, + "links": page.Result, + "emails": page.Emails, + "datepicker": datepicker, } + + // 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 + } + } + } + subDataJSON, err := json.Marshal(subData) if err != nil { subDataJSON = []byte("{}") @@ -243,10 +284,59 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;`) out := bytes.Replace(body, []byte(""), inject, 1) + setNoCacheHeaders(c) + c.Data(http.StatusOK, "text/html; charset=utf-8", out) +} + +// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN +// clients and browsers always fetch fresh traffic/expiry data. +func setNoCacheHeaders(c *gin.Context) { c.Header("Cache-Control", "no-cache, no-store, must-revalidate") c.Header("Pragma", "no-cache") c.Header("Expires", "0") - c.Data(http.StatusOK, "text/html; charset=utf-8", out) +} + +// loadSubTemplate returns the parsed custom subscription template located in +// themeDir, preferring sub.html over index.html. Parsed templates are cached and +// only re-parsed when the underlying file's modification time changes, so admin +// edits are picked up without paying a disk read + HTML parse on every request. +// +// It returns (nil, nil) when themeDir is not a usable directory or contains no +// template file — the caller should fall back to the default page. A non-nil +// error means a template file exists but failed to parse. +func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) { + info, err := os.Stat(themeDir) + if err != nil || !info.IsDir() { + return nil, nil + } + + templatePath := filepath.Join(themeDir, "index.html") + if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil { + templatePath = filepath.Join(themeDir, "sub.html") + } + + fi, err := os.Stat(templatePath) + if err != nil { + return nil, nil + } + modTime := fi.ModTime() + + a.subTemplateMu.RLock() + cached := a.subTemplateCache[templatePath] + a.subTemplateMu.RUnlock() + if cached != nil && cached.modTime.Equal(modTime) { + return cached.tmpl, nil + } + + tmpl, err := template.ParseFiles(templatePath) + if err != nil { + return nil, err + } + + a.subTemplateMu.Lock() + a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime} + a.subTemplateMu.Unlock() + return tmpl, nil } // subJsons handles HTTP requests for JSON subscription configurations. diff --git a/sub/subController_test.go b/sub/subController_test.go new file mode 100644 index 000000000..3e6b5ce94 --- /dev/null +++ b/sub/subController_test.go @@ -0,0 +1,149 @@ +package sub + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +// newTestSUBController builds a controller with just the bits loadSubTemplate +// needs, so the template tests don't require a database. +func newTestSUBController() *SUBController { + return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}} +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string { + t.Helper() + tmpl, err := a.loadSubTemplate(dir) + if err != nil { + t.Fatalf("loadSubTemplate: unexpected error: %v", err) + } + if tmpl == nil { + t.Fatal("loadSubTemplate: expected a template, got nil") + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + t.Fatalf("execute: %v", err) + } + return buf.String() +} + +func TestLoadSubTemplate_RendersIndex(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "index.html"), `

{{ .sId }}

`) + + got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"}) + if want := `

abc-123

`; got != want { + t.Fatalf("rendered = %q, want %q", got, want) + } +} + +func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "index.html"), `from-index`) + writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`) + + got := renderTemplate(t, newTestSUBController(), dir, nil) + if got != "from-sub" { + t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub") + } +} + +func TestLoadSubTemplate_FallbackCases(t *testing.T) { + a := newTestSUBController() + + t.Run("missing dir", func(t *testing.T) { + tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist")) + if tmpl != nil || err != nil { + t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err) + } + }) + + t.Run("path is a file not a dir", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "index.html") + writeFile(t, file, `whatever`) + tmpl, err := a.loadSubTemplate(file) + if tmpl != nil || err != nil { + t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err) + } + }) + + t.Run("dir without template file", func(t *testing.T) { + tmpl, err := a.loadSubTemplate(t.TempDir()) + if tmpl != nil || err != nil { + t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err) + } + }) +} + +func TestLoadSubTemplate_MalformedTemplate(t *testing.T) { + dir := t.TempDir() + // Unterminated action — html/template fails to parse this. + writeFile(t, filepath.Join(dir, "index.html"), `

{{ .sId

`) + + tmpl, err := newTestSUBController().loadSubTemplate(dir) + if err == nil { + t.Fatal("expected a parse error for a malformed template, got nil") + } + if tmpl != nil { + t.Fatalf("expected nil template on parse error, got %v", tmpl) + } +} + +func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) { + a := newTestSUBController() + dir := t.TempDir() + path := filepath.Join(dir, "index.html") + + // v1 with a fixed mtime. + writeFile(t, path, `v1`) + t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := os.Chtimes(path, t1, t1); err != nil { + t.Fatalf("chtimes: %v", err) + } + + first, err := a.loadSubTemplate(dir) + if err != nil || first == nil { + t.Fatalf("first load: (%v, %v)", first, err) + } + + // Same mtime → cache hit returns the identical parsed template. + second, err := a.loadSubTemplate(dir) + if err != nil { + t.Fatalf("second load: %v", err) + } + if second != first { + t.Fatal("expected cache hit to return the same *template.Template pointer") + } + + // New content + newer mtime → cache invalidated, fresh content served. + writeFile(t, path, `v2`) + t2 := t1.Add(time.Hour) + if err := os.Chtimes(path, t2, t2); err != nil { + t.Fatalf("chtimes: %v", err) + } + + third, err := a.loadSubTemplate(dir) + if err != nil || third == nil { + t.Fatalf("third load: (%v, %v)", third, err) + } + if third == first { + t.Fatal("expected cache invalidation to re-parse the template after mtime change") + } + var buf bytes.Buffer + if err := third.Execute(&buf, nil); err != nil { + t.Fatalf("execute: %v", err) + } + if buf.String() != "v2" { + t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2") + } +} diff --git a/sub_templates/README.md b/sub_templates/README.md new file mode 100644 index 000000000..15065c7b0 --- /dev/null +++ b/sub_templates/README.md @@ -0,0 +1,44 @@ +# 3x-ui Custom Subscription Templates + +This directory allows you to use custom HTML templates for your users' subscription pages. + +## How to use a Custom Template + +1. Go to the 3x-ui panel settings. +2. Under **Settings → Subscription → Information**, locate the **Sub Theme Directory** field. +3. Provide the absolute path to the folder containing your template (e.g. `/etc/3x-ui/sub_templates/my-theme/`). +4. Save the settings. + +> **Note:** 3x-ui does not ship any templates by default. Create your own template folder anywhere +> on the server, put an `index.html` (or `sub.html`) inside it, and point **Sub Theme Directory** at +> that absolute path. Leave the field empty to use the default built-in page. + +## Creating a Template + +A custom template must be an HTML file named `index.html` or `sub.html` located within the directory you specified in the settings. +The panel uses standard Go `html/template` to render the subscription page. + +### Available Variables + +When rendering the template, the following variables are injected into the template context (`{{ .variable }}`): + +* `{{ .sId }}`: Subscription ID (UUID). +* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean). +* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB"). +* `{{ .upload }}`: Formatted upload traffic. +* `{{ .total }}`: Formatted total traffic limit. +* `{{ .used }}`: Formatted used traffic (download + upload). +* `{{ .remained }}`: Formatted remaining traffic. +* `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`. +* `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen). +* `{{ .downloadByte }}`: Download traffic in exact bytes (int64). +* `{{ .uploadByte }}`: Upload traffic in exact bytes (int64). +* `{{ .totalByte }}`: Total traffic limit in exact bytes (int64). +* `{{ .subUrl }}`: The URL of the subscription page. +* `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription. +* `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration. +* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty. +* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty. +* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`. +* `{{ .emails }}`: A list (slice) of emails related to the subscription. +* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali"). diff --git a/web/entity/entity.go b/web/entity/entity.go index c220bad6e..f1c05dfb5 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -88,6 +88,7 @@ type AllSetting struct { SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams) + SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` // Absolute path to a folder containing a custom subscription page template // LDAP settings LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` diff --git a/web/service/setting.go b/web/service/setting.go index bb1727731..6348e43a5 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -86,6 +86,7 @@ var defaultValueMap = map[string]string{ "subJsonMux": "", "subJsonRules": "", "subJsonFinalMask": "", + "subThemeDir": "", "datepicker": "gregorian", "warp": "", "nord": "", @@ -699,6 +700,10 @@ func (s *SettingService) GetSubJsonFinalMask() (string, error) { return s.getString("subJsonFinalMask") } +func (s *SettingService) GetSubThemeDir() (string, error) { + return s.getString("subThemeDir") +} + func (s *SettingService) GetDatepicker() (string, error) { return s.getString("datepicker") } @@ -973,6 +978,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { "defaultCert": func() (any, error) { return s.GetCertFile() }, "defaultKey": func() (any, error) { return s.GetKeyFile() }, "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, + "subThemeDir": func() (any, error) { return s.GetSubThemeDir() }, "subEnable": func() (any, error) { return s.GetSubEnable() }, "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, "subClashEnable": func() (any, error) { return s.GetSubClashEnable() }, diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index a7f407cbe..6a80f74c5 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN", "subAnnounce": "إعلان", "subAnnounceDesc": "نص الإعلان المعروض في عميل VPN", + "subThemeDir": "مجلد قالب الاشتراك", + "subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.", "subEnableRouting": "تفعيل التوجيه", "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)", "subRoutingRules": "قواعد التوجيه", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 663322b79..7fef89f32 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -1012,6 +1012,8 @@ "subProfileUrlDesc": "A link to your website displayed in the VPN client", "subAnnounce": "Announce", "subAnnounceDesc": "The announcement text displayed in the VPN client", + "subThemeDir": "Sub Theme Directory", + "subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.", "subEnableRouting": "Enable routing", "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)", "subRoutingRules": "Routing rules", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index e3d1ff646..d68d09fa3 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN", "subAnnounce": "Anuncio", "subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN", + "subThemeDir": "Directorio del tema de suscripción", + "subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.", "subEnableRouting": "Habilitar enrutamiento", "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)", "subRoutingRules": "Reglas de enrutamiento", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index 179a6df0b..eb879b8f6 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود", "subAnnounce": "اعلان", "subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود", + "subThemeDir": "پوشه قالب صفحه اشتراک", + "subThemeDirDesc": "مسیر مطلق پوشه‌ای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیش‌فرض خالی بگذارید.", "subEnableRouting": "فعال‌سازی مسیریابی", "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)", "subRoutingRules": "قوانین مسیریابی", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index 6e68198c7..326040c29 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN", "subAnnounce": "Pengumuman", "subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN", + "subThemeDir": "Direktori Tema Langganan", + "subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.", "subEnableRouting": "Aktifkan perutean", "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)", "subRoutingRules": "Aturan routing", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 8a9e3479c..77a47b221 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク", "subAnnounce": "お知らせ", "subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト", + "subThemeDir": "サブスクリプションテーマディレクトリ", + "subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。", "subEnableRouting": "ルーティングを有効化", "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)", "subRoutingRules": "ルーティングルール", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 9e01ffea2..128d372ef 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN", "subAnnounce": "Anúncio", "subAnnounceDesc": "O texto do anúncio exibido no cliente VPN", + "subThemeDir": "Diretório do tema de assinatura", + "subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.", "subEnableRouting": "Ativar roteamento", "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)", "subRoutingRules": "Regras de roteamento", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index 0bcb4b03f..c6ea26437 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте", "subAnnounce": "Объявление", "subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте", + "subThemeDir": "Каталог темы подписки", + "subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.", "subEnableRouting": "Включить маршрутизацию", "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)", "subRoutingRules": "Правила маршрутизации", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index 99052f515..27372908f 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "VPN istemcisinde görüntülenen web sitenize giden bağlantı", "subAnnounce": "Duyuru", "subAnnounceDesc": "VPN istemcisinde görüntülenen duyuru metni", + "subThemeDir": "Abonelik Tema Dizini", + "subThemeDirDesc": "Abonelik sayfası için özel bir şablon (index.html/sub.html) içeren klasörün mutlak yolu (örn. /etc/3x-ui/sub_templates/my-theme/). Varsayılan sayfayı kullanmak için boş bırakın.", "subEnableRouting": "Yönlendirmeyi etkinleştir", "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)", "subRoutingRules": "Yönlendirme kuralları", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index f24752811..eab9d9263 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті", "subAnnounce": "Оголошення", "subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті", + "subThemeDir": "Каталог теми підписки", + "subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.", "subEnableRouting": "Увімкнути маршрутизацію", "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)", "subRoutingRules": "Правила маршрутизації", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index bd31b2aaa..4b3bef426 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN", "subAnnounce": "Thông báo", "subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN", + "subThemeDir": "Thư mục giao diện Đăng ký", + "subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.", "subEnableRouting": "Bật định tuyến", "subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)", "subRoutingRules": "Quy tắc định tuyến", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 2abb012b8..1eb0940a8 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "VPN 客户端中显示的网站链接", "subAnnounce": "公告", "subAnnounceDesc": "VPN 客户端中显示的公告文本", + "subThemeDir": "订阅主题目录", + "subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。", "subEnableRouting": "启用路由", "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)", "subRoutingRules": "路由規則", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 8c0f78113..811ac2eb4 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -1011,6 +1011,8 @@ "subProfileUrlDesc": "VPN 用戶端中顯示的網站連結", "subAnnounce": "公告", "subAnnounceDesc": "VPN 用戶端中顯示的公告文字", + "subThemeDir": "訂閱主題目錄", + "subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。", "subEnableRouting": "啟用路由", "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)", "subRoutingRules": "路由規則",