| 1 |
|
// Package assets is the shared hashed-static-asset discovery and serving for |
| 2 |
|
// the custom services of a self-hosted SourceHut instance (compare, spec, dolt, |
| 3 |
|
// cover, bench, tokens). |
| 4 |
|
// |
| 5 |
|
// Every one of those services runs `make css`, which writes exactly one |
| 6 |
|
// content-addressed main.min.<sha>.css into the static tree the binary ships, |
| 7 |
|
// and every one of them then hand-rolled the same four things around it: a |
| 8 |
|
// regexp for the hashed name, a glob to find whichever file this build |
| 9 |
|
// produced, the href the layout links, and a static handler that serves hashed |
| 10 |
|
// files immutable and everything else for an hour. Six copies is six chances to |
| 11 |
|
// drift, and they had: a six-hex-digit hash pattern in one service beside an |
| 12 |
|
// eight-digit one in the next, an anchored per-asset regexp beside a family |
| 13 |
|
// one, a missing stylesheet fatal at startup here and logged there, a directory |
| 14 |
|
// listing refused in three services and published in the fourth. This package |
| 15 |
|
// is the one copy. |
| 16 |
|
// |
| 17 |
|
// Usage, at startup: |
| 18 |
|
// |
| 19 |
|
// cssHref, err := assets.Resolve(staticFS, "static/main.min.*.css", assets.DefaultPrefix) |
| 20 |
|
// if err != nil { |
| 21 |
|
// return nil, err |
| 22 |
|
// } |
| 23 |
|
// if cssHref == "" { |
| 24 |
|
// slog.Warn("no stylesheet in this binary; run `make css` before `go build`") |
| 25 |
|
// } |
| 26 |
|
// svc.StyleHref = cssHref // "" renders a bare page — see Resolve |
| 27 |
|
// |
| 28 |
|
// staticSub, err := fs.Sub(staticFS, "static") |
| 29 |
|
// ... |
| 30 |
|
// mux.Handle(assets.DefaultPrefix, assets.Handler(staticSub, assets.DefaultPrefix, chromeNotFound)) |
| 31 |
|
// |
| 32 |
|
// The filesystem is a parameter everywhere rather than an embed this package |
| 33 |
|
// owns, and for two reasons pulling in opposite directions. From one side, a |
| 34 |
|
// hashed asset is a build product: a checkout never has one and a release build |
| 35 |
|
// always does, so a package-level embed would leave the two branches of every |
| 36 |
|
// function here — asset present, asset absent — unreachable from a test. From |
| 37 |
|
// the other, it is an fs.FS and not an embed.FS because dolt serves its static |
| 38 |
|
// tree from disk, os.DirFS(staticDir), and has to get the same policy as the |
| 39 |
|
// five services that embed theirs. |
| 40 |
|
package assets |
| 41 |
|
|
| 42 |
|
import ( |
| 43 |
|
"fmt" |
| 44 |
|
"io/fs" |
| 45 |
|
"net/http" |
| 46 |
|
"os" |
| 47 |
|
"path" |
| 48 |
|
"regexp" |
| 49 |
|
"strings" |
| 50 |
|
) |
| 51 |
|
|
| 52 |
|
// DefaultPrefix is where every service of this instance mounts its static tree: |
| 53 |
|
// the prefix http.StripPrefix removes before the file server sees a request, |
| 54 |
|
// and the base of every asset URL Resolve builds. It is a default and not a |
| 55 |
|
// constant of the package because the mount point is the caller's routing |
| 56 |
|
// decision; it is here so six services do not each spell it out. |
| 57 |
|
const DefaultPrefix = "/static/" |
| 58 |
|
|
| 59 |
|
// Cache lifetimes, the two halves of the policy CacheControl chooses between. |
| 60 |
|
// |
| 61 |
|
// A content-addressed name may be kept forever, because the name changes |
| 62 |
|
// whenever the bytes do — that is the whole reason `make css` puts a hash in it, |
| 63 |
|
// and serving such a file with anything less than a year is paying for a |
| 64 |
|
// revalidation that can never find a change. |
| 65 |
|
// |
| 66 |
|
// An unhashed asset — a favicon, a logo — gets an hour: long enough to matter, |
| 67 |
|
// short enough that a replacement is not stuck in caches until the next hash |
| 68 |
|
// rotation, which for a file whose name never changes will never come. |
| 69 |
|
const ( |
| 70 |
|
immutableCacheControl = "public, max-age=31536000, immutable" |
| 71 |
|
shortCacheControl = "public, max-age=3600" |
| 72 |
|
) |
| 73 |
|
|
| 74 |
|
// hashedRe matches a content-addressed asset name — the main.min.<sha>.css of |
| 75 |
|
// `make css`, the vendored uplot.iife.min.<sha>.js of bench, the bundle.<sha>.js |
| 76 |
|
// of compare, and whatever else is built into a static tree with a hash in its |
| 77 |
|
// name tomorrow. |
| 78 |
|
// |
| 79 |
|
// One pattern rather than one per asset: what makes a file cacheable forever is |
| 80 |
|
// the hash in its name and not which build step produced it, so an asset named |
| 81 |
|
// the family's way inherits the right lifetime without an edit here. The |
| 82 |
|
// donors that anchored a full name per asset (^main\.min\.[0-9a-f]{6,}\.css$) |
| 83 |
|
// had to grow a second regexp for their second asset, and their two disagreed |
| 84 |
|
// about the hash length within one binary. |
| 85 |
|
// |
| 86 |
|
// Eight hex digits is the floor because every Makefile of this instance cuts |
| 87 |
|
// sha256 to eight; a shorter run of hex is more likely a version number than a |
| 88 |
|
// digest, and admitting it would hand a year of immutability to a file whose |
| 89 |
|
// bytes can change under the name. |
| 90 |
|
// |
| 91 |
|
// ".mjs" is matched alongside ".js" because a module bundle is as |
| 92 |
|
// content-addressed as a script, and an extension this pattern did not know |
| 93 |
|
// would quietly demote a hashed file to the hour an unhashed one gets — the |
| 94 |
|
// failure is silent and shows up only as traffic. |
| 95 |
|
var hashedRe = regexp.MustCompile(`\.[0-9a-f]{8,}\.(css|m?js)$`) |
| 96 |
|
|
| 97 |
|
// IsHashed reports whether name is content-addressed: whether the bytes behind |
| 98 |
|
// it can be trusted never to change, because a new build would produce a new |
| 99 |
|
// name. The argument may be a bare file name or a whole URL path; only the tail |
| 100 |
|
// is inspected. |
| 101 |
20 |
func IsHashed(name string) bool { return hashedRe.MatchString(name) } |
| 102 |
|
|
| 103 |
|
// CacheControl is the lifetime an asset is served with: forever for a |
| 104 |
|
// content-addressed name, an hour for one whose bytes can change under it. |
| 105 |
10 |
func CacheControl(name string) string { |
| 106 |
10 |
if IsHashed(name) { |
| 107 |
7 |
return immutableCacheControl |
| 108 |
7 |
} |
| 109 |
3 |
return shortCacheControl |
| 110 |
|
} |
| 111 |
|
|
| 112 |
|
// Resolve globs fsys for a content-addressed asset and returns its |
| 113 |
|
// site-absolute URL under urlPrefix, or "" when this build produced none. |
| 114 |
|
// |
| 115 |
|
// Absence is reported as "" rather than substituted with an unhashed fallback, |
| 116 |
|
// and it is not an error. There is no placeholder href to invent: a link to a |
| 117 |
|
// file that is not there would 404 on every page load, once per viewer, instead |
| 118 |
|
// of saying what is wrong once, at startup, to whoever can fix it. |
| 119 |
|
// |
| 120 |
|
// Whether that is fatal is the caller's decision, and the donors disagreed — |
| 121 |
|
// compare refused to start without a stylesheet, tokens and bench logged a line |
| 122 |
|
// and rendered unstyled. The shared answer is the second: a service that will |
| 123 |
|
// not boot without a build artefact cannot be run from a checkout, which is |
| 124 |
|
// where its tests and its first bring-up happen. A caller that wants the strict |
| 125 |
|
// reading writes it at its own call site, where the sentence can name the |
| 126 |
|
// service and the make target. |
| 127 |
|
// |
| 128 |
|
// The empty string has to be *guarded* by whoever renders it, not emitted: |
| 129 |
|
// <link rel="stylesheet" href=""> resolves to the page it sits on, so an |
| 130 |
|
// unguarded empty href turns every page load into two. chrome.Page already |
| 131 |
|
// renders a bare page for an empty StyleHref for this reason; a service adding |
| 132 |
|
// a second asset owes its own template the same {{if}}. |
| 133 |
|
// |
| 134 |
|
// The first match wins. `make css` guarantees there is at most one by removing |
| 135 |
|
// the previous build's file before writing the new one, so two matches mean a |
| 136 |
|
// stale artefact in the tree, and picking either is equally arbitrary; the |
| 137 |
|
// remedy is `make clean`, not a sort order in here. |
| 138 |
|
// |
| 139 |
|
// The error is only ever a malformed glob, which is a mistake in the caller's |
| 140 |
|
// source rather than a state of the tree — it is returned instead of panicking |
| 141 |
|
// so a service reports it the way it reports its other startup failures. |
| 142 |
9 |
func Resolve(fsys fs.FS, glob, urlPrefix string) (string, error) { |
| 143 |
9 |
matches, err := fs.Glob(fsys, glob) |
| 144 |
9 |
if err != nil { |
| 145 |
1 |
return "", fmt.Errorf("assets: glob %s: %w", glob, err) |
| 146 |
1 |
} |
| 147 |
8 |
if len(matches) == 0 { |
| 148 |
1 |
return "", nil |
| 149 |
1 |
} |
| 150 |
7 |
return NormalizePrefix(urlPrefix) + path.Base(matches[0]), nil |
| 151 |
|
} |
| 152 |
|
|
| 153 |
|
// Lookup resolves a request path to the name of a file in fsys, reporting false |
| 154 |
|
// for anything that is not one. |
| 155 |
|
// |
| 156 |
|
// It exists because http.FileServer answers the two "not a file" cases in ways |
| 157 |
|
// these services must not. A directory becomes a *listing*: /static/ would |
| 158 |
|
// publish the whole inventory of the binary — every vendored bundle and the |
| 159 |
|
// hashed stylesheet name, which is a build fingerprint nothing else on the |
| 160 |
|
// surface discloses — as a public, hour-cacheable page. A missing file becomes |
| 161 |
|
// net/http's own `404 page not found` in text/plain, which on a surface whose |
| 162 |
|
// every other answer is a page with a nav is the one dead end a viewer cannot |
| 163 |
|
// get out of. |
| 164 |
|
// |
| 165 |
|
// It is exported because a service that already resolves its own 404 wants the |
| 166 |
|
// decision without the serving, and because Handler and the file server behind |
| 167 |
|
// it must agree about what exists: the lookup is a Stat on the same FS the file |
| 168 |
|
// server reads. |
| 169 |
|
// |
| 170 |
|
// A name io/fs rejects — an empty one, a '..' element, an absolute path, the |
| 171 |
|
// second slash of //static — fails the Stat and is reported the same way, which |
| 172 |
|
// is the answer a traversal attempt deserves anyway. |
| 173 |
16 |
func Lookup(fsys fs.FS, urlPrefix, urlPath string) (string, bool) { |
| 174 |
16 |
name, ok := strings.CutPrefix(urlPath, NormalizePrefix(urlPrefix)) |
| 175 |
16 |
if !ok { |
| 176 |
2 |
return "", false |
| 177 |
2 |
} |
| 178 |
14 |
info, err := fs.Stat(fsys, name) |
| 179 |
14 |
if err != nil || info.IsDir() { |
| 180 |
7 |
return "", false |
| 181 |
7 |
} |
| 182 |
7 |
return name, true |
| 183 |
|
} |
| 184 |
|
|
| 185 |
|
// NormalizePrefix returns urlPrefix in the one spelling Resolve, Lookup and |
| 186 |
|
// Handler all agree on: site-absolute and slash-terminated, DefaultPrefix for |
| 187 |
|
// the empty string. |
| 188 |
|
// |
| 189 |
|
// It is exported so a caller that builds an asset URL by hand — a template |
| 190 |
|
// helper, a test — cannot end up with "/staticmain.min.abc.css" while the |
| 191 |
|
// handler is stripping "/static/". |
| 192 |
36 |
func NormalizePrefix(urlPrefix string) string { |
| 193 |
36 |
if urlPrefix == "" { |
| 194 |
2 |
return DefaultPrefix |
| 195 |
2 |
} |
| 196 |
34 |
if !strings.HasPrefix(urlPrefix, "/") { |
| 197 |
3 |
urlPrefix = "/" + urlPrefix |
| 198 |
3 |
} |
| 199 |
34 |
if !strings.HasSuffix(urlPrefix, "/") { |
| 200 |
4 |
urlPrefix += "/" |
| 201 |
4 |
} |
| 202 |
34 |
return urlPrefix |
| 203 |
|
} |
| 204 |
|
|
| 205 |
|
// Handler serves fsys under urlPrefix with the cache policy of CacheControl. |
| 206 |
|
// |
| 207 |
|
// It is the one route of these surfaces that opts out of the private, no-store |
| 208 |
|
// policy the rest of a logged-in page carries, and it does so in full: the |
| 209 |
|
// asset's lifetime is written and the `Vary` is removed rather than left in |
| 210 |
|
// place — an asset served identically to everybody but declared to vary on |
| 211 |
|
// Cookie is an asset no shared cache will ever reuse, which is the whole point |
| 212 |
|
// of hashing its name in the first place. |
| 213 |
|
// |
| 214 |
|
// The opt-out is granted per asset, once the file has been found, and it is |
| 215 |
|
// *written* later still (see writer). notFound answers everything else — a |
| 216 |
|
// directory, a name that is not there, a traversal attempt — and is where a |
| 217 |
|
// service passes its own chrome-wrapped 404 so an asset URL typed by hand has a |
| 218 |
|
// nav to get out of. A nil notFound falls back to net/http's plaintext 404. |
| 219 |
8 |
func Handler(fsys fs.FS, urlPrefix string, notFound http.Handler) http.Handler { |
| 220 |
8 |
prefix := NormalizePrefix(urlPrefix) |
| 221 |
8 |
if notFound == nil { |
| 222 |
6 |
notFound = http.HandlerFunc(http.NotFound) |
| 223 |
6 |
} |
| 224 |
|
// The file server is built here from the same fsys Lookup consults, so the |
| 225 |
|
// two cannot disagree about what exists — a caller that passed one FS to the |
| 226 |
|
// handler and kept another for the lookup would be serving one tree and |
| 227 |
|
// answering questions about a different one. |
| 228 |
8 |
files := http.StripPrefix(prefix, http.FileServer(http.FS(fsys))) |
| 229 |
8 |
|
| 230 |
9 |
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 231 |
9 |
name, ok := Lookup(fsys, prefix, r.URL.Path) |
| 232 |
9 |
if !ok { |
| 233 |
3 |
notFound.ServeHTTP(w, r) |
| 234 |
3 |
return |
| 235 |
3 |
} |
| 236 |
|
|
| 237 |
|
// The one type stated here, and the one header that cannot wait for |
| 238 |
|
// writer: ServeContent reads the header map to decide whether it has to |
| 239 |
|
// sniff, so a Content-Type stamped at WriteHeader time would arrive |
| 240 |
|
// after the decision it exists to make. |
| 241 |
|
// |
| 242 |
|
// net/http derives the rest from mime.TypeByExtension, which is seeded |
| 243 |
|
// from the *host's* mime tables (/etc/mime.types and friends) and lets |
| 244 |
|
// them override Go's builtins — so a vendored script is served as |
| 245 |
|
// whatever the image underneath happens to say about ".js", which on |
| 246 |
|
// some is application/x-javascript and on a minimal one may be nothing |
| 247 |
|
// at all, i.e. sniffed. A browser refuses to execute a module script |
| 248 |
|
// whose type is not a JavaScript MIME type, so a chart would be missing |
| 249 |
|
// on one deployment and present on another from the same binary. |
| 250 |
|
// ServeContent leaves a Content-Type that is already set. |
| 251 |
|
// |
| 252 |
|
// ".mjs" is here because it is the spelling a module bundle is most |
| 253 |
|
// likely to arrive under, and the one the host tables are least likely |
| 254 |
|
// to know — an extension registered later than ".js" and absent from a |
| 255 |
|
// minimal image is exactly the case this branch exists for. |
| 256 |
6 |
if ext := path.Ext(name); ext == ".js" || ext == ".mjs" { |
| 257 |
1 |
w.Header().Set("Content-Type", "text/javascript; charset=utf-8") |
| 258 |
1 |
} |
| 259 |
|
|
| 260 |
6 |
files.ServeHTTP(&writer{ResponseWriter: w, cacheControl: CacheControl(name)}, r) |
| 261 |
|
}) |
| 262 |
|
} |
| 263 |
|
|
| 264 |
|
// writer puts the public cache policy of an asset on the response at the moment |
| 265 |
|
// the answer is committed, and not a line earlier. |
| 266 |
|
// |
| 267 |
|
// Writing it onto the header map before delegating is the obvious spelling and |
| 268 |
|
// it is wrong, because the header map outlives the handler that filled it: a |
| 269 |
|
// panic anywhere after that line is recovered by whatever middleware renders |
| 270 |
|
// the 500 — a page carrying a viewer's login block — into a response that |
| 271 |
|
// already says `public, max-age=3600` with the `Vary` deleted. The directives |
| 272 |
|
// belong to the bytes of an asset, so they are attached where the bytes are, |
| 273 |
|
// and an answer that never gets to write keeps the private directives every |
| 274 |
|
// other page on the surface carries. |
| 275 |
|
// |
| 276 |
|
// Everything that does reach the stamp is the delegate's answer about a file |
| 277 |
|
// Lookup has already found — a 200, a 304 for a conditional request, a 206 or |
| 278 |
|
// the 416 of an unsatisfiable Range — and every one of those describes the same |
| 279 |
|
// public bytes, so none of them is stamped conditionally. |
| 280 |
|
type writer struct { |
| 281 |
|
http.ResponseWriter |
| 282 |
|
|
| 283 |
|
cacheControl string |
| 284 |
|
stamped bool |
| 285 |
|
} |
| 286 |
|
|
| 287 |
6 |
func (w *writer) WriteHeader(status int) { |
| 288 |
6 |
w.stamp() |
| 289 |
6 |
w.ResponseWriter.WriteHeader(status) |
| 290 |
6 |
} |
| 291 |
|
|
| 292 |
|
// Write covers the delegate that writes a body without a WriteHeader of its |
| 293 |
|
// own: net/http commits an implicit 200 inside Write, and the header map is |
| 294 |
|
// frozen from that point on. |
| 295 |
5 |
func (w *writer) Write(b []byte) (int, error) { |
| 296 |
5 |
w.stamp() |
| 297 |
5 |
return w.ResponseWriter.Write(b) |
| 298 |
5 |
} |
| 299 |
|
|
| 300 |
|
// Unwrap is http.ResponseController's seam. A wrapper that does not implement |
| 301 |
|
// it hides the flush and the deadlines of the writer underneath from anything |
| 302 |
|
// that asks for them later. |
| 303 |
0 |
func (w *writer) Unwrap() http.ResponseWriter { return w.ResponseWriter } |
| 304 |
|
|
| 305 |
11 |
func (w *writer) stamp() { |
| 306 |
11 |
if w.stamped { |
| 307 |
5 |
return |
| 308 |
5 |
} |
| 309 |
6 |
w.stamped = true |
| 310 |
6 |
|
| 311 |
6 |
// Set and Del, not Add: the middleware has already written a page's policy |
| 312 |
6 |
// and this is the asset's opt-out from it. The Vary goes rather than being |
| 313 |
6 |
// overwritten, for the reason Handler gives. |
| 314 |
6 |
w.Header().Set("Cache-Control", w.cacheControl) |
| 315 |
6 |
w.Header().Del("Vary") |
| 316 |
|
} |
| 317 |
|
|
| 318 |
|
// DirFS is os.DirFS for a configured directory, and an empty filesystem for an |
| 319 |
|
// unconfigured one. |
| 320 |
|
// |
| 321 |
|
// os.DirFS("") does not mean "this build ships no assets". It resolves every |
| 322 |
|
// name against the filesystem root, so one unset config key turns a static |
| 323 |
|
// handler into a reader of the host — reachable, on this instance, by leaving |
| 324 |
|
// a single line out of config.ini. The guard is four lines that nobody writes |
| 325 |
|
// until they have seen it happen, which is the argument for it living here. |
| 326 |
2 |
func DirFS(dir string) fs.FS { |
| 327 |
2 |
if dir == "" { |
| 328 |
1 |
return emptyFS{} |
| 329 |
1 |
} |
| 330 |
1 |
return os.DirFS(dir) |
| 331 |
|
} |
| 332 |
|
|
| 333 |
|
// emptyFS is a filesystem in which nothing exists. |
| 334 |
|
type emptyFS struct{} |
| 335 |
|
|
| 336 |
2 |
func (emptyFS) Open(name string) (fs.File, error) { |
| 337 |
2 |
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} |
| 338 |
2 |
} |