diff --git a/internal/api/frontend_embed.go b/internal/api/frontend_embed.go index 677def6c3..5edf29a54 100644 --- a/internal/api/frontend_embed.go +++ b/internal/api/frontend_embed.go @@ -2,6 +2,7 @@ package api import ( "embed" + "io" "io/fs" "net/http" "strings" @@ -32,15 +33,39 @@ func serveFrontendHandler() http.HandlerFunc { log.Fatal().Err(err).Msg("Failed to get embedded frontend") } - fileServer := http.FileServer(fsys) - return func(w http.ResponseWriter, r *http.Request) { // Clean the path p := r.URL.Path - // Default to index.html for root - if p == "/" { - p = "/index.html" + // Handle root path specially to avoid FileServer's directory redirect + // Issue #334: Serve index.html directly without using FileServer for root + if p == "/" || p == "" { + // Directly serve index.html content + file, err := fsys.Open("index.html") + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + + // Check that it's not a directory + _, err = file.Stat() + if err != nil { + http.NotFound(w, r) + return + } + + // Read the file content + content, err := io.ReadAll(file) + if err != nil { + http.NotFound(w, r) + return + } + + // Serve the content + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(content) + return } // Remove leading slash for filesystem lookup @@ -49,10 +74,33 @@ func serveFrontendHandler() http.HandlerFunc { // Check if file exists in embedded FS file, err := fsys.Open(lookupPath) if err == nil { - file.Close() - // File exists, serve it - fileServer.ServeHTTP(w, r) - return + defer file.Close() + + // Get file info + stat, err := file.Stat() + if err == nil && !stat.IsDir() { + // Read and serve the file + content, err := io.ReadAll(file) + if err == nil { + // Detect content type + contentType := "application/octet-stream" + if strings.HasSuffix(lookupPath, ".html") { + contentType = "text/html; charset=utf-8" + } else if strings.HasSuffix(lookupPath, ".css") { + contentType = "text/css; charset=utf-8" + } else if strings.HasSuffix(lookupPath, ".js") { + contentType = "application/javascript; charset=utf-8" + } else if strings.HasSuffix(lookupPath, ".json") { + contentType = "application/json" + } else if strings.HasSuffix(lookupPath, ".svg") { + contentType = "image/svg+xml" + } + + w.Header().Set("Content-Type", contentType) + w.Write(content) + return + } + } } // For SPA routing, serve index.html for non-API routes @@ -60,9 +108,16 @@ func serveFrontendHandler() http.HandlerFunc { !strings.HasPrefix(p, "/ws") && !strings.HasPrefix(p, "/socket.io/") { // Serve index.html for client-side routing - r.URL.Path = "/index.html" - fileServer.ServeHTTP(w, r) - return + indexFile, err := fsys.Open("index.html") + if err == nil { + defer indexFile.Close() + content, err := io.ReadAll(indexFile) + if err == nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(content) + return + } + } } // Not found diff --git a/internal/api/middleware.go b/internal/api/middleware.go index cd81c201f..b6f94f26d 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -30,6 +30,12 @@ func (e *APIError) Error() string { // ErrorHandler is a middleware that handles panics and errors func ErrorHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Fix for issue #334: Normalize empty path to "/" before ServeMux processes it + // This prevents the automatic redirect from "" to "./" + if r.URL.Path == "" { + r.URL.Path = "/" + } + // Skip error handling for WebSocket endpoints if r.Header.Get("Upgrade") == "websocket" { next.ServeHTTP(w, r) diff --git a/internal/api/router.go b/internal/api/router.go index 761f8a828..aa9d7c6d7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -636,9 +636,8 @@ func (r *Router) setupRoutes() { // Simple stats page r.mux.HandleFunc("/simple-stats", r.handleSimpleStats) - // Serve embedded frontend - log.Info().Msg("Serving embedded frontend") - r.mux.Handle("/", serveFrontendHandler()) + // Note: Frontend handler is handled manually in ServeHTTP to prevent redirect issues + // See issue #334 - ServeMux redirects empty path to "./" which breaks reverse proxies } @@ -658,6 +657,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusOK) return } + // Check if we need authentication needsAuth := true @@ -798,7 +798,24 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Log request start := time.Now() - r.mux.ServeHTTP(w, req) + + // Fix for issue #334: Custom routing to prevent ServeMux's "./" redirect + // When accessing without trailing slash, ServeMux redirects to "./" which is wrong + // We handle routing manually to avoid this issue + + // Check if this is an API or WebSocket route + if strings.HasPrefix(req.URL.Path, "/api/") || + strings.HasPrefix(req.URL.Path, "/ws") || + strings.HasPrefix(req.URL.Path, "/socket.io/") || + req.URL.Path == "/simple-stats" { + // Use the mux for API and special routes + r.mux.ServeHTTP(w, req) + } else { + // Serve frontend for all other paths (including root) + handler := serveFrontendHandler() + handler(w, req) + } + log.Debug(). Str("method", req.Method). Str("path", req.URL.Path).