/
source_fs.go
352 lines (294 loc) · 9.29 KB
/
source_fs.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
// Copyright (c) ClaceIO, LLC
// SPDX-License-Identifier: Apache-2.0
package util
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/claceio/clace/internal/utils"
)
// SourceFs is the implementation of source file system
type SourceFs struct {
utils.ReadableFS
Root string
isDev bool
staticFiles []string
mu sync.RWMutex
nameToHash map[string]string // lookup (path to hash path)
hashToName map[string][2]string // reverse lookup (hash path to path)
}
var _ utils.ReadableFS = (*SourceFs)(nil)
type WritableSourceFs struct {
*SourceFs
}
var _ utils.WritableFS = (*WritableSourceFs)(nil)
func (w *WritableSourceFs) Write(name string, bytes []byte) error {
if !w.isDev {
return fmt.Errorf("cannot write to source fs")
}
wfs, ok := w.ReadableFS.(utils.WritableFS)
if !ok {
return fmt.Errorf("cannot write to source fs (not writable mode)")
}
return wfs.Write(name, bytes)
}
func (w *WritableSourceFs) Remove(name string) error {
if !w.isDev {
return fmt.Errorf("cannot remove file from source fs")
}
wfs, ok := w.ReadableFS.(utils.WritableFS)
if !ok {
return fmt.Errorf("cannot remove file from source fs (not writable mode)")
}
return wfs.Remove(name)
}
func NewSourceFs(dir string, fs utils.ReadableFS, isDev bool) (*SourceFs, error) {
var staticFiles []string
if !isDev {
// For prod mode, get the list of static files for early hints
staticFiles = fs.StaticFiles()
}
return &SourceFs{
Root: dir,
ReadableFS: fs,
isDev: isDev,
staticFiles: staticFiles,
// File hashing code based on https://github.com/benbjohnson/hashfs/blob/main/hashfs.go
// Copyright (c) 2020 Ben Johnson. MIT License
nameToHash: make(map[string]string),
hashToName: make(map[string][2]string)}, nil
}
func (f *SourceFs) StaticFiles() []string {
return f.staticFiles
}
func (f *SourceFs) ClearCache() {
f.mu.Lock()
defer f.mu.Unlock()
clear(f.nameToHash)
clear(f.hashToName)
}
func (f *SourceFs) Glob(pattern string) ([]string, error) {
return fs.Glob(f.ReadableFS, pattern)
}
func (f *SourceFs) ParseFS(funcMap template.FuncMap, patterns ...string) (*template.Template, error) {
return template.New("claceapp").Funcs(funcMap).ParseFS(f.ReadableFS, patterns...)
}
func (f *SourceFs) Stat(name string) (fs.FileInfo, error) {
return f.ReadableFS.Stat(name)
}
// Open returns a reference to the named file.
// If name is a hash name then the underlying file is used.
func (f *SourceFs) Open(name string) (fs.File, error) {
fi, _, err := f.open(name)
return fi, err
}
func (f *SourceFs) open(name string) (_ fs.File, hash string, err error) {
// Parse filename to see if it contains a hash.
// If so, check if hash name matches.
base, hash := f.ParseName(name)
if hash != "" && f.HashName(base) == name {
name = base
}
fi, err := f.ReadableFS.Open(name)
return fi, hash, err
}
// HashName returns the hash name for a path, if exists.
// Otherwise returns the original path.
func (f *SourceFs) HashName(name string) string {
// Lookup cached formatted name, if exists.
f.mu.RLock()
if s := f.nameToHash[name]; s != "" {
f.mu.RUnlock()
return s
}
f.mu.RUnlock()
// Read file contents. Return original filename if we receive an error.
buf, err := fs.ReadFile(f.ReadableFS, name)
if err != nil {
fmt.Printf("HashName readfile error: %s %s\n", err, name) //TODO: log
return name
}
// Compute hash and build filename.
hash := sha256.Sum256(buf)
hashhex := hex.EncodeToString(hash[:])
hashname := FormatName(name, hashhex)
// Store in lookups.
f.mu.Lock()
f.nameToHash[name] = hashname
f.hashToName[hashname] = [2]string{name, hashhex}
f.mu.Unlock()
return hashname
}
// FormatName returns a hash name that inserts hash before the filename's
// extension. If no extension exists on filename then the hash is appended.
// Returns blank string the original filename if hash is blank. Returns a blank
// string if the filename is blank.
func FormatName(filename, hash string) string {
if filename == "" {
return ""
} else if hash == "" {
return filename
}
dir, base := path.Split(filename)
if i := strings.Index(base, "."); i != -1 {
return path.Join(dir, fmt.Sprintf("%s-%s%s", base[:i], hash, base[i:]))
}
return path.Join(dir, fmt.Sprintf("%s-%s", base, hash))
}
// ParseName splits formatted hash filename into its base & hash components.
func (f *SourceFs) ParseName(filename string) (base, hash string) {
f.mu.RLock()
defer f.mu.RUnlock()
if hashed, ok := f.hashToName[filename]; ok {
return hashed[0], hashed[1]
}
return ParseName(filename)
}
// ParseName splits formatted hash filename into its base & hash components.
func ParseName(filename string) (base, hash string) {
if filename == "" {
return "", ""
}
dir, base := path.Split(filename)
// Extract pre-hash & extension.
pre, ext := base, ""
if i := strings.Index(base, "."); i != -1 {
pre = base[:i]
ext = base[i:]
}
// If prehash doesn't contain the hash, then exit.
if !hashSuffixRegex.MatchString(pre) {
return filename, ""
}
return path.Join(dir, pre[:len(pre)-65]+ext), pre[len(pre)-64:]
}
var hashSuffixRegex = regexp.MustCompile(`-[0-9a-f]{64}`)
// FileServer returns an http.Handler for serving FS files. It provides a
// simplified implementation of http.FileServer which is used to aggressively
// cache files on the client since the file hash is in the filename.
//
// Because FileServer is focused on small known path files, several features
// of http.FileServer have been removed including canonicalizing directories,
// defaulting index.html pages, precondition checks, & content range headers.
func FileServer(fsys *SourceFs) http.Handler {
return &fsHandler{fsys: fsys}
}
type fsHandler struct {
fsys *SourceFs
}
func (h *fsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Clean up filename based on URL path.
filename := r.URL.Path
if filename == "/" {
filename = "."
} else {
filename = strings.TrimPrefix(filename, "/")
}
filename = path.Clean(filename)
// Read file from attached file system.
f, hash, err := h.fsys.open(filename)
if os.IsNotExist(err) {
http.Error(w, "404 page not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "500 Error serving static file: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
// Fetch file info. Disallow directories from being displayed.
fi, err := f.Stat()
if err != nil {
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
return
} else if fi.IsDir() {
http.Error(w, "403 Forbidden", http.StatusForbidden)
return
}
// Cache the file aggressively if the file contains a hash.
if hash != "" {
w.Header().Set("Cache-Control", `public, max-age=31536000`)
w.Header().Set("ETag", "\""+hash+"\"")
}
seeker, ok := f.(io.ReadSeeker)
if !ok {
http.Error(w, "500 Filesystem does not implement Seek interface", http.StatusInternalServerError)
return
}
// If this is a request without Range headers and brotli encoding is accepted,
// Return the data which is already in a compressed form
served, err := h.serveCompressed(w, r, filename, fi.ModTime(), seeker)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if served {
return
}
http.ServeContent(w, r, filename, fi.ModTime(), seeker)
}
const COMPRESSION_TYPE = "br" // brotli uses br as the encoding type
func (h *fsHandler) canServeCompressed(r *http.Request) bool {
rangeHeader := r.Header.Get("Range")
if rangeHeader != "" {
// Range headers are being used, fallback to http.ServeContent
return false
}
encodingHeader := r.Header.Get("Accept-Encoding")
acceptedEncodings := strings.Split(strings.ToLower(encodingHeader), ",")
brotliMatchFound := false
for _, acceptedEncoding := range acceptedEncodings {
if strings.TrimSpace(acceptedEncoding) == COMPRESSION_TYPE {
brotliMatchFound = true
break
}
}
return brotliMatchFound
}
var unixEpochTime = time.Unix(0, 0)
// serveCompressed checks if the compressed file data can be streamed directly to the client, without
// the need to decompress and then recompress. If the client accepts brotli compressed data and there are no
// range headers, then this optimization can be used.
func (h *fsHandler) serveCompressed(w http.ResponseWriter, r *http.Request, filename string, modtime time.Time, content io.ReadSeeker) (bool, error) {
if !h.canServeCompressed(r) {
return false, nil
}
compressedReader, ok := content.(utils.CompressedReader)
if !ok {
return false, nil
}
data, compressionType, err := compressedReader.ReadCompressed()
if err != nil {
return false, err
}
if compressionType != COMPRESSION_TYPE {
// the data is not compressed with brotli, fallback to http.ServeContent
return false, nil
}
contentType := mime.TypeByExtension(filepath.Ext(filename))
if contentType == "" {
return false, nil
}
if !modtime.IsZero() && !modtime.Equal(unixEpochTime) {
w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Encoding", COMPRESSION_TYPE)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.Header().Set("X-Clace-Compressed", "true")
w.Header().Add("Vary", "Accept-Encoding")
w.WriteHeader(http.StatusOK)
w.Write(data)
return true, nil
}