dist.go 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. package controller
  2. import (
  3. "bytes"
  4. "embed"
  5. "encoding/json"
  6. htmlpkg "html"
  7. "net/http"
  8. "strings"
  9. "time"
  10. "github.com/gin-gonic/gin"
  11. "github.com/mhsanaei/3x-ui/v3/config"
  12. "github.com/mhsanaei/3x-ui/v3/logger"
  13. "github.com/mhsanaei/3x-ui/v3/web/session"
  14. )
  15. var distFS embed.FS
  16. func SetDistFS(fs embed.FS) {
  17. distFS = fs
  18. }
  19. var distPageBuildTime = time.Now()
  20. // ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
  21. // panel API. Postman / Insomnia / openapi-generator consume this URL
  22. // directly; the in-panel Swagger UI page also fetches it. The spec is
  23. // produced at frontend build time by scripts/build-openapi.mjs and
  24. // embedded into the binary via the dist FS.
  25. func ServeOpenAPISpec(c *gin.Context) {
  26. body, err := distFS.ReadFile("dist/openapi.json")
  27. if err != nil {
  28. c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
  29. return
  30. }
  31. // The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
  32. // under a non-root web base path, Swagger UI "Try it out" and external
  33. // generators must target that prefix, so rewrite the single server entry to
  34. // the runtime base path before serving.
  35. if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
  36. if rebuilt, err := withServerBasePath(body, basePath); err != nil {
  37. logger.Warning("openapi.json: could not inject base path:", err)
  38. } else {
  39. body = rebuilt
  40. }
  41. }
  42. c.Header("Cache-Control", "public, max-age=300")
  43. c.Data(http.StatusOK, "application/json; charset=utf-8", body)
  44. }
  45. // withServerBasePath rewrites the spec's `servers` entry so requests target the
  46. // panel's configured web base path. Only the top-level `servers` field is
  47. // replaced; every other field is preserved verbatim via json.RawMessage.
  48. func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
  49. var doc map[string]json.RawMessage
  50. if err := json.Unmarshal(spec, &doc); err != nil {
  51. return nil, err
  52. }
  53. servers, err := json.Marshal([]map[string]string{{
  54. "url": strings.TrimSuffix(basePath, "/"),
  55. "description": "Current panel",
  56. }})
  57. if err != nil {
  58. return nil, err
  59. }
  60. doc["servers"] = servers
  61. return json.Marshal(doc)
  62. }
  63. func serveDistPage(c *gin.Context, name string) {
  64. body, err := distFS.ReadFile("dist/" + name)
  65. if err != nil {
  66. c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
  67. return
  68. }
  69. basePath := c.GetString("base_path")
  70. if basePath == "" {
  71. basePath = "/"
  72. }
  73. if basePath != "/" {
  74. body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
  75. body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
  76. }
  77. jsEscape := strings.NewReplacer(
  78. `\`, `\\`,
  79. `"`, `\"`,
  80. "\n", `\n`,
  81. "\r", `\r`,
  82. "<", `<`,
  83. ">", `>`,
  84. "&", `&`,
  85. )
  86. escapedBase := jsEscape.Replace(basePath)
  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. basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
  94. nonceAttr := ""
  95. if nonce := c.GetString("csp_nonce"); nonce != "" {
  96. nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
  97. }
  98. script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
  99. if name != "login.html" {
  100. escapedVer := jsEscape.Replace(config.GetVersion())
  101. script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
  102. script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
  103. }
  104. script += `;</script>`
  105. inject := []byte(script)
  106. inject = append(inject, csrfMeta...)
  107. inject = append(inject, basePathMeta...)
  108. inject = append(inject, []byte(`</head>`)...)
  109. out := bytes.Replace(body, []byte("</head>"), inject, 1)
  110. c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
  111. c.Header("Pragma", "no-cache")
  112. c.Header("Expires", "0")
  113. c.Header("Last-Modified", distPageBuildTime.UTC().Format(http.TimeFormat))
  114. c.Data(http.StatusOK, "text/html; charset=utf-8", out)
  115. }