mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
feat(server): Phase 8 — cut HTML routes over to web/dist/
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/<name> from the embedded FS and applies two
transforms before sending:
1. injects <script>window.__X_UI_BASE_PATH__="..."</script>
just before </head> so AppSidebar links resolve under the
panel's basePath.
2. when basePath != "/", rewrites Vite's absolute /assets/ URLs
to <basePath>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 <noreply@anthropic.com>
This commit is contained in:
parent
b69cc7a18e
commit
8c8085f985
4 changed files with 154 additions and 7 deletions
92
web/controller/dist.go
Normal file
92
web/controller/dist.go
Normal file
|
|
@ -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/<page>.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/<name>` from the embedded FS and writes it
|
||||||
|
// to the response. Two transforms run before send:
|
||||||
|
//
|
||||||
|
// 1. `<script>window.__X_UI_BASE_PATH__ = "..."</script>` is injected
|
||||||
|
// just before </head> 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(`<script>window.__X_UI_BASE_PATH__="` + escaped + `";</script></head>`)
|
||||||
|
out := bytes.Replace(body, []byte("</head>"), 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)
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,10 @@ func (a *IndexController) index(c *gin.Context) {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "panel/")
|
c.Redirect(http.StatusTemporaryRedirect, "panel/")
|
||||||
return
|
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.
|
// login handles user authentication and session creation.
|
||||||
|
|
|
||||||
|
|
@ -45,24 +45,29 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
a.xraySettingController = NewXraySettingController(g)
|
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.
|
// index renders the main panel index page.
|
||||||
func (a *XUIController) index(c *gin.Context) {
|
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.
|
// inbounds renders the inbounds management page.
|
||||||
func (a *XUIController) inbounds(c *gin.Context) {
|
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.
|
// settings renders the settings management page.
|
||||||
func (a *XUIController) settings(c *gin.Context) {
|
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.
|
// xraySettings renders the Xray settings page.
|
||||||
func (a *XUIController) xraySettings(c *gin.Context) {
|
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.
|
// csrfToken returns the session CSRF token to authenticated SPA clients.
|
||||||
|
|
|
||||||
51
web/web.go
51
web/web.go
|
|
@ -43,8 +43,35 @@ var htmlFS embed.FS
|
||||||
//go:embed translation/*
|
//go:embed translation/*
|
||||||
var i18nFS embed.FS
|
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()
|
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/<hash>.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 {
|
type wrapAssetsFS struct {
|
||||||
embed.FS
|
embed.FS
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +108,13 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time {
|
||||||
return startTime
|
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.
|
// EmbeddedHTML returns the embedded HTML templates filesystem for reuse by other servers.
|
||||||
func EmbeddedHTML() embed.FS {
|
func EmbeddedHTML() embed.FS {
|
||||||
return htmlFS
|
return htmlFS
|
||||||
|
|
@ -265,7 +299,10 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
}
|
}
|
||||||
// Use the registered func map with the loaded templates
|
// Use the registered func map with the loaded templates
|
||||||
engine.LoadHTMLFiles(files...)
|
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 {
|
} else {
|
||||||
// for production
|
// for production
|
||||||
template, err := s.getHtmlTemplate(funcMap)
|
template, err := s.getHtmlTemplate(funcMap)
|
||||||
|
|
@ -273,12 +310,22 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
engine.SetHTMLTemplate(template)
|
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`)
|
// Apply the redirect middleware (`/xui` to `/panel`)
|
||||||
engine.Use(middleware.RedirectMiddleware(basePath))
|
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)
|
g := engine.Group(basePath)
|
||||||
|
|
||||||
s.index = controller.NewIndexController(g)
|
s.index = controller.NewIndexController(g)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue