diff --git a/internal/webui/handler.go b/internal/webui/handler.go index ade79e4..da9649d 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -55,6 +55,45 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound) } +// staticContentTypes pins the Content-Type of common WebUI assets so we do not +// rely on mime.TypeByExtension, which on Windows consults the registry and can +// return the wrong type (e.g. application/xml for .css) when third-party +// software has overwritten HKEY_CLASSES_ROOT entries. Browsers strictly enforce +// stylesheet/script MIME types and will refuse to apply a misidentified asset, +// breaking the /admin page on affected machines. +var staticContentTypes = map[string]string{ + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".htm": "text/html; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".txt": "text/plain; charset=utf-8", + ".wasm": "application/wasm", +} + +// setStaticContentType pins the response Content-Type by file extension so that +// http.ServeFile does not fall back to mime.TypeByExtension (which on Windows +// reads the registry and may return an incorrect type). +func setStaticContentType(w http.ResponseWriter, fullPath string) { + ext := strings.ToLower(filepath.Ext(fullPath)) + if ct, ok := staticContentTypes[ext]; ok { + w.Header().Set("Content-Type", ct) + } +} + func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) { path := strings.TrimPrefix(r.URL.Path, "/admin") path = strings.TrimPrefix(path, "/") @@ -70,6 +109,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi } else { w.Header().Set("Cache-Control", "no-store, must-revalidate") } + setStaticContentType(w, full) http.ServeFile(w, r, full) return } @@ -82,6 +122,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi return } w.Header().Set("Cache-Control", "no-store, must-revalidate") + setStaticContentType(w, index) http.ServeFile(w, r, index) } diff --git a/internal/webui/handler_test.go b/internal/webui/handler_test.go new file mode 100644 index 0000000..99832c5 --- /dev/null +++ b/internal/webui/handler_test.go @@ -0,0 +1,102 @@ +package webui + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestServeFromDiskPinsContentType ensures static admin assets are returned +// with an explicit, RFC-compliant Content-Type that does not depend on +// mime.TypeByExtension. On Windows mime.TypeByExtension consults the registry +// (HKEY_CLASSES_ROOT) which third-party software can corrupt — for example +// installing certain editors rewrites .css to application/xml — and Chrome +// then refuses to apply a stylesheet whose Content-Type is not text/css, +// breaking the /admin page entirely. Pinning the type by file extension makes +// the response deterministic across operating systems and machine state. +func TestServeFromDiskPinsContentType(t *testing.T) { + staticDir := t.TempDir() + assetsDir := filepath.Join(staticDir, "assets") + if err := os.MkdirAll(assetsDir, 0o755); err != nil { + t.Fatalf("mkdir assets: %v", err) + } + + files := map[string]string{ + "index.html": "", + "assets/index.css": "body{}", + "assets/index.js": "console.log(1)", + "assets/icon.svg": ``, + "assets/source.js.map": `{"version":3}`, + } + for rel, body := range files { + full := filepath.Join(staticDir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + h := &Handler{StaticDir: staticDir} + + cases := []struct { + urlPath string + wantPrefix string + wantCacheCtl string + }{ + {"/admin/assets/index.css", "text/css", "public, max-age=31536000, immutable"}, + {"/admin/assets/index.js", "text/javascript", "public, max-age=31536000, immutable"}, + {"/admin/assets/icon.svg", "image/svg+xml", "public, max-age=31536000, immutable"}, + {"/admin/assets/source.js.map", "application/json", "public, max-age=31536000, immutable"}, + // "/admin/index.html" is intentionally omitted: http.ServeFile redirects + // requests for index.html to "./", matching Go's net/http behavior. The + // route the SPA actually lands on is "/admin/" below. + {"/admin/", "text/html", "no-store, must-revalidate"}, + } + + for _, tc := range cases { + t.Run(tc.urlPath, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.urlPath, nil) + rec := httptest.NewRecorder() + h.serveFromDisk(rec, req, staticDir) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, tc.wantPrefix) { + t.Fatalf("Content-Type = %q, want prefix %q", ct, tc.wantPrefix) + } + if got := rec.Header().Get("Cache-Control"); got != tc.wantCacheCtl { + t.Fatalf("Cache-Control = %q, want %q", got, tc.wantCacheCtl) + } + }) + } +} + +// TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown +// extensions leave the Content-Type header unset, so http.ServeFile can apply +// its own detection (sniffing or mime.TypeByExtension) for cases the pinned +// table does not cover. +func TestSetStaticContentTypeUnknownExtensionFallsThrough(t *testing.T) { + rec := httptest.NewRecorder() + setStaticContentType(rec, "/tmp/data.unknownext") + if got := rec.Header().Get("Content-Type"); got != "" { + t.Fatalf("Content-Type = %q, want empty for unknown extension", got) + } +} + +// TestSetStaticContentTypeIsCaseInsensitive guards against a regression where +// uppercase extensions (e.g. STYLE.CSS shipped from some build pipelines) +// would bypass the pinned table and fall back to the registry on Windows. +func TestSetStaticContentTypeIsCaseInsensitive(t *testing.T) { + rec := httptest.NewRecorder() + setStaticContentType(rec, "/tmp/STYLE.CSS") + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/css") { + t.Fatalf("Content-Type = %q, want text/css prefix", got) + } +}