Files
nmon/internal/webui/webui.go

178 lines
4.8 KiB
Go

// Package webui serves the embedded single-page app. The build copies web/dist
// into ./dist (make web); the committed placeholder keeps `go build` working
// on machines without node.
package webui
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"mime"
"net/http"
"path"
"strings"
)
// dist is the build copied from web/dist (git-ignored, so a build never dirties the tree);
// placeholder is committed and used when dist holds no index.html.
//
//go:embed all:dist
var dist embed.FS
//go:embed placeholder
var placeholder embed.FS
// Dist returns the embedded UI build, or a one-line "UI not built" page when
// the binary was built without `make web`.
func Dist() fs.FS {
if _, err := fs.Stat(dist, "dist/"+indexName); err == nil {
return mustSub(dist, "dist")
}
return mustSub(placeholder, "placeholder")
}
func mustSub(f fs.FS, dir string) fs.FS {
sub, err := fs.Sub(f, dir)
if err != nil {
panic(fmt.Sprintf("webui: embedded %s missing: %v", dir, err)) // cannot happen: go:embed guarantees the directory
}
return sub
}
// reserved are the path prefixes that belong to the API handler, never the SPA.
var reserved = []string{"/api/", "/docs", "/openapi", "/metrics", "/schemas", "/auth/", "/healthz"}
const (
indexName = "index.html"
cacheImmutable = "public, max-age=31536000, immutable"
minGzipBytes = 512
)
// asset is one embedded file, with its gzip form precomputed so serving is a write.
type asset struct {
body, gz []byte
etag string
ctype string
}
type server struct {
assets map[string]*asset // keyed by slash-rooted path, "/index.html"
next http.Handler
}
// Handler serves the files of fsys at "/" and sends everything else to next.
// Unknown GET and HEAD paths outside the API prefixes fall back to index.html
// so client-side routes survive a reload. Hashed files under /assets/ are
// immutable; index.html is always revalidated.
func Handler(fsys fs.FS, next http.Handler) (http.Handler, error) {
s := &server{assets: map[string]*asset{}, next: next}
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
body, err := fs.ReadFile(fsys, p)
if err != nil {
return err
}
s.assets["/"+p] = newAsset(p, body)
return nil
})
if err != nil {
return nil, fmt.Errorf("webui: read dist: %w", err)
}
if s.assets["/"+indexName] == nil {
return nil, fmt.Errorf("webui: no %s in the embedded build", indexName)
}
return s, nil
}
func newAsset(name string, body []byte) *asset {
sum := sha256.Sum256(body)
ct := mime.TypeByExtension(path.Ext(name))
if ct == "" {
ct = http.DetectContentType(body)
}
a := &asset{body: body, etag: `"` + hex.EncodeToString(sum[:8]) + `"`, ctype: ct}
if len(body) >= minGzipBytes && compressible(ct) {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
// Writes to a bytes.Buffer cannot fail.
_, _ = zw.Write(body)
_ = zw.Close()
if buf.Len() < len(body) {
a.gz = buf.Bytes()
}
}
return a
}
func compressible(ct string) bool {
return strings.HasPrefix(ct, "text/") || strings.Contains(ct, "javascript") || strings.Contains(ct, "json") ||
strings.Contains(ct, "svg") || strings.Contains(ct, "xml")
}
func isReserved(p string) bool {
for _, r := range reserved {
if strings.HasPrefix(p, r) || p == strings.TrimSuffix(r, "/") {
return true
}
}
return false
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if isReserved(r.URL.Path) || (r.Method != http.MethodGet && r.Method != http.MethodHead) {
s.next.ServeHTTP(w, r)
return
}
name := path.Clean("/" + r.URL.Path)
a := s.assets[name]
if a == nil || name == "/" {
if path.Ext(name) != "" && strings.HasPrefix(name, "/assets/") {
http.NotFound(w, r) // a missing hashed file must not come back as HTML
return
}
name, a = "/"+indexName, s.assets["/"+indexName]
}
s.write(w, r, name, a)
}
func (s *server) write(w http.ResponseWriter, r *http.Request, name string, a *asset) {
h := w.Header()
h.Set("Content-Type", a.ctype)
h.Set("ETag", a.etag)
h.Add("Vary", "Accept-Encoding")
if strings.HasPrefix(name, "/assets/") {
h.Set("Cache-Control", cacheImmutable)
} else {
h.Set("Cache-Control", "no-cache")
}
if r.Header.Get("If-None-Match") == a.etag {
w.WriteHeader(http.StatusNotModified)
return
}
body := a.body
if a.gz != nil && acceptsGzip(r) {
h.Set("Content-Encoding", "gzip")
body = a.gz
}
h.Set("Content-Length", fmt.Sprint(len(body)))
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(body) // a client that hung up has nobody to tell
}
}
func acceptsGzip(r *http.Request) bool {
for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) == "gzip" {
return true
}
}
return false
}