package filesystem import ( "errors" "fmt" "io/fs" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "github.com/gofiber/fiber/v3" ) // Config defines the config for middleware. type Config struct { // Next defines a function to skip this middleware when returned true. // // Optional. Default: nil Next func(c fiber.Ctx) bool // Root is a FileSystem that provides access // to a collection of files and directories. // // Required. Default: nil Root fs.FS `json:"-"` // PathPrefix defines a prefix to be added to a filepath when // reading a file from the FileSystem. // // Optional. Default "." PathPrefix string `json:"path_prefix"` // Enable directory browsing. // // Optional. Default: false Browse bool `json:"browse"` // Index file for serving a directory. // // Optional. Default: "index.html" Index string `json:"index"` // When set to true, enables direct download for files. // // Optional. Default: false. Download bool `json:"download"` // The value for the Cache-Control HTTP-header // that is set on the file response. MaxAge is defined in seconds. // // Optional. Default value 0. MaxAge int `json:"max_age"` // File to return if path is not found. Useful for SPA's. // // Optional. Default: "" NotFoundFile string `json:"not_found_file"` // The value for the Content-Type HTTP-header // that is set on the file response // // Optional. Default: "" ContentTypeCharset string `json:"content_type_charset"` } // ConfigDefault is the default config var ConfigDefault = Config{ Next: nil, Root: nil, PathPrefix: ".", Browse: false, Index: "/index.html", MaxAge: 0, ContentTypeCharset: "", } // New creates a new middleware handler. // // filesystem does not handle url encoded values (for example spaces) // on it's own. If you need that functionality, set "UnescapePath" // in fiber.Config func New(config ...Config) fiber.Handler { // Set default config cfg := ConfigDefault // Override config if provided if len(config) > 0 { cfg = config[0] // Set default values if cfg.Index == "" { cfg.Index = ConfigDefault.Index } if cfg.PathPrefix == "" { cfg.PathPrefix = ConfigDefault.PathPrefix } if !strings.HasPrefix(cfg.Index, "/") { cfg.Index = "/" + cfg.Index } if cfg.NotFoundFile != "" && !strings.HasPrefix(cfg.NotFoundFile, "/") { cfg.NotFoundFile = "/" + cfg.NotFoundFile } } if cfg.Root == nil { panic("filesystem: Root cannot be nil") } // PathPrefix configurations for io/fs compatibility. if cfg.PathPrefix != "." && !strings.HasPrefix(cfg.PathPrefix, "/") { cfg.PathPrefix = "./" + cfg.PathPrefix } if cfg.NotFoundFile != "" { cfg.NotFoundFile = filepath.Join(cfg.PathPrefix, filepath.Clean("/"+cfg.NotFoundFile)) } var once sync.Once var prefix string cacheControlStr := "public, max-age=" + strconv.Itoa(cfg.MaxAge) // Return new handler return func(c fiber.Ctx) error { // Don't execute middleware if Next returns true if cfg.Next != nil && cfg.Next(c) { return c.Next() } method := c.Method() // We only serve static assets on GET or HEAD methods if method != fiber.MethodGet && method != fiber.MethodHead { return c.Next() } // Set prefix once once.Do(func() { prefix = c.Route().Path }) // Strip prefix path := strings.TrimPrefix(c.Path(), prefix) if !strings.HasPrefix(path, "/") { path = "/" + path } var ( file fs.File stat os.FileInfo ) // Add PathPrefix if cfg.PathPrefix != "" { // PathPrefix already has a "/" prefix path = filepath.Join(cfg.PathPrefix, filepath.Clean("/"+path)) } if len(path) > 1 { path = strings.TrimRight(path, "/") } file, err := openFile(cfg.Root, path) if err != nil && errors.Is(err, fs.ErrNotExist) && cfg.NotFoundFile != "" { file, err = openFile(cfg.Root, cfg.NotFoundFile) } if err != nil { if errors.Is(err, fs.ErrNotExist) { return c.Status(fiber.StatusNotFound).Next() } return fmt.Errorf("failed to open: %w", err) } stat, err = file.Stat() if err != nil { return fmt.Errorf("failed to stat: %w", err) } // Serve index if path is directory if stat.IsDir() { indexPath := strings.TrimRight(path, "/") + cfg.Index indexPath = filepath.Join(cfg.PathPrefix, filepath.Clean("/"+indexPath)) index, err := openFile(cfg.Root, indexPath) if err == nil { indexStat, err := index.Stat() if err == nil { file = index stat = indexStat } } } // Browse directory if no index found and browsing is enabled if stat.IsDir() { if cfg.Browse { return dirList(c, file) } return fiber.ErrForbidden } c.Status(fiber.StatusOK) modTime := stat.ModTime() contentLength := int(stat.Size()) // Set Content Type header if cfg.ContentTypeCharset == "" { c.Type(getFileExtension(stat.Name())) } else { c.Type(getFileExtension(stat.Name()), cfg.ContentTypeCharset) } // Set Last Modified header if !modTime.IsZero() { c.Set(fiber.HeaderLastModified, modTime.UTC().Format(http.TimeFormat)) } // Sets the response Content-Disposition header to attachment if the Download option is true and if it's a file if cfg.Download && !stat.IsDir() { c.Attachment() } if method == fiber.MethodGet { if cfg.MaxAge > 0 { c.Set(fiber.HeaderCacheControl, cacheControlStr) } c.Response().SetBodyStream(file, contentLength) return nil } if method == fiber.MethodHead { c.Request().ResetBody() // Fasthttp should skipbody by default if HEAD? c.Response().SkipBody = true c.Response().Header.SetContentLength(contentLength) if err := file.Close(); err != nil { return fmt.Errorf("failed to close: %w", err) } return nil } return c.Next() } } // SendFile serves a file from an fs.FS filesystem at the specified path. // It handles content serving, sets appropriate headers, and returns errors when needed. // Usage: err := SendFile(ctx, fs, "/path/to/file.txt") func SendFile(c fiber.Ctx, filesystem fs.FS, path string) error { var ( file fs.File stat os.FileInfo ) path = filepath.Join(".", filepath.Clean("/"+path)) file, err := openFile(filesystem, path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return fiber.ErrNotFound } return fmt.Errorf("failed to open: %w", err) } stat, err = file.Stat() if err != nil { return fmt.Errorf("failed to stat: %w", err) } // Serve index if path is directory if stat.IsDir() { indexPath := strings.TrimRight(path, "/") + ConfigDefault.Index index, err := openFile(filesystem, indexPath) if err == nil { indexStat, err := index.Stat() if err == nil { file = index stat = indexStat } } } // Return forbidden if no index found if stat.IsDir() { return fiber.ErrForbidden } c.Status(fiber.StatusOK) modTime := stat.ModTime() contentLength := int(stat.Size()) // Set Content Type header c.Type(getFileExtension(stat.Name())) // Set Last Modified header if !modTime.IsZero() { c.Set(fiber.HeaderLastModified, modTime.UTC().Format(http.TimeFormat)) } method := c.Method() if method == fiber.MethodGet { c.Response().SetBodyStream(file, contentLength) return nil } if method == fiber.MethodHead { c.Request().ResetBody() // Fasthttp should skipbody by default if HEAD? c.Response().SkipBody = true c.Response().Header.SetContentLength(contentLength) if err := file.Close(); err != nil { return fmt.Errorf("failed to close: %w", err) } return nil } return nil }