detached 83e495f

assets.go RawHistoryFolder

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, Lazy, 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	{"lazy.js", func(a *Assets, u string) { a.Lazy = u }},
68	{"mr.js", func(a *Assets, u string) { a.MR = u }},
69	{"issue-form.js", func(a *Assets, u string) { a.IssueForm = u }},
70	{"pow-worker.js", func(a *Assets, u string) { a.PowWorker = u }},
71}
72
73// sheetAssets are the files the stylesheet references as url(<dir>/<file>):
74// fonts and icons (pages hold no inline SVG; icons are CSS masks).
75var sheetAssets = []struct{ glob, typ string }{
76	{"static/fonts/*.woff2", "font/woff2"},
77	{"static/icons/*.svg", "image/svg+xml"},
78}
79
80// loadAssets hashes the embedded files. The stylesheet is the concatenation
81// of static/css/*.css in name order; its url(<dir>/<file>) references become
82// the hashed URLs of the files of sheetAssets.
83func loadAssets() (*assetSet, error) {
84	s := &assetSet{byName: map[string]asset{}}
85	put := func(dir, file string, body []byte, typ string) string {
86		sum := sha256.Sum256(body)
87		ext := path.Ext(file)
88		name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
89		a := asset{body: body, typ: typ}
90		if compressible(typ) {
91			if z, err := gzipBest(body); err == nil && len(z) < len(body) {
92				a.gz = z
93			}
94		}
95		s.byName[name] = a
96		return StaticPrefix + name
97	}
98
99	var refs []string
100	for _, sa := range sheetAssets {
101		files, err := fs.Glob(staticFS, sa.glob)
102		if err != nil {
103			return nil, err
104		}
105		for _, f := range files {
106			b, err := staticFS.ReadFile(f)
107			if err != nil {
108				return nil, err
109			}
110			dir := path.Base(path.Dir(f))
111			u := put(dir, path.Base(f), b, sa.typ)
112			refs = append(refs, "url("+dir+"/"+path.Base(f)+")", "url("+u+")")
113			if f == "static/fonts/atkinson-next.woff2" {
114				s.urls.Font = u
115			}
116		}
117	}
118
119	files, err := fs.Glob(staticFS, "static/css/*.css")
120	if err != nil {
121		return nil, err
122	}
123	sort.Strings(files)
124	var css strings.Builder
125	for _, f := range files {
126		b, err := staticFS.ReadFile(f)
127		if err != nil {
128			return nil, err
129		}
130		css.WriteString("/* " + path.Base(f) + " */\n")
131		css.Write(b)
132	}
133	if s.urls.Font == "" {
134		return nil, errors.New("web: the UI font static/fonts/atkinson-next.woff2 is missing")
135	}
136	sheet := strings.NewReplacer(refs...).Replace(css.String())
137	s.urls.CSS = put("css", "ermite.css", []byte(sheet), "text/css; charset=utf-8")
138
139	rules, err := staticFS.ReadFile("static/rules.json")
140	if err != nil {
141		return nil, err
142	}
143	s.urls.Rules = put("rules", "rules.json", rules, "application/speculationrules+json")
144
145	for _, sc := range scripts {
146		b, err := staticFS.ReadFile("static/js/" + sc.file)
147		if err != nil {
148			return nil, err
149		}
150		sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
151	}
152
153	// The favicon is the brand picture of the header.
154	icon, err := staticFS.ReadFile("static/icons/brand.svg")
155	if err != nil {
156		return nil, err
157	}
158	s.urls.Icon = put("img", "icon.svg", icon, "image/svg+xml")
159
160	if err := s.loadApp(put); err != nil {
161		return nil, err
162	}
163	sw, err := loadServiceWorker()
164	if err != nil {
165		return nil, err
166	}
167	s.sw = sw
168	return s, nil
169}
170
171// appIcons are the PNG icons of the installed app (static/app), in the
172// manifest; purpose "maskable" has the picture inside the safe zone on a
173// full-bleed background, for launchers that crop to a circle or a squircle.
174var appIcons = []struct{ file, sizes, purpose string }{
175	{"icon-192.png", "192x192", "any"},
176	{"icon-512.png", "512x512", "any"},
177	{"icon-maskable-512.png", "512x512", "maskable"},
178}
179
180// webManifest is the shape of the web app manifest: what a browser asks for to
181// offer "install" / "add to home screen", and the id and description of the
182// richer install dialog.
183type webManifest struct {
184	// ID is the identity of the app, independent of the (hashed) URL of the
185	// manifest and of start_url.
186	ID              string         `json:"id"`
187	Name            string         `json:"name"`
188	ShortName       string         `json:"short_name"`
189	Description     string         `json:"description"`
190	StartURL        string         `json:"start_url"`
191	Scope           string         `json:"scope"`
192	Display         string         `json:"display"`
193	BackgroundColor string         `json:"background_color"`
194	ThemeColor      string         `json:"theme_color"`
195	Icons           []manifestIcon `json:"icons"`
196}
197
198type manifestIcon struct {
199	Src     string `json:"src"`
200	Sizes   string `json:"sizes"`
201	Type    string `json:"type"`
202	Purpose string `json:"purpose"`
203}
204
205// appColor is the dark canvas: the splash screen and the bar of the installed
206// app (the pages follow the system theme; the manifest takes one colour).
207const appColor = "#050505"
208
209// loadApp adds the app icons and the manifest, which names them by their
210// hashed URLs.
211func (s *assetSet) loadApp(put func(dir, name string, b []byte, typ string) string) error {
212	m := webManifest{
213		ID: "/", Name: "ermite", ShortName: "ermite", StartURL: "/", Scope: "/", Display: "standalone",
214		Description:     "Self-hosted Git forge: browse code, review merge requests and follow CI.",
215		BackgroundColor: appColor, ThemeColor: appColor,
216	}
217	for _, ic := range appIcons {
218		b, err := staticFS.ReadFile("static/app/" + ic.file)
219		if err != nil {
220			return err
221		}
222		m.Icons = append(m.Icons, manifestIcon{Src: put("app", ic.file, b, "image/png"), Sizes: ic.sizes, Type: "image/png", Purpose: ic.purpose})
223	}
224	apple, err := staticFS.ReadFile("static/app/apple-touch-icon.png")
225	if err != nil {
226		return err
227	}
228	s.urls.AppleIcon = put("app", "apple-touch-icon.png", apple, "image/png")
229	b, err := json.Marshal(m)
230	if err != nil {
231		return err
232	}
233	s.urls.Manifest = put("app", "manifest.webmanifest", b, "application/manifest+json")
234	return nil
235}
236
237// serve answers GET /-/static/<hashed name>. A name that does not match a
238// current asset is a 404, so a stale hash never serves the wrong content.
239func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
240	a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
241	if !ok {
242		http.NotFound(w, r)
243		return
244	}
245	h := w.Header()
246	h.Set("Content-Type", a.typ)
247	h.Set("Cache-Control", "public, max-age=31536000, immutable")
248	body := a.body
249	if a.gz != nil {
250		addVary(h, "Accept-Encoding")
251		if acceptsGzip(r.Header) {
252			body = a.gz
253			h.Set("Content-Encoding", "gzip")
254		}
255	}
256	h.Set("Content-Length", itoa(len(body)))
257	if r.Method != http.MethodHead {
258		_, _ = w.Write(body)
259	}
260}
261
262// compressible tells whether a content type is worth gzip: text and
263// structured text. Fonts (woff2) and raster images are compressed already.
264func compressible(typ string) bool {
265	mt, _, _ := strings.Cut(typ, ";")
266	mt = strings.TrimSpace(strings.ToLower(mt))
267	return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" ||
268		mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json")
269}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close