diff --git a/internal/server/server.go b/internal/server/server.go index 68d0f75f..e901f1c9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -118,7 +118,7 @@ func (s *Server) Router() http.Handler { subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer) } - spa := web.Handler() + spa := web.Handler(s.BrandingCfg) r.NotFound(func(w http.ResponseWriter, req *http.Request) { p := req.URL.Path if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") { diff --git a/web/embed.go b/web/embed.go index b2fbf9a3..2c63b3c9 100644 --- a/web/embed.go +++ b/web/embed.go @@ -10,6 +10,8 @@ import ( "net/http" "path" "strings" + + "git.fabledsword.com/bvandeusen/minstrel/internal/config" ) //go:embed all:build @@ -24,7 +26,10 @@ var buildFS embed.FS // // Callers are expected to register this as the router's NotFound/catch-all so // explicitly-routed paths (like /api/* and /rest/*) take precedence. -func Handler() http.Handler { +// +// The branding parameter is applied to index.html once at construction via +// html/template; all requests serve the resulting cached bytes. +func Handler(branding config.BrandingConfig) http.Handler { sub, err := fs.Sub(buildFS, "build") if err != nil { // fs.Sub on a valid embed path can only fail if the embed directive @@ -32,11 +37,16 @@ func Handler() http.Handler { panic("web: fs.Sub(build) failed: " + err.Error()) } - index, err := fs.ReadFile(sub, "index.html") + raw, err := fs.ReadFile(sub, "index.html") if err != nil { panic("web: embedded build/index.html missing: " + err.Error()) } + index, err := applyBrandingTemplate(string(raw), branding) + if err != nil { + panic("web: branding template failed: " + err.Error()) + } + fileServer := http.FileServer(http.FS(sub)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/web/template.go b/web/template.go new file mode 100644 index 00000000..90c6f74e --- /dev/null +++ b/web/template.go @@ -0,0 +1,32 @@ +package web + +import ( + "bytes" + "fmt" + "html/template" + + "git.fabledsword.com/bvandeusen/minstrel/internal/config" +) + +// applyBrandingTemplate parses the given HTML source as an html/template, +// substitutes the branding fields, and returns the rendered bytes. Used +// by Handler at construction time so each request serves cached output +// rather than re-rendering. The Vite build (T4) injects the {{ .AppName }} +// and similar tokens into the head of build/index.html; in dev (vite dev) +// no tokens are present and the template effectively returns the source +// unchanged. +// +// html/template's context-aware escaping handles XSS for both the +// HTML-attribute contexts (e.g. ) and the JS-string +// context inside . +func applyBrandingTemplate(htmlSrc string, branding config.BrandingConfig) ([]byte, error) { + tpl, err := template.New("index").Parse(htmlSrc) + if err != nil { + return nil, fmt.Errorf("parse index template: %w", err) + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, branding); err != nil { + return nil, fmt.Errorf("execute index template: %w", err) + } + return buf.Bytes(), nil +} diff --git a/web/template_test.go b/web/template_test.go new file mode 100644 index 00000000..415cdd80 --- /dev/null +++ b/web/template_test.go @@ -0,0 +1,92 @@ +package web + +import ( + "strings" + "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/config" +) + +const sampleIndexHTML = ` + +{{ .AppName }} + + + +` + +func TestApplyBrandingTemplate_Defaults(t *testing.T) { + out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{ + AppName: "Minstrel", + Description: "Self-hosted music server.", + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + body := string(out) + if !strings.Contains(body, "Minstrel") { + t.Errorf("missing default title; body=%s", body) + } +} + +func TestApplyBrandingTemplate_CustomBranding(t *testing.T) { + out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{ + AppName: "Family Jukebox", + Description: "Our home library.", + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + body := string(out) + if !strings.Contains(body, "Family Jukebox") { + t.Errorf("custom AppName not in title; body=%s", body) + } + if !strings.Contains(body, `og:title" content="Family Jukebox"`) { + t.Errorf("custom AppName not in og:title; body=%s", body) + } + if !strings.Contains(body, `appName: "Family Jukebox"`) { + t.Errorf("custom AppName not in inline window.__MINSTREL__; body=%s", body) + } +} + +func TestApplyBrandingTemplate_XSSEscape_ScriptContext(t *testing.T) { + // Operator config is trusted, but defensive escaping is still required: + // an operator who doesn't realize their value is interpolated into HTML + // shouldn't be able to accidentally break out of contexts. + out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{ + AppName: "", + Description: "ok", + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + body := string(out) + + scriptOpen := strings.Index(body, "") + if scriptClose < 0 { + t.Fatalf("inline script block has no close; body=%s", body) + } + scriptBody := body[scriptOpen : scriptOpen+scriptClose] + if strings.Contains(scriptBody, "") { + t.Errorf("unescaped inside inline-script context: %s", scriptBody) + } +} + +func TestApplyBrandingTemplate_XSSEscape_AttrContext(t *testing.T) { + out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{ + AppName: `">`, + Description: "ok", + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + body := string(out) + // The unescaped HTML should NOT appear as a live tag in attribute context. + if strings.Contains(body, `">`) { + t.Errorf("unescaped attribute breakout: %s", body) + } +}