diff --git a/controller/internal/setup/css_test.go b/controller/internal/setup/css_test.go new file mode 100644 index 0000000..75c1f7f --- /dev/null +++ b/controller/internal/setup/css_test.go @@ -0,0 +1,48 @@ +package setup + +import ( + "bytes" + "io" + "log" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/web" +) + +// TestSetupCSSServesEmbedded (Scenario E, TASK-D0): the setup wizard must serve +// the web package's EMBEDDED stylesheet. The pre-fix handleCSS read a filesystem +// path derived from dataDir, which does not exist inside the container image, so +// production setup mode silently served the minimalCSS fallback. +func TestSetupCSSServesEmbedded(t *testing.T) { + // dataDir whose derived legacy path (internal/web/templates/style.css + // relative to its grandparent) cannot exist. + dataDir := filepath.Join(t.TempDir(), "nonexistent-nested", "data") + logger := log.New(io.Discard, "", 0) + s := NewServer(&config.Config{}, dataDir, logger, "test") + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/static/style.css", nil) + s.Handler().ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.Bytes() + if len(body) <= 10000 { + t.Fatalf("body length = %d, want > 10000 (minimalCSS fallback served?)", len(body)) + } + embedded, err := web.StyleCSS() + if err != nil { + t.Fatalf("web.StyleCSS() error: %v", err) + } + if !bytes.Equal(body, embedded) { + t.Error("served CSS differs from the embedded web stylesheet") + } + if strings.Contains(string(body), ".setup-container") { + t.Error("minimalCSS fallback was served instead of the embedded stylesheet") + } +} diff --git a/controller/internal/setup/handlers.go b/controller/internal/setup/handlers.go index 625d12f..91cbfff 100644 --- a/controller/internal/setup/handlers.go +++ b/controller/internal/setup/handlers.go @@ -189,11 +189,12 @@ func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) { // --- Static Assets (reuse from web package embed) --- func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) { - // Read the main style.css from the web package templates - cssPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "templates", "style.css") - data, err := os.ReadFile(cssPath) + // Serve the web package's embedded stylesheet. The previous implementation + // read a filesystem path derived from dataDir, which does not exist inside + // the container image — setup mode silently fell back to minimalCSS. + data, err := web.StyleCSS() if err != nil { - // Fallback: serve minimal CSS + s.logger.Printf("[WARN] Setup: embedded style.css unavailable (%v) — serving minimal fallback CSS", err) w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Write([]byte(minimalCSS)) return diff --git a/controller/internal/web/embed.go b/controller/internal/web/embed.go index 5b1bc12..4335c54 100644 --- a/controller/internal/web/embed.go +++ b/controller/internal/web/embed.go @@ -7,3 +7,13 @@ var templateFS embed.FS //go:embed static/chart.min.js var chartJS []byte + +//go:embed static/fonts/*.woff2 +var fontFS embed.FS + +// StyleCSS returns the embedded dashboard stylesheet. Exported so the setup +// wizard can serve the same CSS instead of reading a filesystem path that +// does not exist inside the container image. +func StyleCSS() ([]byte, error) { + return templateFS.ReadFile("templates/style.css") +} diff --git a/controller/internal/web/fonts_test.go b/controller/internal/web/fonts_test.go new file mode 100644 index 0000000..7d8a974 --- /dev/null +++ b/controller/internal/web/fonts_test.go @@ -0,0 +1,58 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" +) + +// TestServeFontHandler asserts the vendored woff2 files are embedded and served +// with the right content type + immutable caching (design system v2, D0 Part 1.1). +func TestServeFontHandler(t *testing.T) { + s := &Server{} + for _, name := range []string{"pjs-latin.woff2", "pjs-latin-ext.woff2", "jbm-latin.woff2", "jbm-latin-ext.woff2"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/static/fonts/"+name, nil) + s.serveFontHandler(rec, req, name) + if rec.Code != 200 { + t.Errorf("%s: status = %d, want 200", name, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "font/woff2" { + t.Errorf("%s: Content-Type = %q, want font/woff2", name, ct) + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { + t.Errorf("%s: Cache-Control = %q, want immutable", name, cc) + } + // woff2 magic number: "wOF2" + if body := rec.Body.Bytes(); len(body) < 4 || string(body[:4]) != "wOF2" { + t.Errorf("%s: body is not a woff2 file (len=%d)", name, len(body)) + } + } + + // Unknown font → 404, and path traversal is neutralized by filepath.Base. + rec := httptest.NewRecorder() + s.serveFontHandler(rec, httptest.NewRequest("GET", "/static/fonts/nope.woff2", nil), "nope.woff2") + if rec.Code != 404 { + t.Errorf("unknown font: status = %d, want 404", rec.Code) + } +} + +// TestStyleCSSAccessor asserts the exported accessor returns the embedded +// stylesheet (consumed by the setup wizard) with the vendored font-face rules +// and no CDN import. +func TestStyleCSSAccessor(t *testing.T) { + data, err := StyleCSS() + if err != nil { + t.Fatalf("StyleCSS() error: %v", err) + } + css := string(data) + if len(css) < 10000 { + t.Fatalf("StyleCSS() length = %d, want > 10000", len(css)) + } + if !strings.Contains(css, "@font-face") || !strings.Contains(css, "/static/fonts/pjs-latin.woff2") { + t.Error("StyleCSS() missing vendored @font-face rules") + } + if strings.Contains(css, "fonts.googleapis.com") { + t.Error("StyleCSS() still references the Google Fonts CDN") + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 8192196..eeeac10 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -322,6 +322,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.serveCSSHandler(w, r) case path == "/static/chart.min.js": s.serveChartJSHandler(w, r) + case strings.HasPrefix(path, "/static/fonts/"): + s.serveFontHandler(w, r, strings.TrimPrefix(path, "/static/fonts/")) case path == "/static/felhom-logo.svg": s.serveLogoHandler(w, r) case path == "/static/favicon.svg": @@ -489,6 +491,20 @@ func (s *Server) serveChartJSHandler(w http.ResponseWriter, r *http.Request) { w.Write(chartJS) } +// serveFontHandler serves the vendored woff2 files embedded in the binary +// (self-hosted fonts — no Google Fonts CDN dependency on offline nodes). +func (s *Server) serveFontHandler(w http.ResponseWriter, r *http.Request, filename string) { + filename = filepath.Base(filename) + data, err := fontFS.ReadFile("static/fonts/" + filename) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "font/woff2") + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Write(data) +} + func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) { // Try synced asset first (allows logo updates via Hub without rebuild) if s.assetsSyncer != nil { diff --git a/controller/internal/web/static/fonts/jbm-latin-ext.woff2 b/controller/internal/web/static/fonts/jbm-latin-ext.woff2 new file mode 100644 index 0000000..82f9668 Binary files /dev/null and b/controller/internal/web/static/fonts/jbm-latin-ext.woff2 differ diff --git a/controller/internal/web/static/fonts/jbm-latin.woff2 b/controller/internal/web/static/fonts/jbm-latin.woff2 new file mode 100644 index 0000000..4d09cda Binary files /dev/null and b/controller/internal/web/static/fonts/jbm-latin.woff2 differ diff --git a/controller/internal/web/static/fonts/pjs-latin-ext.woff2 b/controller/internal/web/static/fonts/pjs-latin-ext.woff2 new file mode 100644 index 0000000..f82597c Binary files /dev/null and b/controller/internal/web/static/fonts/pjs-latin-ext.woff2 differ diff --git a/controller/internal/web/static/fonts/pjs-latin.woff2 b/controller/internal/web/static/fonts/pjs-latin.woff2 new file mode 100644 index 0000000..a180dc4 Binary files /dev/null and b/controller/internal/web/static/fonts/pjs-latin.woff2 differ diff --git a/controller/internal/web/templates/icons.html b/controller/internal/web/templates/icons.html new file mode 100644 index 0000000..04b0d32 --- /dev/null +++ b/controller/internal/web/templates/icons.html @@ -0,0 +1,32 @@ +{{define "icon_sprite"}}{{end}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index 0db08a1..984e2bb 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -14,6 +14,7 @@
+ {{template "icon_sprite"}}