summaryrefslogtreecommitdiff
path: root/modules/caddyhttp/fileserver/staticfiles.go
diff options
context:
space:
mode:
Diffstat (limited to 'modules/caddyhttp/fileserver/staticfiles.go')
-rw-r--r--modules/caddyhttp/fileserver/staticfiles.go416
1 files changed, 416 insertions, 0 deletions
diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go
new file mode 100644
index 0000000..e859abe
--- /dev/null
+++ b/modules/caddyhttp/fileserver/staticfiles.go
@@ -0,0 +1,416 @@
+package fileserver
+
+import (
+ "fmt"
+ "html/template"
+ weakrand "math/rand"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "bitbucket.org/lightcodelabs/caddy2"
+ "bitbucket.org/lightcodelabs/caddy2/modules/caddyhttp"
+)
+
+func init() {
+ weakrand.Seed(time.Now().UnixNano())
+
+ caddy2.RegisterModule(caddy2.Module{
+ Name: "http.responders.file_server",
+ New: func() (interface{}, error) { return new(FileServer), nil },
+ })
+}
+
+// FileServer implements a static file server responder for Caddy.
+type FileServer struct {
+ Root string `json:"root"` // default is current directory
+ Hide []string `json:"hide"`
+ IndexNames []string `json:"index_names"`
+ Files []string `json:"files"` // all relative to the root; default is request URI path
+ Rehandle bool `json:"rehandle"` // issue a rehandle (internal redirect) if request is rewritten
+ SelectionPolicy string `json:"selection_policy"`
+ Fallback caddyhttp.RouteList `json:"fallback"`
+ Browse *Browse `json:"browse"`
+ // TODO: Etag
+ // TODO: Content negotiation
+}
+
+// Provision sets up the static files responder.
+func (fsrv *FileServer) Provision(ctx caddy2.Context) error {
+ if fsrv.Fallback != nil {
+ err := fsrv.Fallback.Provision(ctx)
+ if err != nil {
+ return fmt.Errorf("setting up fallback routes: %v", err)
+ }
+ }
+
+ if fsrv.IndexNames == nil {
+ fsrv.IndexNames = defaultIndexNames
+ }
+
+ if fsrv.Browse != nil {
+ var tpl *template.Template
+ var err error
+ if fsrv.Browse.TemplateFile != "" {
+ tpl, err = template.ParseFiles(fsrv.Browse.TemplateFile)
+ if err != nil {
+ return fmt.Errorf("parsing browse template file: %v", err)
+ }
+ } else {
+ tpl, err = template.New("default_listing").Parse(defaultBrowseTemplate)
+ if err != nil {
+ return fmt.Errorf("parsing default browse template: %v", err)
+ }
+ }
+ fsrv.Browse.template = tpl
+ }
+
+ return nil
+}
+
+const (
+ selectionPolicyFirstExisting = "first_existing"
+ selectionPolicyLargestSize = "largest_size"
+ selectionPolicySmallestSize = "smallest_size"
+ selectionPolicyRecentlyMod = "most_recently_modified"
+)
+
+// Validate ensures that sf has a valid configuration.
+func (fsrv *FileServer) Validate() error {
+ switch fsrv.SelectionPolicy {
+ case "",
+ selectionPolicyFirstExisting,
+ selectionPolicyLargestSize,
+ selectionPolicySmallestSize,
+ selectionPolicyRecentlyMod:
+ default:
+ return fmt.Errorf("unknown selection policy %s", fsrv.SelectionPolicy)
+ }
+ return nil
+}
+
+func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
+ repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer)
+
+ filesToHide := fsrv.transformHidePaths(repl)
+
+ // map the request to a filename
+ pathBefore := r.URL.Path
+ filename := fsrv.selectFile(r, repl, filesToHide)
+ if filename == "" {
+ // no files worked, so resort to fallback
+ if fsrv.Fallback != nil {
+ fallback := fsrv.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
+ if r.URL.Path != pathBefore && fsrv.Rehandle {
+ return caddyhttp.ErrRehandle
+ }
+
+ // get information about the file
+ info, err := os.Stat(filename)
+ if err != nil {
+ err = mapDirOpenError(err, filename)
+ 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(fsrv.IndexNames) > 0 {
+ for _, indexPage := range fsrv.IndexNames {
+ indexPath := sanitizedPathJoin(filename, indexPage)
+ if fileHidden(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
+ r.URL.Path = path.Join(r.URL.Path, indexPage)
+ if fsrv.Rehandle {
+ return caddyhttp.ErrRehandle
+ }
+
+ info = indexInfo
+ filename = indexPath
+ break
+ }
+ }
+
+ // if still referencing a directory, delegate
+ // to browse or return an error
+ if info.IsDir() {
+ if fsrv.Browse != nil && !fileHidden(filename, filesToHide) {
+ return fsrv.serveBrowse(filename, w, r)
+ }
+ return caddyhttp.Error(http.StatusNotFound, nil)
+ }
+
+ // TODO: content negotiation (brotli sidecar files, etc...)
+
+ // one last check to ensure the file isn't hidden (we might
+ // have changed the filename from when we last checked)
+ if fileHidden(filename, filesToHide) {
+ return caddyhttp.Error(http.StatusNotFound, nil)
+ }
+
+ // open the file
+ file, err := fsrv.openFile(filename, w)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ // TODO: Etag
+
+ // TODO: Disable content-type sniffing by setting a content-type...
+
+ // 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
+}
+
+// openFile opens the file at the given filename. If there was an error,
+// the response is configured to inform the client how to best handle it
+// and a well-described handler error is returned (do not wrap the
+// returned error value).
+func (fsrv *FileServer) openFile(filename string, w http.ResponseWriter) (*os.File, error) {
+ file, err := os.Open(filename)
+ if err != nil {
+ err = mapDirOpenError(err, filename)
+ if os.IsNotExist(err) {
+ return nil, caddyhttp.Error(http.StatusNotFound, err)
+ } else if os.IsPermission(err) {
+ return nil, 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 nil, caddyhttp.Error(http.StatusServiceUnavailable, err)
+ }
+ return file, nil
+}
+
+// mapDirOpenError maps the provided non-nil error from opening name
+// to a possibly better non-nil error. In particular, it turns OS-specific errors
+// about opening files in non-directories into os.ErrNotExist. See golang/go#18984.
+// Adapted from the Go standard library; originally written by Nathaniel Caza.
+// https://go-review.googlesource.com/c/go/+/36635/
+// https://go-review.googlesource.com/c/go/+/36804/
+func mapDirOpenError(originalErr error, name string) error {
+ if os.IsNotExist(originalErr) || os.IsPermission(originalErr) {
+ return originalErr
+ }
+
+ parts := strings.Split(name, string(filepath.Separator))
+ for i := range parts {
+ if parts[i] == "" {
+ continue
+ }
+ fi, err := os.Stat(strings.Join(parts[:i+1], string(filepath.Separator)))
+ if err != nil {
+ return originalErr
+ }
+ if !fi.IsDir() {
+ return os.ErrNotExist
+ }
+ }
+
+ return originalErr
+}
+
+// transformHidePaths performs replacements for all the elements of
+// fsrv.Hide and returns a new list of the transformed values.
+func (fsrv *FileServer) transformHidePaths(repl caddy2.Replacer) []string {
+ hide := make([]string, len(fsrv.Hide))
+ for i := range fsrv.Hide {
+ hide[i] = repl.ReplaceAll(fsrv.Hide[i], "")
+ }
+ return hide
+}
+
+// sanitizedPathJoin performs filepath.Join(root, reqPath) that
+// is safe against directory traversal attacks. It uses logic
+// similar to that in the Go standard library, specifically
+// in the implementation of http.Dir. The root is assumed to
+// be a trusted path, but reqPath is not.
+func sanitizedPathJoin(root, reqPath string) string {
+ // TODO: Caddy 1 uses this:
+ // prevent absolute path access on Windows, e.g. http://localhost:5000/C:\Windows\notepad.exe
+ // if runtime.GOOS == "windows" && len(reqPath) > 0 && filepath.IsAbs(reqPath[1:]) {
+ // TODO.
+ // }
+
+ // TODO: whereas std lib's http.Dir.Open() uses this:
+ // if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
+ // return nil, errors.New("http: invalid character in file path")
+ // }
+
+ // TODO: see https://play.golang.org/p/oh77BiVQFti for another thing to consider
+
+ if root == "" {
+ root = "."
+ }
+ return filepath.Join(root, filepath.FromSlash(path.Clean("/"+reqPath)))
+}
+
+// selectFile uses the specified selection policy (or first_existing
+// by default) to map the request r to a filename. The full path to
+// the file is returned if one is found; otherwise, an empty string
+// is returned.
+func (fsrv *FileServer) selectFile(r *http.Request, repl caddy2.Replacer, filesToHide []string) string {
+ root := repl.ReplaceAll(fsrv.Root, "")
+
+ if fsrv.Files == nil {
+ return sanitizedPathJoin(root, r.URL.Path)
+ }
+
+ switch fsrv.SelectionPolicy {
+ case "", selectionPolicyFirstExisting:
+ filesToHide := fsrv.transformHidePaths(repl)
+ for _, f := range fsrv.Files {
+ suffix := repl.ReplaceAll(f, "")
+ fullpath := sanitizedPathJoin(root, suffix)
+ if !fileHidden(fullpath, filesToHide) && fileExists(fullpath) {
+ r.URL.Path = suffix
+ return fullpath
+ }
+ }
+
+ case selectionPolicyLargestSize:
+ var largestSize int64
+ var largestFilename string
+ var largestSuffix string
+ for _, f := range fsrv.Files {
+ suffix := repl.ReplaceAll(f, "")
+ fullpath := sanitizedPathJoin(root, suffix)
+ if fileHidden(fullpath, filesToHide) {
+ continue
+ }
+ 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 selectionPolicySmallestSize:
+ var smallestSize int64
+ var smallestFilename string
+ var smallestSuffix string
+ for _, f := range fsrv.Files {
+ suffix := repl.ReplaceAll(f, "")
+ fullpath := sanitizedPathJoin(root, suffix)
+ if fileHidden(fullpath, filesToHide) {
+ continue
+ }
+ 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 selectionPolicyRecentlyMod:
+ var recentDate time.Time
+ var recentFilename string
+ var recentSuffix string
+ for _, f := range fsrv.Files {
+ suffix := repl.ReplaceAll(f, "")
+ fullpath := sanitizedPathJoin(root, suffix)
+ if fileHidden(fullpath, filesToHide) {
+ continue
+ }
+ 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)
+}
+
+// fileHidden returns true if filename is hidden
+// according to the hide list.
+func fileHidden(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 = (*FileServer)(nil)