coverage~bigbes/sr-ht-ecoreb36a9272assets/assets.go

Coverage
98.0% 49/50 statements
Δ
+0.0
Blob
844ffd2
Uncovered L303
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 }