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 = ` +
+