mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-23 07:53:36 +00:00
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.
308 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|