test-web / test (push) Successful in 55s
test-go / test (push) Successful in 1m14s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
test-go / integration (push) Canceled after 2m50s
There were no security headers at all. Now: - every response: nosniff, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy (no camera/mic/geolocation), X-Frame-Options DENY; each set only if the handler hasn't. - HSTS only when the trusted proxy reports HTTPS (rule 94); never a redirect. - index.html carries a Content-Security-Policy whose script-src is 'self' plus the sha256 of each inline script in the page as served, computed after the branding template runs. No 'unsafe-inline' or 'unsafe-eval' for scripts. img-src admits remote https/http because Lidarr suggestion art is a remote poster URL. Hashing in Go rather than via SvelteKit's kit.csp covers the inline scripts SvelteKit doesn't know about (app.html's theme bootstrap and the branding global injected at build) and stays correct whatever the app name is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
2.4 KiB
Go
78 lines
2.4 KiB
Go
// Package web embeds the SvelteKit SPA build output and serves it over HTTP.
|
|
// Requests for real files under build/ are served verbatim; anything else —
|
|
// deep links like /artists/abc, missing asset paths — returns build/index.html
|
|
// so the SPA can resolve the route client-side.
|
|
package web
|
|
|
|
import (
|
|
"embed"
|
|
"io/fs"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
|
)
|
|
|
|
//go:embed all:build
|
|
var buildFS embed.FS
|
|
|
|
// Handler returns an http.Handler that serves the embedded SPA.
|
|
//
|
|
// Behavior:
|
|
// - GET / -> build/index.html
|
|
// - GET /<path that exists in build/> -> that file, via http.FileServer
|
|
// - GET /<anything else> -> build/index.html (SPA fallback)
|
|
//
|
|
// Callers are expected to register this as the router's NotFound/catch-all so
|
|
// explicitly-routed paths (like /api/* and /rest/*) take precedence.
|
|
//
|
|
// 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
|
|
// is malformed, which the compile would have caught.
|
|
panic("web: fs.Sub(build) failed: " + err.Error())
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
csp := contentSecurityPolicy(index)
|
|
|
|
fileServer := http.FileServer(http.FS(sub))
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
clean := path.Clean(r.URL.Path)
|
|
if clean == "/" || clean == "." {
|
|
serveIndex(w, index, csp)
|
|
return
|
|
}
|
|
name := strings.TrimPrefix(clean, "/")
|
|
if info, err := fs.Stat(sub, name); err == nil && !info.IsDir() {
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
serveIndex(w, index, csp)
|
|
})
|
|
}
|
|
|
|
func serveIndex(w http.ResponseWriter, index []byte, csp string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
// The policy only means anything on the document; JSON, audio and image
|
|
// responses can't run script, so it rides here rather than on every
|
|
// response.
|
|
w.Header().Set("Content-Security-Policy", csp)
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
_, _ = w.Write(index)
|
|
}
|