// 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. // // The handler also computes one build id when it is built (BuildID: the dist // tree plus the binary's build information). Every response carries it in the // X-Summer-Build header, {prefix}/build.json answers it, and index.html gets // it in the summer-admin-build meta, so an open admin tab can tell that a new // version was deployed. package boardwalk import ( "bytes" "crypto/sha256" "embed" "encoding/hex" "errors" "fmt" "html" "io" "io/fs" "mime" "net/http" "path" "runtime/debug" "slices" "strconv" "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__" // BuildHeader is the response header that carries the build id on every // response the handler writes. const BuildHeader = "X-Summer-Build" // BuildToken is the token in dist/index.html's summer-admin-build meta that // the handler replaces with the build id. An index without it is served // unchanged and the SPA turns its version check off. const BuildToken = "__SUMMER_ADMIN_BUILD__" // BuildPath is the path under the prefix that answers the current build as // JSON ({"build":""}, no-store). It wins over a dist file of that name. const BuildPath = "build.json" // buildIDLength matches the length cabana uses for plugin asset versions. const buildIDLength = 12 // readBuildInfo reads the binary's build information; tests replace it. var readBuildInfo = debug.ReadBuildInfo // contentSecurityPolicy keeps the admin out of frames and allows scripts // only from its own origin; the build contains no inline script. Injected // markup can neither post a native form off-origin (form-action 'self') nor // frame fetched content (frame-src 'none'); a sandboxed srcdoc preview is not // fetched, so it still renders. const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'" 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", ".ico": "image/x-icon", ".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 } // The id is computed once, when the handler is built; it is hex, so it // needs no escaping in the meta. build, err := BuildID(root, buildInfo()) if err != nil { return nil, err } index = bytes.ReplaceAll(index, []byte(BuildToken), []byte(build)) return &handler{root: root, prefix: prefix, index: index, build: build, notFoundAPI: notFoundAPI}, nil } // buildInfo returns the binary's build information, or nil when it is not // available. func buildInfo() *debug.BuildInfo { info, ok := readBuildInfo() if !ok { return nil } return info } // BuildID identifies one deploy of the admin: the first 12 lowercase hex // characters of a sha256 over // // - the dist tree, raw (before any prefix rewrite): every regular file in // fs.WalkDir order as its slash path, its length and its contents; // - then, when info is not nil, the binary's build identity: the Go // version, the main module path, version and sum, the vcs.revision and // vcs.modified settings when present, and every dependency's path, // version and sum (and its replacement's), sorted by path. // // Other build settings (flags, GOOS and the like) are left out. With info nil // the id is the dist hash alone. The same tree with the same info always // gives the same id; a changed dist file or a different binary (a new commit, // a dependency bump, a toolchain change) gives a new one. func BuildID(root fs.FS, info *debug.BuildInfo) (string, error) { sum := sha256.New() err := fs.WalkDir(root, ".", func(name string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.Type().IsRegular() { return nil } body, err := fs.ReadFile(root, name) if err != nil { return err } _, _ = io.WriteString(sum, name) _, _ = sum.Write([]byte{0}) _, _ = io.WriteString(sum, strconv.Itoa(len(body))) _, _ = sum.Write([]byte{0}) _, _ = sum.Write(body) return nil }) if err != nil { return "", fmt.Errorf("boardwalk: build id: %w", err) } if info != nil { writeBuildInfo(sum, info) } return hex.EncodeToString(sum.Sum(nil))[:buildIDLength], nil } // writeBuildInfo writes the deploy-identifying part of info, one line per // item, after a NUL-separated marker. func writeBuildInfo(w io.Writer, info *debug.BuildInfo) { _, _ = io.WriteString(w, "\x00buildinfo\x00") fmt.Fprintf(w, "go %s\n", info.GoVersion) fmt.Fprintf(w, "main %s %s %s\n", info.Main.Path, info.Main.Version, info.Main.Sum) for _, key := range []string{"vcs.revision", "vcs.modified"} { for _, s := range info.Settings { if s.Key == key { fmt.Fprintf(w, "setting %s=%s\n", s.Key, s.Value) break } } } deps := slices.Clone(info.Deps) slices.SortStableFunc(deps, func(a, b *debug.Module) int { return strings.Compare(a.Path, b.Path) }) for _, d := range deps { fmt.Fprintf(w, "dep %s %s %s", d.Path, d.Version, d.Sum) if d.Replace != nil { fmt.Fprintf(w, " => %s %s %s", d.Replace.Path, d.Replace.Version, d.Replace.Sum) } _, _ = io.WriteString(w, "\n") } } // 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 build string notFoundAPI http.Handler } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { SetSecurityHeaders(w.Header()) w.Header().Set(BuildHeader, h.build) 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 == BuildPath { h.serveBuild(w) return } 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)) } // serveBuild answers the handler's build id as {"build":""}; the id is // hex, so it is written without marshalling. func (h *handler) serveBuild(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _, _ = io.WriteString(w, `{"build":"`+h.build+`"}`) } 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)) } // ContentType is the Content-Type the admin serves for a file name: explicit // UTF-8 JavaScript and CSS types (module scripts and nosniff'd stylesheets // need them), then the platform MIME table, then application/octet-stream. 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" } // SetSecurityHeaders sets the admin response headers: nosniff, a same-origin // referrer policy, no framing, the admin Content-Security-Policy // (script-src 'self', form-action 'self', frame-src 'none': no native form // post off-origin, no framed content) and noindex. 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") }