// Package boardwalk serves the embedded admin SPA build (D-01, D-02). // // The committed dist/ is path-agnostic: Vite builds it with a relative base // and index.html carries the __SUMMER_ADMIN_BASE__ token. Handler rewrites // index.html once for the configured backend.uri, serves hashed assets with // long-lived caching, falls back to index.html for client-side routes and // hands every unmatched api/ path back to the caller so API misses stay JSON. package boardwalk import ( "bytes" "embed" "errors" "fmt" "html" "io/fs" "mime" "net/http" "path" "strings" "time" ) //go:embed all:dist var distFS embed.FS // BaseToken is replaced in dist/index.html by the configured admin prefix. const BaseToken = "__SUMMER_ADMIN_BASE__" // contentSecurityPolicy keeps the admin out of frames and allows scripts // only from its own origin; the build contains no inline script. const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'" var contentTypes = map[string]string{ ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".woff2": "font/woff2", ".woff": "font/woff", ".svg": "image/svg+xml", ".json": "application/json", } // Dist returns the embedded build tree rooted at dist/. func Dist() (fs.FS, error) { return fs.Sub(distFS, "dist") } // Handler serves the embedded SPA under prefix (for example /backend). // notFoundAPI answers any request whose path under the prefix is api or // starts with api/; it must write the admin API's JSON 404 envelope. func Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error) { root, err := Dist() if err != nil { return nil, err } return newHandler(root, prefix, notFoundAPI) } func newHandler(root fs.FS, prefix string, notFoundAPI http.Handler) (http.Handler, error) { if notFoundAPI == nil { return nil, errors.New("boardwalk: notFoundAPI handler is nil") } prefix = strings.TrimRight(prefix, "/") if !strings.HasPrefix(prefix, "/") { return nil, fmt.Errorf("boardwalk: prefix %q must start with /", prefix) } raw, err := fs.ReadFile(root, "index.html") if err != nil { return nil, fmt.Errorf("boardwalk: dist/index.html: %w", err) } index, err := RewriteIndex(raw, prefix) if err != nil { return nil, err } return &handler{root: root, prefix: prefix, index: index, notFoundAPI: notFoundAPI}, nil } // RewriteIndex points relative asset URLs at prefix and injects the prefix // into the summer-admin-base meta. It fails when the token is absent, which // catches a stale or hand-edited dist at boot. func RewriteIndex(raw []byte, prefix string) ([]byte, error) { if !bytes.Contains(raw, []byte(BaseToken)) { return nil, errors.New("boardwalk: dist/index.html has no " + BaseToken + " token; rebuild the admin SPA") } escaped := html.EscapeString(prefix) out := bytes.ReplaceAll(raw, []byte(`="./`), []byte(`="`+escaped+`/`)) out = bytes.ReplaceAll(out, []byte(BaseToken), []byte(escaped)) return out, nil } type handler struct { root fs.FS prefix string index []byte notFoundAPI http.Handler } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { setSecurityHeaders(w.Header()) rel := strings.TrimPrefix(r.URL.Path, h.prefix) rel = strings.TrimPrefix(rel, "/") if rel == "api" || strings.HasPrefix(rel, "api/") { h.notFoundAPI.ServeHTTP(w, r) return } name := strings.TrimPrefix(path.Clean("/"+rel), "/") if name == "" || name == "index.html" { h.serveIndex(w, r) return } if info, err := fs.Stat(h.root, name); err == nil { if info.Mode().IsRegular() { h.serveFile(w, r, name) return } // A directory is never listed; it is treated as a client route. h.serveIndex(w, r) return } if path.Ext(name) != "" { http.NotFound(w, r) return } h.serveIndex(w, r) } func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index)) } func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) { body, err := fs.ReadFile(h.root, name) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", contentType(name)) if strings.HasPrefix(name, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body)) } func contentType(name string) string { ext := strings.ToLower(path.Ext(name)) if ct, ok := contentTypes[ext]; ok { return ct } if ct := mime.TypeByExtension(ext); ct != "" { return ct } return "application/octet-stream" } func setSecurityHeaders(h http.Header) { h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") h.Set("X-Frame-Options", "DENY") h.Set("Content-Security-Policy", contentSecurityPolicy) h.Set("X-Robots-Tag", "noindex, nofollow") }