Ctrl K
ComunidadGuías…

internal/ui/embed.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package ui provides an embedded SPA file server for the Sparrow web frontend.
//
// The static build output from web/build/ is embedded at compile time.
// When SPARROW_SERVE_UI=true, the Go binary serves the frontend on the
// same port as the REST API. Chi router ensures API routes always
// take precedence; the UI handler is registered as the NotFound fallback.
//
// Build the frontend first:
//
//	cd web && npm run build
//	cd .. && go build ./cmd/server
package ui

import (
	"crypto/sha256"
	"embed"
	"encoding/base64"
	"io/fs"
	"log/slog"
	"net/http"
	"regexp"
	"strings"
)

//go:embed all:dist
var embeddedFS embed.FS

// Handler returns an http.Handler that serves the embedded SPA.
// It serves static files from the embedded filesystem, and falls back to
// index.html for any path that doesn't match a static file (SPA client-side routing).
//
// Pages are served exactly as built: no credential is ever written into them.
// The UI signs in with the API key, an access token, or an invite link.
func Handler(logger *slog.Logger) http.Handler {
	// Strip the "dist" prefix from the embedded FS so files are served from root.
	staticFS, err := fs.Sub(embeddedFS, "dist")
	if err != nil {
		// This should never happen since "dist" is embedded at compile time.
		panic("ui: failed to create sub filesystem: " + err.Error())
	}
	return newHandler(logger, staticFS)
}

// newHandler serves the SPA from staticFS (the build output root).
func newHandler(logger *slog.Logger, staticFS fs.FS) http.Handler {
	fileServer := http.FileServer(http.FS(staticFS))

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Try to serve the requested file directly.
		path := strings.TrimPrefix(r.URL.Path, "/")

		// Check if the file exists in the embedded FS.
		if path != "" {
			if f, err := staticFS.Open(path); err == nil {
				_ = f.Close()
				// File exists — serve it with proper caching.
				// Immutable assets (hashed filenames) get long cache.
				if strings.HasPrefix(path, "_app/immutable/") {
					w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
				}
				fileServer.ServeHTTP(w, r)
				return
			}
		}

		// File doesn't exist — serve index.html for SPA client-side routing.
		indexBytes, err := fs.ReadFile(staticFS, "index.html")
		if err != nil {
			logger.ErrorContext(r.Context(), "ui: index.html not found in embedded filesystem — was the frontend built?")
			http.Error(w, "UI not available. Build the frontend with: cd web && npm run build", http.StatusNotFound)
			return
		}

		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Header().Set("Cache-Control", "no-cache")
		_, _ = w.Write(indexBytes)
	})
}

// scriptRe matches all <script ...>...</script> blocks including empty-body
// ones (external scripts with src="..."). The opening-tag portion is captured
// separately so we can check for a src attribute without re-parsing.
var scriptRe = regexp.MustCompile(`(?s)(<script(?:\s[^>]*)?>)(.*?)</script>`)
var srcAttrRe = regexp.MustCompile(`\bsrc\s*=`)

// InlineScriptHashes reads index.html from the embedded build output,
// extracts the bodies of all inline <script> tags (those without a src
// attribute), and returns their SHA-256 hashes in CSP format:
// 'sha256-<base64>'.
//
// These hashes let the Content-Security-Policy use script hashes instead
// of 'unsafe-inline', so only the exact scripts the build produced are
// allowed to execute. Returns nil when no built frontend is embedded.
func InlineScriptHashes() []string {
	data, err := fs.ReadFile(embeddedFS, "dist/index.html")
	if err != nil {
		return nil
	}
	return computeInlineScriptHashes(data)
}

// inlineScriptHashesFromFS is the testable variant that accepts an arbitrary FS.
func inlineScriptHashesFromFS(staticFS fs.FS) []string {
	data, err := fs.ReadFile(staticFS, "index.html")
	if err != nil {
		return nil
	}
	return computeInlineScriptHashes(data)
}

// computeInlineScriptHashes extracts inline script bodies from raw HTML
// and returns their CSP sha256 hashes.
func computeInlineScriptHashes(html []byte) []string {
	matches := scriptRe.FindAllSubmatch(html, -1)
	var hashes []string
	for _, m := range matches {
		openTag := m[1] // e.g. `<script>` or `<script src="...">`
		body := m[2]
		// Skip scripts with a src attribute — those are external.
		if srcAttrRe.Match(openTag) {
			continue
		}
		// Skip empty inline scripts (nothing to hash).
		if len(body) == 0 {
			continue
		}
		h := sha256.Sum256(body)
		hashes = append(hashes, "'sha256-"+base64.StdEncoding.EncodeToString(h[:])+"'")
	}
	return hashes
}

// Available reports whether the embedded UI contains a built frontend.
// Returns false if only the placeholder .gitkeep exists.
func Available() bool {
	entries, err := fs.ReadDir(embeddedFS, "dist")
	if err != nil {
		return false
	}
	for _, e := range entries {
		if e.Name() == "index.html" {
			return true
		}
	}
	return false
}