fix(ui): serve embedded SPA via ServeContent to avoid index.html redirect loop
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

http.FileServer canonicalizes /index.html -> "./", which for /ui/ produced a
301 redirect loop and made the control room unreachable. Serve embedded files
directly with http.ServeContent instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 15:28:54 +02:00
parent e8e230b4a5
commit 5d02126e16

View File

@@ -1,14 +1,17 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db"
@@ -24,30 +27,38 @@ import (
// uiHandler serves the control-room SPA from assets embedded at build time // uiHandler serves the control-room SPA from assets embedded at build time
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*; // (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
// the /ui prefix is stripped to index into the embedded dist/ tree. // the /ui prefix is stripped to index into the embedded dist/ tree. Files are
// written via http.ServeContent (not http.FileServer) to avoid its
// index.html -> "./" canonical redirect, which loops for /ui/.
func uiHandler() http.Handler { func uiHandler() http.Handler {
dist, err := web.DistFS() dist, err := web.DistFS()
if err != nil { if err != nil {
slog.Warn("ui: embedded assets unavailable", "error", err) slog.Warn("ui: embedded assets unavailable", "error", err)
return http.NotFoundHandler() return http.NotFoundHandler()
} }
fileServer := http.FileServer(http.FS(dist)) serve := func(w http.ResponseWriter, r *http.Request, name string) bool {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f, err := dist.Open(name)
path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/") if err != nil {
if path == "" { return false
path = "index.html"
} }
if f, err := dist.Open(path); err == nil { defer f.Close()
f.Close() data, err := io.ReadAll(f)
r.URL.Path = "/" + path if err != nil {
fileServer.ServeHTTP(w, r) return false
}
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
return true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
if name == "" {
name = "index.html"
}
if serve(w, r, name) {
return return
} }
// SPA fallback: serve index.html for unknown client-side routes. // SPA fallback: serve index.html for unknown client-side routes.
if f, err := dist.Open("index.html"); err == nil { if serve(w, r, "index.html") {
f.Close()
r.URL.Path = "/index.html"
fileServer.ServeHTTP(w, r)
return return
} }
http.NotFound(w, r) http.NotFound(w, r)