Files
pulse/internal/api/frontend_embed.go
T
pulse-triage[bot] d0c3a7fadf Return real not-found responses for missing frontend assets
Keep SPA fallback for client routes, including extensionless documentation viewers, but not missing document files, bundles or other static resources. Missing resources are not cached, so a later installation can supply them without a stale negative response.

Contract-Neutral: Correct static-resource failure responses at the existing SPA boundary; no API, authentication, data schema or supported client route changes.
Change-source: pulse-maintainer
2026-10-25 02:18:32 +00:00

333 lines
9.3 KiB
Go

package api
import (
"bytes"
"embed"
"html"
"io"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strings"
"sync"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// cspNoncePlaceholder is replaced at serve time with the per-request nonce.
var cspNoncePlaceholder = []byte("__CSP_NONCE__")
// serveIndexWithNonce writes index.html content to w, replacing any
// __CSP_NONCE__ placeholders with the nonce from the request context.
func serveIndexWithNonce(w http.ResponseWriter, r *http.Request, content []byte) {
if nonce := CSPNonceFromContext(r.Context()); nonce != "" {
content = bytes.ReplaceAll(content, cspNoncePlaceholder, []byte(nonce))
content = addNonceToInlineHTMLTags(content, nonce)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
w.Write(content)
}
func addNonceToInlineHTMLTags(content []byte, nonce string) []byte {
if len(content) == 0 || nonce == "" {
return content
}
nonceAttr := []byte(` nonce="` + html.EscapeString(nonce) + `"`)
content = addNonceToInlineHTMLTag(content, "script", "src", nonceAttr)
content = addNonceToInlineHTMLTag(content, "style", "", nonceAttr)
return content
}
func addNonceToInlineHTMLTag(content []byte, tagName string, skipAttr string, nonceAttr []byte) []byte {
lowerContent := bytes.ToLower(content)
needle := []byte("<" + strings.ToLower(tagName))
searchAt := 0
copiedUntil := 0
var out []byte
for searchAt < len(content) {
relativeStart := bytes.Index(lowerContent[searchAt:], needle)
if relativeStart == -1 {
break
}
start := searchAt + relativeStart
tagNameEnd := start + len(needle)
if tagNameEnd < len(content) && !isHTMLTagBoundary(lowerContent[tagNameEnd]) {
searchAt = tagNameEnd
continue
}
relativeEnd := bytes.IndexByte(content[tagNameEnd:], '>')
if relativeEnd == -1 {
break
}
end := tagNameEnd + relativeEnd
tag := lowerContent[start : end+1]
if htmlStartTagHasAttribute(tag, "nonce") || (skipAttr != "" && htmlStartTagHasAttribute(tag, skipAttr)) {
searchAt = end + 1
continue
}
if out == nil {
out = make([]byte, 0, len(content)+len(nonceAttr))
}
out = append(out, content[copiedUntil:end]...)
out = append(out, nonceAttr...)
out = append(out, content[end])
copiedUntil = end + 1
searchAt = end + 1
}
if out == nil {
return content
}
out = append(out, content[copiedUntil:]...)
return out
}
func htmlStartTagHasAttribute(tag []byte, attr string) bool {
if len(tag) == 0 || attr == "" {
return false
}
attrBytes := []byte(strings.ToLower(attr))
searchAt := 0
for searchAt < len(tag) {
idx := bytes.Index(tag[searchAt:], attrBytes)
if idx == -1 {
return false
}
start := searchAt + idx
end := start + len(attrBytes)
beforeOK := start == 0 || isHTMLAttributeBoundaryBefore(tag[start-1])
afterOK := end >= len(tag) || isHTMLAttributeBoundaryAfter(tag[end])
if beforeOK && afterOK {
return true
}
searchAt = end
}
return false
}
func isHTMLTagBoundary(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '/' || b == '>'
}
func isHTMLAttributeBoundaryBefore(b byte) bool {
return b == '<' || b == '/' || b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
func isHTMLAttributeBoundaryAfter(b byte) bool {
return b == '=' || b == '/' || b == '>' || b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
// Embed the entire frontend dist directory
//
//go:embed all:frontend-modern/dist
var embeddedFrontend embed.FS
var (
devProxyOnce sync.Once
devProxy *httputil.ReverseProxy
devProxyErr error
)
func getFrontendDevProxy() (*httputil.ReverseProxy, error) {
devProxyOnce.Do(func() {
devURL := utils.GetenvTrim("FRONTEND_DEV_SERVER")
if devURL == "" {
return
}
target, err := url.Parse(devURL)
if err != nil {
devProxyErr = err
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Error().Err(err).Str("path", r.URL.Path).Msg("Frontend dev proxy error")
w.WriteHeader(http.StatusBadGateway)
}
devProxy = proxy
log.Warn().Str("frontend_dev_server", target.String()).Msg("Serving frontend via development proxy")
})
if devProxyErr != nil {
return nil, devProxyErr
}
return devProxy, nil
}
// getFrontendFS returns the embedded frontend filesystem
func getFrontendFS() (http.FileSystem, error) {
if dir := utils.GetenvTrim("PULSE_FRONTEND_DIR"); dir != "" {
log.Warn().Str("frontend_dir", dir).Msg("Serving frontend from filesystem override")
return http.Dir(dir), nil
}
// Strip the prefix to serve files from root
fsys, err := fs.Sub(embeddedFrontend, "frontend-modern/dist")
if err != nil {
return nil, err
}
return http.FS(fsys), nil
}
// serveFrontendHandler returns a handler for serving the embedded frontend
func serveFrontendHandler() http.HandlerFunc {
if proxy, err := getFrontendDevProxy(); err != nil {
log.Error().Err(err).Msg("Failed to initialize frontend dev proxy, falling back to embedded assets")
} else if proxy != nil {
return func(w http.ResponseWriter, r *http.Request) {
proxy.ServeHTTP(w, r)
}
}
// Get the embedded filesystem
fsys, err := getFrontendFS()
if err != nil {
log.Fatal().Err(err).Msg("Failed to get embedded frontend")
}
return func(w http.ResponseWriter, r *http.Request) {
// Clean the path
p := r.URL.Path
// Handle root path specially to avoid FileServer's directory redirect
// Issue #334: Serve index.html directly without using FileServer for root
if p == "/" || p == "" {
// Directly serve index.html content
file, err := fsys.Open("index.html")
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
// Check that it's not a directory
_, err = file.Stat()
if err != nil {
http.NotFound(w, r)
return
}
// Read the file content
content, err := io.ReadAll(file)
if err != nil {
http.NotFound(w, r)
return
}
serveIndexWithNonce(w, r, content)
return
}
// Remove leading slash for filesystem lookup
lookupPath := strings.TrimPrefix(p, "/")
// Check if file exists in embedded FS
file, err := fsys.Open(lookupPath)
if err == nil {
defer file.Close()
// Get file info
stat, err := file.Stat()
if err == nil && !stat.IsDir() {
// Read and serve the file
content, err := io.ReadAll(file)
if err == nil {
// Detect content type
contentType := "application/octet-stream"
isImmutable := false
if strings.HasSuffix(lookupPath, ".html") {
// HTML files get nonce injection
serveIndexWithNonce(w, r, content)
return
} else if strings.HasSuffix(lookupPath, ".css") {
contentType = "text/css; charset=utf-8"
// CSS files with hashes are immutable (e.g., index-abc123.css)
isImmutable = strings.Contains(lookupPath, "-") && strings.Contains(lookupPath, ".css")
} else if strings.HasSuffix(lookupPath, ".js") {
contentType = "application/javascript; charset=utf-8"
// JS files with hashes are immutable (e.g., index-BXHytNQV.js)
isImmutable = strings.Contains(lookupPath, "-") && strings.Contains(lookupPath, ".js")
} else if strings.HasSuffix(lookupPath, ".json") {
contentType = "application/json"
} else if strings.HasSuffix(lookupPath, ".svg") {
contentType = "image/svg+xml"
} else if strings.HasSuffix(lookupPath, ".md") {
// Shipped docs (/docs/*.md) open in a browser tab from
// in-app links; text/plain renders them readable instead
// of forcing a download.
contentType = "text/plain; charset=utf-8"
}
w.Header().Set("Content-Type", contentType)
// Hashed assets are immutable - cache aggressively
// Non-hashed assets should not be cached
if isImmutable {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
w.Write(content)
return
}
}
}
// A missing static resource is not a client-side route. Returning the
// app shell here conceals missing docs/chunks behind a successful HTML
// response. Keep extensionless viewer routes such as /docs/RECOVERY.
if isFrontendAssetPath(p) {
w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, r)
return
}
// For SPA routing, serve index.html for non-API routes
if !strings.HasPrefix(p, "/api/") &&
!strings.HasPrefix(p, "/ws") {
// Serve index.html for client-side routing
indexFile, err := fsys.Open("index.html")
if err == nil {
defer indexFile.Close()
content, err := io.ReadAll(indexFile)
if err == nil {
serveIndexWithNonce(w, r, content)
return
}
}
}
// Not found
http.NotFound(w, r)
}
}
func isFrontendAssetPath(p string) bool {
if p == "/assets" || strings.HasPrefix(p, "/assets/") {
return true
}
switch strings.ToLower(path.Ext(p)) {
case ".html", ".js", ".mjs", ".css", ".map", ".json", ".md",
".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico",
".woff", ".woff2", ".ttf", ".otf", ".wasm", ".txt", ".xml", ".webmanifest":
return true
default:
return false
}
}