diff --git a/frontend/src/pages/sub/SubPage.vue b/frontend/src/pages/sub/SubPage.vue
new file mode 100644
index 00000000..c0e0873c
--- /dev/null
+++ b/frontend/src/pages/sub/SubPage.vue
@@ -0,0 +1,467 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('subscription.title') }}
+ {{ sId }}
+
+
+
+
+
+
+
+ {{ t('pages.settings.language') }}
+
+
+ {{ l.icon }}
+ {{ l.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('pages.settings.subSettings') }}
+
+
+
+
+
+
+ {{ t('pages.settings.subSettings') }} JSON
+
+
+
+
+
+
+
+
+
+
+
+ {{ sId }}
+
+ {{ t('subscription.inactive') }}
+ {{ t('subscription.unlimited') }}
+
+ {{ isActive ? t('subscription.active') : t('subscription.inactive') }}
+
+
+ {{ download }}
+ {{ upload }}
+ {{ used }}
+ {{ total }}
+
+ {{ remained }}
+
+
+ {{ IntlUtil.formatDate(lastOnlineMs) }}
+ -
+
+
+ {{ t('subscription.noExpiry') }}
+ {{ IntlUtil.formatDate(expireMs) }}
+
+
+
+
+
+
+
{{ linkName(link, idx) }}
+
+
+ {{ link }}
+
+
+
+
+
+
+
+
+
+ Android
+
+
+
+ V2Box
+ V2RayNG
+ Sing-box
+ V2RayTun
+ NPV Tunnel
+ Happ
+
+
+
+
+
+
+
+ iOS
+
+
+
+ Shadowrocket
+ V2Box
+ Streisand
+ V2RayTun
+ NPV Tunnel
+ Happ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/subpage.js b/frontend/src/subpage.js
new file mode 100644
index 00000000..159aee1b
--- /dev/null
+++ b/frontend/src/subpage.js
@@ -0,0 +1,18 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+// The sub page is served by the subscription HTTP server (sub/sub.go)
+// at //?html=1. Go injects window.__SUB_PAGE_DATA__
+// with the parsed traffic/quota/expiry view-model and the rendered
+// share links — the SPA reads those at mount.
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import SubPage from '@/pages/sub/SubPage.vue';
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+ message.config({ getContainer: () => messageContainer });
+}
+
+createApp(SubPage).use(Antd).use(i18n).mount('#app');
diff --git a/frontend/subpage.html b/frontend/subpage.html
new file mode 100644
index 00000000..d0af6c7e
--- /dev/null
+++ b/frontend/subpage.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Subscription
+
+
+
+
+
+
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index b1e50e41..1eddd9ef 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -80,6 +80,7 @@ export default defineConfig({
settings: path.resolve(__dirname, 'settings.html'),
inbounds: path.resolve(__dirname, 'inbounds.html'),
xray: path.resolve(__dirname, 'xray.html'),
+ subpage: path.resolve(__dirname, 'subpage.html'),
},
},
},
diff --git a/sub/sub.go b/sub/sub.go
index b940cc95..7a327cc4 100644
--- a/sub/sub.go
+++ b/sub/sub.go
@@ -5,13 +5,11 @@ package sub
import (
"context"
"crypto/tls"
- "html/template"
"io"
"io/fs"
"net"
"net/http"
"os"
- "path/filepath"
"strconv"
"strings"
@@ -26,21 +24,6 @@ import (
"github.com/gin-gonic/gin"
)
-// setEmbeddedTemplates parses and sets embedded templates on the engine
-func setEmbeddedTemplates(engine *gin.Engine) error {
- t, err := template.New("").Funcs(engine.FuncMap).ParseFS(
- webpkg.EmbeddedHTML(),
- "html/common/page.html",
- "html/component/aThemeSwitch.html",
- "html/settings/panel/subscription/subpage.html",
- )
- if err != nil {
- return err
- }
- engine.SetHTMLTemplate(t)
- return nil
-}
-
// Server represents the subscription server that serves subscription links and JSON configurations.
type Server struct {
httpServer *http.Server
@@ -190,45 +173,25 @@ func (s *Server) initRouter() (*gin.Engine, error) {
// set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware())
- // register i18n function similar to web server
- i18nWebFunc := func(key string, params ...string) string {
- return locale.I18n(locale.Web, key, params...)
- }
- engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc})
-
- // Templates: prefer embedded; fallback to disk if necessary
- if err := setEmbeddedTemplates(engine); err != nil {
- logger.Warning("sub: failed to parse embedded templates:", err)
- if files, derr := s.getHtmlFiles(); derr == nil {
- engine.LoadHTMLFiles(files...)
- } else {
- logger.Error("sub: no templates available (embedded parse and disk load failed)", err, derr)
- }
- }
-
- // Assets: use disk if present, fallback to embedded
- // Serve under both root (/assets) and under the subscription path prefix (LinksPath + "assets")
- // so reverse proxies with a URI prefix can load assets correctly.
- // Determine LinksPath earlier to compute prefixed assets mount.
+ // Mount the Vite-built dist/assets/ so the subscription page's JS/CSS
+ // bundles load from `/assets/...`. Also mount the same FS under the
+ // subscription path prefix (LinksPath + "assets") so reverse proxies
+ // running the panel under a URI prefix can resolve those URLs too.
// Note: LinksPath always starts and ends with "/" (validated in settings).
var linksPathForAssets string
if LinksPath == "/" {
linksPathForAssets = "/assets"
} else {
- // ensure single slash join
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
}
- // Mount assets in multiple paths to handle different URL patterns
var assetsFS http.FileSystem
- if _, err := os.Stat("web/assets"); err == nil {
- assetsFS = http.FS(os.DirFS("web/assets"))
+ if _, err := os.Stat("web/dist/assets"); err == nil {
+ assetsFS = http.FS(os.DirFS("web/dist/assets"))
+ } else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil {
+ assetsFS = http.FS(subFS)
} else {
- if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
- assetsFS = http.FS(subFS)
- } else {
- logger.Error("sub: failed to mount embedded assets:", err)
- }
+ logger.Error("sub: failed to mount embedded dist assets:", err)
}
if assetsFS != nil {
@@ -237,19 +200,17 @@ func (s *Server) initRouter() (*gin.Engine, error) {
engine.StaticFS(linksPathForAssets, assetsFS)
}
- // Add middleware to handle dynamic asset paths with subid
+ // Browser may resolve subpage assets relative to the request URL —
+ // /sub///assets/... — so route those to the same FS.
if LinksPath != "/" {
engine.Use(func(c *gin.Context) {
path := c.Request.URL.Path
- // Check if this is an asset request with subid pattern: /sub/path/{subid}/assets/...
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
- // Extract the asset path after /assets/
assetsIndex := strings.Index(path, "/assets/")
if assetsIndex != -1 {
assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
if assetPath != "" {
- // Serve the asset file
c.FileFromFS(assetPath, assetsFS)
c.Abort()
return
@@ -271,30 +232,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return engine, nil
}
-// getHtmlFiles loads templates from local folder (used in debug mode)
-func (s *Server) getHtmlFiles() ([]string, error) {
- dir, _ := os.Getwd()
- files := []string{}
- // common layout
- common := filepath.Join(dir, "web", "html", "common", "page.html")
- if _, err := os.Stat(common); err == nil {
- files = append(files, common)
- }
- // components used
- theme := filepath.Join(dir, "web", "html", "component", "aThemeSwitch.html")
- if _, err := os.Stat(theme); err == nil {
- files = append(files, theme)
- }
- // page itself
- page := filepath.Join(dir, "web", "html", "subpage.html")
- if _, err := os.Stat(page); err == nil {
- files = append(files, page)
- } else {
- return nil, err
- }
- return files, nil
-}
-
// Start initializes and starts the subscription server with configured settings.
func (s *Server) Start() (err error) {
// This is an anonymous function, no function name
diff --git a/sub/subController.go b/sub/subController.go
index a765ef06..c87ea716 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -1,12 +1,15 @@
package sub
import (
+ "bytes"
"encoding/base64"
+ "encoding/json"
"fmt"
+ "net/http"
"strconv"
"strings"
- "github.com/mhsanaei/3x-ui/v2/config"
+ webpkg "github.com/mhsanaei/3x-ui/v2/web"
"github.com/gin-gonic/gin"
)
@@ -110,7 +113,6 @@ func (a *SUBController) subs(c *gin.Context) {
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
accept := c.GetHeader("Accept")
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
- // Build page data in service
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
if !a.jsonEnabled {
subJsonURL = ""
@@ -118,43 +120,13 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.clashEnabled {
subClashURL = ""
}
- // Get base_path from context (set by middleware)
basePath, exists := c.Get("base_path")
if !exists {
basePath = "/"
}
- // Add subId to base_path for asset URLs
basePathStr := basePath.(string)
- if basePathStr == "/" {
- basePathStr = "/" + subId + "/"
- } else {
- // Remove trailing slash if exists, add subId, then add trailing slash
- basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
- }
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
- c.HTML(200, "subpage.html", gin.H{
- "title": "subscription.title",
- "cur_ver": config.GetVersion(),
- "host": page.Host,
- "base_path": page.BasePath,
- "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,
- "datepicker": page.Datepicker,
- "downloadByte": page.DownloadByte,
- "uploadByte": page.UploadByte,
- "totalByte": page.TotalByte,
- "subUrl": page.SubUrl,
- "subJsonUrl": page.SubJsonUrl,
- "subClashUrl": page.SubClashUrl,
- "result": page.Result,
- })
+ a.serveSubPage(c, basePathStr, page)
return
}
@@ -174,6 +146,78 @@ func (a *SUBController) subs(c *gin.Context) {
}
}
+// serveSubPage renders web/dist/subpage.html for the current subscription
+// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
+// we inject that here, along with window.__X_UI_BASE_PATH__ so the
+// page's static asset references resolve correctly when the panel runs
+// behind a URL prefix.
+func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
+ dist := webpkg.EmbeddedDist()
+ body, err := dist.ReadFile("dist/subpage.html")
+ if err != nil {
+ c.String(http.StatusInternalServerError, "missing embedded subpage")
+ return
+ }
+
+ // Vite emits absolute asset URLs (`/assets/...`); when the panel is
+ // installed under a custom URL prefix, rewrite them so the bundle
+ // loads from `assets/...` where the static handler is
+ // actually mounted.
+ if basePath != "/" && basePath != "" {
+ body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
+ body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
+ }
+
+ // JSON-marshal the view-model so the SPA can read it as a plain
+ // object on mount. PageData fields are already in the shape the Vue
+ // component expects, plus a `links` array carrying the rendered
+ // share URLs.
+ 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,
+ }
+ subDataJSON, err := json.Marshal(subData)
+ if err != nil {
+ subDataJSON = []byte("{}")
+ }
+
+ // Defense-in-depth string-escape for the basePath embed — admin-
+ // controlled but cheap to harden.
+ jsEscape := strings.NewReplacer(
+ `\`, `\\`,
+ `"`, `\"`,
+ "\n", `\n`,
+ "\r", `\r`,
+ "<", `<`,
+ ">", `>`,
+ "&", `&`,
+ )
+ escapedBase := jsEscape.Replace(basePath)
+
+ inject := []byte(``)
+ out := bytes.Replace(body, []byte(""), inject, 1)
+
+ 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)
+}
+
// subJsons handles HTTP requests for JSON subscription configurations.
func (a *SUBController) subJsons(c *gin.Context) {
subId := c.Param("subid")
diff --git a/web/html/settings/panel/subscription/subpage.html b/web/html/settings/panel/subscription/subpage.html
deleted file mode 100644
index adfbea93..00000000
--- a/web/html/settings/panel/subscription/subpage.html
+++ /dev/null
@@ -1,288 +0,0 @@
-{{ template "page/head_start" .}}
-
-
-
-
-
-
-
-{{ template "page/head_end" .}}
-
-{{ template "page/body_start" .}}
-
-
-
-
-
-
-
- {{ i18n "subscription.title" }}
- {{ .sId }}
-
-
-
-
-
-
-
- {{ i18n "pages.settings.language"
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ i18n
- "pages.settings.subSettings"}}
-
-
-
-
-
-
-
-
-
-
-
- {{ i18n
- "pages.settings.subSettings"}}
- Json
-
-
-
-
-
-
-
-
-
-
-
- Clash / Mihomo
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- [[
- app.sId
- ]]
-
-
- {{ i18n "subscription.inactive" }}
-
-
- {{ i18n
- "subscription.unlimited" }}
-
-
- [[
- isActive ? '{{ i18n
- "subscription.active" }}' : '{{ i18n
- "subscription.inactive" }}'
- ]]
-
-
- [[
- app.download
- ]]
- [[
- app.upload
- ]]
- [[ app.used
- ]]
- [[
- app.total
- ]]
- [[
- app.remained ]]
-
-
- [[ IntlUtil.formatDate(app.lastOnlineMs) ]]
-
-
- -
-
-
-
-
- {{ i18n "subscription.noExpiry" }}
-
-
- [[ IntlUtil.formatDate(app.expireMs) ]]
-
-
-
-
-
-
-
-
-
-
- [[ linkName(link, idx) ]]
-
-
- [[ link ]]
-
-
-
-
-
-
-
-
-
-
-
-
- Android
-
-
- V2Box
- V2RayNG
- Sing-box
- V2RayTun
- NPV
- Tunnel
- Happ
-
-
-
-
-
-
-
- iOS
-
-
- Shadowrocket
- V2Box
- Streisand
- V2RayTun
- NPV
- Tunnel
-
- Happ
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{{template "component/aThemeSwitch" .}}
-
-
-{{ template "page/body_end" .}}
\ No newline at end of file