diff options
Diffstat (limited to 'modules/caddyhttp/fileserver/staticfiles.go')
| -rw-r--r-- | modules/caddyhttp/fileserver/staticfiles.go | 416 | 
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) | 
