Bladeren bron

fix(web): serve panel SPA routes from NoRoute (#5536)

* fix(web): serve panel SPA routes from NoRoute

Return the React shell for authenticated panel document routes that are not explicitly registered in Gin, such as /panel/hosts. Keep API, CSRF, static-file, method, and Accept exclusions so API misses remain 404 and auth semantics stay unchanged.

* fix(web): remove unreachable panel path guard

The panel path is always built by appending /panel, so it can never be empty.
Remove the redundant fallback branch without changing SPA routing behavior.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(web): allowlist static-asset extensions in SPA fallback

The blanket path.Ext check rejected any panel route whose last segment contained a dot, which would reintroduce the refresh 404 for a future client route carrying a dotted parameter (version, domain, or email-like value). Restrict the static-asset exclusion to a known, case-insensitive extension allowlist and add predicate regression cases.
w3struk 18 uur geleden
bovenliggende
commit
ae9bbdf267
4 gewijzigde bestanden met toevoegingen van 320 en 7 verwijderingen
  1. 6 6
      internal/web/controller/dist.go
  2. 81 0
      internal/web/controller/spa.go
  3. 228 0
      internal/web/controller/spa_test.go
  4. 5 1
      internal/web/web.go

+ 6 - 6
internal/web/controller/dist.go

@@ -2,9 +2,9 @@ package controller
 
 import (
 	"bytes"
-	"embed"
 	"encoding/json"
 	htmlpkg "html"
+	"io/fs"
 	"net/http"
 	"strings"
 	"time"
@@ -16,10 +16,10 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
 )
 
-var distFS embed.FS
+var distFS fs.FS
 
-func SetDistFS(fs embed.FS) {
-	distFS = fs
+func SetDistFS(fsys fs.FS) {
+	distFS = fsys
 }
 
 var distPageBuildTime = time.Now()
@@ -30,7 +30,7 @@ var distPageBuildTime = time.Now()
 // 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 := distFS.ReadFile("dist/openapi.json")
+	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
@@ -72,7 +72,7 @@ func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
 }
 
 func serveDistPage(c *gin.Context, name string) {
-	body, err := distFS.ReadFile("dist/" + name)
+	body, err := fs.ReadFile(distFS, "dist/"+name)
 	if err != nil {
 		c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
 		return

+ 81 - 0
internal/web/controller/spa.go

@@ -2,6 +2,8 @@ package controller
 
 import (
 	"net/http"
+	"path"
+	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
@@ -57,6 +59,85 @@ func (a *XUIController) panelSPA(c *gin.Context) {
 	serveDistPage(c, "index.html")
 }
 
+// HandleNoRoutePanelSPA serves the React shell for client-side routes that were
+// not explicitly registered in Gin. It intentionally runs from engine.NoRoute
+// instead of a /panel/*path wildcard so explicit JSON/API routes keep their
+// normal routing semantics.
+func (a *XUIController) HandleNoRoutePanelSPA(c *gin.Context) bool {
+	if !isPanelSPAFallbackRequest(c) {
+		return false
+	}
+
+	if !session.IsLogin(c) {
+		if isAjax(c) {
+			pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
+		} else {
+			c.Header("Cache-Control", "no-store")
+			c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
+		}
+		c.Abort()
+		return true
+	}
+
+	a.panelSPA(c)
+	return true
+}
+
+func isPanelSPAFallbackRequest(c *gin.Context) bool {
+	if c.Request.Method != http.MethodGet {
+		return false
+	}
+	if !acceptsHTML(c.GetHeader("Accept")) {
+		return false
+	}
+
+	basePath := c.GetString("base_path")
+	if basePath == "" {
+		basePath = "/"
+	}
+	panelPath := strings.TrimRight(basePath, "/") + "/panel"
+
+	reqPath := c.Request.URL.Path
+	if reqPath != panelPath && !strings.HasPrefix(reqPath, panelPath+"/") {
+		return false
+	}
+
+	if reqPath == panelPath+"/csrf-token" || strings.HasPrefix(reqPath, panelPath+"/csrf-token/") {
+		return false
+	}
+	if reqPath == panelPath+"/api" || strings.HasPrefix(reqPath, panelPath+"/api/") {
+		return false
+	}
+	if isStaticAssetPath(reqPath) {
+		return false
+	}
+	return true
+}
+
+var staticAssetExts = map[string]struct{}{
+	".js": {}, ".mjs": {}, ".cjs": {}, ".css": {}, ".map": {}, ".json": {},
+	".png": {}, ".jpg": {}, ".jpeg": {}, ".gif": {}, ".svg": {}, ".ico": {},
+	".webp": {}, ".avif": {}, ".woff": {}, ".woff2": {}, ".ttf": {}, ".eot": {},
+	".otf": {}, ".wasm": {}, ".txt": {}, ".xml": {}, ".webmanifest": {},
+}
+
+func isStaticAssetPath(reqPath string) bool {
+	ext := strings.ToLower(path.Ext(reqPath))
+	if ext == "" {
+		return false
+	}
+	_, ok := staticAssetExts[ext]
+	return ok
+}
+
+func acceptsHTML(accept string) bool {
+	if accept == "" {
+		return true
+	}
+	accept = strings.ToLower(accept)
+	return strings.Contains(accept, "text/html") || strings.Contains(accept, "*/*")
+}
+
 // csrfToken returns the session CSRF token to authenticated SPA clients.
 // The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
 // but checkLogin still gates the response — anonymous callers get 401/redirect.

+ 228 - 0
internal/web/controller/spa_test.go

@@ -0,0 +1,228 @@
+package controller
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"testing/fstest"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-contrib/sessions/cookie"
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
+)
+
+func newSPAFallbackTestEngine(t *testing.T) *gin.Engine {
+	return newSPAFallbackTestEngineWithBasePath(t, "/admin-random/")
+}
+
+func newSPAFallbackTestEngineWithBasePath(t *testing.T, basePath string) *gin.Engine {
+	t.Helper()
+	gin.SetMode(gin.TestMode)
+
+	oldDistFS := distFS
+	SetDistFS(fstest.MapFS{
+		"dist/index.html": {Data: []byte(`<!doctype html><html><head></head><body>spa shell</body></html>`)},
+	})
+	t.Cleanup(func() { SetDistFS(oldDistFS) })
+
+	engine := gin.New()
+	engine.Use(sessions.Sessions("3x-ui", cookie.NewStore([]byte("spa-fallback-test-secret"))))
+	engine.Use(func(c *gin.Context) {
+		c.Set("base_path", basePath)
+		c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
+		if c.GetHeader("X-Test-Login") == "1" {
+			session.SetAPIAuthUser(c, &model.User{Id: 1, Username: "test"})
+		}
+		c.Next()
+	})
+
+	ctrl := NewXUIController(engine.Group(basePath))
+	engine.NoRoute(func(c *gin.Context) {
+		if ctrl.HandleNoRoutePanelSPA(c) {
+			return
+		}
+		c.AbortWithStatus(http.StatusNotFound)
+	})
+	return engine
+}
+
+func TestPanelSPAFallbackServesRootBasePath(t *testing.T) {
+	engine := newSPAFallbackTestEngineWithBasePath(t, "/")
+	req := httptest.NewRequest(http.MethodGet, "/panel/hosts", nil)
+	req.Header.Set("Accept", "text/html")
+	req.Header.Set("X-Test-Login", "1")
+	w := httptest.NewRecorder()
+
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+	}
+	if !strings.Contains(w.Body.String(), "spa shell") {
+		t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
+	}
+}
+
+func TestPanelSPAFallbackServesAuthenticatedClientRoutes(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	for _, target := range []string{
+		"/admin-random/panel/hosts",
+		"/admin-random/panel/some/future/route",
+	} {
+		t.Run(target, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, target, nil)
+			req.Header.Set("Accept", "text/html")
+			req.Header.Set("X-Test-Login", "1")
+			w := httptest.NewRecorder()
+
+			engine.ServeHTTP(w, req)
+
+			if w.Code != http.StatusOK {
+				t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+			}
+			if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
+				t.Fatalf("Content-Type = %q, want text/html", ct)
+			}
+			if !strings.Contains(w.Body.String(), "spa shell") {
+				t.Fatalf("body does not contain SPA shell: %s", w.Body.String())
+			}
+		})
+	}
+}
+
+func TestPanelSPAFallbackPreservesAuthSemantics(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	t.Run("browser redirects to login", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
+		req.Header.Set("Accept", "text/html")
+		w := httptest.NewRecorder()
+
+		engine.ServeHTTP(w, req)
+
+		if w.Code != http.StatusTemporaryRedirect {
+			t.Fatalf("status = %d, want 307", w.Code)
+		}
+		if loc := w.Header().Get("Location"); loc != "/admin-random/" {
+			t.Fatalf("Location = %q, want /admin-random/", loc)
+		}
+	})
+
+	t.Run("ajax gets json unauthorized", func(t *testing.T) {
+		req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/hosts", nil)
+		req.Header.Set("Accept", "text/html")
+		req.Header.Set("X-Requested-With", "XMLHttpRequest")
+		w := httptest.NewRecorder()
+
+		engine.ServeHTTP(w, req)
+
+		if w.Code != http.StatusUnauthorized {
+			t.Fatalf("status = %d, want 401", w.Code)
+		}
+		if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
+			t.Fatalf("Content-Type = %q, want application/json", ct)
+		}
+	})
+}
+
+func TestPanelSPAFallbackExclusions(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	for _, tc := range []struct {
+		target string
+		want   int
+	}{
+		{target: "/admin-random/panel/api", want: http.StatusNotFound},
+		{target: "/admin-random/panel/api/unknown", want: http.StatusNotFound},
+		{target: "/admin-random/panel/csrf-token/", want: http.StatusMovedPermanently},
+		{target: "/admin-random/panel/missing.js", want: http.StatusNotFound},
+	} {
+		t.Run(tc.target, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, tc.target, nil)
+			req.Header.Set("Accept", "text/html")
+			req.Header.Set("X-Test-Login", "1")
+			w := httptest.NewRecorder()
+
+			engine.ServeHTTP(w, req)
+
+			if w.Code != tc.want {
+				t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String())
+			}
+			if strings.Contains(w.Body.String(), "spa shell") {
+				t.Fatalf("excluded route was served by SPA fallback: %s", w.Body.String())
+			}
+		})
+	}
+}
+
+func TestPanelCSRFTokenRemainsExplicit(t *testing.T) {
+	engine := newSPAFallbackTestEngine(t)
+
+	req := httptest.NewRequest(http.MethodGet, "/admin-random/panel/csrf-token", nil)
+	req.Header.Set("Accept", "text/html")
+	req.Header.Set("X-Test-Login", "1")
+	w := httptest.NewRecorder()
+
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+	}
+	if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
+		t.Fatalf("Content-Type = %q, want application/json", ct)
+	}
+	if strings.Contains(w.Body.String(), "spa shell") {
+		t.Fatalf("csrf-token was served by SPA fallback: %s", w.Body.String())
+	}
+}
+
+func TestPanelSPAFallbackPredicate(t *testing.T) {
+	oldDistFS := distFS
+	SetDistFS(fstest.MapFS{})
+	t.Cleanup(func() { SetDistFS(oldDistFS) })
+
+	cases := []struct {
+		name   string
+		method string
+		path   string
+		accept string
+		want   bool
+	}{
+		{name: "panel root", method: http.MethodGet, path: "/admin-random/panel", accept: "text/html", want: true},
+		{name: "panel descendant", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "*/*", want: true},
+		{name: "empty accept", method: http.MethodGet, path: "/admin-random/panel/future", want: true},
+		{name: "post excluded", method: http.MethodPost, path: "/admin-random/panel/hosts", accept: "text/html"},
+		{name: "json accept excluded", method: http.MethodGet, path: "/admin-random/panel/hosts", accept: "application/json"},
+		{name: "api root excluded", method: http.MethodGet, path: "/admin-random/panel/api", accept: "text/html"},
+		{name: "api descendant excluded", method: http.MethodGet, path: "/admin-random/panel/api/unknown", accept: "text/html"},
+		{name: "csrf excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token", accept: "text/html"},
+		{name: "csrf descendant excluded", method: http.MethodGet, path: "/admin-random/panel/csrf-token/", accept: "text/html"},
+		{name: "file excluded", method: http.MethodGet, path: "/admin-random/panel/missing.css", accept: "text/html"},
+		{name: "outside panel excluded", method: http.MethodGet, path: "/admin-random/hosts", accept: "text/html"},
+		{name: "dotted email route param served", method: http.MethodGet, path: "/admin-random/panel/clients/[email protected]", accept: "text/html", want: true},
+		{name: "dotted version route param served", method: http.MethodGet, path: "/admin-random/panel/sub/1.2.3", accept: "text/html", want: true},
+		{name: "uppercase asset extension excluded", method: http.MethodGet, path: "/admin-random/panel/app.JS", accept: "text/html"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			c, _ := gin.CreateTestContext(httptest.NewRecorder())
+			c.Set("base_path", "/admin-random/")
+			req := httptest.NewRequest(tc.method, tc.path, nil)
+			if tc.accept != "" {
+				req.Header.Set("Accept", tc.accept)
+			}
+			c.Request = req
+
+			if got := isPanelSPAFallbackRequest(c); got != tc.want {
+				t.Fatalf("isPanelSPAFallbackRequest() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}

+ 5 - 1
internal/web/web.go

@@ -264,8 +264,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		c.JSON(http.StatusOK, gin.H{})
 	})
 
-	// Add a catch-all route to handle undefined paths and return 404
+	// Let unknown panel document routes fall back to the SPA shell, while every
+	// non-SPA miss still returns a hard 404.
 	engine.NoRoute(func(c *gin.Context) {
+		if s.panel.HandleNoRoutePanelSPA(c) {
+			return
+		}
 		c.AbortWithStatus(http.StatusNotFound)
 	})