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