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
package main

// Frontend assets.
//
// The three third-party files the UI needs (a stylesheet, htmx and howler) are
// fetched from their upstream URLs at startup, their SHA-384 computed, and then
// served from memory under a local path with an `integrity=` attribute
// referencing that hash.
//
// This is a port of the Haskell `prefetchHtmlIntegrities`, including the
// network dependency at startup, with one forced exception: stylize.css is
// vendored in resources/ because its upstream repository has been deleted (see
// resources/README.md). Prefetching it is no longer possible at all.
//
// Note what the integrity attribute does and does not buy for the two assets
// that are still fetched: because the hash is computed from whatever was just
// downloaded, rather than being pinned in the source, it does not protect
// against the upstream changing. It only guarantees that the browser sees the
// same bytes the server did. Pinning would mean writing the expected hash next
// to the URL and refusing to start on a mismatch.
//
// Consequence: the server needs network access at startup and will fail to
// start without it — as the deletion of stylize.css demonstrated, since a
// failed prefetch is fatal. See whatcd-resolver.1, section CAVEATS.

import (
	"context"
	"crypto/rand"
	"crypto/sha512"
	_ "embed"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"time"

	"go.opentelemetry.io/otel/trace"
	"golang.org/x/sync/errgroup"
)

// assetTagKind decides whether the asset is included as a stylesheet or a
// script.
type assetTagKind int

const (
	assetLink assetTagKind = iota
	assetScript
)

// assetSpec describes one asset to prefetch.
type assetSpec struct {
	// name is used in logs and spans only.
	name string
	// url is fetched at startup, unless vendored is set.
	url string
	// vendored, when non-nil, is used instead of fetching url.
	vendored []byte
	// localPath is where we serve it, and what the tag references.
	localPath string
	// kind selects <link> or <script>.
	kind assetTagKind
	// wantSourceMap additionally fetches url + ".map".
	wantSourceMap bool
	// ignoreUpstreamContentType serves our own content type instead of the
	// upstream one. raw.githubusercontent.com serves CSS as text/plain, which
	// browsers refuse to apply as a stylesheet.
	ignoreUpstreamContentType bool
}

//go:embed resources/stylize.css
var vendoredStylizeCSS []byte

var assetSpecs = []assetSpec{
	{
		// Vendored, not fetched: the upstream repository has been deleted from
		// GitHub. See resources/README.md.
		name:                      "Stylize CSS",
		url:                       "https://raw.githubusercontent.com/vasanthv/stylize.css/master/stylize.css",
		vendored:                  vendoredStylizeCSS,
		localPath:                 "/resources/stylize.css",
		kind:                      assetLink,
		ignoreUpstreamContentType: true,
	},
	{
		name:      "htmx",
		url:       "https://unpkg.com/htmx.org@1.9.2",
		localPath: "/resources/htmx.js",
		kind:      assetScript,
	},
	{
		name:          "howler.js",
		url:           "https://unpkg.com/howler@2.2.4",
		localPath:     "/resources/howler.js",
		kind:          assetScript,
		wantSourceMap: true,
	},
}

// fetchedAsset is one downloaded asset, ready to serve.
type fetchedAsset struct {
	spec        assetSpec
	body        []byte
	contentType string
	// integrity is the "sha384-<base64>" value for the integrity attribute.
	integrity string
	// sourceMap is the contents of url + ".map", if requested and present.
	sourceMap []byte
}

// assetBundle holds every prefetched asset and the HTML that includes them.
type assetBundle struct {
	assets []fetchedAsset
	// headHTML is the <link>/<script> tags, ready to be placed in <head>.
	headHTML string
}

// prefetchAssets downloads all assets concurrently.
func prefetchAssets(ctx context.Context) (*assetBundle, error) {
	return inSpan1(ctx, "prefetch frontend assets", func(ctx context.Context, span trace.Span) (*assetBundle, error) {
		client := &http.Client{Timeout: 30 * time.Second}
		fetched := make([]fetchedAsset, len(assetSpecs))

		g, gctx := errgroup.WithContext(ctx)
		for i, spec := range assetSpecs {
			i, spec := i, spec
			g.Go(func() error {
				a, err := fetchAsset(gctx, client, spec)
				if err != nil {
					return fmt.Errorf("prefetching resource %s: %w", spec.name, err)
				}
				fetched[i] = a
				return nil
			})
		}
		if err := g.Wait(); err != nil {
			return nil, err
		}

		bundle := &assetBundle{assets: fetched}
		for _, a := range fetched {
			bundle.headHTML += a.tag()
			slog.Info("prefetched asset", "name", a.spec.name, "bytes", len(a.body), "integrity", a.integrity)
		}
		return bundle, nil
	})
}

func fetchAsset(ctx context.Context, client *http.Client, spec assetSpec) (fetchedAsset, error) {
	var (
		body []byte
		ct   string
		err  error
	)
	if spec.vendored != nil {
		body = spec.vendored
	} else {
		body, ct, err = fetchOne(ctx, client, spec.url)
		if err != nil {
			return fetchedAsset{}, err
		}
	}

	a := fetchedAsset{
		spec:      spec,
		body:      body,
		integrity: sriHash(body),
	}

	switch {
	case spec.ignoreUpstreamContentType, ct == "":
		if spec.kind == assetScript {
			a.contentType = "text/javascript; charset=UTF-8"
		} else {
			a.contentType = "text/css; charset=UTF-8"
		}
	default:
		a.contentType = ct
	}

	if spec.wantSourceMap {
		// A missing source map is fine: it only affects debugging.
		smBody, _, err := fetchOne(ctx, client, spec.url+".map")
		if err != nil {
			slog.Warn("could not fetch source map", "name", spec.name, "err", err)
		} else {
			a.sourceMap = smBody
		}
	}
	return a, nil
}

func fetchOne(ctx context.Context, client *http.Client, url string) ([]byte, string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, "", err
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, "", err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, "", err
	}
	if resp.StatusCode != http.StatusOK {
		return nil, "", fmt.Errorf("server returned a non-200 error code, code %d", resp.StatusCode)
	}
	return body, resp.Header.Get("Content-Type"), nil
}

// sriHash computes a Subresource Integrity value.
//
// The format is "<algorithm>-<base64 of the raw digest>", per
// https://www.w3.org/TR/SRI/. SHA-384 to match the Haskell version.
func sriHash(body []byte) string {
	sum := sha512.Sum384(body)
	return "sha384-" + base64.StdEncoding.EncodeToString(sum[:])
}

// tag renders the HTML element that loads this asset.
func (a fetchedAsset) tag() string {
	if a.spec.kind == assetLink {
		return fmt.Sprintf(
			`<link rel="stylesheet" href=%q integrity=%q crossorigin="anonymous">`,
			a.spec.localPath, a.integrity)
	}
	return fmt.Sprintf(
		`<script src=%q integrity=%q crossorigin="anonymous"></script>`,
		a.spec.localPath, a.integrity)
}

// registerRoutes serves each asset, and its source map when there is one.
func (b *assetBundle) registerRoutes(mux *http.ServeMux) {
	for _, a := range b.assets {
		a := a
		mux.HandleFunc(a.spec.localPath, func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Content-Type", a.contentType)
			w.Header().Set("Content-Length", fmt.Sprint(len(a.body)))
			w.WriteHeader(http.StatusOK)
			w.Write(a.body)
		})
		if a.spec.wantSourceMap {
			mux.HandleFunc(a.spec.localPath+".map", func(w http.ResponseWriter, r *http.Request) {
				if a.sourceMap == nil {
					http.NotFound(w, r)
					return
				}
				w.Header().Set("Content-Type", "application/json")
				w.Header().Set("Content-Length", fmt.Sprint(len(a.sourceMap)))
				w.WriteHeader(http.StatusOK)
				w.Write(a.sourceMap)
			})
		}
	}
}

// newUniqueRunID returns a random id identifying this process.
//
// The page embeds it and polls /autorefresh; when the value differs the server
// has restarted and the browser reloads to pick up the new markup.
func newUniqueRunID() string {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		// Only used to detect restarts, so a time-based fallback is fine.
		return fmt.Sprintf("%d", time.Now().UnixNano())
	}
	return hex.EncodeToString(b[:])
}