mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-12 15:46:06 +00:00
e6bef229ae
Behind Cloudflare with Rocket Loader enabled, the panel's entry bundles were rewritten and executed through Rocket Loader's own loader instead of as native ES modules (a reporter's network capture shows the main bundle initiated by rocket-loader.min.js). That breaks module semantics and script ordering, leaving a blank page after login even though every asset returns 200 - most visibly with a custom URI path, where the injected base path must be set before the bundle boots. Stamp data-cfasync="false" - Cloudflare's documented per-script opt-out - on the built entry script tags via a build-time transformIndexHtml hook (Vite regenerates entry tags, so a source-HTML attribute would be stripped), and on the runtime-injected base-path/version inline script in serveDistPage. Closes #5868
132 lines
4.1 KiB
Go
132 lines
4.1 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
htmlpkg "html"
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
|
)
|
|
|
|
var distFS fs.FS
|
|
|
|
func SetDistFS(fsys fs.FS) {
|
|
distFS = fsys
|
|
}
|
|
|
|
var distPageBuildTime = time.Now()
|
|
|
|
// ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
|
|
// panel API. Postman / Insomnia / openapi-generator consume this URL
|
|
// directly; the in-panel Swagger UI page also fetches it. The spec is
|
|
// produced at frontend build time by scripts/build-openapi.mjs and
|
|
// embedded into the binary via the dist FS.
|
|
func ServeOpenAPISpec(c *gin.Context) {
|
|
body, err := fs.ReadFile(distFS, "dist/openapi.json")
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
|
return
|
|
}
|
|
|
|
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
|
|
// under a non-root web base path, Swagger UI "Try it out" and external
|
|
// generators must target that prefix, so rewrite the single server entry to
|
|
// the runtime base path before serving.
|
|
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
|
|
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
|
|
logger.Warning("openapi.json: could not inject base path:", err)
|
|
} else {
|
|
body = rebuilt
|
|
}
|
|
}
|
|
|
|
c.Header("Cache-Control", "public, max-age=300")
|
|
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
|
}
|
|
|
|
// withServerBasePath rewrites the spec's `servers` entry so requests target the
|
|
// panel's configured web base path. Only the top-level `servers` field is
|
|
// replaced; every other field is preserved verbatim via json.RawMessage.
|
|
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
|
|
var doc map[string]json.RawMessage
|
|
if err := json.Unmarshal(spec, &doc); err != nil {
|
|
return nil, err
|
|
}
|
|
servers, err := json.Marshal([]map[string]string{{
|
|
"url": strings.TrimSuffix(basePath, "/"),
|
|
"description": "Current panel",
|
|
}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc["servers"] = servers
|
|
return json.Marshal(doc)
|
|
}
|
|
|
|
func serveDistPage(c *gin.Context, name string) {
|
|
body, err := fs.ReadFile(distFS, "dist/"+name)
|
|
if err != nil {
|
|
c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
|
|
return
|
|
}
|
|
|
|
basePath := c.GetString("base_path")
|
|
if basePath == "" {
|
|
basePath = "/"
|
|
}
|
|
|
|
if basePath != "/" {
|
|
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
|
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
|
}
|
|
|
|
jsEscape := strings.NewReplacer(
|
|
`\`, `\\`,
|
|
`"`, `\"`,
|
|
"\n", `\n`,
|
|
"\r", `\r`,
|
|
"<", `<`,
|
|
">", `>`,
|
|
"&", `&`,
|
|
)
|
|
escapedBase := jsEscape.Replace(basePath)
|
|
csrfToken, err := session.EnsureCSRFToken(c)
|
|
if err != nil {
|
|
logger.Warning("Unable to mint CSRF token for", name+":", err)
|
|
csrfToken = ""
|
|
}
|
|
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
|
basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
|
|
|
|
nonceAttr := ""
|
|
if nonce := c.GetString("csp_nonce"); nonce != "" {
|
|
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
|
|
}
|
|
script := `<script data-cfasync="false"` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
|
if name != "login.html" {
|
|
escapedVer := jsEscape.Replace(config.GetPanelVersion())
|
|
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
|
script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
|
|
}
|
|
script += `;</script>`
|
|
inject := []byte(script)
|
|
inject = append(inject, csrfMeta...)
|
|
inject = append(inject, basePathMeta...)
|
|
inject = append(inject, []byte(`</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)
|
|
}
|