Skip to content

Commit 5cb64cc

Browse files
authored
fix: admin portal redirect loop — serve index.html directly (#96)
Go's http.FileServer treats requests for /index.html as a directory index and returns 301 Location: ./ — which resolves back to /admin/, creating an infinite redirect loop. The SPA fallback was routing through FileServer after setting r.URL.Path = "/index.html", triggering this behavior on every request. Pre-read index.html into memory at startup and serve it directly with w.Write() for the SPA fallback path. Requests for /index.html are also routed through the direct-serve path to avoid the same redirect. Static assets (JS, CSS) still use http.FileServer for proper Content-Type and caching headers. Extract newSPAHandler(fs.FS) so tests use a synthetic filesystem and achieve full coverage regardless of whether the frontend was built.
1 parent 3f895ad commit 5cb64cc

File tree

2 files changed

+100
-33
lines changed

2 files changed

+100
-33
lines changed

internal/adminui/embed.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,30 +35,42 @@ func Handler() http.Handler {
3535
// Should never happen with a valid embed directive.
3636
return http.NotFoundHandler()
3737
}
38-
fileServer := http.FileServer(http.FS(sub))
38+
return newSPAHandler(sub)
39+
}
40+
41+
// newSPAHandler builds an SPA handler from the given filesystem. Exported via
42+
// Handler() for production use; called directly in tests with a synthetic FS.
43+
func newSPAHandler(root fs.FS) http.Handler {
44+
fileServer := http.FileServer(http.FS(root))
45+
46+
// Pre-read index.html so the SPA fallback can serve it directly.
47+
// Routing through http.FileServer with path="/index.html" causes a
48+
// 301 redirect loop: FileServer treats index.html as a directory
49+
// index and redirects to "./" which resolves back to the same URL.
50+
indexHTML, _ := fs.ReadFile(root, "index.html")
3951

4052
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4153
// Clean the path: strip leading slash for fs lookup
4254
path := strings.TrimPrefix(r.URL.Path, "/")
4355

44-
// If the path points to an actual file, serve it directly
45-
if path != "" {
46-
if f, err := sub.Open(path); err == nil {
56+
// If the path points to an actual file (other than index.html),
57+
// serve it via FileServer for proper Content-Type and caching.
58+
// index.html is excluded because FileServer redirects it to "./"
59+
// which causes a redirect loop when mounted under a prefix.
60+
if path != "" && path != "index.html" {
61+
if f, err := root.Open(path); err == nil {
4762
_ = f.Close()
4863
fileServer.ServeHTTP(w, r)
4964
return
5065
}
5166
}
5267

53-
// SPA fallback: serve index.html for unmatched routes
54-
// If index.html doesn't exist (empty dist), return 404
55-
f, err := sub.Open("index.html")
56-
if err != nil {
68+
// SPA fallback: serve index.html directly for unmatched routes.
69+
if indexHTML == nil {
5770
http.NotFound(w, r)
5871
return
5972
}
60-
_ = f.Close()
61-
r.URL.Path = "/index.html"
62-
fileServer.ServeHTTP(w, r)
73+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
74+
_, _ = w.Write(indexHTML)
6375
})
6476
}

internal/adminui/embed_test.go

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"testing"
7+
"testing/fstest"
78

89
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
911
)
1012

11-
// These tests handle two valid states:
12-
// - Clean checkout / CI / make verify: dist has only .gitkeep → Available() = false
13-
// - After make frontend-build: dist has built assets → Available() = true
14-
//
15-
// make verify runs embed-clean first, so the CI path is always exercised.
13+
// testFS builds a synthetic in-memory filesystem for testing the SPA handler
14+
// independent of whether the real frontend was built.
15+
func testFS() fstest.MapFS {
16+
return fstest.MapFS{
17+
"index.html": {Data: []byte("<html>SPA</html>")},
18+
"assets/app.js": {Data: []byte("console.log('app')")},
19+
"assets/style.css": {Data: []byte("body{}")},
20+
}
21+
}
1622

1723
func TestAvailable(t *testing.T) {
1824
// Available() returns whether built frontend assets are embedded.
@@ -29,33 +35,82 @@ func TestHandler_ReturnsHandler(t *testing.T) {
2935
assert.NotNil(t, h)
3036
}
3137

32-
func TestHandler_Root(t *testing.T) {
33-
h := Handler()
38+
func TestSPAHandler_Root_ServesIndexHTML(t *testing.T) {
39+
h := newSPAHandler(testFS())
3440

3541
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
3642
rec := httptest.NewRecorder()
3743
h.ServeHTTP(rec, req)
3844

39-
if Available() {
40-
// SPA fallback rewrites to /index.html; http.FileServer redirects
41-
// /index.html back to ./ (Go hides the default index file).
42-
assert.Equal(t, http.StatusMovedPermanently, rec.Code)
43-
} else {
44-
// No index.html in dist — SPA fallback returns 404.
45-
assert.Equal(t, http.StatusNotFound, rec.Code)
46-
}
45+
assert.Equal(t, http.StatusOK, rec.Code)
46+
assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
47+
assert.Contains(t, rec.Body.String(), "<html>SPA</html>")
4748
}
4849

49-
func TestHandler_SPAFallback(t *testing.T) {
50-
h := Handler()
50+
func TestSPAHandler_SPAFallback_ServesIndexHTML(t *testing.T) {
51+
h := newSPAHandler(testFS())
5152

5253
req := httptest.NewRequest(http.MethodGet, "/dashboard", http.NoBody)
5354
rec := httptest.NewRecorder()
5455
h.ServeHTTP(rec, req)
5556

56-
if Available() {
57-
assert.Equal(t, http.StatusMovedPermanently, rec.Code)
58-
} else {
59-
assert.Equal(t, http.StatusNotFound, rec.Code)
60-
}
57+
assert.Equal(t, http.StatusOK, rec.Code)
58+
assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
59+
assert.Contains(t, rec.Body.String(), "<html>SPA</html>")
60+
}
61+
62+
func TestSPAHandler_StaticAsset_ServedByFileServer(t *testing.T) {
63+
h := newSPAHandler(testFS())
64+
65+
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", http.NoBody)
66+
rec := httptest.NewRecorder()
67+
h.ServeHTTP(rec, req)
68+
69+
assert.Equal(t, http.StatusOK, rec.Code)
70+
assert.Contains(t, rec.Body.String(), "console.log")
71+
}
72+
73+
func TestSPAHandler_CSSAsset(t *testing.T) {
74+
h := newSPAHandler(testFS())
75+
76+
req := httptest.NewRequest(http.MethodGet, "/assets/style.css", http.NoBody)
77+
rec := httptest.NewRecorder()
78+
h.ServeHTTP(rec, req)
79+
80+
assert.Equal(t, http.StatusOK, rec.Code)
81+
assert.Contains(t, rec.Body.String(), "body{}")
82+
}
83+
84+
func TestSPAHandler_NoIndexHTML_Returns404(t *testing.T) {
85+
emptyFS := fstest.MapFS{}
86+
h := newSPAHandler(emptyFS)
87+
88+
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
89+
rec := httptest.NewRecorder()
90+
h.ServeHTTP(rec, req)
91+
92+
assert.Equal(t, http.StatusNotFound, rec.Code)
93+
}
94+
95+
func TestSPAHandler_NoRedirectLoop(t *testing.T) {
96+
h := newSPAHandler(testFS())
97+
98+
// The exact bug: /index.html must NOT produce a 301
99+
req := httptest.NewRequest(http.MethodGet, "/index.html", http.NoBody)
100+
rec := httptest.NewRecorder()
101+
h.ServeHTTP(rec, req)
102+
103+
// /index.html is a real file, so FileServer serves it (200), not redirect
104+
require.Equal(t, http.StatusOK, rec.Code, "index.html must not redirect")
105+
}
106+
107+
func TestSPAHandler_NestedUnknownRoute_Fallback(t *testing.T) {
108+
h := newSPAHandler(testFS())
109+
110+
req := httptest.NewRequest(http.MethodGet, "/settings/profile", http.NoBody)
111+
rec := httptest.NewRecorder()
112+
h.ServeHTTP(rec, req)
113+
114+
assert.Equal(t, http.StatusOK, rec.Code)
115+
assert.Contains(t, rec.Body.String(), "<html>SPA</html>")
61116
}

0 commit comments

Comments
 (0)