Pulse/internal/api/frontend_embed.go
rcourtman 07fb7dbbac Serve shipped markdown docs as text instead of a forced download
The embedded frontend file server had no content type for .md, so every
in-app "Full details" / "Terms of Service" / security-guide link to
/docs/*.md answered application/octet-stream and the browser downloaded
the file instead of showing it. Shipped docs now serve as
text/plain; charset=utf-8 and open readable in the tab the app targets.
2026-07-08 14:36:55 +01:00

308 lines
8.5 KiB
Go

package api
import (
"bytes"
"embed"
"html"
"io"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"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
}
}
}
// 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)
}
}