github.com/IRelaxxx/servefiles/v3@v3.4.6/assets.go (about) 1 // MIT License 2 // 3 // Copyright (c) 2016 Rick Beton 4 // 5 // Permission is hereby granted, free of charge, to any person obtaining a copy 6 // of this software and associated documentation files (the "Software"), to deal 7 // in the Software without restriction, including without limitation the rights 8 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 // copies of the Software, and to permit persons to whom the Software is 10 // furnished to do so, subject to the following conditions: 11 // 12 // The above copyright notice and this permission notice shall be included in all 13 // copies or substantial portions of the Software. 14 // 15 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 // SOFTWARE. 22 23 package servefiles 24 25 import ( 26 "fmt" 27 "math/rand" 28 "mime" 29 "net/http" 30 "os" 31 "path/filepath" 32 "strconv" 33 "strings" 34 "sync" 35 "time" 36 37 "github.com/rickb777/path" 38 "github.com/spf13/afero" 39 ) 40 41 // This needs to track the same string in net/http (which is unlikely ever to change) 42 const indexPage = "index.html" 43 44 // Assets sets the options for asset handling. Use AssetHandler to create the handler(s) you need. 45 type Assets struct { 46 // Choose a number greater than zero to strip off some leading segments from the URL path. This helps if 47 // you want, say, a sequence number in the URL so that only has the effect of managing far-future cache 48 // control. Use zero for default behaviour. 49 UnwantedPrefixSegments int 50 51 // Set the expiry duration for assets. This will be set via headers in the response. This should never be 52 // negative. Use zero to disable asset caching in clients and proxies. 53 MaxAge time.Duration 54 55 // Configurable http.Handler which is called when no matching route is found. If it is not set, 56 // http.NotFound is used. 57 NotFound http.Handler 58 59 fs afero.Fs 60 server http.Handler 61 expiryElasticity time.Duration 62 timestamp int64 63 timestampExpiry string 64 lock *sync.Mutex 65 Spa bool 66 } 67 68 // Type conformance proof 69 var _ http.Handler = &Assets{} 70 71 //------------------------------------------------------------------------------------------------- 72 73 // NewAssetHandler creates an Assets value. The parameter is the directory containing the asset files; 74 // this can be absolute or relative to the directory in which the server process is started. 75 // 76 // This function cleans (i.e. normalises) the asset path. 77 func NewAssetHandler(assetPath string) *Assets { 78 cleanAssetPath := cleanPathAndAppendSlash(assetPath) 79 Debugf("NewAssetHandler %s\n", cleanAssetPath) 80 fs := afero.NewBasePathFs(afero.NewOsFs(), cleanAssetPath) 81 return NewAssetHandlerFS(fs) 82 } 83 84 // NewAssetHandlerFS creates an Assets value for a given filesystem. 85 func NewAssetHandlerFS(fs afero.Fs) *Assets { 86 return &Assets{ 87 fs: fs, 88 server: http.FileServer(afero.NewHttpFs(fs)), 89 lock: &sync.Mutex{}, 90 } 91 } 92 93 // StripOff alters the handler to strip off a specified number of segments from the path before 94 // looking for the matching asset. For example, if StripOff(2) has been applied, the requested 95 // path "/a/b/c/d/doc.js" would be shortened to "c/d/doc.js". 96 // 97 // The returned handler is a new copy of the original one. 98 func (a Assets) StripOff(unwantedPrefixSegments int) *Assets { 99 if unwantedPrefixSegments < 0 { 100 panic("Negative unwantedPrefixSegments") 101 } 102 a.UnwantedPrefixSegments = unwantedPrefixSegments 103 return &a 104 } 105 106 // WithMaxAge alters the handler to set the specified max age on the served assets. 107 // 108 // The returned handler is a new copy of the original one. 109 func (a Assets) WithMaxAge(maxAge time.Duration) *Assets { 110 if maxAge < 0 { 111 panic("Negative maxAge") 112 } 113 a.MaxAge = maxAge 114 return &a 115 } 116 117 // WithNotFound alters the handler so that 404-not found cases are passed to a specified 118 // handler. Without this, the default handler is the one provided in the net/http package. 119 // 120 // The returned handler is a new copy of the original one. 121 func (a Assets) WithNotFound(notFound http.Handler) *Assets { 122 a.NotFound = notFound 123 return &a 124 } 125 126 // WithSPA alters the handler so that all requestet files without a file extention instead return index.html 127 // 128 // The returned handler is a new copy of the original one. 129 func (a Assets) WithSPA() *Assets { 130 a.Spa = true 131 return &a 132 } 133 134 //------------------------------------------------------------------------------------------------- 135 136 // Calculate the 'Expires' value using an approximation that reduces unimportant re-calculation. 137 // We don't need to do this accurately because the 'Cache-Control' maxAge value takes precedence 138 // anyway. So the value is cached and shared between requests for a short while. 139 func (a *Assets) expires() string { 140 if a.expiryElasticity == 0 { 141 // lazy initialisation 142 a.expiryElasticity = 1 + a.MaxAge/100 143 } 144 145 now := time.Now().UTC() 146 unix := now.Unix() 147 148 if unix > a.timestamp { 149 later := now.Add(a.MaxAge + a.expiryElasticity) // add expiryElasticity to avoid negative expiry 150 a.lock.Lock() 151 defer a.lock.Unlock() 152 // cache the formatted string for one second to avoid repeated formatting 153 // race condition is ignored here, but note the order below 154 a.timestampExpiry = later.Format(time.RFC1123) 155 a.timestamp = unix + int64(a.expiryElasticity) 156 } 157 158 return a.timestampExpiry 159 } 160 161 //------------------------------------------------------------------------------------------------- 162 163 func isSPARequest(resource string) bool { 164 // two cases 165 // 1. there is no dot -> "/" or some other path was requested 166 // 2. there is a dot, so check if the last dot is after the last slash, if that is a case it is a filepath 167 if strings.Count(resource, ".") == 0 { 168 return true 169 } 170 lastDot := strings.LastIndex(resource, ".") 171 lastSlash := strings.LastIndex(resource, "/") 172 if lastDot < lastSlash { 173 return true 174 } 175 return false 176 } 177 178 type fileData struct { 179 resource string 180 code code 181 fi os.FileInfo 182 } 183 184 func calculateEtag(fi os.FileInfo) string { 185 if fi == nil { 186 return "" 187 } 188 return fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size()) 189 } 190 191 func handleSaturatedServer(header http.Header, resource string, err error) fileData { 192 // Possibly the server is under heavy load and ran out of file descriptors 193 backoff := 2 + rand.Int31()%4 // 2–6 seconds to prevent a stampede 194 header.Set("Retry-After", strconv.Itoa(int(backoff))) 195 return fileData{resource, ServiceUnavailable, nil} 196 } 197 198 func (a *Assets) checkResource(resource string, header http.Header) fileData { 199 d, err := a.fs.Stat(resource) 200 if err != nil { 201 if os.IsNotExist(err) { 202 // gzipped does not exist; original might but this gets checked later 203 Debugf("Assets checkResource 404 %s\n", resource) 204 return fileData{"", NotFound, nil} 205 206 } else if os.IsPermission(err) { 207 // incorrectly assembled gzipped asset is treated as an error 208 Debugf("Assets checkResource 403 %s\n", resource) 209 return fileData{resource, Forbidden, nil} 210 } 211 212 Debugf("Assets handleSaturatedServer 503 %s\n", resource) 213 return handleSaturatedServer(header, resource, err) 214 } 215 216 if d.IsDir() { 217 // directory edge case is simply passed on to the standard library 218 return fileData{resource, Directory, nil} 219 } 220 221 Debugf("Assets checkResource 100 %s\n", resource) 222 return fileData{resource, Continue, d} 223 } 224 225 func (a *Assets) chooseResource(header http.Header, req *http.Request) (string, code) { 226 resource := path.Drop(req.URL.Path, a.UnwantedPrefixSegments) 227 if a.Spa && isSPARequest(resource) { 228 resource = "/" 229 } 230 if strings.HasSuffix(resource, "/") { 231 resource += indexPage 232 } 233 Debugf("Assets chooseResource %s %s %s\n", req.Method, req.URL.Path, resource) 234 235 if a.MaxAge > 0 { 236 header.Set("Expires", a.expires()) 237 header.Set("Cache-Control", fmt.Sprintf("public, maxAge=%d", a.MaxAge/time.Second)) 238 } 239 240 acceptEncoding := commaSeparatedList(req.Header.Get("Accept-Encoding")) 241 if acceptEncoding.Contains("br") { 242 brotli := resource + ".br" 243 244 fdbr := a.checkResource(brotli, header) 245 246 if fdbr.code == Continue { 247 ext := filepath.Ext(resource) 248 header.Set("Content-Type", mime.TypeByExtension(ext)) 249 // the standard library sometimes overrides the content type via sniffing 250 header.Set("X-Content-Type-Options", "nosniff") 251 header.Set("Content-Encoding", "br") 252 header.Add("Vary", "Accept-Encoding") 253 // weak etag because the representation is not the original file but a compressed variant 254 header.Set("ETag", "W/"+calculateEtag(fdbr.fi)) 255 return brotli, Continue 256 } 257 } 258 259 if acceptEncoding.Contains("gzip") { 260 gzipped := resource + ".gz" 261 262 fdgz := a.checkResource(gzipped, header) 263 264 if fdgz.code == Continue { 265 ext := filepath.Ext(resource) 266 header.Set("Content-Type", mime.TypeByExtension(ext)) 267 // the standard library sometimes overrides the content type via sniffing 268 header.Set("X-Content-Type-Options", "nosniff") 269 header.Set("Content-Encoding", "gzip") 270 header.Add("Vary", "Accept-Encoding") 271 // weak etag because the representation is not the original file but a compressed variant 272 header.Set("ETag", "W/"+calculateEtag(fdgz.fi)) 273 return gzipped, Continue 274 } 275 } 276 277 // no intervention; the file will be served normally by the standard api 278 fd := a.checkResource(resource, header) 279 280 if fd.code > 0 { 281 // strong etag because the representation is the original file 282 header.Set("ETag", calculateEtag(fd.fi)) 283 } 284 285 return fd.resource, fd.code 286 } 287 288 // ServeHTTP implements the http.Handler interface. Note that it (a) handles 289 // headers for compression, expiry etc, and then (b) calls the standard 290 // http.ServeHTTP handler for each request. This ensures that it follows 291 // all the standard logic paths implemented there, including conditional 292 // requests and content negotiation. 293 func (a *Assets) ServeHTTP(w http.ResponseWriter, req *http.Request) { 294 resource, code := a.chooseResource(w.Header(), req) 295 296 if code == NotFound && a.NotFound != nil { 297 // use the provided not-found handler 298 Debugf("Assets ServeHTTP (not found) %s %s R:%+v W:%+v\n", req.Method, req.URL.Path, req.Header, w.Header()) 299 300 // ww has silently dropped the headers and body from the built-in handler in this case, 301 // so complete the response using the original handler. 302 w.Header().Set("X-Content-Type-Options", "nosniff") 303 a.NotFound.ServeHTTP(w, req) 304 return 305 } 306 307 if code >= 400 { 308 Debugf("Assets ServeHTTP (error %d) %s %s R:%+v W:%+v\n", code, req.Method, req.URL.Path, req.Header, w.Header()) 309 http.Error(w, code.String(), int(code)) 310 return 311 } 312 313 original := req.URL.Path 314 req.URL.Path = resource 315 316 // Conditional requests and content negotiation are handled in the standard net/http API. 317 // Note that req.URL remains unchanged, even if prefix stripping is turned on, because the resource is 318 // the only value that matters. 319 Debugf("Assets ServeHTTP (ok %d) %s %s (was %s) R:%+v W:%+v\n", code, req.Method, req.URL.Path, original, req.Header, w.Header()) 320 a.server.ServeHTTP(w, req) 321 } 322 323 func cleanPathAndAppendSlash(s string) string { 324 clean := path.Clean(s) 325 return string(append([]byte(clean), '/')) 326 } 327 328 //------------------------------------------------------------------------------------------------- 329 330 // Printer is something that allows formatted printing. This is only used for diagnostics. 331 type Printer func(format string, v ...interface{}) 332 333 // Debugf is a function that allows diagnostics to be emitted. By default it does very 334 // little and has almost no impact. Set it to some other function (e.g. using log.Printf) to 335 // see the diagnostics. 336 var Debugf Printer = func(format string, v ...interface{}) {} 337 338 // example (paste this into setup code elsewhere) 339 //var servefiles.Debugf Printer = log.Printf