1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "crypto/sha256"
7 "embed"
8 "encoding/hex"
9 "io/fs"
10 "net/http"
11 "path"
12 "sort"
13 "strings"
14)
15
16//go:embed static
17var staticFS embed.FS
18
19// StaticPrefix is where the embedded assets are served.
20const StaticPrefix = "/-/static/"
21
22// Assets are the URLs of the assets a page loads. Their names carry a hash of
23// the content, so they can be cached for ever.
24type Assets struct {
25 CSS, HTMX, Live, SSE, App, Icon string
26}
27
28// asset is one embedded file under its content-hashed name.
29type asset struct {
30 body []byte
31 typ string
32}
33
34// assetSet is the embedded files by hashed URL path (under StaticPrefix).
35type assetSet struct {
36 byName map[string]asset
37 urls Assets
38}
39
40// scripts are the JavaScript files of the page, in embedded static/js: the
41// file to serve, as it is named in Assets.
42var scripts = []struct {
43 file string
44 set func(*Assets, string)
45}{
46 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
47 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
48 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
49 {"ermite.js", func(a *Assets, u string) { a.App = u }},
50}
51
52// loadAssets hashes the embedded files. The stylesheet is the concatenation
53// of static/css/*.css in name order, as in the mockup.
54func loadAssets() (*assetSet, error) {
55 s := &assetSet{byName: map[string]asset{}}
56 put := func(dir, file string, body []byte, typ string) string {
57 sum := sha256.Sum256(body)
58 ext := path.Ext(file)
59 name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext
60 s.byName[name] = asset{body: body, typ: typ}
61 return StaticPrefix + name
62 }
63
64 files, err := fs.Glob(staticFS, "static/css/*.css")
65 if err != nil {
66 return nil, err
67 }
68 sort.Strings(files)
69 var css strings.Builder
70 for _, f := range files {
71 b, err := staticFS.ReadFile(f)
72 if err != nil {
73 return nil, err
74 }
75 css.WriteString("/* " + path.Base(f) + " */\n")
76 css.Write(b)
77 }
78 s.urls.CSS = put("css", "ermite.css", []byte(css.String()), "text/css; charset=utf-8")
79
80 for _, sc := range scripts {
81 b, err := staticFS.ReadFile("static/js/" + sc.file)
82 if err != nil {
83 return nil, err
84 }
85 sc.set(&s.urls, put("js", sc.file, b, "text/javascript; charset=utf-8"))
86 }
87
88 icon, err := staticFS.ReadFile("static/icon.svg")
89 if err != nil {
90 return nil, err
91 }
92 s.urls.Icon = put("img", "icon.svg", icon, "image/svg+xml")
93 return s, nil
94}
95
96// serve answers GET /-/static/<hashed name>. A name that does not match a
97// current asset is a 404, so a stale hash never serves the wrong content.
98func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) {
99 a, ok := s.byName[strings.TrimPrefix(r.URL.Path, StaticPrefix)]
100 if !ok {
101 http.NotFound(w, r)
102 return
103 }
104 h := w.Header()
105 h.Set("Content-Type", a.typ)
106 h.Set("Cache-Control", "public, max-age=31536000, immutable")
107 h.Set("Content-Length", itoa(len(a.body)))
108 if r.Method != http.MethodHead {
109 _, _ = w.Write(a.body)
110 }
111}