fix(ui): serve embedded SPA via ServeContent to avoid index.html redirect loop
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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user