~bigbes/sr-ht-ecore

ref: 3310ac7e62d2765536e2640f63346d654781ae53 sr-ht-ecore/assets/assets.go -rw-r--r-- 14.2 KiB
3310ac7e — Eugene Blikh bearer, pages: the refusal table and the form read that must not drift 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
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 == "" {
//		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"
	"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")
}