// 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..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 == "" { // slog.Warn("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" "os" "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..css of // `make css`, the vendored uplot.iife.min..js of bench, the bundle..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: // 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") } // DirFS is os.DirFS for a configured directory, and an empty filesystem for an // unconfigured one. // // os.DirFS("") does not mean "this build ships no assets". It resolves every // name against the filesystem root, so one unset config key turns a static // handler into a reader of the host — reachable, on this instance, by leaving // a single line out of config.ini. The guard is four lines that nobody writes // until they have seen it happen, which is the argument for it living here. func DirFS(dir string) fs.FS { if dir == "" { return emptyFS{} } return os.DirFS(dir) } // emptyFS is a filesystem in which nothing exists. type emptyFS struct{} func (emptyFS) Open(name string) (fs.File, error) { return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} }