Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/pimonitor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func run(args []string) error {
}
coll := collector.New(collCfg, log)

staticHandler, err := web.Handler(version)
staticHandler, err := web.Handler()
if err != nil {
return fmt.Errorf("load embedded web assets: %w", err)
}
Expand Down
27 changes: 21 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ server-side; it also echoes back the build-time `version` injected via

## Web dashboard (`internal/web`)

`web.Handler(version)` embeds `internal/web/assets/*` (`index.html`, `app.js`, `chart.js`,
`web.Handler()` embeds `internal/web/assets/*` (`index.html`, `app.js`, `chart.js`,
`gauge.js`, `theme-init.js`, `style.css`) into the binary via `//go:embed` and serves them
with `http.FileServerFS`. There is **no frontend build step** β€” no bundler, no npm
toolchain, no framework β€” the assets are plain HTML/CSS/JS shipped as-is, consistent with
Expand All @@ -293,11 +293,26 @@ the project's "prefer the standard library, minimize dependency surface" philoso

Because embedded files carry a zero `ModTime`, `http.FileServerFS` alone would emit no
`Last-Modified`/`ETag` and no `Cache-Control`, forcing a full refetch of every asset on
every page load. `Handler` wraps the file server to set `Cache-Control: no-cache` and an
`Etag` derived from the build-time `version` (the same string injected via
`-ldflags -X main.version=...` and echoed by `GET /api/v1/config`), so browsers
revalidate with a cheap conditional request (a 304 when the version is unchanged) and
still refetch immediately once an upgrade changes `version`.
every page load. `Handler` wraps the file server to set `Cache-Control: no-cache` and a
**per-asset `Etag` derived from that asset's contents** β€” a SHA-256 of each embedded
file, truncated to 16 bytes and hex-encoded, computed once at construction (the asset set
is fixed at compile time and tiny, so this costs microseconds and no per-request work).
Browsers therefore revalidate with a cheap conditional request (a 304 while the file is
unchanged) and refetch as soon as its bytes change.

The hash replaces an earlier `version`-derived ETag, which was wrong in two ways: it gave
every asset the *same* validator despite each URL being a distinct resource, and it never
changed across builds that share a version string β€” which is every unversioned `dev`
build, so `make run` served permanently stale JavaScript after an edit (issue #102).

The validator is attached only to a response that actually *is* the hashed asset. Naming
an asset is not enough: `http.FileServerFS` redirects `*/index.html` to `./` and a
trailing slash on a file to its base, and a 404 has no stable identity at all β€” so a small
`etagWriter` wrapper removes the header again unless the file server settles on `200`,
`206`, or `304` (a 304 must repeat the validator that matched). The header has to be set
*before* delegating, because `http.ServeContent` answers a conditional request from
whatever is already on the writer; stripping it afterwards is what keeps a cacheable 301
or a 404 from carrying a strong validator for a body it does not describe.

**Stored-XSS prevention is enforced by a repository rule, not just convention.**
`internal/web/xss_test.go` (`TestAppJS_NoInnerHTMLInterpolation`) scans `app.js` at test
Expand Down
100 changes: 92 additions & 8 deletions internal/web/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
package web

import (
"crypto/sha256"
"embed"
"fmt"
"encoding/hex"
"io/fs"
"net/http"
"path"
"strings"
)

//go:embed assets
Expand All @@ -16,21 +19,102 @@ var assetsFS embed.FS
//
// Embedded files carry a zero ModTime, so http.FileServerFS would otherwise
// emit no Last-Modified/ETag and no Cache-Control, forcing a full refetch of
// every asset on every page load. Handler sets a version-derived ETag and a
// Cache-Control: no-cache directive instead, so browsers revalidate cheaply
// (a 304 on an unchanged version) and still pick up new assets immediately
// after an upgrade changes version.
func Handler(version string) (http.Handler, error) {
// every asset on every page load. Handler sets Cache-Control: no-cache plus a
// per-asset ETag derived from that asset's contents, so browsers revalidate
// cheaply (a 304 while the file is unchanged) and pick up an edited asset
// immediately β€” including across builds that share a version string, which is
// every unversioned "dev" build from `make run`.
func Handler() (http.Handler, error) {
sub, err := fs.Sub(assetsFS, "assets")
if err != nil {
return nil, err
}
fileServer := http.FileServerFS(sub)
etag := fmt.Sprintf(`"%s"`, version)

// Hashed once at construction: the asset set is fixed at compile time and
// tiny, so this costs microseconds and no per-request work.
etags, err := assetETags(sub)
if err != nil {
return nil, err
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Etag", etag)
// Set before delegating, because http.ServeContent answers a
// conditional request from the header already on the writer. Naming an
// asset is not the same as serving one, though, so etagWriter takes the
// validator back off any response that is not that asset's bytes.
if etag, ok := etags[etagKey(r.URL.Path)]; ok {
Comment thread
LarsLaskowski marked this conversation as resolved.
w.Header().Set("Etag", etag)
w = &etagWriter{ResponseWriter: w}
}
fileServer.ServeHTTP(w, r)
}), nil
}

// etagWriter strips a pre-set Etag once the file server has settled on a status
// code that is not the asset's own representation.
//
// Two shapes reach here having matched a real asset yet never serving it:
// http.FileServerFS redirects "*/index.html" to "./" and a trailing slash on a
// file to its base, and both branch before serveContent runs. A 301 is
// cacheable by default, so leaving the header set would attach a strong
// validator to an empty body describing a different representation β€” the same
// defect as emitting one on a 404.
type etagWriter struct {
http.ResponseWriter
wroteHeader bool
}

func (w *etagWriter) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.wroteHeader = true
switch code {
// 200 and 206 carry the asset's bytes; a 304 must repeat the validator
// that matched (RFC 9110 15.4.5). Everything else β€” redirects, 404s, a
// range the file could not satisfy β€” gets none.
case http.StatusOK, http.StatusPartialContent, http.StatusNotModified:
default:
w.Header().Del("Etag")
}
w.ResponseWriter.WriteHeader(code)
}

// etagKey maps a request path to the asset key used in the etags map,
// resolving the directory index the same way http.FileServerFS does. A hit
// only means the path names an asset β€” whether that asset is what gets served
// is decided by the file server and enforced by etagWriter.
func etagKey(p string) string {
p = strings.TrimPrefix(path.Clean("/"+p), "/")
if p == "" {
p = "index.html"
}
return p
}

// assetETags walks the embedded asset tree and returns a strong ETag per file,
// derived from a SHA-256 of its contents.
func assetETags(fsys fs.FS) (map[string]string, error) {
etags := make(map[string]string)
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(fsys, p)
if err != nil {
return err
}
sum := sha256.Sum256(data)
etags[p] = `"` + hex.EncodeToString(sum[:16]) + `"`
return nil
})
if err != nil {
return nil, err
}
return etags, nil
}
Loading
Loading