1
0

dist.go 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. package controller
  2. import (
  3. "bytes"
  4. "embed"
  5. htmlpkg "html"
  6. "net/http"
  7. "strings"
  8. "time"
  9. "github.com/gin-gonic/gin"
  10. "github.com/mhsanaei/3x-ui/v2/config"
  11. "github.com/mhsanaei/3x-ui/v2/logger"
  12. "github.com/mhsanaei/3x-ui/v2/web/session"
  13. )
  14. // distFS is filled in once at startup by the web package via SetDistFS.
  15. // It holds the Vite-built frontend (the `dist/<page>.html` files) so
  16. // the panel's HTML routes can serve them in production.
  17. //
  18. // We can't `go:embed` the dist directory directly from this package
  19. // because embed.FS only accepts paths relative to the source file —
  20. // dist/ lives one directory up. The web package owns the embed and
  21. // hands the FS to us through this setter.
  22. var distFS embed.FS
  23. // SetDistFS is called once during server bootstrap by the web package
  24. // to hand off the embedded `dist/` filesystem.
  25. func SetDistFS(fs embed.FS) {
  26. distFS = fs
  27. }
  28. // distPageBuildTime is captured at startup so every served HTML page
  29. // reports a stable Last-Modified header and the browser's conditional
  30. // GETs can hit the 304 path on repeat loads.
  31. var distPageBuildTime = time.Now()
  32. // serveDistPage reads `dist/<name>` from the embedded FS and writes it
  33. // to the response. Two transforms run before send:
  34. //
  35. // 1. `<script>window.__X_UI_BASE_PATH__ = "..."</script>` is injected
  36. // just before </head> so the AppSidebar's link generator sees the
  37. // right prefix.
  38. // 2. Absolute Vite-emitted asset URLs (`/assets/...`) are rewritten
  39. // to include the panel's basePath, so installs running under a
  40. // custom URL prefix (e.g. `/myprefix/`) load the bundle from
  41. // `/myprefix/assets/...` where the static handler actually lives.
  42. //
  43. // The HTML responses are served with no-cache so a panel update
  44. // reaches users on the next reload; the long-hashed JS/CSS files
  45. // under /assets/ stay cacheable indefinitely.
  46. func serveDistPage(c *gin.Context, name string) {
  47. body, err := distFS.ReadFile("dist/" + name)
  48. if err != nil {
  49. c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
  50. return
  51. }
  52. basePath := c.GetString("base_path")
  53. if basePath == "" {
  54. basePath = "/"
  55. }
  56. // Rewrite asset URLs only when basePath isn't the root — for the
  57. // default `/` install, Vite's `/assets/...` already resolves
  58. // correctly and we save the byte churn.
  59. if basePath != "/" {
  60. // Vite emits these three attribute shapes for every entry's
  61. // JS / CSS / modulepreload reference. Anchoring the search to
  62. // the leading attribute name avoids matching unrelated /assets
  63. // substrings inside any inlined script.
  64. body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
  65. body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
  66. }
  67. // Escape just enough that a hostile basePath setting can't break
  68. // out of the JS string literal. The setting is admin-controlled
  69. // but defense-in-depth costs nothing here.
  70. jsEscape := strings.NewReplacer(
  71. `\`, `\\`,
  72. `"`, `\"`,
  73. "\n", `\n`,
  74. "\r", `\r`,
  75. "<", `<`,
  76. ">", `>`,
  77. "&", `&`,
  78. )
  79. escapedBase := jsEscape.Replace(basePath)
  80. escapedVer := jsEscape.Replace(config.GetVersion())
  81. // Embed a CSRF token in the served HTML the same way the legacy
  82. // templates did via `<meta name="csrf-token">`. Without this the
  83. // SPA login page has no way to acquire a token (the existing
  84. // /panel/csrf-token endpoint sits behind checkLogin), and POST
  85. // /login is rejected by CSRFMiddleware. EnsureCSRFToken creates
  86. // a session token on first call even for anonymous visitors.
  87. csrfToken, err := session.EnsureCSRFToken(c)
  88. if err != nil {
  89. logger.Warning("Unable to mint CSRF token for", name+":", err)
  90. csrfToken = ""
  91. }
  92. csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
  93. inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase +
  94. `";window.__X_UI_CUR_VER__="` + escapedVer + `";</script>`)
  95. inject = append(inject, csrfMeta...)
  96. inject = append(inject, []byte(`</head>`)...)
  97. out := bytes.Replace(body, []byte("</head>"), inject, 1)
  98. c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
  99. c.Header("Pragma", "no-cache")
  100. c.Header("Expires", "0")
  101. c.Header("Last-Modified", distPageBuildTime.UTC().Format(http.TimeFormat))
  102. c.Data(http.StatusOK, "text/html; charset=utf-8", out)
  103. }