1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "crypto/sha256"
7 "embed"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "io/fs"
12 "net/http"
13 "path"
14 "sort"
15 "strings"
16)
17
18//go:embed static
19var staticFS embed.FS
20
21// StaticPrefix is where the embedded assets are served.
22const StaticPrefix = "/-/static/"
23
24// Assets are the URLs of the assets a page loads. Their names carry a hash of
25// the content, so they can be cached for ever.
26type Assets struct {
27 CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, IssueForm, PowWorker, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string
31 // Font is the UI font, preloaded by every page.
32 Font string
33 // Rules is the speculation rules file named by the Speculation-Rules header.
34 Rules string
35}
36
37// asset is one embedded file under its content-hashed name.
38type asset struct {
39 body []byte
40 typ string
41 // gz is the gzip form of body, compressed once at load; nil when the type
42 // is already compressed or gzip does not make the body smaller.
43 gz []byte
44}
45
46// assetSet is the embedded files by hashed URL path (under StaticPrefix).
47type assetSet struct {
48 byName map[string]asset
49 urls Assets
50 sw serviceWorker
51}
52
53// scripts are the JavaScript files of the page, in embedded static/js: the
54// file to serve, as it is named in Assets.
55var scripts = []struct {
56 file string
57 set func(*Assets, string)
58}{
59 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
60 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
61 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
62 {"store.js", func(a *Assets, u string) { a.Store = u }},
63 {"ermite.js", func(a *Assets, u string) { a.App = u }},
64 {"files.js", func(a *Assets, u string) { a.Files = u }},
65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
66 {"log.js", func(a *Assets, u string) { a.Log = u }},
67 {"mr.js", func(a *Assets, u string) { a.MR = u }},
68 {"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
69 {"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
70}
71
72// sheetAssets are the files the stylesheet references as url(<dir>/<file>):
73// fonts and icons (pages hold no inline SVG; icons are CSS masks).
74var sheetAssets = []struct{ glob, typ string }{
75 {"static/fonts/*.woff2", "font/woff2"},
76 {"static/icons/*.svg", "image/svg+xml"},
77}
78
79// loadAssets hashes the embedded files. The stylesheet is the concatenation
80// of static/css/*.css in name order; its url(<dir>/<file>) references become
81// the hashed URLs of the files of sheetAssets.
82func loadAssets() (*assetSet, error) {
83 s := &assetSet{byName: map[string]asset{}}
84 put := func(dir, file string, body []byte, typ string) string {
85 sum := sha256.Sum256(body)
86 ext := path.Ext(file)
87 name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
88 a := asset{body: body, typ: typ}
89 if compressible(typ) {
90 if z, err := gzipBest(body); err == nil && len(z) < len(body) {
91 a.gz = z
92 }
93 }
94 s.byName[name] = a
95 return StaticPrefix + name
96 }
97
98 var refs []string
99 for _, sa := range sheetAssets {
100 files, err := fs.Glob(staticFS, sa.glob)
101 if err != nil {
102 return nil, err
103 }
104 for _, f := range files {
105 b, err := staticFS.ReadFile(f)
106 if err != nil {
107 return nil, err
108 }
109 dir := path.Base(path.Dir(f))
110 u := put(dir, path.Base(f), b, sa.typ)
111 refs = append(refs, "url("+dir+"/"+path.Base(f)+")", "url("+u+")")
112 if f == "static/fonts/atkinson-next.woff2" {
113 s.urls.Font = u
114 }
115 }
116 }
117
118 files, err := fs.Glob(staticFS, "static/css/*.css")
119 if err != nil {
120 return nil, err
121 }
122 sort.Strings(files)
123 var css strings.Builder
124 for _, f := range files {
125 b, err := staticFS.ReadFile(f)
126 if err != nil {
127 return nil, err
128 }
129 css.WriteString("/* " + path.Base(f) + " */\n")
130 css.Write(b)
131 }
132 if s.urls.Font == "" {
133 return nil, errors.New("web: the UI font static/fonts/atkinson-next.woff2 is missing")
134 }
135 sheet := strings.NewReplacer(refs...).Replace(css.String())
136 s.urls.CSS = put("css", "ermite.css", []byte(sheet), "text/css; charset=utf-8")
137
138 rules, err := staticFS.ReadFile("static/rules.json")
139 if err != nil {
140 return nil, err
141 }
142 s.urls.Rules = put("rules", "rules.json", rules, "application/speculationrules+json")
143
144 for _, sc := range scripts {
145 b, err := staticFS.ReadFile("static/js/" + sc.file)
146 if err != nil {
147 return nil, err
148 }
149 sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
150 }
151
152 // The favicon is the brand picture of the header.
153 icon, err := staticFS.ReadFile("static/icons/brand.svg")
154 if err != nil {
155 return nil, err
156 }
157 s.urls.Icon = put("img", "icon.svg", icon, "image/svg+xml")
158
159 if err := s.loadApp(put); err != nil {
160 return nil, err
161 }
162 sw, err := loadServiceWorker()
163 if err != nil {
164 return nil, err
165 }
166 s.sw = sw
167 return s, nil
168}
169
170// appIcons are the PNG icons of the installed app (static/app), in the
171// manifest; purpose "maskable" has the picture inside the safe zone on a
172// full-bleed background, for launchers that crop to a circle or a squircle.
173var appIcons = []struct{ file, sizes, purpose string }{
174 {"icon-192.png", "192x192", "any"},
175 {"icon-512.png", "512x512", "any"},
176 {"icon-maskable-512.png", "512x512", "maskable"},
177}
178
179// webManifest is the shape of the web app manifest: what a browser asks for to
180// offer "install" / "add to home screen", and the id and description of the
181// richer install dialog.
182type webManifest struct {
183 // ID is the identity of the app, independent of the (hashed) URL of the
184 // manifest and of start_url.
185 ID string `json:"id"`
186 Name string `json:"name"`
187 ShortName string `json:"short_name"`
188 Description string `json:"description"`
189 StartURL string `json:"start_url"`
190 Scope string `json:"scope"`
191 Display string `json:"display"`
192 BackgroundColor string `json:"background_color"`
193 ThemeColor string `json:"theme_color"`
194 Icons []manifestIcon `json:"icons"`
195}
196
197type manifestIcon struct {
198 Src string `json:"src"`
199 Sizes string `json:"sizes"`
200 Type string `json:"type"`
201 Purpose string `json:"purpose"`
202}
203
204// appColor is the dark canvas: the splash screen and the bar of the installed
205// app (the pages follow the system theme; the manifest takes one colour).
206const appColor = "#050505"
207
208// loadApp adds the app icons and the manifest, which names them by their
209// hashed URLs.
210func (s *assetSet) loadApp(put func(dir, name string, b []byte, typ string) string) error {
211 m := webManifest{
212 ID: "/", Name: "ermite", ShortName: "ermite", StartURL: "/", Scope: "/", Display: "standalone",
213 Description: "Self-hosted Git forge: browse code, review merge requests and follow CI.",
214 BackgroundColor: appColor, ThemeColor: appColor,
215 }
216 for _, ic := range appIcons {
217 b, err := staticFS.ReadFile("static/app/" + ic.file)
218 if err != nil {
219 return err
220 }
221 m.Icons = append(m.Icons, manifestIcon{Src: put("app", ic.file, b, "image/png"), Sizes: ic.sizes, Type: "image/png", Purpose: ic.purpose})
222 }
223 apple, err := staticFS.ReadFile("static/app/apple-touch-icon.png")
224 if err != nil {
225 return err
226 }
227 s.urls.AppleIcon = put("app", "apple-touch-icon.png", apple, "image/png")
228 b, err := json.Marshal(m)
229 if err != nil {
230 return err
231 }
232 s.urls.Manifest = put("app", "manifest.webmanifest", b, "application/manifest+json")
233 return nil
234}
235
236// serve answers GET /-/static/<hashed name>. A name that does not match a
237// current asset is a 404, so a stale hash never serves the wrong content.
238func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
239 a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
240 if !ok {
241 http.NotFound(w, r)
242 return
243 }
244 h := w.Header()
245 h.Set("Content-Type", a.typ)
246 h.Set("Cache-Control", "public, max-age=31536000, immutable")
247 body := a.body
248 if a.gz != nil {
249 addVary(h, "Accept-Encoding")
250 if acceptsGzip(r.Header) {
251 body = a.gz
252 h.Set("Content-Encoding", "gzip")
253 }
254 }
255 h.Set("Content-Length", itoa(len(body)))
256 if r.Method != http.MethodHead {
257 _, _ = w.Write(body)
258 }
259}
260
261// compressible tells whether a content type is worth gzip: text and
262// structured text. Fonts (woff2) and raster images are compressed already.
263func compressible(typ string) bool {
264 mt, _, _ := strings.Cut(typ, ";")
265 mt = strings.TrimSpace(strings.ToLower(mt))
266 return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" ||
267 mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json")
268}