diff --git a/config/config.go b/config/config.go index 9a5ec5a..ae5820d 100644 --- a/config/config.go +++ b/config/config.go @@ -43,6 +43,7 @@ type Config struct { WebHookEndpoint string `json:"webhook_endpoint" envconfig:"WEBHOOK_ENDPOINT"` WebHookSecret string `json:"webhook_secret" envconfig:"WEBHOOK_SECRET"` Debug bool `json:"debug" envconfig:"DEBUG"` + Cache bool `json:"cache" envconfig:"CACHE"` Watch bool `json:"watch" envconfig:"WATCH"` LogData bool `json:"log_data" envconfig:"LOG_DATA"` diff --git a/controllers/routes.go b/controllers/routes.go index 7b03ec0..d69c3e9 100644 --- a/controllers/routes.go +++ b/controllers/routes.go @@ -29,7 +29,7 @@ func Register(server server.Server, cfg config.Config) { Level: compress.LevelBestSpeed, })) server.Server.Use(ASSETS_URL, etag.New()) - server.Server.Use(ASSETS_URL, middleware.StaticHandler(&views.StaticFS)) + server.Server.Use(ASSETS_URL, middleware.StaticHandler(&views.StaticFS, cfg.Debug)) server.Server.Use(func(ctx *fiber.Ctx) error { ctx.Locals("cfg", cfg) diff --git a/helpers/middleware/etag.go b/helpers/middleware/etag.go new file mode 100644 index 0000000..59b9c56 --- /dev/null +++ b/helpers/middleware/etag.go @@ -0,0 +1,141 @@ +package middleware + +import ( + "bytes" + "hash/crc32" + + "github.com/gofiber/fiber/v2" + + "github.com/valyala/bytebufferpool" +) + +// Config defines the config for middleware. +type Config struct { + // Weak indicates that a weak validator is used. Weak etags are easy + // to generate, but are far less useful for comparisons. Strong + // validators are ideal for comparisons but can be very difficult + // to generate efficiently. Weak ETag values of two representations + // of the same resources might be semantically equivalent, but not + // byte-for-byte identical. This means weak etags prevent caching + // when byte range requests are used, but strong etags mean range + // requests can still be cached. + Weak bool + + // Next defines a function to skip this middleware when returned true. + // + // Optional. Default: nil + Next func(c *fiber.Ctx) bool +} + +// ConfigDefault is the default config +var ConfigDefault = Config{ + Weak: false, + Next: nil, +} + +// Helper function to set default values +func configDefault(config ...Config) Config { + // Return default config if nothing provided + if len(config) < 1 { + return ConfigDefault + } + + // Override default config + cfg := config[0] + + // Set default values + + return cfg +} + +func New() fiber.Handler { + var ( + normalizedHeaderETag = []byte("Etag") + weakPrefix = []byte("W/") + ) + + const crcPol = 0xD5828281 + crc32q := crc32.MakeTable(crcPol) + + // Return new handler + return func(c *fiber.Ctx) error { + // Return err if next handler returns one + if err := c.Next(); err != nil { + return err + } + + // Don't generate ETags for invalid responses + if c.Response().StatusCode() != fiber.StatusOK { + return nil + } + body := c.Response().Body() + // Skips ETag if no response body is present + if len(body) == 0 { + return nil + } + // Skip ETag if header is already present + if c.Response().Header.PeekBytes(normalizedHeaderETag) != nil { + return nil + } + + // Generate ETag for response + bb := bytebufferpool.Get() + defer bytebufferpool.Put(bb) + + _ = bb.WriteByte('"') //nolint:errcheck // This will never fail + bb.B = appendUint(bb.Bytes(), uint32(len(body))) + _ = bb.WriteByte('-') //nolint:errcheck // This will never fail + bb.B = appendUint(bb.Bytes(), crc32.Checksum(body, crc32q)) + _ = bb.WriteByte('"') //nolint:errcheck // This will never fail + + etag := bb.Bytes() + + // Get ETag header from request + clientEtag := c.Request().Header.Peek(fiber.HeaderIfNoneMatch) + + // Check if client's ETag is weak + if bytes.HasPrefix(clientEtag, weakPrefix) { + // Check if server's ETag is weak + if bytes.Equal(clientEtag[2:], etag) || bytes.Equal(clientEtag[2:], etag[2:]) { + // W/1 == 1 || W/1 == W/1 + c.Context().ResetBody() + + return c.SendStatus(fiber.StatusNotModified) + } + // W/1 != W/2 || W/1 != 2 + c.Response().Header.SetCanonical(normalizedHeaderETag, etag) + + return nil + } + + if bytes.Contains(clientEtag, etag) { + // 1 == 1 + c.Context().ResetBody() + + return c.SendStatus(fiber.StatusNotModified) + } + // 1 != 2 + c.Response().Header.SetCanonical(normalizedHeaderETag, etag) + + return nil + } +} + +// appendUint appends n to dst and returns the extended dst. +func appendUint(dst []byte, n uint32) []byte { + var b [20]byte + buf := b[:] + i := len(buf) + var q uint32 + for n >= 10 { + i-- + q = n / 10 + buf[i] = '0' + byte(n-q*10) + n = q + } + i-- + buf[i] = '0' + byte(n) + + dst = append(dst, buf[i:]...) + return dst +} diff --git a/helpers/middleware/filesystem/filesystem.go b/helpers/middleware/filesystem/filesystem.go new file mode 100644 index 0000000..b6c7365 --- /dev/null +++ b/helpers/middleware/filesystem/filesystem.go @@ -0,0 +1,298 @@ +package filesystem + +import ( + "errors" + "fmt" + "io/fs" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/utils" +) + +// 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 http.FileSystem `json:"-"` + + // PathPrefix defines a prefix to be added to a filepath when + // reading a file from the FileSystem. + // + // Use when using Go 1.16 embed.FS + // + // 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"` + + // 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"` + + // The value for the Content-Type HTTP-header + // that is set on the file response + // + // Optional. Default: true + Dev bool `json:"dev"` +} + +// ConfigDefault is the default config +var ConfigDefault = Config{ + Next: nil, + Root: nil, + PathPrefix: "", + Browse: false, + Index: "/index.html", + MaxAge: 0, + ContentTypeCharset: "", + Dev: true, +} + +// 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 !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") + } + + if cfg.PathPrefix != "" && !strings.HasPrefix(cfg.PathPrefix, "/") { + cfg.PathPrefix = "/" + cfg.PathPrefix + } + + var once sync.Once + var prefix string + cacheControlStr := "public, max-age=" + strconv.Itoa(cfg.MaxAge) + + if cfg.Dev { + cacheControlStr = "no-cache, must-revalidate" + } + + // 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 + } + // Add PathPrefix + if cfg.PathPrefix != "" { + // PathPrefix already has a "/" prefix + path = cfg.PathPrefix + path + } + + if len(path) > 1 { + path = utils.TrimRight(path, '/') + } + file, err := cfg.Root.Open(path) + if err != nil && errors.Is(err, fs.ErrNotExist) && cfg.NotFoundFile != "" { + file, err = cfg.Root.Open(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 := utils.TrimRight(path, '/') + cfg.Index + index, err := cfg.Root.Open(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)) + } + + 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 HTTP file system 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 http.FileSystem, path string) error { + file, err := filesystem.Open(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 := utils.TrimRight(path, '/') + ConfigDefault.Index + index, err := filesystem.Open(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 +} diff --git a/helpers/middleware/filesystem/filesystem_test.go b/helpers/middleware/filesystem/filesystem_test.go new file mode 100644 index 0000000..4c646fd --- /dev/null +++ b/helpers/middleware/filesystem/filesystem_test.go @@ -0,0 +1,235 @@ +//nolint:bodyclose // Much easier to just ignore memory leaks in tests +package filesystem + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/utils" +) + +// go test -run Test_FileSystem +func Test_FileSystem(t *testing.T) { + t.Parallel() + app := fiber.New() + + app.Use("/test", New(Config{ + Root: http.Dir("../../.github/testdata/fs"), + })) + + app.Use("/dir", New(Config{ + Root: http.Dir("../../.github/testdata/fs"), + Browse: true, + })) + + app.Get("/", func(c *fiber.Ctx) error { + return c.SendString("Hello, World!") + }) + + app.Use("/spatest", New(Config{ + Root: http.Dir("../../.github/testdata/fs"), + Index: "index.html", + NotFoundFile: "index.html", + })) + + app.Use("/prefix", New(Config{ + Root: http.Dir("../../.github/testdata/fs"), + PathPrefix: "img", + })) + + tests := []struct { + name string + url string + statusCode int + contentType string + modifiedTime string + }{ + { + name: "Should be returns status 200 with suitable content-type", + url: "/test/index.html", + statusCode: 200, + contentType: "text/html", + }, + { + name: "Should be returns status 200 with suitable content-type", + url: "/test", + statusCode: 200, + contentType: "text/html", + }, + { + name: "Should be returns status 200 with suitable content-type", + url: "/test/css/style.css", + statusCode: 200, + contentType: "text/css", + }, + { + name: "Should be returns status 404", + url: "/test/nofile.js", + statusCode: 404, + }, + { + name: "Should be returns status 404", + url: "/test/nofile", + statusCode: 404, + }, + { + name: "Should be returns status 200", + url: "/", + statusCode: 200, + contentType: "text/plain; charset=utf-8", + }, + { + name: "Should be returns status 403", + url: "/test/img", + statusCode: 403, + }, + { + name: "Should list the directory contents", + url: "/dir/img", + statusCode: 200, + contentType: "text/html", + }, + { + name: "Should list the directory contents", + url: "/dir/img/", + statusCode: 200, + contentType: "text/html", + }, + { + name: "Should be returns status 200", + url: "/dir/img/fiber.png", + statusCode: 200, + contentType: "image/png", + }, + { + name: "Should be return status 200", + url: "/spatest/doesnotexist", + statusCode: 200, + contentType: "text/html", + }, + { + name: "PathPrefix should be applied", + url: "/prefix/fiber.png", + statusCode: 200, + contentType: "image/png", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, tt.url, nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, tt.statusCode, resp.StatusCode) + + if tt.contentType != "" { + ct := resp.Header.Get("Content-Type") + utils.AssertEqual(t, tt.contentType, ct) + } + }) + } +} + +// go test -run Test_FileSystem_Next +func Test_FileSystem_Next(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + Root: http.Dir("../../.github/testdata/fs"), + Next: func(_ *fiber.Ctx) bool { + return true + }, + })) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, fiber.StatusNotFound, resp.StatusCode) +} + +func Test_FileSystem_NonGetAndHead(t *testing.T) { + t.Parallel() + app := fiber.New() + + app.Use("/test", New(Config{ + Root: http.Dir("../../.github/testdata/fs"), + })) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/test", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, 404, resp.StatusCode) +} + +func Test_FileSystem_Head(t *testing.T) { + t.Parallel() + app := fiber.New() + + app.Use("/test", New(Config{ + Root: http.Dir("../../.github/testdata/fs"), + })) + + req, err := http.NewRequestWithContext(context.Background(), fiber.MethodHead, "/test", nil) + utils.AssertEqual(t, nil, err) + resp, err := app.Test(req) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, 200, resp.StatusCode) +} + +func Test_FileSystem_NoRoot(t *testing.T) { + t.Parallel() + defer func() { + utils.AssertEqual(t, "filesystem: Root cannot be nil", recover()) + }() + + app := fiber.New() + app.Use(New()) + _, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil)) + utils.AssertEqual(t, nil, err) +} + +func Test_FileSystem_UsingParam(t *testing.T) { + t.Parallel() + app := fiber.New() + + app.Use("/:path", func(c *fiber.Ctx) error { + return SendFile(c, http.Dir("../../.github/testdata/fs"), c.Params("path")+".html") + }) + + req, err := http.NewRequestWithContext(context.Background(), fiber.MethodHead, "/index", nil) + utils.AssertEqual(t, nil, err) + resp, err := app.Test(req) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, 200, resp.StatusCode) +} + +func Test_FileSystem_UsingParam_NonFile(t *testing.T) { + t.Parallel() + app := fiber.New() + + app.Use("/:path", func(c *fiber.Ctx) error { + return SendFile(c, http.Dir("../../.github/testdata/fs"), c.Params("path")+".html") + }) + + req, err := http.NewRequestWithContext(context.Background(), fiber.MethodHead, "/template", nil) + utils.AssertEqual(t, nil, err) + resp, err := app.Test(req) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, 404, resp.StatusCode) +} + +func Test_FileSystem_UsingContentTypeCharset(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + Root: http.Dir("../../.github/testdata/fs/index.html"), + ContentTypeCharset: "UTF-8", + })) + + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil)) + utils.AssertEqual(t, nil, err) + utils.AssertEqual(t, 200, resp.StatusCode) + utils.AssertEqual(t, "text/html; charset=UTF-8", resp.Header.Get("Content-Type")) +} diff --git a/helpers/middleware/filesystem/utils.go b/helpers/middleware/filesystem/utils.go new file mode 100644 index 0000000..4e96db6 --- /dev/null +++ b/helpers/middleware/filesystem/utils.go @@ -0,0 +1,66 @@ +package filesystem + +import ( + "fmt" + "html" + "net/http" + "os" + "path" + "sort" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/utils" +) + +func getFileExtension(p string) string { + n := strings.LastIndexByte(p, '.') + if n < 0 { + return "" + } + return p[n:] +} + +func dirList(c *fiber.Ctx, f http.File) error { + fileinfos, err := f.Readdir(-1) + if err != nil { + return fmt.Errorf("failed to read dir: %w", err) + } + + fm := make(map[string]os.FileInfo, len(fileinfos)) + filenames := make([]string, 0, len(fileinfos)) + for _, fi := range fileinfos { + name := fi.Name() + fm[name] = fi + filenames = append(filenames, name) + } + + basePathEscaped := html.EscapeString(c.Path()) + _, _ = fmt.Fprintf(c, "