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)