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 @@ + + + + + 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 - "pages.settings.subSettings"}} - - - - - - - - - - - - {{ i18n - "pages.settings.subSettings"}} - Json - - - - - - - - - - - - Clash / Mihomo - - - - - - - - - - - - - - - [[ - app.sId - ]] - - - - - - [[ - app.download - ]] - [[ - app.upload - ]] - [[ app.used - ]] - [[ - app.total - ]] - [[ - app.remained ]] - - - - - - - - - - - - -
-
-
- - [[ linkName(link, idx) ]] - - -
-
-
- - - - - - - - - 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