1
0
Эх сурвалжийг харах

feat: customizable subscription page templates (#5079)

* 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 <[email protected]>
Co-authored-by: Rqzbeh <[email protected]>
Rouzbeh† 9 цаг өмнө
parent
commit
abf6b8799e

+ 10 - 0
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",

+ 2 - 0
frontend/src/generated/examples.ts

@@ -56,6 +56,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subRoutingRules": "",
     "subShowInfo": false,
     "subSupportUrl": "",
+    "subThemeDir": "",
     "subTitle": "",
     "subURI": "",
     "subUpdates": 0,
@@ -143,6 +144,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "subRoutingRules": "",
     "subShowInfo": false,
     "subSupportUrl": "",
+    "subThemeDir": "",
     "subTitle": "",
     "subURI": "",
     "subUpdates": 0,

+ 10 - 0
frontend/src/generated/schemas.ts

@@ -217,6 +217,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "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<string, unknown> = {
       "subRoutingRules",
       "subShowInfo",
       "subSupportUrl",
+      "subThemeDir",
       "subTitle",
       "subURI",
       "subUpdates",
@@ -640,6 +645,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "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<string, unknown> = {
       "subRoutingRules",
       "subShowInfo",
       "subSupportUrl",
+      "subThemeDir",
       "subTitle",
       "subURI",
       "subUpdates",

+ 2 - 0
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;

+ 2 - 0
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),

+ 1 - 0
frontend/src/models/setting.ts

@@ -60,6 +60,7 @@ export class AllSetting {
   subJsonMux = '';
   subJsonRules = '';
   subJsonFinalMask = '';
+  subThemeDir = '';
 
   timeLocation = 'Local';
 

+ 5 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -157,6 +157,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
                 onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
             </SettingListItem>
 
+            <SettingListItem paddings="small" title={t('pages.settings.subThemeDir')} description={t('pages.settings.subThemeDirDesc')}>
+              <Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
+                onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
+            </SettingListItem>
+
             <Divider>Happ</Divider>
 
             <SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>

+ 111 - 21
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) + `;</script></head>`)
 	out := bytes.Replace(body, []byte("</head>"), 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.

+ 149 - 0
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"), `<h1>{{ .sId }}</h1>`)
+
+	got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
+	if want := `<h1>abc-123</h1>`; 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"), `<h1>{{ .sId </h1>`)
+
+	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")
+	}
+}

+ 44 - 0
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").

+ 1 - 0
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"`

+ 6 - 0
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() },

+ 2 - 0
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": "قواعد التوجيه",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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": "قوانین مسیریابی",

+ 2 - 0
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",

+ 2 - 0
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": "ルーティングルール",

+ 2 - 0
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",

+ 2 - 0
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": "Правила маршрутизации",

+ 2 - 0
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ı",

+ 2 - 0
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": "Правила маршрутизації",

+ 2 - 0
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",

+ 2 - 0
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": "路由規則",

+ 2 - 0
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": "路由規則",