diff --git a/app.go b/app.go index 01941edd..293f29ba 100644 --- a/app.go +++ b/app.go @@ -123,6 +123,10 @@ type App struct { configured Config // customConstraints is a list of external constraints customConstraints []CustomConstraint + // sendfiles stores configurations for handling ctx.SendFile operations + sendfiles []*sendFileStore + // sendfilesMutex is a mutex used for sendfile operations + sendfilesMutex sync.RWMutex } // Config is a struct holding the server settings. @@ -440,6 +444,7 @@ func New(config ...Config) *App { getString: utils.UnsafeString, latestRoute: &Route{}, customBinders: []CustomBinder{}, + sendfiles: []*sendFileStore{}, } // Create Ctx pool diff --git a/ctx.go b/ctx.go index 4ae46a2f..aac52dea 100644 --- a/ctx.go +++ b/ctx.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "io/fs" "mime/multipart" "net" "net/http" @@ -69,6 +70,84 @@ type DefaultCtx struct { redirectionMessages []string // Messages of the previous redirect } +// SendFile defines configuration options when to transfer file with SendFile. +type SendFile struct { + // FS is the file system to serve the static files from. + // You can use interfaces compatible with fs.FS like embed.FS, os.DirFS etc. + // + // Optional. Default: nil + FS fs.FS + + // When set to true, the server tries minimizing CPU usage by caching compressed files. + // This works differently than the github.com/gofiber/compression middleware. + // You have to set Content-Encoding header to compress the file. + // Available compression methods are gzip, br, and zstd. + // + // Optional. Default value false + Compress bool `json:"compress"` + + // When set to true, enables byte range requests. + // + // Optional. Default value false + ByteRange bool `json:"byte_range"` + + // When set to true, enables direct download. + // + // Optional. Default: false. + Download bool `json:"download"` + + // Expiration duration for inactive file handlers. + // Use a negative time.Duration to disable it. + // + // Optional. Default value 10 * time.Second. + CacheDuration time.Duration `json:"cache_duration"` + + // 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"` +} + +// sendFileStore is used to keep the SendFile configuration and the handler. +type sendFileStore struct { + handler fasthttp.RequestHandler + config SendFile + cacheControlValue string +} + +// compareConfig compares the current SendFile config with the new one +// and returns true if they are different. +// +// Here we don't use reflect.DeepEqual because it is quite slow compared to manual comparison. +func (sf *sendFileStore) compareConfig(cfg SendFile) bool { + if sf.config.FS != cfg.FS { + return false + } + + if sf.config.Compress != cfg.Compress { + return false + } + + if sf.config.ByteRange != cfg.ByteRange { + return false + } + + if sf.config.Download != cfg.Download { + return false + } + + if sf.config.CacheDuration != cfg.CacheDuration { + return false + } + + if sf.config.MaxAge != cfg.MaxAge { + return false + } + + return true +} + // TLSHandler object type TLSHandler struct { clientHelloInfo *tls.ClientHelloInfo @@ -1414,48 +1493,87 @@ func (c *DefaultCtx) Send(body []byte) error { return nil } -var ( - sendFileOnce sync.Once - sendFileFS *fasthttp.FS - sendFileHandler fasthttp.RequestHandler -) - // SendFile transfers the file from the given path. // The file is not compressed by default, enable this by passing a 'true' argument // Sets the Content-Type response HTTP header field based on the filenames extension. -func (c *DefaultCtx) SendFile(file string, compress ...bool) error { +func (c *DefaultCtx) SendFile(file string, config ...SendFile) error { // Save the filename, we will need it in the error message if the file isn't found filename := file - // https://github.com/valyala/fasthttp/blob/c7576cc10cabfc9c993317a2d3f8355497bea156/fs.go#L129-L134 - sendFileOnce.Do(func() { - const cacheDuration = 10 * time.Second - sendFileFS = &fasthttp.FS{ + var cfg SendFile + if len(config) > 0 { + cfg = config[0] + } + + if cfg.CacheDuration == 0 { + cfg.CacheDuration = 10 * time.Second + } + + var fsHandler fasthttp.RequestHandler + var cacheControlValue string + + c.app.sendfilesMutex.RLock() + for _, sf := range c.app.sendfiles { + if sf.compareConfig(cfg) { + fsHandler = sf.handler + cacheControlValue = sf.cacheControlValue + break + } + } + c.app.sendfilesMutex.RUnlock() + + if fsHandler == nil { + fasthttpFS := &fasthttp.FS{ Root: "", + FS: cfg.FS, AllowEmptyRoot: true, GenerateIndexPages: false, - AcceptByteRange: true, - Compress: true, - CompressBrotli: true, + AcceptByteRange: cfg.ByteRange, + Compress: cfg.Compress, + CompressBrotli: cfg.Compress, CompressedFileSuffixes: c.app.config.CompressedFileSuffixes, - CacheDuration: cacheDuration, + CacheDuration: cfg.CacheDuration, + SkipCache: cfg.CacheDuration < 0, IndexNames: []string{"index.html"}, PathNotFound: func(ctx *fasthttp.RequestCtx) { ctx.Response.SetStatusCode(StatusNotFound) }, } - sendFileHandler = sendFileFS.NewRequestHandler() - }) + + if cfg.FS != nil { + fasthttpFS.Root = "." + } + + sf := &sendFileStore{ + config: cfg, + handler: fasthttpFS.NewRequestHandler(), + } + + maxAge := cfg.MaxAge + if maxAge > 0 { + sf.cacheControlValue = "public, max-age=" + strconv.Itoa(maxAge) + } + + // set vars + fsHandler = sf.handler + cacheControlValue = sf.cacheControlValue + + c.app.sendfilesMutex.Lock() + c.app.sendfiles = append(c.app.sendfiles, sf) + c.app.sendfilesMutex.Unlock() + } // Keep original path for mutable params c.pathOriginal = utils.CopyString(c.pathOriginal) - // Disable compression - if len(compress) == 0 || !compress[0] { + + // Delete the Accept-Encoding header if compression is disabled + if !cfg.Compress { // https://github.com/valyala/fasthttp/blob/7cc6f4c513f9e0d3686142e0a1a5aa2f76b3194a/fs.go#L55 c.fasthttp.Request.Header.Del(HeaderAcceptEncoding) } + // copy of https://github.com/valyala/fasthttp/blob/7cc6f4c513f9e0d3686142e0a1a5aa2f76b3194a/fs.go#L103-L121 with small adjustments - if len(file) == 0 || !filepath.IsAbs(file) { + if len(file) == 0 || (!filepath.IsAbs(file) && cfg.FS == nil) { // extend relative path to absolute path hasTrailingSlash := len(file) > 0 && (file[len(file)-1] == '/' || file[len(file)-1] == '\\') @@ -1468,6 +1586,7 @@ func (c *DefaultCtx) SendFile(file string, compress ...bool) error { file += "/" } } + // convert the path to forward slashes regardless the OS in order to set the URI properly // the handler will convert back to OS path separator before opening the file file = filepath.ToSlash(file) @@ -1475,22 +1594,43 @@ func (c *DefaultCtx) SendFile(file string, compress ...bool) error { // Restore the original requested URL originalURL := utils.CopyString(c.OriginalURL()) defer c.fasthttp.Request.SetRequestURI(originalURL) + // Set new URI for fileHandler c.fasthttp.Request.SetRequestURI(file) + // Save status code status := c.fasthttp.Response.StatusCode() + // Serve file - sendFileHandler(c.fasthttp) + fsHandler(c.fasthttp) + + // Sets the response Content-Disposition header to attachment if the Download option is true + if cfg.Download { + c.Attachment() + } + // Get the status code which is set by fasthttp fsStatus := c.fasthttp.Response.StatusCode() - // Set the status code set by the user if it is different from the fasthttp status code and 200 - if status != fsStatus && status != StatusOK { - c.Status(status) - } + // Check for error if status != StatusNotFound && fsStatus == StatusNotFound { return NewError(StatusNotFound, fmt.Sprintf("sendfile: file %s not found", filename)) } + + // Set the status code set by the user if it is different from the fasthttp status code and 200 + if status != fsStatus && status != StatusOK { + c.Status(status) + } + + // Apply cache control header + if status != StatusNotFound && status != StatusForbidden { + if len(cacheControlValue) > 0 { + c.Context().Response.Header.Set(HeaderCacheControl, cacheControlValue) + } + + return nil + } + return nil } diff --git a/ctx_interface_gen.go b/ctx_interface_gen.go index 5aeaf2f1..bffbe79d 100644 --- a/ctx_interface_gen.go +++ b/ctx_interface_gen.go @@ -273,7 +273,7 @@ type Ctx interface { // SendFile transfers the file from the given path. // The file is not compressed by default, enable this by passing a 'true' argument // Sets the Content-Type response HTTP header field based on the filenames extension. - SendFile(file string, compress ...bool) error + SendFile(file string, config ...SendFile) error // SendStatus sets the HTTP status code and if the response body is empty, // it sets the correct status message in the body. SendStatus(status int) error diff --git a/ctx_test.go b/ctx_test.go index 86d11356..8f13dba1 100644 --- a/ctx_test.go +++ b/ctx_test.go @@ -11,6 +11,7 @@ import ( "compress/zlib" "context" "crypto/tls" + "embed" "encoding/xml" "errors" "fmt" @@ -2970,19 +2971,254 @@ func Test_Ctx_SendFile(t *testing.T) { app.ReleaseCtx(c) } +func Test_Ctx_SendFile_Download(t *testing.T) { + t.Parallel() + app := New() + + // fetch file content + f, err := os.Open("./ctx.go") + require.NoError(t, err) + defer func() { + require.NoError(t, f.Close()) + }() + expectFileContent, err := io.ReadAll(f) + require.NoError(t, err) + // fetch file info for the not modified test case + _, err = os.Stat("./ctx.go") + require.NoError(t, err) + + // simple test case + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + err = c.SendFile("ctx.go", SendFile{ + Download: true, + }) + // check expectation + require.NoError(t, err) + require.Equal(t, expectFileContent, c.Response().Body()) + require.Equal(t, "attachment", string(c.Response().Header.Peek(HeaderContentDisposition))) + require.Equal(t, StatusOK, c.Response().StatusCode()) + app.ReleaseCtx(c) +} + +func Test_Ctx_SendFile_MaxAge(t *testing.T) { + t.Parallel() + app := New() + + // fetch file content + f, err := os.Open("./ctx.go") + require.NoError(t, err) + defer func() { + require.NoError(t, f.Close()) + }() + expectFileContent, err := io.ReadAll(f) + require.NoError(t, err) + + // fetch file info for the not modified test case + _, err = os.Stat("./ctx.go") + require.NoError(t, err) + + // simple test case + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + err = c.SendFile("ctx.go", SendFile{ + MaxAge: 100, + }) + + // check expectation + require.NoError(t, err) + require.Equal(t, expectFileContent, c.Response().Body()) + require.Equal(t, "public, max-age=100", string(c.Context().Response.Header.Peek(HeaderCacheControl)), "CacheControl Control") + require.Equal(t, StatusOK, c.Response().StatusCode()) + app.ReleaseCtx(c) +} + +func Test_Static_Compress(t *testing.T) { + t.Parallel() + + app := New() + app.Get("/file", func(c Ctx) error { + return c.SendFile("ctx.go", SendFile{ + Compress: true, + }) + }) + + // Note: deflate is not supported by fasthttp.FS + algorithms := []string{"zstd", "gzip", "br"} + for _, algo := range algorithms { + algo := algo + + t.Run(algo+"_compression", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(MethodGet, "/file", nil) + req.Header.Set("Accept-Encoding", algo) + resp, err := app.Test(req, 10*time.Second) + + require.NoError(t, err, "app.Test(req)") + require.Equal(t, 200, resp.StatusCode, "Status code") + require.NotEqual(t, "58726", resp.Header.Get(HeaderContentLength)) + }) + } +} + +func Test_Ctx_SendFile_Compress_CheckCompressed(t *testing.T) { + t.Parallel() + app := New() + + // fetch file content + f, err := os.Open("./ctx.go") + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, f.Close()) + }) + + expectedFileContent, err := io.ReadAll(f) + require.NoError(t, err) + + sendFileBodyReader := func(compression string) []byte { + reqCtx := &fasthttp.RequestCtx{} + reqCtx.Request.Header.Add(HeaderAcceptEncoding, compression) + + c := app.AcquireCtx(reqCtx) + err = c.SendFile("./ctx.go", SendFile{ + Compress: true, + }) + require.NoError(t, err) + + return c.Response().Body() + } + + t.Run("gzip", func(t *testing.T) { + t.Parallel() + + body, err := fasthttp.AppendGunzipBytes(nil, sendFileBodyReader("gzip")) + require.NoError(t, err) + + require.Equal(t, expectedFileContent, body) + }) + + t.Run("zstd", func(t *testing.T) { + t.Parallel() + + body, err := fasthttp.AppendUnzstdBytes(nil, sendFileBodyReader("zstd")) + require.NoError(t, err) + + require.Equal(t, expectedFileContent, body) + }) + + t.Run("br", func(t *testing.T) { + t.Parallel() + + body, err := fasthttp.AppendUnbrotliBytes(nil, sendFileBodyReader("br")) + require.NoError(t, err) + + require.Equal(t, expectedFileContent, body) + }) +} + +//go:embed ctx.go +var embedFile embed.FS + +func Test_Ctx_SendFile_EmbedFS(t *testing.T) { + t.Parallel() + app := New() + + f, err := os.Open("./ctx.go") + require.NoError(t, err) + + defer func() { + require.NoError(t, f.Close()) + }() + + expectFileContent, err := io.ReadAll(f) + require.NoError(t, err) + + app.Get("/test", func(c Ctx) error { + return c.SendFile("ctx.go", SendFile{ + FS: embedFile, + }) + }) + + resp, err := app.Test(httptest.NewRequest(MethodGet, "/test", nil)) + require.NoError(t, err) + require.Equal(t, StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, expectFileContent, body) +} + // go test -race -run Test_Ctx_SendFile_404 func Test_Ctx_SendFile_404(t *testing.T) { t.Parallel() app := New() app.Get("/", func(c Ctx) error { - err := c.SendFile(filepath.FromSlash("john_dow.go/")) - require.Error(t, err) - return err + return c.SendFile("ctx12.go") }) resp, err := app.Test(httptest.NewRequest(MethodGet, "/", nil)) require.NoError(t, err) require.Equal(t, StatusNotFound, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "sendfile: file ctx12.go not found", string(body)) +} + +func Test_Ctx_SendFile_Multiple(t *testing.T) { + t.Parallel() + + app := New() + app.Get("/test", func(c Ctx) error { + switch c.Query("file") { + case "1": + return c.SendFile("ctx.go") + case "2": + return c.SendFile("app.go") + case "3": + return c.SendFile("ctx.go", SendFile{ + Download: true, + }) + case "4": + return c.SendFile("app_test.go", SendFile{ + FS: os.DirFS("."), + }) + default: + return c.SendStatus(StatusNotFound) + } + }) + + app.Get("/test2", func(c Ctx) error { + return c.SendFile("ctx.go", SendFile{ + Download: true, + }) + }) + + testCases := []struct { + url string + body string + contentDisposition string + }{ + {"/test?file=1", "type DefaultCtx struct", ""}, + {"/test?file=2", "type App struct", ""}, + {"/test?file=3", "type DefaultCtx struct", "attachment"}, + {"/test?file=4", "Test_App_MethodNotAllowed", ""}, + {"/test2", "type DefaultCtx struct", "attachment"}, + {"/test2", "type DefaultCtx struct", "attachment"}, + } + + for _, tc := range testCases { + resp, err := app.Test(httptest.NewRequest(MethodGet, tc.url, nil)) + require.NoError(t, err) + require.Equal(t, StatusOK, resp.StatusCode) + require.Equal(t, tc.contentDisposition, resp.Header.Get(HeaderContentDisposition)) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), tc.body) + } + + require.Len(t, app.sendfiles, 3) } // go test -race -run Test_Ctx_SendFile_Immutable @@ -3050,6 +3286,56 @@ func Test_Ctx_SendFile_RestoreOriginalURL(t *testing.T) { require.NoError(t, err2) } +func Test_SendFile_withRoutes(t *testing.T) { + t.Parallel() + + app := New() + app.Get("/file", func(c Ctx) error { + return c.SendFile("ctx.go") + }) + + app.Get("/file/download", func(c Ctx) error { + return c.SendFile("ctx.go", SendFile{ + Download: true, + }) + }) + + app.Get("/file/fs", func(c Ctx) error { + return c.SendFile("ctx.go", SendFile{ + FS: os.DirFS("."), + }) + }) + + resp, err := app.Test(httptest.NewRequest(MethodGet, "/file", nil)) + require.NoError(t, err) + require.Equal(t, StatusOK, resp.StatusCode) + + resp, err = app.Test(httptest.NewRequest(MethodGet, "/file/download", nil)) + require.NoError(t, err) + require.Equal(t, StatusOK, resp.StatusCode) + require.Equal(t, "attachment", resp.Header.Get(HeaderContentDisposition)) + + resp, err = app.Test(httptest.NewRequest(MethodGet, "/file/fs", nil)) + require.NoError(t, err) + require.Equal(t, StatusOK, resp.StatusCode) +} + +func Benchmark_Ctx_SendFile(b *testing.B) { + app := New() + c := app.AcquireCtx(&fasthttp.RequestCtx{}) + + b.ReportAllocs() + b.ResetTimer() + + var err error + for n := 0; n < b.N; n++ { + err = c.SendFile("ctx.go") + } + + require.NoError(b, err) + require.Contains(b, string(c.Response().Body()), "type DefaultCtx struct") +} + // go test -run Test_Ctx_JSON func Test_Ctx_JSON(t *testing.T) { t.Parallel() diff --git a/docs/api/ctx.md b/docs/api/ctx.md index ae898181..ba903928 100644 --- a/docs/api/ctx.md +++ b/docs/api/ctx.md @@ -1689,12 +1689,49 @@ app.Get("/", func(c fiber.Ctx) error { Transfers the file from the given path. Sets the [Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) response HTTP header field based on the **filenames** extension. -:::caution -Method doesn´t use **gzipping** by default, set it to **true** to enable. -::: +```go title="Config" title="Config" +// SendFile defines configuration options when to transfer file with SendFile. +type SendFile struct { + // FS is the file system to serve the static files from. + // You can use interfaces compatible with fs.FS like embed.FS, os.DirFS etc. + // + // Optional. Default: nil + FS fs.FS + + // When set to true, the server tries minimizing CPU usage by caching compressed files. + // This works differently than the github.com/gofiber/compression middleware. + // You have to set Content-Encoding header to compress the file. + // Available compression methods are gzip, br, and zstd. + // + // Optional. Default value false + Compress bool `json:"compress"` + + // When set to true, enables byte range requests. + // + // Optional. Default value false + ByteRange bool `json:"byte_range"` + + // When set to true, enables direct download. + // + // Optional. Default: false. + Download bool `json:"download"` + + // Expiration duration for inactive file handlers. + // Use a negative time.Duration to disable it. + // + // Optional. Default value 10 * time.Second. + CacheDuration time.Duration `json:"cache_duration"` + + // 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"` +} +``` ```go title="Signature" title="Signature" -func (c Ctx) SendFile(file string, compress ...bool) error +func (c Ctx) SendFile(file string, config ...SendFile) error ``` ```go title="Example" @@ -1702,7 +1739,9 @@ app.Get("/not-found", func(c fiber.Ctx) error { return c.SendFile("./public/404.html"); // Disable compression - return c.SendFile("./static/index.html", false); + return c.SendFile("./static/index.html", SendFile{ + Compress: false, + }); }) ``` @@ -1717,7 +1756,47 @@ app.Get("/file-with-url-chars", func(c fiber.Ctx) error { ``` :::info -For sending files from embedded file system [this functionality](../middleware/static.md#serving-files-using-embedfs) can be used +You can set `CacheDuration` config property to `-1` to disable caching. +::: + +```go title="Example" +app.Get("/file", func(c fiber.Ctx) error { + return c.SendFile("style.css", SendFile{ + CacheDuration: -1, + }) +}) +``` + +:::info +You can use multiple SendFile with different configurations in single route. Fiber creates different filesystem handler per config. +::: + +```go title="Example" +app.Get("/file", func(c fiber.Ctx) error { + switch c.Query("config") { + case "filesystem": + return c.SendFile("style.css", SendFile{ + FS: os.DirFS(".") + }) + case "filesystem-compress": + return c.SendFile("style.css", SendFile{ + FS: os.DirFS("."), + Compress: true, + }) + case "compress": + return c.SendFile("style.css", SendFile{ + Compress: true, + }) + default: + return c.SendFile("style.css") + } + + return nil +}) +``` + +:::info +For sending multiple files from embedded file system [this functionality](../middleware/static.md#serving-files-using-embedfs) can be used ::: ## SendStatus diff --git a/docs/whats_new.md b/docs/whats_new.md index 7dd159ab..c1f7eea7 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -219,6 +219,7 @@ DRAFT section * Bind -> for Binding instead of View, us c.ViewBind() * Format -> Param: body interface{} -> handlers ...ResFmt * Redirect -> c.Redirect().To() +* SendFile now supports different configurations using the config parameter. ---