From abf6b8799e63d622cebe7d0d7dade9131d2646e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:04:47 +0200 Subject: [PATCH] feat: customizable subscription page templates (#5079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add support for subscription-based outbounds with auto-update - New OutboundSubscription model (full support on both SQLite and PostgreSQL) - Go subscription link parser (vmess/vless/trojan/ss/hysteria2/wireguard) matching frontend behavior - Stable tag assignment across refreshes (designed for balancer + routing use) - Runtime merge of subscription outbounds into Xray config (additive only) - Full CRUD + manual refresh + preview API - Background auto-update job (per-subscription interval) - Frontend management UI in Outbounds tab (Subscriptions drawer) + tag integration in balancers/routing rules - Proper dual-database support including CLI migration path Review & hardening notes: - Fixed merge logic bug that could drop manual outbounds - Added SSRF/private-IP protection on subscription URLs using SanitizePublicHTTPURL - Improved update interval UX (hours + minutes) - Auto-fetch on first subscription creation - Added detailed comments on tag stability strategy and balancer implications when servers are added/removed/rotated - Updated migrationModels() for CLI migrate-db support * fix: resolve frontend lint/type errors and Go build break Frontend (eslint + tsc clean): - Destructure subscriptionOutboundTags prop in RoutingTab and BalancersTab. It was declared in the interface and used in useMemo but never destructured, so it resolved as an unresolved global (react-hooks warning + tsc "Cannot find name"). The prop is passed by XrayPage, so the feature was silently inert. - OutboundsTab: remove unused useEffect import, add an OutboundSub type to replace any[] state and the any/any table render signature, type the subscriptionOutbounds cast, and replace unused catch (e) bindings with parameter-less catch. Also type HttpUtil.post as OutboundSub so r.obj?.id type-checks. Backend (go build clean): - outbound_subscription_job: websocket.MessageTypeXray is undefined; use the existing MessageTypeOutbounds since the job refreshes outbound subscriptions. * fix(xray): make outbound subscription creation work end-to-end - Correct API paths from /panel/xray/outbound-subs to /panel/api/xray/outbound-subs. The controller is mounted under /panel/api, so the old paths hit the SPA page route (GET-only) and 404'd on POST. - Send the create-subscription body as a plain object instead of URLSearchParams. The axios request interceptor serializes bodies with qs.stringify, which can't read URLSearchParams' internal storage and produced an empty body, so the backend rejected it with "subscription URL is required". - Use message.useMessage() + context holder instead of the static antd message API (resolves the "Static function can not consume context" warning), matching XrayPage's pattern. - Migrate the subscriptions Drawer to antd v6 props: width -> size, destroyOnClose -> destroyOnHidden, and Space direction -> orientation. * feat(xray): show traffic/test for subscription outbounds; harden + test the feature Display (the reported issue): - Replace the flat read-only pills with a proper read-only table (desktop) and cards (mobile) in a new SubscriptionOutbounds component, showing Address, Protocol, Traffic (matched by tag — already collected by Xray), and a Test button with Latency. No edit/delete/move (read-only). - Test subscription outbounds via the existing /testOutbound endpoint, with results keyed by tag (subscriptionTestStates + testSubscriptionOutbound in useXraySetting, wired through XrayPage). Generalize isTesting/testResult to a string|number key so the same helpers serve index- and tag-keyed states. i18n: - Replace all hardcoded English subscription strings with t() calls and add pages.xray.outboundSub.* keys to en-US.json (other locales fall back). Backend hardening + tests: - xray.go: drop the tautological `subSvc != nil` check. - outbound_subscription: re-validate every redirect hop against private/ internal addresses (CheckRedirect) and cap the redirect chain, closing an SSRF gap where only the initial host was checked. - Extract assignStableTags as a pure function and add unit tests for tag stability and SSRF rejection (the feature previously had no tests). Misc: - gofmt util/link/outbound.go (it was not gofmt-clean). * fix(xray): make outbound-subs feature pass CI (test compile, route docs, openapi) - outbound_test.go: remove unused `inner`/`lines` variables that broke the `util/link` test build (declared and not used). - Document the 7 outbound-subscription routes in endpoints.ts (list, create, update, delete, del alias, refresh, parse) so TestAPIRoutesDocumented passes. - Regenerate frontend/public/openapi.json (npm run gen) to include the new endpoints, satisfying the codegen freshness check. * feat(xray): per-subscription allow-private, gap-filled tags, UI tweaks, delete refresh Backend: - Add a per-subscription AllowPrivate flag (default off). Create/Update/refresh and the redirect check sanitize the URL with it, so localhost/LAN sources work only when explicitly opted in; the SSRF guard still blocks private targets by default. Controller reads the allowPrivate form field on create/update/parse. - Default outbound tag prefix now uses the smallest free "subN-" number instead of the auto-increment id, so deleting a subscription frees its number for reuse (a fresh start gives sub1) while staying stable per subscription. Extracted a pure defaultPrefixNumber() with unit tests. - deleteOutboundSub now signals SetToNeedRestart so xray drops the outbounds. Frontend: - "Allow private address" toggle in the add form (sends allowPrivate). - Delete now refreshes the xray view immediately (no manual page reload). - Subscriptions manager opens as a centered Modal instead of a right-side Drawer. - Move Outbounds to a top-level sidebar item under Nodes (out of Xray Configs). - Collapse WARP/NordVPN into a "more" dropdown. - Document the allowPrivate param in endpoints.ts. * i18n(xray): translate outbound-subscription UI into all locales - Translate the pages.xray.outboundSub.* strings (and allowPrivate label/hint) into all 12 non-English locales, matching each file's existing terminology. - Remove the unused outboundSub.add ("Add subscription") key from every locale. * feat: add custom subscription page template support Allow panel admins to use a custom HTML template for the subscription page instead of the default React-based SPA. Changes ------- Backend - web/service/setting.go: Add subThemeDir setting (default: empty) with a getter GetSubThemeDir(). - web/entity/entity.go: Add SubThemeDir field to AllSetting. - sub/subController.go: In serveSubPage, before falling back to the embedded SPA, check if subThemeDir is set and the directory exists. Look for sub.html first, then index.html. Parse with Go html/template and execute, injecting all standard page variables as template context. On any parse/execute error, log and fall through to the default page. Two backward-compat aliases added to the template data map: - result = links (for tx-ui v2 templates using {{ range .result }}) - jsonUrl = subJsonUrl Frontend - frontend/src/models/setting.ts: Add subThemeDir = '' to AllSetting. - frontend/src/pages/settings/SubscriptionGeneralTab.tsx: Add a Sub Theme Directory input in Subscription settings. Templates - sub_templates/README.md: Full authoring guide with all variables. - sub_templates/tx-ui/index.html: The tx-ui subscription page template migrated from v2 to v3 data shape. Credits ------- Bundled tx-ui template from AghayeCoder: https://github.com/AghayeCoder/tx-ui * chore: regenerate OpenAPI schemas and types for custom sub-template feature * feat(xray): subscription manager — edit, reorder/priority, status, preview, refresh-all Backend: - Per-subscription Priority + Prepend: subscriptions are ordered by Priority and placed before (Prepend) or after the manual template outbounds in the merge, so a subscription server can become the default. New Move(up/down) endpoint re-normalizes priorities; merge split into prepend/template/append. - List now returns a derived OutboundCount and orders by priority, and strips the heavy LastFetchedOutbounds/LinkIdentities blobs from the list payload. - Create/Update accept the prepend flag; new subs append at the end of priority. Frontend (Outbound Subscriptions modal): - Edit existing subscriptions (reuses the form + Update endpoint). - Inline enable/disable Switch, Status column (OK / error tooltip), Outbounds count column, per-row refresh spinner, "Refresh all" button. - Reorder (move up/down) controls + a "Before manual outbounds" toggle. - Preview button: fetch+parse a URL via /parse without saving. - Document the move route + prepend param in endpoints.ts; regenerate openapi.json. * i18n(xray): translate new subscription-manager strings into all locales Add the prepend/prependHint, preview/previewEmpty, refreshAll, statusOk and toastUpdated keys to all 12 non-English locales, matching each file's terminology. * refactor(sub): harden custom template rendering, drop bundled tx-ui template Builds on the custom subscription page template feature. Rendering hardening (sub/subController.go): - Render the custom template into a buffer and only write the response on success. Previously template.Execute wrote straight to the ResponseWriter, so a mid-render failure left a partially-written body and then fell through to the default page, corrupting the response (superfluous WriteHeader). - Cache parsed templates keyed by path, invalidated by file mtime, so each subscription page load no longer re-reads and re-parses the file from disk; admin edits are still picked up automatically. - Verify the configured path is a directory (IsDir) and log a Warning when it is set but unusable / an Error when a template fails to parse, instead of silently falling back. - Expose two new template variables: subTitle and subSupportUrl. Cleanup: - Remove the bundled tx-ui template and all tx-ui / AghayeCoder references (including the result/jsonUrl v2-compat aliases); use a generic my-theme example path in docs/UI/translation. - i18n the "Sub Theme Directory" setting (en-US subThemeDir/subThemeDirDesc) instead of hardcoded English. - Fix README: expire is seconds (not ms), lastOnline is ms; correct the settings tab name; note templates are admin-provided, not bundled/deployed. Tests: - Add sub/subController_test.go covering loadSubTemplate: render, sub.html precedence, fallback cases, malformed template, and mtime cache invalidation. Verified end-to-end in Docker: custom template renders with all variables, all fallback paths return the clean default page (no corruption), and the mtime cache reflects live edits. * i18n(settings): translate subThemeDir into all locales Add the subThemeDir / subThemeDirDesc keys (Sub Theme Directory setting) to all 12 non-English locales, matching each file's existing terminology. They previously fell back to en-US. --------- Co-authored-by: MHSanaei Co-authored-by: Rqzbeh --- frontend/public/openapi.json | 10 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 10 ++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/models/setting.ts | 1 + .../pages/settings/SubscriptionGeneralTab.tsx | 5 + sub/subController.go | 132 +++++++++++++--- sub/subController_test.go | 149 ++++++++++++++++++ sub_templates/README.md | 44 ++++++ web/entity/entity.go | 1 + web/service/setting.go | 6 + web/translation/ar-EG.json | 2 + web/translation/en-US.json | 2 + web/translation/es-ES.json | 2 + web/translation/fa-IR.json | 2 + web/translation/id-ID.json | 2 + web/translation/ja-JP.json | 2 + web/translation/pt-BR.json | 2 + web/translation/ru-RU.json | 2 + web/translation/tr-TR.json | 2 + web/translation/uk-UA.json | 2 + web/translation/vi-VN.json | 2 + web/translation/zh-CN.json | 2 + web/translation/zh-TW.json | 2 + 25 files changed, 369 insertions(+), 21 deletions(-) create mode 100644 sub/subController_test.go create mode 100644 sub_templates/README.md 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": "路由規則",