From fec7fa8bfda713e8042b9bbf9a480c7792b78c41 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 20 May 2019 10:59:20 -0600 Subject: Implement most of static file server; refactor and improve Replacer --- modules/caddyhttp/staticfiles/staticfiles.go | 299 ++++++++++++++++++++++++++- 1 file changed, 296 insertions(+), 3 deletions(-) (limited to 'modules/caddyhttp/staticfiles/staticfiles.go') diff --git a/modules/caddyhttp/staticfiles/staticfiles.go b/modules/caddyhttp/staticfiles/staticfiles.go index 2a6fe37..0ef3c63 100644 --- a/modules/caddyhttp/staticfiles/staticfiles.go +++ b/modules/caddyhttp/staticfiles/staticfiles.go @@ -1,7 +1,15 @@ package staticfiles import ( + "fmt" + weakrand "math/rand" "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" "bitbucket.org/lightcodelabs/caddy2" "bitbucket.org/lightcodelabs/caddy2/modules/caddyhttp" @@ -16,13 +24,298 @@ func init() { // StaticFiles implements a static file server responder for Caddy. type StaticFiles struct { - Root string + Root string `json:"root"` // default is current directory + IndexNames []string `json:"index_names"` + Files []string `json:"files"` // all relative to the root; default is request URI path + SelectionPolicy string `json:"selection_policy"` + Fallback caddyhttp.RouteList `json:"fallback"` + Browse *Browse `json:"browse"` + Hide []string `json:"hide"` + Rehandle bool `json:"rehandle"` // issue a rehandle (internal redirect) if request is rewritten + // TODO: Etag + // TODO: Content negotiation } -func (sf StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error { - http.FileServer(http.Dir(sf.Root)).ServeHTTP(w, r) +// Provision sets up the static files responder. +func (sf *StaticFiles) Provision(ctx caddy2.Context) error { + if sf.Fallback != nil { + err := sf.Fallback.Provision(ctx) + if err != nil { + return fmt.Errorf("setting up fallback routes: %v", err) + } + } + if sf.IndexNames == nil { + sf.IndexNames = defaultIndexNames + } return nil } +// Validate ensures that sf has a valid configuration. +func (sf *StaticFiles) Validate() error { + switch sf.SelectionPolicy { + case "", + "first_existing", + "largest_size", + "smallest_size", + "most_recently_modified": + default: + return fmt.Errorf("unknown selection policy %s", sf.SelectionPolicy) + } + return nil +} + +func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + // TODO: Prevent directory traversal, see https://play.golang.org/p/oh77BiVQFti + + // http.FileServer(http.Dir(sf.Directory)).ServeHTTP(w, r) + + ////////////// + + // TODO: Still needed? + // // Prevent absolute path access on Windows, e.g.: http://localhost:5000/C:\Windows\notepad.exe + // // TODO: does stdlib http.Dir handle this? see first check of http.Dir.Open()... + // if runtime.GOOS == "windows" && len(reqPath) > 0 && filepath.IsAbs(reqPath[1:]) { + // return caddyhttp.Error(http.StatusNotFound, fmt.Errorf("request path was absolute")) + // } + + repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer) + + // map the request to a filename + pathBefore := r.URL.Path + filename := sf.selectFile(r, repl) + if filename == "" { + // no files worked, so resort to fallback + if sf.Fallback != nil { + fallback := sf.Fallback.BuildCompositeRoute(w, r) + return fallback.ServeHTTP(w, r) + } + return caddyhttp.Error(http.StatusNotFound, nil) + } + + // if the ultimate destination has changed, submit + // this request for a rehandling (internal redirect) + // if configured to do so + // TODO: double check this against https://docs.nginx.com/nginx/admin-guide/web-server/serving-static-content/ + if r.URL.Path != pathBefore && sf.Rehandle { + return caddyhttp.ErrRehandle + } + + // get information about the file + info, err := os.Stat(filename) + if err != nil { + if os.IsNotExist(err) { + return caddyhttp.Error(http.StatusNotFound, err) + } else if os.IsPermission(err) { + return caddyhttp.Error(http.StatusForbidden, err) + } + // TODO: treat this as resource exhaustion like with os.Open? Or unnecessary here? + return caddyhttp.Error(http.StatusInternalServerError, err) + } + + // if the request mapped to a directory, see if + // there is an index file we can serve + if info.IsDir() && len(sf.IndexNames) > 0 { + filesToHide := sf.transformHidePaths(repl) + + for _, indexPage := range sf.IndexNames { + indexPath := path.Join(filename, indexPage) + if fileIsHidden(indexPath, filesToHide) { + // pretend this file doesn't exist + continue + } + + indexInfo, err := os.Stat(indexPath) + if err != nil { + continue + } + + // we found an index file that might work, + // so rewrite the request path and, if + // configured, do an internal redirect + // TODO: I don't know if the logic for rewriting + // the URL here is the right logic + r.URL.Path = path.Join(r.URL.Path, indexPage) + if sf.Rehandle { + return caddyhttp.ErrRehandle + } + + info = indexInfo + break + } + } + + // if still referencing a directory, delegate + // to browse or return an error + if info.IsDir() { + if sf.Browse != nil { + return sf.Browse.ServeHTTP(w, r) + } + return caddyhttp.Error(http.StatusNotFound, nil) + } + + // open the file + file, err := os.Open(info.Name()) + if err != nil { + if os.IsNotExist(err) { + return caddyhttp.Error(http.StatusNotFound, err) + } else if os.IsPermission(err) { + return caddyhttp.Error(http.StatusForbidden, err) + } + // maybe the server is under load and ran out of file descriptors? + // have client wait arbitrary seconds to help prevent a stampede + backoff := weakrand.Intn(maxBackoff-minBackoff) + minBackoff + w.Header().Set("Retry-After", strconv.Itoa(backoff)) + return caddyhttp.Error(http.StatusServiceUnavailable, err) + } + defer file.Close() + + // TODO: Right now we return an invalid response if the + // request is for a directory and there is no index file + // or dir browsing; we should return a 404 I think... + + // TODO: Etag? + + // TODO: content negotiation? (brotli sidecar files, etc...) + + // let the standard library do what it does best; note, however, + // that errors generated by ServeContent are written immediately + // to the response, so we cannot handle them (but errors here are rare) + http.ServeContent(w, r, info.Name(), info.ModTime(), file) + + return nil +} + +func (sf *StaticFiles) transformHidePaths(repl caddy2.Replacer) []string { + hide := make([]string, len(sf.Hide)) + for i := range sf.Hide { + hide[i] = repl.ReplaceAll(sf.Hide[i], "") + } + return hide +} + +func (sf *StaticFiles) selectFile(r *http.Request, repl caddy2.Replacer) string { + root := repl.ReplaceAll(sf.Root, "") + if root == "" { + root = "." + } + + if sf.Files == nil { + return filepath.Join(root, r.URL.Path) + } + + switch sf.SelectionPolicy { + // TODO: Make these policy names constants + case "", "first_existing": + filesToHide := sf.transformHidePaths(repl) + for _, f := range sf.Files { + suffix := repl.ReplaceAll(f, "") + // TODO: sanitize path + fullpath := filepath.Join(root, suffix) + if !fileIsHidden(fullpath, filesToHide) && fileExists(fullpath) { + r.URL.Path = suffix + return fullpath + } + } + + case "largest_size": + var largestSize int64 + var largestFilename string + var largestSuffix string + for _, f := range sf.Files { + suffix := repl.ReplaceAll(f, "") + // TODO: sanitize path + fullpath := filepath.Join(root, suffix) + info, err := os.Stat(fullpath) + if err == nil && info.Size() > largestSize { + largestSize = info.Size() + largestFilename = fullpath + largestSuffix = suffix + } + } + r.URL.Path = largestSuffix + return largestFilename + + case "smallest_size": + var smallestSize int64 + var smallestFilename string + var smallestSuffix string + for _, f := range sf.Files { + suffix := repl.ReplaceAll(f, "") + // TODO: sanitize path + fullpath := filepath.Join(root, suffix) + info, err := os.Stat(fullpath) + if err == nil && (smallestSize == 0 || info.Size() < smallestSize) { + smallestSize = info.Size() + smallestFilename = fullpath + smallestSuffix = suffix + } + } + r.URL.Path = smallestSuffix + return smallestFilename + + case "most_recently_modified": + var recentDate time.Time + var recentFilename string + var recentSuffix string + for _, f := range sf.Files { + suffix := repl.ReplaceAll(f, "") + // TODO: sanitize path + fullpath := filepath.Join(root, suffix) + info, err := os.Stat(fullpath) + if err == nil && + (recentDate.IsZero() || info.ModTime().After(recentDate)) { + recentDate = info.ModTime() + recentFilename = fullpath + recentSuffix = suffix + } + } + r.URL.Path = recentSuffix + return recentFilename + } + + return "" +} + +// fileExists returns true if file exists. +func fileExists(file string) bool { + _, err := os.Stat(file) + return !os.IsNotExist(err) +} + +func fileIsHidden(filename string, hide []string) bool { + nameOnly := filepath.Base(filename) + sep := string(filepath.Separator) + + // see if file is hidden + for _, h := range hide { + // assuming h is a glob/shell-like pattern, + // use it to compare the whole file path; + // but if there is no separator in h, then + // just compare against the file's name + compare := filename + if !strings.Contains(h, sep) { + compare = nameOnly + } + + hidden, err := filepath.Match(h, compare) + if err != nil { + // malformed pattern; fallback by checking prefix + if strings.HasPrefix(filename, h) { + return true + } + } + if hidden { + // file name or path matches hide pattern + return true + } + } + + return false +} + +var defaultIndexNames = []string{"index.html"} + +const minBackoff, maxBackoff = 2, 5 + // Interface guard var _ caddyhttp.Handler = (*StaticFiles)(nil) -- cgit v1.2.3