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