- boardwalk.BuildID hashes the dist tree plus the binary's build info once
per handler; X-Summer-Build on every response, {prefix}/build.json and the
summer-admin-build meta carry it
- the SPA reads the booted build, re-checks on focus, visibility and every
5 minutes, and shows UpdateBanner on a different build
311 lines
10 KiB
Go
311 lines
10 KiB
Go
// 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":"<id>"}, 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":"<id>"}; 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")
|
|
}
|