v3 (feature): add configuration support to c.SendFile() (#3017)

*  v3 (feature): add configuration support to c.SendFile()

* update

* cover more edge-cases

* optimize

* update compression, add mutex for sendfile slice

* fix data races

* add benchmark

* update docs

* update docs

* update

* update tests

* fix linter

* update

* update tests

---------

Co-authored-by: Juan Calderon-Perez <835733+gaby@users.noreply.github.com>
pull/3054/head^2
M. Efe Çetin 2024-06-30 22:15:22 +03:00 committed by GitHub
parent 83731cef85
commit 21ede5954c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 546 additions and 35 deletions

5
app.go
View File

@ -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

190
ctx.go
View File

@ -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
}

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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.
---