diff --git a/cmd/server/main.go b/cmd/server/main.go index 33bf6ce..7535257 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -149,8 +149,17 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) // serveSPA serves the React build artifacts and falls back to index.html for // non-API, non-asset paths so client-side routing keeps working. func serveSPA(r *gin.Engine, webDir string) { - r.Static("/assets", filepath.Join(webDir, "assets")) - r.StaticFile("/favicon.ico", filepath.Join(webDir, "favicon.ico")) + assets := r.Group("/assets") + assets.Use(func(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Next() + }) + assets.Static("/", filepath.Join(webDir, "assets")) + for _, icon := range []string{"/favicon.ico", "/favicon.svg"} { + iconPath := filepath.Join(webDir, strings.TrimPrefix(icon, "/")) + r.GET(icon, serveNoCacheFile(iconPath)) + r.HEAD(icon, serveNoCacheFile(iconPath)) + } r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path // Do not swallow API / Emby compatibility routes; clients expect JSON @@ -159,10 +168,36 @@ func serveSPA(r *gin.Engine, webDir string) { c.Status(http.StatusNotFound) return } - c.File(filepath.Join(webDir, "index.html")) + serveSPAIndex(c, filepath.Join(webDir, "index.html")) }) } +func serveNoCacheFile(filePath string) gin.HandlerFunc { + return func(c *gin.Context) { + setNoCacheHeaders(c) + if _, err := os.Stat(filePath); err != nil { + c.Status(http.StatusNotFound) + return + } + c.File(filePath) + } +} + +func serveSPAIndex(c *gin.Context, indexPath string) { + setNoCacheHeaders(c) + if _, err := os.Stat(indexPath); err != nil { + c.String(http.StatusNotFound, "MediaStationGo web UI not found: %s", indexPath) + return + } + c.File(indexPath) +} + +func setNoCacheHeaders(c *gin.Context) { + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") +} + func shouldBypassSPAFallback(path string) bool { lower := strings.ToLower(path) for _, prefix := range []string{ diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..0472eaf --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestServeSPANoCachesIndexAndServesRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + webDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("
"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(webDir, "favicon.svg"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + router := gin.New() + serveSPA(router, webDir) + + for _, path := range []string{"/", "/login", "/media/abc"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("%s status = %d, want 200", path, w.Code) + } + if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") { + t.Fatalf("%s Cache-Control = %q, want no-store", path, got) + } + if !strings.Contains(w.Body.String(), "root") { + t.Fatalf("%s did not serve index.html: %q", path, w.Body.String()) + } + } +} + +func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + webDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("index"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil { + t.Fatal(err) + } + + router := gin.New() + serveSPA(router, webDir) + + assetReq := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil) + assetResp := httptest.NewRecorder() + router.ServeHTTP(assetResp, assetReq) + if assetResp.Code != http.StatusOK { + t.Fatalf("asset status = %d, want 200", assetResp.Code) + } + if got := assetResp.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") { + t.Fatalf("asset Cache-Control = %q, want immutable", got) + } + + apiReq := httptest.NewRequest(http.MethodGet, "/api/missing", nil) + apiResp := httptest.NewRecorder() + router.ServeHTTP(apiResp, apiReq) + if apiResp.Code != http.StatusNotFound { + t.Fatalf("api fallback status = %d, want 404", apiResp.Code) + } + if strings.Contains(apiResp.Body.String(), "index") { + t.Fatalf("api route should not serve SPA index: %q", apiResp.Body.String()) + } +} + +func TestServeSPAMissingIndexReportsExplicit404(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + serveSPA(router, t.TempDir()) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } + if !strings.Contains(w.Body.String(), "web UI not found") { + t.Fatalf("body = %q, want explicit missing UI message", w.Body.String()) + } +}