spa.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. package controller
  2. import (
  3. "net/http"
  4. "path"
  5. "strings"
  6. "github.com/mhsanaei/3x-ui/v3/internal/web/entity"
  7. "github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
  8. "github.com/mhsanaei/3x-ui/v3/internal/web/session"
  9. "github.com/gin-gonic/gin"
  10. )
  11. // XUIController is the main controller for the X-UI panel, serving the SPA shell.
  12. type XUIController struct {
  13. BaseController
  14. }
  15. // NewXUIController creates a new XUIController and initializes its routes.
  16. func NewXUIController(g *gin.RouterGroup) *XUIController {
  17. a := &XUIController{}
  18. a.initRouter(g)
  19. return a
  20. }
  21. // initRouter sets up the main panel routes and initializes sub-controllers.
  22. //
  23. // The HTML routes all hand the same single-page-app shell (index.html) to the
  24. // browser; React Router takes over and renders the correct page from the URL.
  25. // The /panel/api, /panel/setting, /panel/xray sub-routers register POST/JSON
  26. // endpoints on different paths and stay untouched by the shell handler.
  27. func (a *XUIController) initRouter(g *gin.RouterGroup) {
  28. g = g.Group("/panel")
  29. g.Use(a.checkLogin)
  30. g.Use(middleware.CSRFMiddleware())
  31. g.GET("/", a.panelSPA)
  32. g.GET("/inbounds", a.panelSPA)
  33. g.GET("/clients", a.panelSPA)
  34. g.GET("/groups", a.panelSPA)
  35. g.GET("/nodes", a.panelSPA)
  36. g.GET("/settings", a.panelSPA)
  37. g.GET("/xray", a.panelSPA)
  38. g.GET("/outbound", a.panelSPA)
  39. g.GET("/routing", a.panelSPA)
  40. g.GET("/api-docs", a.panelSPA)
  41. // SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
  42. // so they fetch the session token via this endpoint at startup and replay it
  43. // on subsequent unsafe requests through axios.
  44. g.GET("/csrf-token", a.csrfToken)
  45. }
  46. // panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
  47. // API endpoint returns the same index.html — React Router reads the URL and
  48. // mounts the matching page on the client.
  49. func (a *XUIController) panelSPA(c *gin.Context) {
  50. serveDistPage(c, "index.html")
  51. }
  52. // HandleNoRoutePanelSPA serves the React shell for client-side routes that were
  53. // not explicitly registered in Gin. It intentionally runs from engine.NoRoute
  54. // instead of a /panel/*path wildcard so explicit JSON/API routes keep their
  55. // normal routing semantics.
  56. func (a *XUIController) HandleNoRoutePanelSPA(c *gin.Context) bool {
  57. if !isPanelSPAFallbackRequest(c) {
  58. return false
  59. }
  60. if !session.IsLogin(c) {
  61. if isAjax(c) {
  62. pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
  63. } else {
  64. c.Header("Cache-Control", "no-store")
  65. c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
  66. }
  67. c.Abort()
  68. return true
  69. }
  70. a.panelSPA(c)
  71. return true
  72. }
  73. func isPanelSPAFallbackRequest(c *gin.Context) bool {
  74. if c.Request.Method != http.MethodGet {
  75. return false
  76. }
  77. if !acceptsHTML(c.GetHeader("Accept")) {
  78. return false
  79. }
  80. basePath := c.GetString("base_path")
  81. if basePath == "" {
  82. basePath = "/"
  83. }
  84. panelPath := strings.TrimRight(basePath, "/") + "/panel"
  85. reqPath := c.Request.URL.Path
  86. if reqPath != panelPath && !strings.HasPrefix(reqPath, panelPath+"/") {
  87. return false
  88. }
  89. if reqPath == panelPath+"/csrf-token" || strings.HasPrefix(reqPath, panelPath+"/csrf-token/") {
  90. return false
  91. }
  92. if reqPath == panelPath+"/api" || strings.HasPrefix(reqPath, panelPath+"/api/") {
  93. return false
  94. }
  95. if isStaticAssetPath(reqPath) {
  96. return false
  97. }
  98. return true
  99. }
  100. var staticAssetExts = map[string]struct{}{
  101. ".js": {}, ".mjs": {}, ".cjs": {}, ".css": {}, ".map": {}, ".json": {},
  102. ".png": {}, ".jpg": {}, ".jpeg": {}, ".gif": {}, ".svg": {}, ".ico": {},
  103. ".webp": {}, ".avif": {}, ".woff": {}, ".woff2": {}, ".ttf": {}, ".eot": {},
  104. ".otf": {}, ".wasm": {}, ".txt": {}, ".xml": {}, ".webmanifest": {},
  105. }
  106. func isStaticAssetPath(reqPath string) bool {
  107. ext := strings.ToLower(path.Ext(reqPath))
  108. if ext == "" {
  109. return false
  110. }
  111. _, ok := staticAssetExts[ext]
  112. return ok
  113. }
  114. func acceptsHTML(accept string) bool {
  115. if accept == "" {
  116. return true
  117. }
  118. accept = strings.ToLower(accept)
  119. return strings.Contains(accept, "text/html") || strings.Contains(accept, "*/*")
  120. }
  121. // csrfToken returns the session CSRF token to authenticated SPA clients.
  122. // The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
  123. // but checkLogin still gates the response — anonymous callers get 401/redirect.
  124. func (a *XUIController) csrfToken(c *gin.Context) {
  125. token, err := session.EnsureCSRFToken(c)
  126. if err != nil {
  127. c.JSON(http.StatusInternalServerError, entity.Msg{Success: false, Msg: err.Error()})
  128. return
  129. }
  130. c.JSON(http.StatusOK, entity.Msg{Success: true, Obj: token})
  131. }