1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "crypto/sha256"
7 "embed"
8 "encoding/hex"
9 "errors"
10 "io/fs"
11 "net/http"
12 "path"
13 "sort"
14 "strings"
15)
16
17//go:embed static
18var staticFS embed.FS
19
20// StaticPrefix is where the embedded assets are served.
21const StaticPrefix = "/-/static/"
22
23// Assets are the URLs of the assets a page loads. Their names carry a hash of
24// the content, so they can be cached for ever.
25type Assets struct {
26 CSS, HTMX, Live, SSE, App, Files, Icon string
27 // Font is the UI font, preloaded by every page.
28 Font string
29 // Rules is the speculation rules file named by the Speculation-Rules header.
30 Rules string
31}
32
33// asset is one embedded file under its content-hashed name.
34type asset struct {
35 body []byte
36 typ string
37}
38
39// assetSet is the embedded files by hashed URL path (under StaticPrefix).
40type assetSet struct {
41 byName map[string]asset
42 urls Assets
43}
44
45// scripts are the JavaScript files of the page, in embedded static/js: the
46// file to serve, as it is named in Assets.
47var scripts = []struct {
48 file string
49 set func(*Assets, string)
50}{
51 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
52 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
53 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
54 {"ermite.js", func(a *Assets, u string) { a.App = u }},
55 {"files.js", func(a *Assets, u string) { a.Files = u }},
56}
57
58// sheetAssets are the files the stylesheet references as url(<dir>/<file>):
59// fonts and icons (pages hold no inline SVG; icons are CSS masks).
60var sheetAssets = []struct{ glob, typ string }{
61 {"static/fonts/*.woff2", "font/woff2"},
62 {"static/icons/*.svg", "image/svg+xml"},
63}
64
65// loadAssets hashes the embedded files. The stylesheet is the concatenation
66// of static/css/*.css in name order; its url(<dir>/<file>) references become
67// the hashed URLs of the files of sheetAssets.
68func loadAssets() (*assetSet, error) {
69 s := &assetSet{byName: map[string]asset{}}
70 put := func(dir, file string, body []byte, typ string) string {
71 sum := sha256.Sum256(body)
72 ext := path.Ext(file)
73 name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
74 s.byName[name] = asset{body: body, typ: typ}
75 return StaticPrefix + name
76 }
77
78 var refs []string
79 for _, sa := range sheetAssets {
80 files, err := fs.Glob(staticFS, sa.glob)
81 if err != nil {
82 return nil, err
83 }
84 for _, f := range files {
85 b, err := staticFS.ReadFile(f)
86 if err != nil {
87 return nil, err
88 }
89 dir := path.Base(path.Dir(f))
90 u := put(dir, path.Base(f), b, sa.typ)
91 refs = append(refs, "url("+dir+"/"+path.Base(f)+")", "url("+u+")")
92 if f == "static/fonts/atkinson-next.woff2" {
93 s.urls.Font = u
94 }
95 }
96 }
97
98 files, err := fs.Glob(staticFS, "static/css/*.css")
99 if err != nil {
100 return nil, err
101 }
102 sort.Strings(files)
103 var css strings.Builder
104 for _, f := range files {
105 b, err := staticFS.ReadFile(f)
106 if err != nil {
107 return nil, err
108 }
109 css.WriteString("/* " + path.Base(f) + " */\n")
110 css.Write(b)
111 }
112 if s.urls.Font == "" {
113 return nil, errors.New("web: the UI font static/fonts/atkinson-next.woff2 is missing")
114 }
115 sheet := strings.NewReplacer(refs...).Replace(css.String())
116 s.urls.CSS = put("css", "ermite.css", []byte(sheet), "text/css; charset=utf-8")
117
118 rules, err := staticFS.ReadFile("static/rules.json")
119 if err != nil {
120 return nil, err
121 }
122 s.urls.Rules = put("rules", "rules.json", rules, "application/speculationrules+json")
123
124 for _, sc := range scripts {
125 b, err := staticFS.ReadFile("static/js/" + sc.file)
126 if err != nil {
127 return nil, err
128 }
129 sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
130 }
131
132 // The favicon is the brand picture of the header.
133 icon, err := staticFS.ReadFile("static/icons/brand.svg")
134 if err != nil {
135 return nil, err
136 }
137 s.urls.Icon = put("img", "icon.svg", icon, "image/svg+xml")
138 return s, nil
139}
140
141// serve answers GET /-/static/<hashed name>. A name that does not match a
142// current asset is a 404, so a stale hash never serves the wrong content.
143func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
144 a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
145 if !ok {
146 http.NotFound(w, r)
147 return
148 }
149 h := w.Header()
150 h.Set("Content-Type", a.typ)
151 h.Set("Cache-Control", "public, max-age=31536000, immutable")
152 h.Set("Content-Length", itoa(len(a.body)))
153 if r.Method != http.MethodHead {
154 _, _ = w.Write(a.body)
155 }
156}