diff --git a/modules/caddyhttp/caddyhttp.go b/modules/caddyhttp/caddyhttp.go index 379c8f21..41b0c667 100644 --- a/modules/caddyhttp/caddyhttp.go +++ b/modules/caddyhttp/caddyhttp.go @@ -5,7 +5,7 @@ import ( "crypto/tls" "fmt" "log" - mathrand "math/rand" + weakrand "math/rand" "net" "net/http" "strconv" @@ -18,7 +18,7 @@ import ( ) func init() { - mathrand.Seed(time.Now().UnixNano()) + weakrand.Seed(time.Now().UnixNano()) err := caddy2.RegisterModule(caddy2.Module{ Name: "http", diff --git a/modules/caddyhttp/staticfiles/browse.go b/modules/caddyhttp/staticfiles/browse.go index 15ff1059..2bb130fa 100644 --- a/modules/caddyhttp/staticfiles/browse.go +++ b/modules/caddyhttp/staticfiles/browse.go @@ -1,58 +1,65 @@ package staticfiles import ( + "bytes" + "encoding/json" + "html/template" "net/http" + "os" + "path" + "strings" + + "bitbucket.org/lightcodelabs/caddy2" + "bitbucket.org/lightcodelabs/caddy2/modules/caddyhttp" ) // Browse configures directory browsing. type Browse struct { + TemplateFile string `json:"template_file"` + + template *template.Template } -// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. -// If so, control is handed over to ServeListing. -func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) error { - // TODO: convert this handler +func (sf *StaticFiles) serveBrowse(dirPath string, w http.ResponseWriter, r *http.Request) error { + dir, err := sf.openFile(dirPath, w) + if err != nil { + return err + } + defer dir.Close() + + repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer) + + listing, err := sf.loadDirectoryContents(dir, r.URL.Path, repl) + switch { + case os.IsPermission(err): + return caddyhttp.Error(http.StatusForbidden, err) + case os.IsNotExist(err): + return caddyhttp.Error(http.StatusNotFound, err) + case err != nil: + return caddyhttp.Error(http.StatusInternalServerError, err) + } + + sf.browseApplyQueryParams(w, r, &listing) + + // write response as either JSON or HTML + var buf *bytes.Buffer + acceptHeader := strings.ToLower(strings.Join(r.Header["Accept"], ",")) + if strings.Contains(acceptHeader, "application/json") { + if buf, err = sf.browseWriteJSON(listing); err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + } else { + if buf, err = sf.browseWriteHTML(listing); err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } + buf.WriteTo(w) + return nil - // // Browse works on existing directories; delegate everything else - // requestedFilepath, err := bc.Fs.Root.Open(r.URL.Path) - // if err != nil { - // switch { - // case os.IsPermission(err): - // return http.StatusForbidden, err - // case os.IsExist(err): - // return http.StatusNotFound, err - // default: - // return b.Next.ServeHTTP(w, r) - // } - // } - // defer requestedFilepath.Close() - - // info, err := requestedFilepath.Stat() - // if err != nil { - // switch { - // case os.IsPermission(err): - // return http.StatusForbidden, err - // case os.IsExist(err): - // return http.StatusGone, err - // default: - // return b.Next.ServeHTTP(w, r) - // } - // } - // if !info.IsDir() { - // return b.Next.ServeHTTP(w, r) - // } - - // // Do not reply to anything else because it might be nonsensical - // switch r.Method { - // case http.MethodGet, http.MethodHead: - // // proceed, noop - // case "PROPFIND", http.MethodOptions: - // return http.StatusNotImplemented, nil - // default: - // return b.Next.ServeHTTP(w, r) - // } - + // TODO: Sigh... do we have to put this here? // // Browsing navigation gets messed up if browsing a directory // // that doesn't end in "/" (which it should, anyway) // u := *r.URL @@ -68,138 +75,83 @@ func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) error { // return b.ServeListing(w, r, requestedFilepath, bc) } -// func (b Browse) loadDirectoryContents(requestedFilepath http.File, urlPath string, config *Config) (*Listing, bool, error) { -// files, err := requestedFilepath.Readdir(-1) -// if err != nil { -// return nil, false, err -// } +func (sf *StaticFiles) loadDirectoryContents(dir *os.File, urlPath string, repl caddy2.Replacer) (browseListing, error) { + files, err := dir.Readdir(-1) + if err != nil { + return browseListing{}, err + } -// // Determine if user can browse up another folder -// var canGoUp bool -// curPathDir := path.Dir(strings.TrimSuffix(urlPath, "/")) -// for _, other := range b.Configs { -// if strings.HasPrefix(curPathDir, other.PathScope) { -// canGoUp = true -// break -// } -// } + // determine if user can browse up another folder + curPathDir := path.Dir(strings.TrimSuffix(urlPath, "/")) + canGoUp := strings.HasPrefix(curPathDir, sf.Root) -// // Assemble listing of directory contents -// listing, hasIndex := directoryListing(files, canGoUp, urlPath, config) + return sf.directoryListing(files, canGoUp, urlPath, repl), nil +} -// return &listing, hasIndex, nil -// } +// browseApplyQueryParams applies query parameters to the listing. +// It mutates the listing and may set cookies. +func (sf *StaticFiles) browseApplyQueryParams(w http.ResponseWriter, r *http.Request, listing *browseListing) { + sortParam := r.URL.Query().Get("sort") + orderParam := r.URL.Query().Get("order") + limitParam := r.URL.Query().Get("limit") -// // handleSortOrder gets and stores for a Listing the 'sort' and 'order', -// // and reads 'limit' if given. The latter is 0 if not given. -// // -// // This sets Cookies. -// func (b Browse) handleSortOrder(w http.ResponseWriter, r *http.Request, scope string) (sort string, order string, limit int, err error) { -// sort, order, limitQuery := r.URL.Query().Get("sort"), r.URL.Query().Get("order"), r.URL.Query().Get("limit") + // first figure out what to sort by + switch sortParam { + case "": + sortParam = sortByNameDirFirst + if sortCookie, sortErr := r.Cookie("sort"); sortErr == nil { + sortParam = sortCookie.Value + } + case sortByName, sortByNameDirFirst, sortBySize, sortByTime: + http.SetCookie(w, &http.Cookie{Name: "sort", Value: sortParam, Secure: r.TLS != nil}) + } -// // If the query 'sort' or 'order' is empty, use defaults or any values previously saved in Cookies -// switch sort { -// case "": -// sort = sortByNameDirFirst -// if sortCookie, sortErr := r.Cookie("sort"); sortErr == nil { -// sort = sortCookie.Value -// } -// case sortByName, sortByNameDirFirst, sortBySize, sortByTime: -// http.SetCookie(w, &http.Cookie{Name: "sort", Value: sort, Path: scope, Secure: r.TLS != nil}) -// } + // then figure out the order + switch orderParam { + case "": + orderParam = "asc" + if orderCookie, orderErr := r.Cookie("order"); orderErr == nil { + orderParam = orderCookie.Value + } + case "asc", "desc": + http.SetCookie(w, &http.Cookie{Name: "order", Value: orderParam, Secure: r.TLS != nil}) + } -// switch order { -// case "": -// order = "asc" -// if orderCookie, orderErr := r.Cookie("order"); orderErr == nil { -// order = orderCookie.Value -// } -// case "asc", "desc": -// http.SetCookie(w, &http.Cookie{Name: "order", Value: order, Path: scope, Secure: r.TLS != nil}) -// } + // finally, apply the sorting and limiting + listing.applySortAndLimit(sortParam, orderParam, limitParam) +} -// if limitQuery != "" { -// limit, err = strconv.Atoi(limitQuery) -// if err != nil { // if the 'limit' query can't be interpreted as a number, return err -// return -// } -// } +func (sf *StaticFiles) browseWriteJSON(listing browseListing) (*bytes.Buffer, error) { + buf := new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(listing.Items) + return buf, err +} -// return -// } +func (sf *StaticFiles) browseWriteHTML(listing browseListing) (*bytes.Buffer, error) { + buf := new(bytes.Buffer) + err := sf.Browse.template.Execute(buf, listing) + return buf, err +} -// // ServeListing returns a formatted view of 'requestedFilepath' contents'. -// func (b Browse) ServeListing(w http.ResponseWriter, r *http.Request, requestedFilepath http.File, bc *Config) (int, error) { -// listing, containsIndex, err := b.loadDirectoryContents(requestedFilepath, r.URL.Path, bc) -// if err != nil { -// switch { -// case os.IsPermission(err): -// return http.StatusForbidden, err -// case os.IsExist(err): -// return http.StatusGone, err -// default: -// return http.StatusInternalServerError, err -// } -// } -// if containsIndex && !b.IgnoreIndexes { // directory isn't browsable -// return b.Next.ServeHTTP(w, r) -// } -// listing.Context = httpserver.Context{ -// Root: bc.Fs.Root, -// Req: r, -// URL: r.URL, -// } -// listing.User = bc.Variables +// isSymlink return true if f is a symbolic link +func isSymlink(f os.FileInfo) bool { + return f.Mode()&os.ModeSymlink != 0 +} -// // Copy the query values into the Listing struct -// var limit int -// listing.Sort, listing.Order, limit, err = b.handleSortOrder(w, r, bc.PathScope) -// if err != nil { -// return http.StatusBadRequest, err -// } +// isSymlinkTargetDir return true if f's symbolic link target +// is a directory. Return false if not a symbolic link. +// TODO: Re-implement +func isSymlinkTargetDir(f os.FileInfo, urlPath string) bool { + // if !isSymlink(f) { + // return false + // } -// listing.applySort() - -// if limit > 0 && limit <= len(listing.Items) { -// listing.Items = listing.Items[:limit] -// listing.ItemsLimitedTo = limit -// } - -// var buf *bytes.Buffer -// acceptHeader := strings.ToLower(strings.Join(r.Header["Accept"], ",")) -// switch { -// case strings.Contains(acceptHeader, "application/json"): -// if buf, err = b.formatAsJSON(listing, bc); err != nil { -// return http.StatusInternalServerError, err -// } -// w.Header().Set("Content-Type", "application/json; charset=utf-8") - -// default: // There's no 'application/json' in the 'Accept' header; browse normally -// if buf, err = b.formatAsHTML(listing, bc); err != nil { -// return http.StatusInternalServerError, err -// } -// w.Header().Set("Content-Type", "text/html; charset=utf-8") - -// } - -// _, _ = buf.WriteTo(w) - -// return http.StatusOK, nil -// } - -// func (b Browse) formatAsJSON(listing *Listing, bc *Config) (*bytes.Buffer, error) { -// marsh, err := json.Marshal(listing.Items) -// if err != nil { -// return nil, err -// } - -// buf := new(bytes.Buffer) -// _, err = buf.Write(marsh) -// return buf, err -// } - -// func (b Browse) formatAsHTML(listing *Listing, bc *Config) (*bytes.Buffer, error) { -// buf := new(bytes.Buffer) -// err := bc.Template.Execute(buf, listing) -// return buf, err -// } + // // TODO: Ensure path is sanitized + // target:= path.Join(root, urlPath, f.Name())) + // targetInfo, err := os.Stat(target) + // if err != nil { + // return false + // } + // return targetInfo.IsDir() + return false +} diff --git a/modules/caddyhttp/staticfiles/browselisting.go b/modules/caddyhttp/staticfiles/browselisting.go new file mode 100644 index 00000000..11e6b9c7 --- /dev/null +++ b/modules/caddyhttp/staticfiles/browselisting.go @@ -0,0 +1,245 @@ +package staticfiles + +import ( + "net/url" + "os" + "path" + "sort" + "strconv" + "strings" + "time" + + "bitbucket.org/lightcodelabs/caddy2" + "github.com/dustin/go-humanize" +) + +func (sf *StaticFiles) directoryListing(files []os.FileInfo, canGoUp bool, urlPath string, repl caddy2.Replacer) browseListing { + filesToHide := sf.transformHidePaths(repl) + + var ( + fileInfos []fileInfo + dirCount, fileCount int + ) + + for _, f := range files { + name := f.Name() + + if fileHidden(name, filesToHide) { + continue + } + + isDir := f.IsDir() || isSymlinkTargetDir(f, urlPath) + + if isDir { + name += "/" + dirCount++ + } else { + fileCount++ + } + + u := url.URL{Path: "./" + name} // prepend with "./" to fix paths with ':' in the name + + fileInfos = append(fileInfos, fileInfo{ + IsDir: isDir, + IsSymlink: isSymlink(f), + Name: f.Name(), + Size: f.Size(), + URL: u.String(), + ModTime: f.ModTime().UTC(), + Mode: f.Mode(), + }) + } + + return browseListing{ + Name: path.Base(urlPath), + Path: urlPath, + CanGoUp: canGoUp, + Items: fileInfos, + NumDirs: dirCount, + NumFiles: fileCount, + } +} + +type browseListing struct { + // The name of the directory (the last element of the path). + Name string + + // The full path of the request. + Path string + + // Whether the parent directory is browseable. + CanGoUp bool + + // The items (files and folders) in the path. + Items []fileInfo + + // The number of directories in the listing. + NumDirs int + + // The number of files (items that aren't directories) in the listing. + NumFiles int + + // Sort column used + Sort string + + // Sorting order + Order string + + // If ≠0 then Items have been limited to that many elements. + ItemsLimitedTo int +} + +// Breadcrumbs returns l.Path where every element maps +// the link to the text to display. +func (l browseListing) Breadcrumbs() []crumb { + var result []crumb + + if len(l.Path) == 0 { + return result + } + + // skip trailing slash + lpath := l.Path + if lpath[len(lpath)-1] == '/' { + lpath = lpath[:len(lpath)-1] + } + + parts := strings.Split(lpath, "/") + for i := range parts { + txt := parts[i] + if i == 0 && parts[i] == "" { + txt = "/" + } + lnk := strings.Repeat("../", len(parts)-i-1) + result = append(result, crumb{Link: lnk, Text: txt}) + } + + return result +} + +func (l *browseListing) applySortAndLimit(sortParam, orderParam, limitParam string) { + l.Sort = sortParam + l.Order = orderParam + + if l.Order == "desc" { + switch l.Sort { + case sortByName: + sort.Sort(sort.Reverse(byName(*l))) + case sortByNameDirFirst: + sort.Sort(sort.Reverse(byNameDirFirst(*l))) + case sortBySize: + sort.Sort(sort.Reverse(bySize(*l))) + case sortByTime: + sort.Sort(sort.Reverse(byTime(*l))) + } + } else { + switch l.Sort { + case sortByName: + sort.Sort(byName(*l)) + case sortByNameDirFirst: + sort.Sort(byNameDirFirst(*l)) + case sortBySize: + sort.Sort(bySize(*l)) + case sortByTime: + sort.Sort(byTime(*l)) + } + } + + if limitParam != "" { + limit, _ := strconv.Atoi(limitParam) + if limit > 0 && limit <= len(l.Items) { + l.Items = l.Items[:limit] + l.ItemsLimitedTo = limit + } + } +} + +// crumb represents part of a breadcrumb menu, +// pairing a link with the text to display. +type crumb struct { + Link, Text string +} + +// fileInfo contains serializable information +// about a file or directory. +type fileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + URL string `json:"url"` + ModTime time.Time `json:"mod_time"` + Mode os.FileMode `json:"mode"` + IsDir bool `json:"is_dir"` + IsSymlink bool `json:"is_symlink"` +} + +// HumanSize returns the size of the file as a +// human-readable string in IEC format (i.e. +// power of 2 or base 1024). +func (fi fileInfo) HumanSize() string { + return humanize.IBytes(uint64(fi.Size)) +} + +// HumanModTime returns the modified time of the file +// as a human-readable string given by format. +func (fi fileInfo) HumanModTime(format string) string { + return fi.ModTime.Format(format) +} + +type byName browseListing +type byNameDirFirst browseListing +type bySize browseListing +type byTime browseListing + +func (l byName) Len() int { return len(l.Items) } +func (l byName) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } + +func (l byName) Less(i, j int) bool { + return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) +} + +func (l byNameDirFirst) Len() int { return len(l.Items) } +func (l byNameDirFirst) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } + +func (l byNameDirFirst) Less(i, j int) bool { + // sort by name if both are dir or file + if l.Items[i].IsDir == l.Items[j].IsDir { + return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) + } + // sort dir ahead of file + return l.Items[i].IsDir +} + +func (l bySize) Len() int { return len(l.Items) } +func (l bySize) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } + +func (l bySize) Less(i, j int) bool { + const directoryOffset = -1 << 31 // = -math.MinInt32 + + iSize, jSize := l.Items[i].Size, l.Items[j].Size + + // directory sizes depend on the file system; to + // provide a consistent experience, put them up front + // and sort them by name + if l.Items[i].IsDir { + iSize = directoryOffset + } + if l.Items[j].IsDir { + jSize = directoryOffset + } + if l.Items[i].IsDir && l.Items[j].IsDir { + return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) + } + + return iSize < jSize +} + +func (l byTime) Len() int { return len(l.Items) } +func (l byTime) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } +func (l byTime) Less(i, j int) bool { return l.Items[i].ModTime.Before(l.Items[j].ModTime) } + +const ( + sortByName = "name" + sortByNameDirFirst = "name_dir_first" + sortBySize = "size" + sortByTime = "time" +) diff --git a/modules/caddyhttp/staticfiles/browsetpl.go b/modules/caddyhttp/staticfiles/browsetpl.go new file mode 100644 index 00000000..ff2a1e1f --- /dev/null +++ b/modules/caddyhttp/staticfiles/browsetpl.go @@ -0,0 +1,403 @@ +package staticfiles + +const defaultBrowseTemplate = ` + + + {{html .Name}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ {{range $i, $crumb := .Breadcrumbs}}{{html $crumb.Text}}{{if ne $i 0}}/{{end}}{{end}} +

+
+
+
+
+ {{.NumDirs}} director{{if eq 1 .NumDirs}}y{{else}}ies{{end}} + {{.NumFiles}} file{{if ne 1 .NumFiles}}s{{end}} + {{- if ne 0 .ItemsLimitedTo}} + (of which only {{.ItemsLimitedTo}} are displayed) + {{- end}} + +
+
+
+ + + + + + + + + + + + {{- if .CanGoUp}} + + + + + + + + {{- end}} + {{- range .Items}} + + + + {{- if .IsDir}} + + {{- else}} + + {{- end}} + + + + {{- end}} + +
+ {{- if and (eq .Sort "namedirfirst") (ne .Order "desc")}} + + {{- else if and (eq .Sort "namedirfirst") (ne .Order "asc")}} + + {{- else}} + + {{- end}} + + {{- if and (eq .Sort "name") (ne .Order "desc")}} + Name + {{- else if and (eq .Sort "name") (ne .Order "asc")}} + Name + {{- else}} + Name + {{- end}} + + {{- if and (eq .Sort "size") (ne .Order "desc")}} + Size + {{- else if and (eq .Sort "size") (ne .Order "asc")}} + Size + {{- else}} + Size + {{- end}} + + {{- if and (eq .Sort "time") (ne .Order "desc")}} + Modified + {{- else if and (eq .Sort "time") (ne .Order "asc")}} + Modified + {{- else}} + Modified + {{- end}} +
+ + Go up + +
+ + {{- if .IsDir}} + + {{- else}} + + {{- end}} + {{html .Name}} + + {{.HumanSize}}
+
+
+ + + +` diff --git a/modules/caddyhttp/staticfiles/staticfiles.go b/modules/caddyhttp/staticfiles/staticfiles.go index 0ef3c63f..e3af3528 100644 --- a/modules/caddyhttp/staticfiles/staticfiles.go +++ b/modules/caddyhttp/staticfiles/staticfiles.go @@ -2,6 +2,7 @@ package staticfiles import ( "fmt" + "html/template" weakrand "math/rand" "net/http" "os" @@ -16,6 +17,8 @@ import ( ) func init() { + weakrand.Seed(time.Now().UnixNano()) + caddy2.RegisterModule(caddy2.Module{ Name: "http.responders.static_files", New: func() (interface{}, error) { return new(StaticFiles), nil }, @@ -25,13 +28,13 @@ func init() { // StaticFiles implements a static file server responder for Caddy. type StaticFiles 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 + 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"` - Hide []string `json:"hide"` - Rehandle bool `json:"rehandle"` // issue a rehandle (internal redirect) if request is rewritten // TODO: Etag // TODO: Content negotiation } @@ -44,9 +47,28 @@ func (sf *StaticFiles) Provision(ctx caddy2.Context) error { return fmt.Errorf("setting up fallback routes: %v", err) } } + if sf.IndexNames == nil { sf.IndexNames = defaultIndexNames } + + if sf.Browse != nil { + var tpl *template.Template + var err error + if sf.Browse.TemplateFile != "" { + tpl, err = template.ParseFiles(sf.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) + } + } + sf.Browse.template = tpl + } + return nil } @@ -67,10 +89,6 @@ func (sf *StaticFiles) Validate() error { 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()... @@ -119,7 +137,7 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error { for _, indexPage := range sf.IndexNames { indexPath := path.Join(filename, indexPage) - if fileIsHidden(indexPath, filesToHide) { + if fileHidden(indexPath, filesToHide) { // pretend this file doesn't exist continue } @@ -140,6 +158,7 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error { } info = indexInfo + filename = indexPath break } } @@ -148,43 +167,54 @@ func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error { // to browse or return an error if info.IsDir() { if sf.Browse != nil { - return sf.Browse.ServeHTTP(w, r) + return sf.serveBrowse(filename, w, r) } return caddyhttp.Error(http.StatusNotFound, nil) } // open the file - file, err := os.Open(info.Name()) + file, err := sf.openFile(filename, w) 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) + return 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) + // 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 (sf *StaticFiles) openFile(filename string, w http.ResponseWriter) (*os.File, error) { + file, err := os.Open(filename) + if err != nil { + 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 +} + +// transformHidePaths performs replacements for all the elements of +// sf.Hide and returns a new list of the transformed values. func (sf *StaticFiles) transformHidePaths(repl caddy2.Replacer) []string { hide := make([]string, len(sf.Hide)) for i := range sf.Hide { @@ -193,6 +223,10 @@ func (sf *StaticFiles) transformHidePaths(repl caddy2.Replacer) []string { return hide } +// 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 (sf *StaticFiles) selectFile(r *http.Request, repl caddy2.Replacer) string { root := repl.ReplaceAll(sf.Root, "") if root == "" { @@ -211,7 +245,7 @@ func (sf *StaticFiles) selectFile(r *http.Request, repl caddy2.Replacer) string suffix := repl.ReplaceAll(f, "") // TODO: sanitize path fullpath := filepath.Join(root, suffix) - if !fileIsHidden(fullpath, filesToHide) && fileExists(fullpath) { + if !fileHidden(fullpath, filesToHide) && fileExists(fullpath) { r.URL.Path = suffix return fullpath } @@ -282,7 +316,9 @@ func fileExists(file string) bool { return !os.IsNotExist(err) } -func fileIsHidden(filename string, hide []string) bool { +// 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)