15
package fileserver
16
17
import (
18
+ "encoding/json"
19
+ "errors"
20
"fmt"
21
+ "io"
22
+ "io/fs"
23
weakrand "math/rand"
24
"mime"
25
"net/http"
43
caddy.RegisterModule(FileServer{})
44
}
45
42
-// FileServer implements a static file server responder for Caddy.
46
+// FileServer implements a handler that serves static files.
47
+//
48
+// The path of the file to serve is constructed by joining the site root
49
+// and the sanitized request path. Any and all files within the root and
50
+// links with targets outside the site root may therefore be accessed.
51
+// For example, with a site root of `/www`, requests to `/foo/bar.txt`
52
+// will serve the file at `/www/foo/bar.txt`.
53
+//
54
+// The request path is sanitized using the Go standard library's
55
+// path.Clean() function (https://pkg.go.dev/path#Clean) before being
56
+// joined to the root. Request paths must be valid and well-formed.
57
+//
58
+// For requests that access directories instead of regular files,
59
+// Caddy will attempt to serve an index file if present. For example,
60
+// a request to `/dir/` will attempt to serve `/dir/index.html` if
61
+// it exists. The index file names to try are configurable. If a
62
+// requested directory does not have an index file, Caddy writes a
63
+// 404 response. Alternatively, file browsing can be enabled with
64
+// the "browse" parameter which shows a list of files when directories
65
+// are requested if no index file is present.
66
+//
67
+// By default, this handler will canonicalize URIs so that requests to
68
+// directories end with a slash, but requests to regular files do not.
69
+// This is enforced with HTTP redirects automatically and can be disabled.
70
+// Canonicalization redirects are not issued, however, if a URI rewrite
71
+// modified the last component of the path (the filename).
72
+//
73
+// This handler sets the Etag and Last-Modified headers for static files.
74
+// It does not perform MIME sniffing to determine Content-Type based on
75
+// contents, but does use the extension (if known); see the Go docs for
76
+// details: https://pkg.go.dev/mime#TypeByExtension
77
+//
78
+// The file server properly handles requests with If-Match,
79
+// If-Unmodified-Since, If-Modified-Since, If-None-Match, Range, and
80
+// If-Range headers. It includes the file's modification time in the
81
+// Last-Modified header of the response.
82
type FileServer struct {
83
+ // The file system implementation to use. By default, Caddy uses the local
84
+ // disk file system.
85
+ //
86
+ // File system modules used here must adhere to the following requirements:
87
+ // - Implement fs.StatFS interface.
88
+ // - Support seeking on opened files; i.e.returned fs.File values must
89
+ // implement the io.Seeker interface. This is required for determining
90
+ // Content-Length and satisfying Range requests.
91
+ // - fs.File values that represent directories must implement the
92
+ // fs.ReadDirFile interface so that directory listings can be procured.
93
+ FileSystemRaw json.RawMessage `json:"file_system,omitempty" caddy:"namespace=caddy.fs inline_key=backend"`
94
+ fileSystem fs.StatFS
95
+
96
// The path to the root of the site. Default is `{http.vars.root}` if set,
45
- // or current working directory otherwise.
97
+ // or current working directory otherwise. This should be a trusted value.
98
+ //
99
+ // Note that a site root is not a sandbox. Although the file server does
100
+ // sanitize the request URI to prevent directory traversal, files (including
101
+ // links) within the site root may be directly accessed based on the request
102
+ // path. Files and folders within the root should be secure and trustworthy.
103
Root string `json:"root,omitempty"`
104
105
// A list of files or folders to hide; the file server will pretend as if
120
Hide []string `json:"hide,omitempty"`
121
122
// The names of files to try as index files if a folder is requested.
123
+ // Default: index.html, index.txt.
124
IndexNames []string `json:"index_names,omitempty"`
125
126
// Enables file listings if a directory was requested and no index
153
// If no order specified here, the first encoding from the Accept-Encoding header
154
// that both client and server support is used
155
PrecompressedOrder []string `json:"precompressed_order,omitempty"`
98
-
99
- precompressors map[string]encode.Precompressed
156
+ precompressors map[string]encode.Precompressed
157
158
logger *zap.Logger
159
}
170
func (fsrv *FileServer) Provision(ctx caddy.Context) error {
171
fsrv.logger = ctx.Logger(fsrv)
172
173
+ // establish which file system (possibly a virtual one) we'll be using
174
+ if len(fsrv.FileSystemRaw) > 0 {
175
+ mod, err := ctx.LoadModule(fsrv, "FileSystemRaw")
176
+ if err != nil {
177
+ return fmt.Errorf("loading file system module: %v", err)
178
+ }
179
+ fsrv.fileSystem = mod.(fs.StatFS)
180
+ }
181
+ if fsrv.fileSystem == nil {
182
+ fsrv.fileSystem = osFS{}
183
+ }
184
+
185
if fsrv.Root == "" {
186
fsrv.Root = "{http.vars.root}"
187
}
200
}
201
}
202
203
+ // support precompressed sidecar files
204
mods, err := ctx.LoadModule(fsrv, "PrecompressedRaw")
205
if err != nil {
206
return fmt.Errorf("loading encoder modules: %v", err)
254
zap.String("result", filename))
255
256
// get information about the file
187
- info, err := os.Stat(filename)
257
+ info, err := fsrv.fileSystem.Stat(filename)
258
if err != nil {
189
- err = mapDirOpenError(err, filename)
190
- if os.IsNotExist(err) {
259
+ err = fsrv.mapDirOpenError(err, filename)
260
+ if errors.Is(err, fs.ErrNotExist) {
261
return fsrv.notFound(w, r, next)
192
- } else if os.IsPermission(err) {
262
+ } else if errors.Is(err, fs.ErrPermission) {
263
return caddyhttp.Error(http.StatusForbidden, err)
264
}
265
return caddyhttp.Error(http.StatusInternalServerError, err)
280
continue
281
}
282
213
- indexInfo, err := os.Stat(indexPath)
283
+ indexInfo, err := fsrv.fileSystem.Stat(indexPath)
284
if err != nil {
285
continue
286
}
350
}
351
}
352
283
- var file *os.File
353
+ var file fs.File
354
355
// check for precompressed files
356
for _, ae := range encode.AcceptedEncodings(r, fsrv.PrecompressedOrder) {
359
continue
360
}
361
compressedFilename := filename + precompress.Suffix()
292
- compressedInfo, err := os.Stat(compressedFilename)
362
+ compressedInfo, err := fsrv.fileSystem.Stat(compressedFilename)
363
if err != nil || compressedInfo.IsDir() {
364
fsrv.logger.Debug("precompressed file not accessible", zap.String("filename", compressedFilename), zap.Error(err))
365
continue
398
399
// set the ETag - note that a conditional If-None-Match request is handled
400
// by http.ServeContent below, which checks against this ETag value
331
- w.Header().Set("ETag", calculateEtag(info))
401
+ w.Header().Set("Etag", calculateEtag(info))
402
403
if w.Header().Get("Content-Type") == "" {
404
mtyp := mime.TypeByExtension(filepath.Ext(filename))
405
if mtyp == "" {
336
- // do not allow Go to sniff the content-type; see
337
- // https://www.youtube.com/watch?v=8t8JYpt0egE
338
- // TODO: If we want a Content-Type, consider writing a default of application/octet-stream - this is secure but violates spec
406
+ // do not allow Go to sniff the content-type; see https://www.youtube.com/watch?v=8t8JYpt0egE
407
w.Header()["Content-Type"] = nil
408
} else {
409
w.Header().Set("Content-Type", mtyp)
443
// that errors generated by ServeContent are written immediately
444
// to the response, so we cannot handle them (but errors there
445
// are rare)
378
- http.ServeContent(w, r, info.Name(), info.ModTime(), file)
446
+ http.ServeContent(w, r, info.Name(), info.ModTime(), file.(io.ReadSeeker))
447
448
return nil
449
}
452
// the response is configured to inform the client how to best handle it
453
// and a well-described handler error is returned (do not wrap the
454
// returned error value).
387
-func (fsrv *FileServer) openFile(filename string, w http.ResponseWriter) (*os.File, error) {
388
- file, err := os.Open(filename)
455
+func (fsrv *FileServer) openFile(filename string, w http.ResponseWriter) (fs.File, error) {
456
+ file, err := fsrv.fileSystem.Open(filename)
457
if err != nil {
390
- err = mapDirOpenError(err, filename)
458
+ err = fsrv.mapDirOpenError(err, filename)
459
if os.IsNotExist(err) {
460
fsrv.logger.Debug("file not found", zap.String("filename", filename), zap.Error(err))
461
return nil, caddyhttp.Error(http.StatusNotFound, err)
480
// Adapted from the Go standard library; originally written by Nathaniel Caza.
481
// https://go-review.googlesource.com/c/go/+/36635/
482
// https://go-review.googlesource.com/c/go/+/36804/
415
-func mapDirOpenError(originalErr error, name string) error {
416
- if os.IsNotExist(originalErr) || os.IsPermission(originalErr) {
483
+func (fsrv *FileServer) mapDirOpenError(originalErr error, name string) error {
484
+ if errors.Is(originalErr, fs.ErrNotExist) || errors.Is(originalErr, fs.ErrPermission) {
485
return originalErr
486
}
487
490
if parts[i] == "" {
491
continue
492
}
425
- fi, err := os.Stat(strings.Join(parts[:i+1], separator))
493
+ fi, err := fsrv.fileSystem.Stat(strings.Join(parts[:i+1], separator))
494
if err != nil {
495
return originalErr
496
}
497
if !fi.IsDir() {
430
- return os.ErrNotExist
498
+ return fs.ErrNotExist
499
}
500
}
501
613
wr.ResponseWriter.WriteHeader(wr.code)
614
}
615
616
+// osFS is a simple fs.StatFS implementation that uses the local
617
+// file system. (We do not use os.DirFS because we do our own
618
+// rooting or path prefixing without being constrained to a single
619
+// root folder. The standard os.DirFS implementation is problematic
620
+// since roots can be dynamic in our application.)
621
+type osFS struct{}
622
+
623
+func (osFS) Open(name string) (fs.File, error) { return os.Open(name) }
624
+func (osFS) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
625
+
626
var defaultIndexNames = []string{"index.html", "index.txt"}
627
628
const (