| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- package static
- import (
- "bytes"
- "errors"
- "fmt"
- "io/fs"
- "net/url"
- "os"
- pathpkg "path"
- "path/filepath"
- "slices"
- "strconv"
- "strings"
- "sync"
- "github.com/gofiber/utils/v2"
- "github.com/valyala/fasthttp"
- "github.com/gofiber/fiber/v3"
- )
- var ErrInvalidPath = errors.New("invalid path")
- // sanitizePath validates and cleans the requested path.
- // It returns an error if the path attempts to traverse directories.
- func sanitizePath(p []byte, filesystem fs.FS) ([]byte, error) {
- var s string
- hasTrailingSlash := len(p) > 0 && p[len(p)-1] == '/'
- if bytes.IndexByte(p, '\\') >= 0 {
- b := make([]byte, len(p))
- copy(b, p)
- for i := range b {
- if b[i] == '\\' {
- b[i] = '/'
- }
- }
- s = utils.UnsafeString(b)
- } else {
- s = utils.UnsafeString(p)
- }
- // repeatedly unescape until it no longer changes, catching errors
- for strings.IndexByte(s, '%') >= 0 {
- us, err := url.PathUnescape(s)
- if err != nil {
- return nil, ErrInvalidPath
- }
- if us == s {
- break
- }
- s = us
- }
- if strings.IndexByte(s, '\\') >= 0 {
- return nil, ErrInvalidPath
- }
- // reject any null bytes
- if strings.IndexByte(s, '\x00') >= 0 {
- return nil, ErrInvalidPath
- }
- normalized := filepath.ToSlash(s)
- if filesystem == nil && strings.HasPrefix(normalized, "//") {
- return nil, ErrInvalidPath
- }
- s = pathpkg.Clean("/" + normalized)
- trimmed := utils.TrimLeft(s, '/')
- if trimmed != "" {
- if slices.Contains(strings.Split(trimmed, "/"), "..") {
- return nil, ErrInvalidPath
- }
- }
- if filesystem == nil {
- normalizedClean := filepath.ToSlash(trimmed)
- if strings.HasPrefix(normalizedClean, "//") {
- return nil, ErrInvalidPath
- }
- if volume := filepath.VolumeName(normalizedClean); volume != "" {
- return nil, ErrInvalidPath
- }
- if len(normalizedClean) >= 2 && normalizedClean[1] == ':' {
- drive := normalizedClean[0]
- if (drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z') {
- return nil, ErrInvalidPath
- }
- }
- if strings.HasPrefix(filepath.ToSlash(s), "//") {
- return nil, ErrInvalidPath
- }
- }
- if filesystem != nil {
- s = trimmed
- if s == "" {
- return []byte("/"), nil
- }
- if !fs.ValidPath(s) {
- return nil, ErrInvalidPath
- }
- s = "/" + s
- }
- if hasTrailingSlash && len(s) > 1 && s[len(s)-1] != '/' {
- s += "/"
- }
- return utils.UnsafeBytes(s), nil
- }
- // New creates a new middleware handler.
- // The root argument specifies the root directory from which to serve static assets.
- //
- // Note: Root has to be string or fs.FS; otherwise, it will panic.
- func New(root string, cfg ...Config) fiber.Handler {
- config := configDefault(cfg...)
- var createFS sync.Once
- var fileHandler fasthttp.RequestHandler
- var cacheControlValue string
- var rootIsFile bool
- // adjustments for io/fs compatibility
- if config.FS != nil && root == "" {
- root = "."
- }
- return func(c fiber.Ctx) error {
- // Don't execute middleware if Next returns true
- if config.Next != nil && config.Next(c) {
- return c.Next()
- }
- // We only serve static assets on GET or HEAD methods
- method := c.Method()
- if method != fiber.MethodGet && method != fiber.MethodHead {
- return c.Next()
- }
- // Initialize FS
- createFS.Do(func() {
- prefix := c.Route().Path
- if check, err := isFile(root, config.FS); err == nil {
- rootIsFile = check
- }
- // Is prefix a partial wildcard?
- if before, _, found := strings.Cut(prefix, "*"); found {
- // /john* -> /john
- prefix = before
- }
- prefixLen := len(prefix)
- if prefixLen > 1 && prefix[prefixLen-1:] == "/" {
- // /john/ -> /john
- prefixLen--
- }
- // For io/fs.FS, Root must be empty so fasthttp's pathToFilePath
- // returns clean relative paths without prefixing the root.
- // PathRewrite already handles file-root and subdirectory cases.
- fsRoot := root
- if config.FS != nil {
- fsRoot = ""
- }
- fileServer := &fasthttp.FS{
- Root: fsRoot,
- FS: config.FS,
- AllowEmptyRoot: true,
- GenerateIndexPages: config.Browse,
- AcceptByteRange: config.ByteRange,
- Compress: config.Compress,
- CompressBrotli: config.Compress, // Brotli compression won't work without this
- CompressZstd: config.Compress, // Zstd compression won't work without this
- CompressedFileSuffixes: c.App().Config().CompressedFileSuffixes,
- CacheDuration: config.CacheDuration,
- SkipCache: config.CacheDuration < 0,
- IndexNames: config.IndexNames,
- PathNotFound: func(fctx *fasthttp.RequestCtx) {
- fctx.Response.SetStatusCode(fiber.StatusNotFound)
- },
- }
- fileServer.PathRewrite = func(fctx *fasthttp.RequestCtx) []byte {
- path := fctx.Path()
- if len(path) >= prefixLen {
- checkFile, err := isFile(root, fileServer.FS)
- if err != nil {
- return path
- }
- // If the root is a file, we need to reset the path to "/" always.
- switch {
- case checkFile && fileServer.FS == nil:
- path = []byte("/")
- case checkFile && fileServer.FS != nil:
- path = utils.UnsafeBytes(root)
- default:
- path = path[prefixLen:]
- if len(path) == 0 || path[len(path)-1] != '/' {
- path = append(path, '/')
- }
- }
- }
- if len(path) > 0 && path[0] != '/' {
- path = append([]byte("/"), path...)
- }
- sanitized, err := sanitizePath(path, fileServer.FS)
- if err != nil {
- // return a guaranteed-missing path so fs responds with 404
- return []byte("/__fiber_invalid__")
- }
- return sanitized
- }
- maxAge := config.MaxAge
- if maxAge > 0 {
- cacheControlValue = "public, max-age=" + strconv.Itoa(maxAge)
- }
- fileHandler = fileServer.NewRequestHandler()
- })
- // Serve file
- fileHandler(c.RequestCtx())
- // Sets the response Content-Disposition header to attachment if the Download option is true
- if config.Download {
- name := filepath.Base(c.Path())
- if rootIsFile {
- name = filepath.Base(root)
- }
- c.Attachment(name)
- }
- // Return request if found and not forbidden
- status := c.RequestCtx().Response.StatusCode()
- if status != fiber.StatusNotFound && status != fiber.StatusForbidden {
- if cacheControlValue != "" {
- c.RequestCtx().Response.Header.Set(fiber.HeaderCacheControl, cacheControlValue)
- }
- if config.ModifyResponse != nil {
- return config.ModifyResponse(c)
- }
- return nil
- }
- // Return custom 404 handler if provided.
- if config.NotFoundHandler != nil {
- return config.NotFoundHandler(c)
- }
- // Reset response to default
- c.RequestCtx().SetContentType("") // Issue #420
- c.RequestCtx().Response.SetStatusCode(fiber.StatusOK)
- c.RequestCtx().Response.SetBodyString("")
- // Next middleware
- return c.Next()
- }
- }
- // isFile checks if the root is a file.
- func isFile(root string, filesystem fs.FS) (bool, error) {
- var file fs.File
- var err error
- if filesystem != nil {
- file, err = filesystem.Open(root)
- if err != nil {
- return false, fmt.Errorf("static: %w", err)
- }
- defer func() {
- _ = file.Close() //nolint:errcheck // not needed
- }()
- } else {
- file, err = os.Open(filepath.Clean(root))
- if err != nil {
- return false, fmt.Errorf("static: %w", err)
- }
- defer func() {
- _ = file.Close() //nolint:errcheck // not needed
- }()
- }
- stat, err := file.Stat()
- if err != nil {
- return false, fmt.Errorf("static: %w", err)
- }
- return stat.Mode().IsRegular(), nil
- }
|