1package mox
2
3import (
4 "bytes"
5 "compress/gzip"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "io/fs"
11 "log/slog"
12 "net/http"
13 "os"
14 "runtime"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/mjl-/mox/mlog"
20 "github.com/mjl-/mox/moxvar"
21)
22
23// WebappFile serves a merged HTML and JS webapp as a single compressed, cacheable
24// file. It merges the JS into the HTML at first load, caches a gzipped version
25// that is generated on first need, and responds with a Last-Modified header.
26type WebappFile struct {
27 HTML, JS []byte // Embedded html/js data.
28 HTMLPath, JSPath string // Paths to load html/js from during development.
29 CustomStem string // For trying to read css/js customizations from $configdir/$stem.{css,js}.
30
31 sync.Mutex
32 combined []byte
33 combinedGzip []byte
34 mtime time.Time // For Last-Modified and conditional request.
35}
36
37// FallbackMtime returns a time to use for the Last-Modified header in case we
38// cannot find a file, e.g. when used in production.
39func FallbackMtime(log mlog.Log) time.Time {
40 p, err := os.Executable()
41 log.Check(err, "finding executable for mtime")
42 if err == nil {
43 st, err := os.Stat(p)
44 log.Check(err, "stat on executable for mtime")
45 if err == nil {
46 return st.ModTime()
47 }
48 }
49 log.Info("cannot find executable for webappfile mtime, using current time")
50 return time.Now()
51}
52
53func (a *WebappFile) serverError(log mlog.Log, w http.ResponseWriter, err error, action string) {
54 log.Errorx("serve webappfile", err, slog.String("msg", action))
55 http.Error(w, "500 - internal server error", http.StatusInternalServerError)
56}
57
58// Serve serves a combined file, with headers for caching and possibly gzipped.
59func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *http.Request) {
60 // We typically return the embedded file, but during development it's handy
61 // to load from disk.
62 fhtml, _ := os.Open(a.HTMLPath)
63 if fhtml != nil {
64 defer fhtml.Close()
65 }
66 fjs, _ := os.Open(a.JSPath)
67 if fjs != nil {
68 defer fjs.Close()
69 }
70
71 html := a.HTML
72 js := a.JS
73
74 var diskmtime time.Time
75 var refreshdisk bool
76 if fhtml != nil && fjs != nil {
77 sth, err := fhtml.Stat()
78 if err != nil {
79 a.serverError(log, w, err, "stat html")
80 return
81 }
82 stj, err := fjs.Stat()
83 if err != nil {
84 a.serverError(log, w, err, "stat js")
85 return
86 }
87
88 maxmtime := sth.ModTime()
89 if stj.ModTime().After(maxmtime) {
90 maxmtime = stj.ModTime()
91 }
92
93 a.Lock()
94 refreshdisk = maxmtime.After(a.mtime) || a.combined == nil
95 a.Unlock()
96
97 if refreshdisk {
98 html, err = io.ReadAll(fhtml)
99 if err != nil {
100 a.serverError(log, w, err, "reading html")
101 return
102 }
103 js, err = io.ReadAll(fjs)
104 if err != nil {
105 a.serverError(log, w, err, "reading js")
106 return
107 }
108 diskmtime = maxmtime
109 }
110 }
111
112 // Check mtime of css/js files.
113 var haveCustomCSS, haveCustomJS bool
114 checkCustomMtime := func(ext string, have *bool) bool {
115 path := ConfigDirPath(a.CustomStem + "." + ext)
116 if fi, err := os.Stat(path); err != nil {
117 if !errors.Is(err, fs.ErrNotExist) {
118 a.serverError(log, w, err, "stat customization file")
119 return false
120 }
121 } else if mtm := fi.ModTime(); mtm.After(diskmtime) {
122 diskmtime = mtm
123 *have = true
124 }
125 return true
126 }
127 if !checkCustomMtime("css", &haveCustomCSS) || !checkCustomMtime("js", &haveCustomJS) {
128 return
129 }
130 // Detect removal of custom files.
131 if fi, err := os.Stat(ConfigDirPath(".")); err == nil && fi.ModTime().After(diskmtime) {
132 diskmtime = fi.ModTime()
133 }
134
135 a.Lock()
136 refreshdisk = refreshdisk || diskmtime.After(a.mtime)
137 a.Unlock()
138
139 gz := AcceptsGzip(r)
140 var out []byte
141 var mtime time.Time
142 var origSize int64
143
144 ok := func() bool {
145 a.Lock()
146 defer a.Unlock()
147
148 if refreshdisk || a.combined == nil {
149 var customCSS, customJS []byte
150 var err error
151 if haveCustomCSS {
152 customCSS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".css"))
153 if err != nil {
154 a.serverError(log, w, err, "read custom css file")
155 return false
156 }
157 }
158 if haveCustomJS {
159 customJS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".js"))
160 if err != nil {
161 a.serverError(log, w, err, "read custom js file")
162 return false
163 }
164 }
165
166 cssp := []byte(`/* css placeholder */`)
167 cssi := bytes.Index(html, cssp)
168 if cssi < 0 {
169 a.serverError(log, w, errors.New("css placeholder not found"), "generating combined html")
170 return false
171 }
172 jsp := []byte(`/* js placeholder */`)
173 jsi := bytes.Index(html, jsp)
174 if jsi < 0 {
175 a.serverError(log, w, errors.New("js placeholder not found"), "generating combined html")
176 return false
177 }
178 var b bytes.Buffer
179 b.Write(html[:cssi])
180 fmt.Fprintf(&b, "/* Custom CSS by admin from $configdir/%s.css: */\n", a.CustomStem)
181 b.Write(customCSS)
182 b.Write(html[cssi+len(cssp) : jsi])
183 fmt.Fprintf(&b, "// Custom JS by admin from $configdir/%s.js:\n", a.CustomStem)
184 b.Write(customJS)
185 fmt.Fprintf(&b, "\n// Javascript is generated from typescript, don't modify the javascript because changes will be lost.\nconst moxversion = \"%s\";\nconst moxgoos = \"%s\";\nconst moxgoarch = \"%s\";\n", moxvar.Version, runtime.GOOS, runtime.GOARCH)
186 b.Write(js)
187 b.Write(html[jsi+len(jsp):])
188 out = b.Bytes()
189 a.combined = out
190 if refreshdisk {
191 a.mtime = diskmtime
192 } else {
193 a.mtime = FallbackMtime(log)
194 }
195 a.combinedGzip = nil
196 } else {
197 out = a.combined
198 }
199 if gz {
200 if a.combinedGzip == nil {
201 var b bytes.Buffer
202 gzw, err := gzip.NewWriterLevel(&b, gzip.BestCompression)
203 if err == nil {
204 _, err = gzw.Write(out)
205 }
206 if err == nil {
207 err = gzw.Close()
208 }
209 if err != nil {
210 a.serverError(log, w, err, "gzipping combined html")
211 return false
212 }
213 a.combinedGzip = b.Bytes()
214 }
215 origSize = int64(len(out))
216 out = a.combinedGzip
217 }
218 mtime = a.mtime
219 return true
220 }()
221 if !ok {
222 return
223 }
224
225 w.Header().Set("Content-Type", "text/html; charset=utf-8")
226 http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out))
227}
228
229// gzipInjector is a http.ResponseWriter that optionally injects a
230// Content-Encoding: gzip header, only in case of status 200 OK. Used with
231// http.ServeContent to serve gzipped content if the client supports it. We cannot
232// just unconditionally add the content-encoding header, because we don't know
233// enough if we will be sending data: http.ServeContent may be sending a "not
234// modified" response, and possibly others.
235type gzipInjector struct {
236 http.ResponseWriter // Keep most methods.
237 gz bool
238 origSize int64
239}
240
241// WriteHeader adds a Content-Encoding: gzip header before actually writing the
242// headers and status.
243func (w gzipInjector) WriteHeader(statusCode int) {
244 if w.gz && statusCode == http.StatusOK {
245 w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
246 if lw, ok := w.ResponseWriter.(interface{ SetUncompressedSize(int64) }); ok {
247 lw.SetUncompressedSize(w.origSize)
248 }
249 }
250 w.ResponseWriter.WriteHeader(statusCode)
251}
252
253// AcceptsGzip returns whether the client accepts gzipped responses.
254func AcceptsGzip(r *http.Request) bool {
255 s := r.Header.Get("Accept-Encoding")
256 t := strings.Split(s, ",")
257 for _, e := range t {
258 e = strings.TrimSpace(e)
259 tt := strings.Split(e, ";")
260 if len(tt) > 1 && t[1] == "q=0" {
261 continue
262 }
263 if tt[0] == "gzip" {
264 return true
265 }
266 }
267 return false
268}
269