mirror of
https://github.com/caddyserver/caddy.git
synced 2024-12-23 22:27:38 -05:00
357 lines
10 KiB
Go
357 lines
10 KiB
Go
package staticfiles
|
|
|
|
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.static_files",
|
|
New: func() (interface{}, error) { return new(StaticFiles), nil },
|
|
})
|
|
}
|
|
|
|
// 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
|
|
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 (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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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 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
|
|
// 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
|
|
filename = indexPath
|
|
break
|
|
}
|
|
}
|
|
|
|
// if still referencing a directory, delegate
|
|
// to browse or return an error
|
|
if info.IsDir() {
|
|
if sf.Browse != nil {
|
|
return sf.serveBrowse(filename, w, r)
|
|
}
|
|
return caddyhttp.Error(http.StatusNotFound, nil)
|
|
}
|
|
|
|
// open the file
|
|
file, err := sf.openFile(filename, w)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
hide[i] = repl.ReplaceAll(sf.Hide[i], "")
|
|
}
|
|
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 == "" {
|
|
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 !fileHidden(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)
|
|
}
|
|
|
|
// 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 = (*StaticFiles)(nil)
|