fix: prevent 301 redirect to relative path (./) when accessing root without trailing slash (addresses #334)

- Replaced http.FileServer with custom file serving to avoid automatic directory redirects
- Manually serve index.html for root path requests
- Custom routing bypasses ServeMux for frontend files to prevent redirect behavior
- This fixes reverse proxy and Cloudflare tunnel compatibility issues
This commit is contained in:
Pulse Monitor
2025-08-19 18:56:18 +00:00
parent fa48a54aa0
commit d9d7c4e5ff
3 changed files with 94 additions and 16 deletions
+67 -12
View File
@@ -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
+6
View File
@@ -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)
+21 -4
View File
@@ -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).