@@ 0,0 1,315 @@
+// Package assets is the shared hashed-static-asset discovery and serving for
+// the custom services of a self-hosted SourceHut instance (compare, spec, dolt,
+// cover, bench, tokens).
+//
+// Every one of those services runs `make css`, which writes exactly one
+// content-addressed main.min.<sha>.css into the static tree the binary ships,
+// and every one of them then hand-rolled the same four things around it: a
+// regexp for the hashed name, a glob to find whichever file this build
+// produced, the href the layout links, and a static handler that serves hashed
+// files immutable and everything else for an hour. Six copies is six chances to
+// drift, and they had: a six-hex-digit hash pattern in one service beside an
+// eight-digit one in the next, an anchored per-asset regexp beside a family
+// one, a missing stylesheet fatal at startup here and logged there, a directory
+// listing refused in three services and published in the fourth. This package
+// is the one copy.
+//
+// Usage, at startup:
+//
+// cssHref, err := assets.Resolve(staticFS, "static/main.min.*.css", assets.DefaultPrefix)
+// if err != nil {
+// return nil, err
+// }
+// if cssHref == "" {
+// log.Printf("web: no stylesheet in this binary; run `make css` before `go build`")
+// }
+// svc.StyleHref = cssHref // "" renders a bare page — see Resolve
+//
+// staticSub, err := fs.Sub(staticFS, "static")
+// ...
+// mux.Handle(assets.DefaultPrefix, assets.Handler(staticSub, assets.DefaultPrefix, chromeNotFound))
+//
+// The filesystem is a parameter everywhere rather than an embed this package
+// owns, and for two reasons pulling in opposite directions. From one side, a
+// hashed asset is a build product: a checkout never has one and a release build
+// always does, so a package-level embed would leave the two branches of every
+// function here — asset present, asset absent — unreachable from a test. From
+// the other, it is an fs.FS and not an embed.FS because dolt serves its static
+// tree from disk, os.DirFS(staticDir), and has to get the same policy as the
+// five services that embed theirs.
+package assets
+
+import (
+ "fmt"
+ "io/fs"
+ "net/http"
+ "path"
+ "regexp"
+ "strings"
+)
+
+// DefaultPrefix is where every service of this instance mounts its static tree:
+// the prefix http.StripPrefix removes before the file server sees a request,
+// and the base of every asset URL Resolve builds. It is a default and not a
+// constant of the package because the mount point is the caller's routing
+// decision; it is here so six services do not each spell it out.
+const DefaultPrefix = "/static/"
+
+// Cache lifetimes, the two halves of the policy CacheControl chooses between.
+//
+// A content-addressed name may be kept forever, because the name changes
+// whenever the bytes do — that is the whole reason `make css` puts a hash in it,
+// and serving such a file with anything less than a year is paying for a
+// revalidation that can never find a change.
+//
+// An unhashed asset — a favicon, a logo — gets an hour: long enough to matter,
+// short enough that a replacement is not stuck in caches until the next hash
+// rotation, which for a file whose name never changes will never come.
+const (
+ immutableCacheControl = "public, max-age=31536000, immutable"
+ shortCacheControl = "public, max-age=3600"
+)
+
+// hashedRe matches a content-addressed asset name — the main.min.<sha>.css of
+// `make css`, the vendored uplot.iife.min.<sha>.js of bench, the bundle.<sha>.js
+// of compare, and whatever else is built into a static tree with a hash in its
+// name tomorrow.
+//
+// One pattern rather than one per asset: what makes a file cacheable forever is
+// the hash in its name and not which build step produced it, so an asset named
+// the family's way inherits the right lifetime without an edit here. The
+// donors that anchored a full name per asset (^main\.min\.[0-9a-f]{6,}\.css$)
+// had to grow a second regexp for their second asset, and their two disagreed
+// about the hash length within one binary.
+//
+// Eight hex digits is the floor because every Makefile of this instance cuts
+// sha256 to eight; a shorter run of hex is more likely a version number than a
+// digest, and admitting it would hand a year of immutability to a file whose
+// bytes can change under the name.
+//
+// ".mjs" is matched alongside ".js" because a module bundle is as
+// content-addressed as a script, and an extension this pattern did not know
+// would quietly demote a hashed file to the hour an unhashed one gets — the
+// failure is silent and shows up only as traffic.
+var hashedRe = regexp.MustCompile(`\.[0-9a-f]{8,}\.(css|m?js)$`)
+
+// IsHashed reports whether name is content-addressed: whether the bytes behind
+// it can be trusted never to change, because a new build would produce a new
+// name. The argument may be a bare file name or a whole URL path; only the tail
+// is inspected.
+func IsHashed(name string) bool { return hashedRe.MatchString(name) }
+
+// CacheControl is the lifetime an asset is served with: forever for a
+// content-addressed name, an hour for one whose bytes can change under it.
+func CacheControl(name string) string {
+ if IsHashed(name) {
+ return immutableCacheControl
+ }
+ return shortCacheControl
+}
+
+// Resolve globs fsys for a content-addressed asset and returns its
+// site-absolute URL under urlPrefix, or "" when this build produced none.
+//
+// Absence is reported as "" rather than substituted with an unhashed fallback,
+// and it is not an error. There is no placeholder href to invent: a link to a
+// file that is not there would 404 on every page load, once per viewer, instead
+// of saying what is wrong once, at startup, to whoever can fix it.
+//
+// Whether that is fatal is the caller's decision, and the donors disagreed —
+// compare refused to start without a stylesheet, tokens and bench logged a line
+// and rendered unstyled. The shared answer is the second: a service that will
+// not boot without a build artefact cannot be run from a checkout, which is
+// where its tests and its first bring-up happen. A caller that wants the strict
+// reading writes it at its own call site, where the sentence can name the
+// service and the make target.
+//
+// The empty string has to be *guarded* by whoever renders it, not emitted:
+// <link rel="stylesheet" href=""> resolves to the page it sits on, so an
+// unguarded empty href turns every page load into two. chrome.Page already
+// renders a bare page for an empty StyleHref for this reason; a service adding
+// a second asset owes its own template the same {{if}}.
+//
+// The first match wins. `make css` guarantees there is at most one by removing
+// the previous build's file before writing the new one, so two matches mean a
+// stale artefact in the tree, and picking either is equally arbitrary; the
+// remedy is `make clean`, not a sort order in here.
+//
+// The error is only ever a malformed glob, which is a mistake in the caller's
+// source rather than a state of the tree — it is returned instead of panicking
+// so a service reports it the way it reports its other startup failures.
+func Resolve(fsys fs.FS, glob, urlPrefix string) (string, error) {
+ matches, err := fs.Glob(fsys, glob)
+ if err != nil {
+ return "", fmt.Errorf("assets: glob %s: %w", glob, err)
+ }
+ if len(matches) == 0 {
+ return "", nil
+ }
+ return NormalizePrefix(urlPrefix) + path.Base(matches[0]), nil
+}
+
+// Lookup resolves a request path to the name of a file in fsys, reporting false
+// for anything that is not one.
+//
+// It exists because http.FileServer answers the two "not a file" cases in ways
+// these services must not. A directory becomes a *listing*: /static/ would
+// publish the whole inventory of the binary — every vendored bundle and the
+// hashed stylesheet name, which is a build fingerprint nothing else on the
+// surface discloses — as a public, hour-cacheable page. A missing file becomes
+// net/http's own `404 page not found` in text/plain, which on a surface whose
+// every other answer is a page with a nav is the one dead end a viewer cannot
+// get out of.
+//
+// It is exported because a service that already resolves its own 404 wants the
+// decision without the serving, and because Handler and the file server behind
+// it must agree about what exists: the lookup is a Stat on the same FS the file
+// server reads.
+//
+// A name io/fs rejects — an empty one, a '..' element, an absolute path, the
+// second slash of //static — fails the Stat and is reported the same way, which
+// is the answer a traversal attempt deserves anyway.
+func Lookup(fsys fs.FS, urlPrefix, urlPath string) (string, bool) {
+ name, ok := strings.CutPrefix(urlPath, NormalizePrefix(urlPrefix))
+ if !ok {
+ return "", false
+ }
+ info, err := fs.Stat(fsys, name)
+ if err != nil || info.IsDir() {
+ return "", false
+ }
+ return name, true
+}
+
+// NormalizePrefix returns urlPrefix in the one spelling Resolve, Lookup and
+// Handler all agree on: site-absolute and slash-terminated, DefaultPrefix for
+// the empty string.
+//
+// It is exported so a caller that builds an asset URL by hand — a template
+// helper, a test — cannot end up with "/staticmain.min.abc.css" while the
+// handler is stripping "/static/".
+func NormalizePrefix(urlPrefix string) string {
+ if urlPrefix == "" {
+ return DefaultPrefix
+ }
+ if !strings.HasPrefix(urlPrefix, "/") {
+ urlPrefix = "/" + urlPrefix
+ }
+ if !strings.HasSuffix(urlPrefix, "/") {
+ urlPrefix += "/"
+ }
+ return urlPrefix
+}
+
+// Handler serves fsys under urlPrefix with the cache policy of CacheControl.
+//
+// It is the one route of these surfaces that opts out of the private, no-store
+// policy the rest of a logged-in page carries, and it does so in full: the
+// asset's lifetime is written and the `Vary` is removed rather than left in
+// place — an asset served identically to everybody but declared to vary on
+// Cookie is an asset no shared cache will ever reuse, which is the whole point
+// of hashing its name in the first place.
+//
+// The opt-out is granted per asset, once the file has been found, and it is
+// *written* later still (see writer). notFound answers everything else — a
+// directory, a name that is not there, a traversal attempt — and is where a
+// service passes its own chrome-wrapped 404 so an asset URL typed by hand has a
+// nav to get out of. A nil notFound falls back to net/http's plaintext 404.
+func Handler(fsys fs.FS, urlPrefix string, notFound http.Handler) http.Handler {
+ prefix := NormalizePrefix(urlPrefix)
+ if notFound == nil {
+ notFound = http.HandlerFunc(http.NotFound)
+ }
+ // The file server is built here from the same fsys Lookup consults, so the
+ // two cannot disagree about what exists — a caller that passed one FS to the
+ // handler and kept another for the lookup would be serving one tree and
+ // answering questions about a different one.
+ files := http.StripPrefix(prefix, http.FileServer(http.FS(fsys)))
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ name, ok := Lookup(fsys, prefix, r.URL.Path)
+ if !ok {
+ notFound.ServeHTTP(w, r)
+ return
+ }
+
+ // The one type stated here, and the one header that cannot wait for
+ // writer: ServeContent reads the header map to decide whether it has to
+ // sniff, so a Content-Type stamped at WriteHeader time would arrive
+ // after the decision it exists to make.
+ //
+ // net/http derives the rest from mime.TypeByExtension, which is seeded
+ // from the *host's* mime tables (/etc/mime.types and friends) and lets
+ // them override Go's builtins — so a vendored script is served as
+ // whatever the image underneath happens to say about ".js", which on
+ // some is application/x-javascript and on a minimal one may be nothing
+ // at all, i.e. sniffed. A browser refuses to execute a module script
+ // whose type is not a JavaScript MIME type, so a chart would be missing
+ // on one deployment and present on another from the same binary.
+ // ServeContent leaves a Content-Type that is already set.
+ //
+ // ".mjs" is here because it is the spelling a module bundle is most
+ // likely to arrive under, and the one the host tables are least likely
+ // to know — an extension registered later than ".js" and absent from a
+ // minimal image is exactly the case this branch exists for.
+ if ext := path.Ext(name); ext == ".js" || ext == ".mjs" {
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ }
+
+ files.ServeHTTP(&writer{ResponseWriter: w, cacheControl: CacheControl(name)}, r)
+ })
+}
+
+// writer puts the public cache policy of an asset on the response at the moment
+// the answer is committed, and not a line earlier.
+//
+// Writing it onto the header map before delegating is the obvious spelling and
+// it is wrong, because the header map outlives the handler that filled it: a
+// panic anywhere after that line is recovered by whatever middleware renders
+// the 500 — a page carrying a viewer's login block — into a response that
+// already says `public, max-age=3600` with the `Vary` deleted. The directives
+// belong to the bytes of an asset, so they are attached where the bytes are,
+// and an answer that never gets to write keeps the private directives every
+// other page on the surface carries.
+//
+// Everything that does reach the stamp is the delegate's answer about a file
+// Lookup has already found — a 200, a 304 for a conditional request, a 206 or
+// the 416 of an unsatisfiable Range — and every one of those describes the same
+// public bytes, so none of them is stamped conditionally.
+type writer struct {
+ http.ResponseWriter
+
+ cacheControl string
+ stamped bool
+}
+
+func (w *writer) WriteHeader(status int) {
+ w.stamp()
+ w.ResponseWriter.WriteHeader(status)
+}
+
+// Write covers the delegate that writes a body without a WriteHeader of its
+// own: net/http commits an implicit 200 inside Write, and the header map is
+// frozen from that point on.
+func (w *writer) Write(b []byte) (int, error) {
+ w.stamp()
+ return w.ResponseWriter.Write(b)
+}
+
+// Unwrap is http.ResponseController's seam. A wrapper that does not implement
+// it hides the flush and the deadlines of the writer underneath from anything
+// that asks for them later.
+func (w *writer) Unwrap() http.ResponseWriter { return w.ResponseWriter }
+
+func (w *writer) stamp() {
+ if w.stamped {
+ return
+ }
+ w.stamped = true
+
+ // Set and Del, not Add: the middleware has already written a page's policy
+ // and this is the asset's opt-out from it. The Vary goes rather than being
+ // overwritten, for the reason Handler gives.
+ w.Header().Set("Cache-Control", w.cacheControl)
+ w.Header().Del("Vary")
+}
@@ 0,0 1,292 @@
+package assets_test
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "testing/fstest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/assets"
+)
+
+// staticFS stands in for the embedded static tree of a real service: a hashed
+// stylesheet, a hashed module bundle and an unhashed logo, which is exactly the
+// mix the cache policy has to split.
+//
+//go:embed testdata/static
+var staticFS embed.FS
+
+const cssGlob = "testdata/static/main.min.*.css"
+
+// staticSub is the tree a service mounts: fs.Sub of the embed, so a request for
+// /static/main.min.0badc0de.css names "main.min.0badc0de.css" in it.
+func staticSub(t *testing.T) fs.FS {
+ t.Helper()
+ sub, err := fs.Sub(staticFS, "testdata/static")
+ require.NoError(t, err)
+ return sub
+}
+
+// dirFS writes the same three files to a temporary directory and returns
+// os.DirFS over it — dolt's shape, whose static tree lives on disk and is
+// configured rather than embedded.
+func dirFS(t *testing.T) fs.FS {
+ t.Helper()
+ dir := t.TempDir()
+ for _, name := range []string{"main.min.0badc0de.css", "bundle.0badc0de.mjs", "logo.svg"} {
+ body, err := staticFS.ReadFile("testdata/static/" + name)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
+ }
+ return os.DirFS(dir)
+}
+
+func TestResolveFindsTheHashedAssetInAnEmbeddedTree(t *testing.T) {
+ href, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix)
+ require.NoError(t, err)
+
+ assert.Equal(t, "/static/main.min.0badc0de.css", href)
+ assert.True(t, assets.IsHashed(href), "a hashed asset is cacheable forever")
+}
+
+func TestResolveFindsTheHashedAssetOnDisk(t *testing.T) {
+ // dolt configures a directory rather than embedding one, so the glob has no
+ // "static/" component in it and the URL prefix is the only thing that puts
+ // the asset under /static/. Both spellings must produce the same href.
+ href, err := assets.Resolve(dirFS(t), "main.min.*.css", assets.DefaultPrefix)
+ require.NoError(t, err)
+
+ assert.Equal(t, "/static/main.min.0badc0de.css", href)
+}
+
+func TestResolveAnswersEmptyWhenTheAssetWasNeverBuilt(t *testing.T) {
+ // The hashed CSS is a build product, so this is the state of every checkout:
+ // Resolve answers "" without an error and the layout renders no <link> at
+ // all rather than one pointing at a file nothing will serve.
+ href, err := assets.Resolve(fstest.MapFS{}, cssGlob, assets.DefaultPrefix)
+ require.NoError(t, err)
+ assert.Empty(t, href)
+}
+
+func TestResolveTakesTheUrlPrefixAsGiven(t *testing.T) {
+ fsys := fstest.MapFS{
+ "static/main.min.deadbeef.css": &fstest.MapFile{Data: []byte("body{}")},
+ }
+
+ for prefix, want := range map[string]string{
+ "": "/static/main.min.deadbeef.css",
+ "/static": "/static/main.min.deadbeef.css",
+ "/static/": "/static/main.min.deadbeef.css",
+ "assets": "/assets/main.min.deadbeef.css",
+ "/a/b/": "/a/b/main.min.deadbeef.css",
+ } {
+ href, err := assets.Resolve(fsys, "static/main.min.*.css", prefix)
+ require.NoError(t, err, prefix)
+ assert.Equal(t, want, href, prefix)
+ }
+}
+
+func TestResolveReportsAMalformedGlob(t *testing.T) {
+ _, err := assets.Resolve(fstest.MapFS{}, "static/[", assets.DefaultPrefix)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "assets: glob")
+}
+
+func TestIsHashedRecognisesTheWholeAssetFamily(t *testing.T) {
+ for _, name := range []string{
+ "main.min.0badc0de.css",
+ "bundle.0badc0de.mjs",
+ "uplot.iife.min.0badc0dedeadbeef.js",
+ "/static/main.min.0badc0de.css",
+ } {
+ assert.True(t, assets.IsHashed(name), name)
+ }
+
+ for _, name := range []string{
+ "main.css", // the dev build, whose bytes change under the name
+ "main.min.abc123.css", // six hex digits is a version, not a digest
+ "logo.svg", // not a build product of the css/js family
+ "main.min.0badc0de.svg",
+ "main.min.0badc0de.css.map",
+ } {
+ assert.False(t, assets.IsHashed(name), name)
+ }
+}
+
+func TestCacheControlSplitsOnTheHash(t *testing.T) {
+ // The split is the whole policy: a name that changes with the bytes may be
+ // kept forever, a name that does not gets an hour so a replacement is not
+ // stuck in caches until a rotation that will never come.
+ assert.Equal(t,
+ "public, max-age=31536000, immutable",
+ assets.CacheControl("main.min.0badc0de.css"))
+ assert.Equal(t,
+ "public, max-age=31536000, immutable",
+ assets.CacheControl("bundle.0badc0de.mjs"))
+ assert.Equal(t, "public, max-age=3600", assets.CacheControl("logo.svg"))
+ assert.Equal(t, "public, max-age=3600", assets.CacheControl("main.css"))
+}
+
+func TestLookupRefusesWhatIsNotAFileUnderThePrefix(t *testing.T) {
+ fsys := staticSub(t)
+
+ for _, urlPath := range []string{
+ "/staticky/main.min.0badc0de.css", // a prefix that only looks like ours
+ "/static", // the mount point itself
+ "/static/", // the directory, i.e. a listing
+ "/static/../assets.go", // traversal
+ "/static//main.min.0badc0de.css", // an empty path element io/fs rejects
+ "/static/nothing.css",
+ } {
+ _, ok := assets.Lookup(fsys, assets.DefaultPrefix, urlPath)
+ assert.False(t, ok, urlPath)
+ }
+
+ name, ok := assets.Lookup(fsys, assets.DefaultPrefix, "/static/main.min.0badc0de.css")
+ assert.True(t, ok)
+ assert.Equal(t, "main.min.0badc0de.css", name)
+}
+
+func TestHandlerServesAHashedAssetImmutableAndDropsVary(t *testing.T) {
+ h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil)
+
+ w := httptest.NewRecorder()
+ // The Vary a page middleware would have set before the route was reached: an
+ // asset served identically to everybody but declared to vary on Cookie is
+ // one no shared cache will reuse.
+ w.Header().Set("Vary", "Cookie")
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil))
+
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
+ assert.Empty(t, w.Header().Get("Vary"))
+ assert.Contains(t, w.Body.String(), "margin:0")
+}
+
+func TestHandlerServesAnUnhashedAssetForAnHour(t *testing.T) {
+ h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/logo.svg", nil))
+
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "public, max-age=3600", w.Header().Get("Cache-Control"))
+}
+
+func TestHandlerTypesAModuleScriptItself(t *testing.T) {
+ // The host's mime tables may not know ".mjs" at all, and a browser refuses
+ // to execute a module script whose type is not a JavaScript MIME type — so
+ // the same binary would render a working page on one image and a broken one
+ // on the next if this were left to mime.TypeByExtension.
+ h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/bundle.0badc0de.mjs", nil))
+
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "text/javascript; charset=utf-8", w.Header().Get("Content-Type"))
+ assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
+}
+
+func TestHandlerServesAnOnDiskTreeTheSameWay(t *testing.T) {
+ h := assets.Handler(dirFS(t), assets.DefaultPrefix, nil)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil))
+
+ require.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
+}
+
+func TestHandlerNeverPublishesAListing(t *testing.T) {
+ // /static/ under http.FileServer is the inventory of the binary — every
+ // vendored bundle and the hashed stylesheet name, which is a build
+ // fingerprint nothing else on the surface discloses.
+ h := assets.Handler(staticSub(t), assets.DefaultPrefix, nil)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/", nil))
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ assert.NotContains(t, w.Body.String(), "main.min.0badc0de.css")
+}
+
+func TestHandlerDelegatesEverythingElseToNotFound(t *testing.T) {
+ // This is where a service passes its chrome-wrapped 404, so an asset URL
+ // typed by hand answers with a page that has a nav to get out of.
+ notFound := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte("<nav>the chrome 404</nav>"))
+ })
+ h := assets.Handler(staticSub(t), assets.DefaultPrefix, notFound)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/nothing.css", nil))
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ assert.Contains(t, w.Body.String(), "the chrome 404")
+}
+
+func TestAnAnswerThatIsNotAnAssetKeepsThePagePolicy(t *testing.T) {
+ // The reason the cache directives are stamped at commit time rather than
+ // before delegating: the header map outlives the decision. A 404 rendered as
+ // a page — with a viewer's login block in it — must not inherit the public,
+ // hour-long policy of the asset that was never served.
+ notFound := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ })
+ h := assets.Handler(staticSub(t), assets.DefaultPrefix, notFound)
+
+ w := httptest.NewRecorder()
+ w.Header().Set("Cache-Control", "private, no-store")
+ w.Header().Set("Vary", "Cookie")
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/nothing.css", nil))
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ assert.Equal(t, "private, no-store", w.Header().Get("Cache-Control"))
+ assert.Equal(t, "Cookie", w.Header().Get("Vary"))
+}
+
+func TestHandlerStampsAConditionalRequestToo(t *testing.T) {
+ // A 304 describes the same public bytes as the 200 it replaces, so it
+ // carries the same policy — a cache that revalidated would otherwise lose
+ // the lifetime it revalidated for.
+ //
+ // The on-disk tree and not the embedded one: files in an embed.FS have a
+ // zero modification time, so net/http emits no Last-Modified for them and a
+ // conditional request is unanswerable in the first place.
+ h := assets.Handler(dirFS(t), assets.DefaultPrefix, nil)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil))
+ require.Equal(t, http.StatusOK, w.Code)
+ lastModified := w.Header().Get("Last-Modified")
+ require.NotEmpty(t, lastModified)
+
+ r := httptest.NewRequest(http.MethodGet, "/static/main.min.0badc0de.css", nil)
+ r.Header.Set("If-Modified-Since", lastModified)
+ w = httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+
+ require.Equal(t, http.StatusNotModified, w.Code)
+ assert.Equal(t, "public, max-age=31536000, immutable", w.Header().Get("Cache-Control"))
+}
+
+func TestNormalizePrefixIsWhatKeepsTheHrefAndTheMountTogether(t *testing.T) {
+ for given, want := range map[string]string{
+ "": "/static/",
+ "/static/": "/static/",
+ "/static": "/static/",
+ "static": "/static/",
+ "assets/": "/assets/",
+ } {
+ assert.Equal(t, want, assets.NormalizePrefix(given), given)
+ }
+}