1414package ui
1515
1616import (
17+ "bytes"
18+ "compress/gzip"
1719 "embed"
1820 "io"
1921 "io/fs"
2022 "net/http"
2123 "path"
24+ "strconv"
2225 "strings"
2326 "time"
2427
@@ -28,17 +31,87 @@ import (
2831//go:embed app/dist
2932var asset embed.FS
3033
31- // https://www.iana.org/assignments/media-types/
32- var fileTypes = map [string ]string {
33- ".css" : "text/css; charset=utf-8" ,
34- ".eot" : "application/vnd.ms-fontobject" ,
35- ".html" : "text/html; charset=utf-8" ,
36- ".ico" : "image/vnd.microsoft.icon" ,
37- ".js" : "text/javascript; charset=utf-8" ,
38- ".svg" : "image/svg+xml" ,
39- ".ttf" : "font/ttf" ,
40- ".woff" : "font/woff" ,
41- ".woff2" : "font/woff2" ,
34+ var fileTypes = map [string ]struct {
35+ contentType string // https://www.iana.org/assignments/media-types/
36+ varyEncoding bool // Must match build configuration in vite.config.mjs.
37+ }{
38+ ".css" : {"text/css; charset=utf-8" , true },
39+ ".eot" : {"application/vnd.ms-fontobject" , true },
40+ ".html" : {"text/html; charset=utf-8" , true },
41+ ".ico" : {"image/vnd.microsoft.icon" , true },
42+ ".js" : {"text/javascript; charset=utf-8" , true },
43+ ".svg" : {"image/svg+xml" , true },
44+ ".ttf" : {"font/ttf" , true },
45+ ".woff" : {"font/woff" , false },
46+ ".woff2" : {"font/woff2" , false },
47+ }
48+
49+ type encoding int
50+
51+ const (
52+ encNone encoding = iota
53+ encGzip
54+ encBrotli
55+ )
56+
57+ type tokenEffect int
58+
59+ const (
60+ effectUnseen tokenEffect = iota
61+ effectReject
62+ effectAccept
63+ )
64+
65+ // selectEncoding parses the Accept-Encoding header and returns the preferred
66+ // encoding. For simplicity, non-zero q-values are not ranked.
67+ func selectEncoding (header string ) encoding {
68+ brotli , gzip , wildcard := effectUnseen , effectUnseen , effectUnseen
69+ for part := range strings .SplitSeq (header , "," ) {
70+ encAndQ := strings .SplitN (strings .TrimSpace (part ), ";" , 2 )
71+
72+ effect := effectAccept
73+ if len (encAndQ ) > 1 {
74+ // https://www.rfc-editor.org/rfc/rfc9110.html#section-12.4.2
75+ if q , ok := strings .CutPrefix (strings .TrimSpace (encAndQ [1 ]), "q=" ); ok {
76+ if weight , err := strconv .ParseFloat (q , 64 ); err == nil && weight <= 0 {
77+ effect = effectReject
78+ }
79+ }
80+ }
81+
82+ switch strings .TrimSpace (encAndQ [0 ]) {
83+ case "br" :
84+ brotli = effect
85+ case "gzip" :
86+ gzip = effect
87+ case "*" :
88+ wildcard = effect
89+ }
90+ }
91+
92+ if brotli == effectAccept || (wildcard == effectAccept && brotli == effectUnseen ) {
93+ return encBrotli
94+ } else if gzip == effectAccept || (wildcard == effectAccept && gzip == effectUnseen ) {
95+ return encGzip
96+ }
97+ return encNone
98+ }
99+
100+ // decompressToReader decompresses f into memory. For simplicity the entire
101+ // file is buffered; this is acceptable given the small size of the assets.
102+ func decompressToReader (f fs.File ) (* bytes.Reader , error ) {
103+ gzReader , err := gzip .NewReader (f )
104+ if err != nil {
105+ return nil , err
106+ }
107+ defer gzReader .Close ()
108+
109+ data , err := io .ReadAll (gzReader )
110+ if err != nil {
111+ return nil , err
112+ }
113+
114+ return bytes .NewReader (data ), nil
42115}
43116
44117// Register registers handlers to serve files for the web interface.
@@ -49,22 +122,59 @@ func Register(r *route.Router) {
49122 }
50123 serve := func (w http.ResponseWriter , req * http.Request , filePath string , immutable bool ) {
51124 ext := strings .ToLower (path .Ext (filePath ))
52- contentType , ok := fileTypes [ext ]
125+ fileType , ok := fileTypes [ext ]
53126 if ! ok {
54127 http .NotFound (w , req )
55128 return
56129 }
57130
58- f , err := appFS .Open (filePath )
59- if err != nil {
60- http .NotFound (w , req )
61- return
131+ if fileType .varyEncoding {
132+ switch selectEncoding (req .Header .Get ("Accept-Encoding" )) {
133+ case encBrotli :
134+ if f , err := appFS .Open (filePath + ".br" ); err == nil {
135+ defer f .Close ()
136+ setCachePolicy (w , immutable )
137+ w .Header ().Set ("Content-Type" , fileType .contentType )
138+ w .Header ().Set ("Content-Encoding" , "br" )
139+ w .Header ().Add ("Vary" , "Accept-Encoding" )
140+ http .ServeContent (w , req , filePath , time.Time {}, f .(io.ReadSeeker ))
141+ return
142+ }
143+ case encGzip :
144+ if f , err := appFS .Open (filePath + ".gz" ); err == nil {
145+ defer f .Close ()
146+ setCachePolicy (w , immutable )
147+ w .Header ().Set ("Content-Type" , fileType .contentType )
148+ w .Header ().Set ("Content-Encoding" , "gzip" )
149+ w .Header ().Add ("Vary" , "Accept-Encoding" )
150+ http .ServeContent (w , req , filePath , time.Time {}, f .(io.ReadSeeker ))
151+ return
152+ }
153+ case encNone :
154+ if f , err := appFS .Open (filePath + ".gz" ); err == nil {
155+ defer f .Close ()
156+ uncompressedBytes , err := decompressToReader (f )
157+ if err != nil {
158+ http .Error (w , "failed to decompress file" , http .StatusInternalServerError )
159+ return
160+ }
161+ setCachePolicy (w , immutable )
162+ w .Header ().Set ("Content-Type" , fileType .contentType )
163+ w .Header ().Add ("Vary" , "Accept-Encoding" )
164+ http .ServeContent (w , req , filePath , time.Time {}, uncompressedBytes )
165+ return
166+ }
167+ }
168+ } else {
169+ if f , err := appFS .Open (filePath ); err == nil {
170+ defer f .Close ()
171+ setCachePolicy (w , immutable )
172+ w .Header ().Set ("Content-Type" , fileType .contentType )
173+ http .ServeContent (w , req , filePath , time.Time {}, f .(io.ReadSeeker ))
174+ return
175+ }
62176 }
63- defer f .Close ()
64-
65- setCachePolicy (w , immutable )
66- w .Header ().Set ("Content-Type" , contentType )
67- http .ServeContent (w , req , filePath , time.Time {}, f .(io.ReadSeeker ))
177+ http .NotFound (w , req )
68178 }
69179
70180 r .Get ("/" , func (w http.ResponseWriter , req * http.Request ) {
0 commit comments