1package mox
2
3import (
4 "bytes"
5 "compress/gzip"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "net/http"
12 "os"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/mjl-/mox/mlog"
18 "github.com/mjl-/mox/moxvar"
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
28 sync.Mutex
29 combined []byte
30 combinedGzip []byte
31 mtime time.Time // For Last-Modified and conditional request.
32}
33
34// FallbackMtime returns a time to use for the Last-Modified header in case we
35// cannot find a file, e.g. when used in production.
36func FallbackMtime(log mlog.Log) time.Time {
37 p, err := os.Executable()
38 log.Check(err, "finding executable for mtime")
39 if err == nil {
40 st, err := os.Stat(p)
41 log.Check(err, "stat on executable for mtime")
42 if err == nil {
43 return st.ModTime()
44 }
45 }
46 log.Info("cannot find executable for webappfile mtime, using current time")
47 return time.Now()
48}
49
50func (a *WebappFile) serverError(log mlog.Log, w http.ResponseWriter, err error, action string) {
51 log.Errorx("serve webappfile", err, slog.String("msg", action))
52 http.Error(w, "500 - internal server error", http.StatusInternalServerError)
53}
54
55// Serve serves a combined file, with headers for caching and possibly gzipped.
56func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *http.Request) {
57 // We typically return the embedded file, but during development it's handy
58 // to load from disk.
59 fhtml, _ := os.Open(a.HTMLPath)
60 if fhtml != nil {
61 defer fhtml.Close()
62 }
63 fjs, _ := os.Open(a.JSPath)
64 if fjs != nil {
65 defer fjs.Close()
66 }
67
68 html := a.HTML
69 js := a.JS
70
71 var diskmtime time.Time
72 var refreshdisk bool
73 if fhtml != nil && fjs != nil {
74 sth, err := fhtml.Stat()
75 if err != nil {
76 a.serverError(log, w, err, "stat html")
77 return
78 }
79 stj, err := fjs.Stat()
80 if err != nil {
81 a.serverError(log, w, err, "stat js")
82 return
83 }
84
85 maxmtime := sth.ModTime()
86 if stj.ModTime().After(maxmtime) {
87 maxmtime = stj.ModTime()
88 }
89
90 a.Lock()
91 refreshdisk = maxmtime.After(a.mtime) || a.combined == nil
92 a.Unlock()
93
94 if refreshdisk {
95 html, err = io.ReadAll(fhtml)
96 if err != nil {
97 a.serverError(log, w, err, "reading html")
98 return
99 }
100 js, err = io.ReadAll(fjs)
101 if err != nil {
102 a.serverError(log, w, err, "reading js")
103 return
104 }
105 diskmtime = maxmtime
106 }
107 }
108
109 gz := AcceptsGzip(r)
110 var out []byte
111 var mtime time.Time
112 var origSize int64
113
114 func() {
115 a.Lock()
116 defer a.Unlock()
117
118 if refreshdisk || a.combined == nil {
119 script := []byte(`<script>/* placeholder */</script>`)
120 index := bytes.Index(html, script)
121 if index < 0 {
122 a.serverError(log, w, errors.New("script not found"), "generating combined html")
123 return
124 }
125 var b bytes.Buffer
126 b.Write(html[:index])
127 fmt.Fprintf(&b, "<script>\n// Javascript is generated from typescript, don't modify the javascript because changes will be lost.\nconst moxversion = \"%s\";\n", moxvar.Version)
128 b.Write(js)
129 b.WriteString("\t\t</script>")
130 b.Write(html[index+len(script):])
131 out = b.Bytes()
132 a.combined = out
133 if refreshdisk {
134 a.mtime = diskmtime
135 } else {
136 a.mtime = FallbackMtime(log)
137 }
138 a.combinedGzip = nil
139 } else {
140 out = a.combined
141 }
142 if gz {
143 if a.combinedGzip == nil {
144 var b bytes.Buffer
145 gzw, err := gzip.NewWriterLevel(&b, gzip.BestCompression)
146 if err == nil {
147 _, err = gzw.Write(out)
148 }
149 if err == nil {
150 err = gzw.Close()
151 }
152 if err != nil {
153 a.serverError(log, w, err, "gzipping combined html")
154 return
155 }
156 a.combinedGzip = b.Bytes()
157 }
158 origSize = int64(len(out))
159 out = a.combinedGzip
160 }
161 mtime = a.mtime
162 }()
163
164 w.Header().Set("Content-Type", "text/html; charset=utf-8")
165 http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out))
166}
167
168// gzipInjector is a http.ResponseWriter that optionally injects a
169// Content-Encoding: gzip header, only in case of status 200 OK. Used with
170// http.ServeContent to serve gzipped content if the client supports it. We cannot
171// just unconditionally add the content-encoding header, because we don't know
172// enough if we will be sending data: http.ServeContent may be sending a "not
173// modified" response, and possibly others.
174type gzipInjector struct {
175 http.ResponseWriter // Keep most methods.
176 gz bool
177 origSize int64
178}
179
180// WriteHeader adds a Content-Encoding: gzip header before actually writing the
181// headers and status.
182func (w gzipInjector) WriteHeader(statusCode int) {
183 if w.gz && statusCode == http.StatusOK {
184 w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
185 if lw, ok := w.ResponseWriter.(interface{ SetUncompressedSize(int64) }); ok {
186 lw.SetUncompressedSize(w.origSize)
187 }
188 }
189 w.ResponseWriter.WriteHeader(statusCode)
190}
191
192// AcceptsGzip returns whether the client accepts gzipped responses.
193func AcceptsGzip(r *http.Request) bool {
194 s := r.Header.Get("Accept-Encoding")
195 t := strings.Split(s, ",")
196 for _, e := range t {
197 e = strings.TrimSpace(e)
198 tt := strings.Split(e, ";")
199 if len(tt) > 1 && t[1] == "q=0" {
200 continue
201 }
202 if tt[0] == "gzip" {
203 return true
204 }
205 }
206 return false
207}
208