feat(server/m7-363): branding-aware index handler with html/template

This commit is contained in:
2026-05-03 17:21:30 -04:00
parent 46ea9a761a
commit 004d935512
4 changed files with 137 additions and 3 deletions
+1 -1
View File
@@ -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/") {
+12 -2
View File
@@ -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) {
+32
View File
@@ -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. <meta content="...">) and the JS-string
// context inside <script>window.__MINSTREL__ = { appName: "..." }</script>.
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
}
+92
View File
@@ -0,0 +1,92 @@
package web
import (
"strings"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
)
const sampleIndexHTML = `<!doctype html>
<html><head>
<title>{{ .AppName }}</title>
<meta property="og:title" content="{{ .AppName }}">
<meta property="og:description" content="{{ .Description }}">
<script>window.__MINSTREL__ = { appName: "{{ .AppName }}", description: "{{ .Description }}" };</script>
</head><body></body></html>`
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, "<title>Minstrel</title>") {
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, "<title>Family Jukebox</title>") {
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: "</script><img onerror=\"alert(1)\">",
Description: "ok",
})
if err != nil {
t.Fatalf("apply: %v", err)
}
body := string(out)
scriptOpen := strings.Index(body, "<script>window.__MINSTREL__")
if scriptOpen < 0 {
t.Fatalf("inline script block missing; body=%s", body)
}
scriptClose := strings.Index(body[scriptOpen:], "</script>")
if scriptClose < 0 {
t.Fatalf("inline script block has no close; body=%s", body)
}
scriptBody := body[scriptOpen : scriptOpen+scriptClose]
if strings.Contains(scriptBody, "</script>") {
t.Errorf("unescaped </script> inside inline-script context: %s", scriptBody)
}
}
func TestApplyBrandingTemplate_XSSEscape_AttrContext(t *testing.T) {
out, err := applyBrandingTemplate(sampleIndexHTML, config.BrandingConfig{
AppName: `"><img onerror="alert(1)">`,
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, `"><img onerror="alert(1)">`) {
t.Errorf("unescaped attribute breakout: %s", body)
}
}