From 8c8085f985858336229311f447a2f29af7ab099f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 8 May 2026 14:39:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20Phase=208=20=E2=80=94=20cut=20H?= =?UTF-8?q?TML=20routes=20over=20to=20web/dist/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production cutover. Every user-facing HTML route now serves the Vue-3-built bundle from web/dist/ instead of rendering the legacy Go template; the long-hashed Vite assets are served at /assets/ from the same embedded filesystem. The legacy templates in web/html/ and the legacy static tree in web/assets/ are kept on disk for now in case a quick revert is needed, but nothing the binary serves references them. What changed: - web.go: a new //go:embed dist/* feeds the controller package via a SetDistFS hand-off before controller construction. The static /assets/ route is rebound: in dev to web/dist/assets/ on disk so Vite's incremental rebuilds show up live; in prod to the embedded dist via wrapDistFS (rooted one level deeper than wrapAssetsFS). - controller/dist.go: serveDistPage helper used by every HTML handler. Reads dist/ from the embedded FS and applies two transforms before sending: 1. injects just before so AppSidebar links resolve under the panel's basePath. 2. when basePath != "/", rewrites Vite's absolute /assets/ URLs to assets/ so installs running under a custom URL prefix load the bundle where the static handler lives. HTML responses go out with no-cache so panel upgrades reach users on the next refresh; hashed JS/CSS stays cacheable. - controller/index.go: IndexController.index now serves dist/login.html for logged-out callers (the redirect for logged-in users is unchanged). - controller/xui.go: XUIController.{index,inbounds,settings,xraySettings} each become a one-line wrapper around serveDistPage. Smoke checklist for the maintainer: - run `cd frontend && npm run build` to refresh web/dist/ before building the Go binary (the embed snapshot is taken at compile time); - visit /panel/, /panel/inbounds, /panel/settings, /panel/xray and confirm each loads its Vue page; - log out and log back in to verify the login flow; - confirm the sidebar links navigate correctly under your install's basePath; - POST flows (e.g. saving settings) still need the CSRF token — that endpoint (/panel/csrf-token, added earlier) is unchanged. Co-Authored-By: Claude Opus 4.7 --- web/controller/dist.go | 92 +++++++++++++++++++++++++++++++++++++++++ web/controller/index.go | 5 ++- web/controller/xui.go | 13 ++++-- web/web.go | 51 ++++++++++++++++++++++- 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 web/controller/dist.go diff --git a/web/controller/dist.go b/web/controller/dist.go new file mode 100644 index 00000000..b2592757 --- /dev/null +++ b/web/controller/dist.go @@ -0,0 +1,92 @@ +package controller + +import ( + "bytes" + "embed" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// distFS is filled in once at startup by the web package via SetDistFS. +// It holds the Vite-built frontend (the `dist/.html` files) so +// the panel's HTML routes can serve them in production. +// +// We can't `go:embed` the dist directory directly from this package +// because embed.FS only accepts paths relative to the source file — +// dist/ lives one directory up. The web package owns the embed and +// hands the FS to us through this setter. +var distFS embed.FS + +// SetDistFS is called once during server bootstrap by the web package +// to hand off the embedded `dist/` filesystem. +func SetDistFS(fs embed.FS) { + distFS = fs +} + +// distPageBuildTime is captured at startup so every served HTML page +// reports a stable Last-Modified header and the browser's conditional +// GETs can hit the 304 path on repeat loads. +var distPageBuildTime = time.Now() + +// serveDistPage reads `dist/` from the embedded FS and writes it +// to the response. Two transforms run before send: +// +// 1. `` is injected +// just before so the AppSidebar's link generator sees the +// right prefix. +// 2. Absolute Vite-emitted asset URLs (`/assets/...`) are rewritten +// to include the panel's basePath, so installs running under a +// custom URL prefix (e.g. `/myprefix/`) load the bundle from +// `/myprefix/assets/...` where the static handler actually lives. +// +// The HTML responses are served with no-cache so a panel update +// reaches users on the next reload; the long-hashed JS/CSS files +// under /assets/ stay cacheable indefinitely. +func serveDistPage(c *gin.Context, name string) { + body, err := distFS.ReadFile("dist/" + name) + if err != nil { + c.String(http.StatusInternalServerError, "missing embedded page: %s", name) + return + } + + basePath := c.GetString("base_path") + if basePath == "" { + basePath = "/" + } + + // Rewrite asset URLs only when basePath isn't the root — for the + // default `/` install, Vite's `/assets/...` already resolves + // correctly and we save the byte churn. + if basePath != "/" { + // Vite emits these three attribute shapes for every entry's + // JS / CSS / modulepreload reference. Anchoring the search to + // the leading attribute name avoids matching unrelated /assets + // substrings inside any inlined script. + body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`)) + body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`)) + } + + // Escape just enough that a hostile basePath setting can't break + // out of the JS string literal. The setting is admin-controlled + // but defense-in-depth costs nothing here. + escaped := strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + "\n", `\n`, + "\r", `\r`, + "<", `<`, + ">", `>`, + "&", `&`, + ).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.Header("Last-Modified", distPageBuildTime.UTC().Format(http.TimeFormat)) + c.Data(http.StatusOK, "text/html; charset=utf-8", out) +} diff --git a/web/controller/index.go b/web/controller/index.go index d3c58da8..de470c0c 100644 --- a/web/controller/index.go +++ b/web/controller/index.go @@ -51,7 +51,10 @@ func (a *IndexController) index(c *gin.Context) { c.Redirect(http.StatusTemporaryRedirect, "panel/") return } - html(c, "login.html", "pages.login.title", nil) + // Phase 8 cutover — serve the Vite-built login page directly. + // The legacy template still exists in web/html/login.html but is + // no longer referenced by any served route. + serveDistPage(c, "login.html") } // login handles user authentication and session creation. diff --git a/web/controller/xui.go b/web/controller/xui.go index 7c2648c2..1e7d2e74 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -45,24 +45,29 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { a.xraySettingController = NewXraySettingController(g) } +// All four panel pages now serve the Vue 3 builds from web/dist/ +// instead of rendering the legacy Go templates. Each handler is a +// thin wrapper around serveDistPage so the basePath injection + +// no-cache headers stay centralised. + // index renders the main panel index page. func (a *XUIController) index(c *gin.Context) { - html(c, "index.html", "pages.index.title", nil) + serveDistPage(c, "index.html") } // inbounds renders the inbounds management page. func (a *XUIController) inbounds(c *gin.Context) { - html(c, "inbounds.html", "pages.inbounds.title", nil) + serveDistPage(c, "inbounds.html") } // settings renders the settings management page. func (a *XUIController) settings(c *gin.Context) { - html(c, "settings.html", "pages.settings.title", nil) + serveDistPage(c, "settings.html") } // xraySettings renders the Xray settings page. func (a *XUIController) xraySettings(c *gin.Context) { - html(c, "xray.html", "pages.xray.title", nil) + serveDistPage(c, "xray.html") } // csrfToken returns the session CSRF token to authenticated SPA clients. diff --git a/web/web.go b/web/web.go index 7d634e70..d20348a2 100644 --- a/web/web.go +++ b/web/web.go @@ -43,8 +43,35 @@ var htmlFS embed.FS //go:embed translation/* var i18nFS embed.FS +// distFS embeds the Vite-built frontend (web/dist/). All five user- +// facing pages are served from here in production; the legacy +// templates in htmlFS / static asset tree in assetsFS are kept on +// disk for now so a quick revert is possible, but nothing the +// binary serves references them after the cutover. +// +//go:embed dist/* +var distFS embed.FS + var startTime = time.Now() +// wrapDistFS adapts the embedded `dist/` directory so it can be mounted +// as the panel's `/assets/` static route. Vite emits its bundled JS/CSS +// under `dist/assets/`; serving the FS rooted at `dist/assets` makes +// `/assets/.js` URLs resolve directly. +type wrapDistFS struct { + embed.FS +} + +func (f *wrapDistFS) Open(name string) (fs.File, error) { + file, err := f.FS.Open("dist/assets/" + name) + if err != nil { + return nil, err + } + return &wrapAssetsFile{ + File: file, + }, nil +} + type wrapAssetsFS struct { embed.FS } @@ -81,6 +108,13 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time { return startTime } +// EmbeddedDist returns the embedded Vite-built frontend filesystem. +// Controllers serve their HTML out of this FS via the dist-page handler +// installed in NewEngine(). +func EmbeddedDist() embed.FS { + return distFS +} + // EmbeddedHTML returns the embedded HTML templates filesystem for reuse by other servers. func EmbeddedHTML() embed.FS { return htmlFS @@ -265,7 +299,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { } // Use the registered func map with the loaded templates engine.LoadHTMLFiles(files...) - engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets"))) + // In dev the bundled `web/dist/assets/` directory is served from + // disk so the Vite watcher's incremental rebuilds show up + // without restarting the binary. + engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/dist/assets"))) } else { // for production template, err := s.getHtmlTemplate(funcMap) @@ -273,12 +310,22 @@ func (s *Server) initRouter() (*gin.Engine, error) { return nil, err } engine.SetHTMLTemplate(template) - engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: assetsFS})) + // `/assets/` now serves the Vite-built bundle. The legacy + // `web/assets/` tree is no longer referenced by any served + // page (every user-facing route comes from web/dist/), so + // the embedded asset filesystem is rooted at dist/assets/. + engine.StaticFS(basePath+"assets", http.FS(&wrapDistFS{FS: distFS})) } // Apply the redirect middleware (`/xui` to `/panel`) engine.Use(middleware.RedirectMiddleware(basePath)) + // Hand the embedded `dist/` filesystem to the controller package + // before any HTML-serving controller is constructed. Phase 8 + // cutover: every HTML route reads from web/dist/ instead of + // rendering a legacy template. + controller.SetDistFS(distFS) + g := engine.Group(basePath) s.index = controller.NewIndexController(g)