Skip to content

Commit bf0adea

Browse files
committed
Improve filesystem support. Add field echo.Filesystem, methods: echo.FileFS, echo.StaticFS, group.FileFS, group.StaticFS. Following methods will use echo.Filesystem to server files: echo.File, echo.Static, group.File, group.Static, Context.File
1 parent 6f6befe commit bf0adea

File tree

6 files changed

+601
-47
lines changed

6 files changed

+601
-47
lines changed

context.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ package echo
33
import (
44
"bytes"
55
"encoding/xml"
6+
"errors"
67
"fmt"
78
"io"
9+
"io/fs"
810
"mime/multipart"
911
"net"
1012
"net/http"
1113
"net/url"
12-
"os"
1314
"path/filepath"
1415
"strings"
1516
"sync"
@@ -569,27 +570,39 @@ func (c *context) Stream(code int, contentType string, r io.Reader) (err error)
569570
return
570571
}
571572

572-
func (c *context) File(file string) (err error) {
573-
f, err := os.Open(file)
573+
func (c *context) File(file string) error {
574+
return fsFile(c, file, c.echo.Filesystem)
575+
}
576+
577+
func (c *context) FileFS(file string, filesystem fs.FS) error {
578+
return fsFile(c, file, filesystem)
579+
}
580+
581+
func fsFile(c Context, file string, filesystem fs.FS) error {
582+
f, err := filesystem.Open(file)
574583
if err != nil {
575-
return NotFoundHandler(c)
584+
return ErrNotFound
576585
}
577586
defer f.Close()
578587

579588
fi, _ := f.Stat()
580589
if fi.IsDir() {
581590
file = filepath.Join(file, indexPage)
582-
f, err = os.Open(file)
591+
f, err = filesystem.Open(file)
583592
if err != nil {
584-
return NotFoundHandler(c)
593+
return ErrNotFound
585594
}
586595
defer f.Close()
587596
if fi, err = f.Stat(); err != nil {
588-
return
597+
return err
589598
}
590599
}
591-
http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), f)
592-
return
600+
ff, ok := f.(io.ReadSeeker)
601+
if !ok {
602+
return errors.New("file does not implement io.ReadSeeker")
603+
}
604+
http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), ff)
605+
return nil
593606
}
594607

595608
func (c *context) Attachment(file, name string) error {

context_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import (
88
"errors"
99
"fmt"
1010
"io"
11+
"io/fs"
1112
"math"
1213
"mime/multipart"
1314
"net/http"
1415
"net/http/httptest"
1516
"net/url"
17+
"os"
1618
"strings"
1719
"testing"
1820
"text/template"
@@ -639,6 +641,128 @@ func TestContextMultipartForm(t *testing.T) {
639641
}
640642
}
641643

644+
func TestContext_File(t *testing.T) {
645+
var testCases = []struct {
646+
name string
647+
whenFile string
648+
whenFS fs.FS
649+
expectStatus int
650+
expectStartsWith []byte
651+
expectError string
652+
}{
653+
{
654+
name: "ok, from default file system",
655+
whenFile: "_fixture/images/walle.png",
656+
whenFS: nil,
657+
expectStatus: http.StatusOK,
658+
expectStartsWith: []byte{0x89, 0x50, 0x4e},
659+
},
660+
{
661+
name: "ok, from custom file system",
662+
whenFile: "walle.png",
663+
whenFS: os.DirFS("_fixture/images"),
664+
expectStatus: http.StatusOK,
665+
expectStartsWith: []byte{0x89, 0x50, 0x4e},
666+
},
667+
{
668+
name: "nok, not existent file",
669+
whenFile: "not.png",
670+
whenFS: os.DirFS("_fixture/images"),
671+
expectStatus: http.StatusOK,
672+
expectStartsWith: nil,
673+
expectError: "code=404, message=Not Found",
674+
},
675+
}
676+
677+
for _, tc := range testCases {
678+
t.Run(tc.name, func(t *testing.T) {
679+
e := New()
680+
if tc.whenFS != nil {
681+
e.Filesystem = tc.whenFS
682+
}
683+
684+
handler := func(ec Context) error {
685+
return ec.(*context).File(tc.whenFile)
686+
}
687+
688+
req := httptest.NewRequest(http.MethodGet, "/match.png", nil)
689+
rec := httptest.NewRecorder()
690+
c := e.NewContext(req, rec)
691+
692+
err := handler(c)
693+
694+
testify.Equal(t, tc.expectStatus, rec.Code)
695+
if tc.expectError != "" {
696+
testify.EqualError(t, err, tc.expectError)
697+
} else {
698+
testify.NoError(t, err)
699+
}
700+
701+
body := rec.Body.Bytes()
702+
if len(body) > len(tc.expectStartsWith) {
703+
body = body[:len(tc.expectStartsWith)]
704+
}
705+
testify.Equal(t, tc.expectStartsWith, body)
706+
})
707+
}
708+
}
709+
710+
func TestContext_FileFS(t *testing.T) {
711+
var testCases = []struct {
712+
name string
713+
whenFile string
714+
whenFS fs.FS
715+
expectStatus int
716+
expectStartsWith []byte
717+
expectError string
718+
}{
719+
{
720+
name: "ok",
721+
whenFile: "walle.png",
722+
whenFS: os.DirFS("_fixture/images"),
723+
expectStatus: http.StatusOK,
724+
expectStartsWith: []byte{0x89, 0x50, 0x4e},
725+
},
726+
{
727+
name: "nok, not existent file",
728+
whenFile: "not.png",
729+
whenFS: os.DirFS("_fixture/images"),
730+
expectStatus: http.StatusOK,
731+
expectStartsWith: nil,
732+
expectError: "code=404, message=Not Found",
733+
},
734+
}
735+
736+
for _, tc := range testCases {
737+
t.Run(tc.name, func(t *testing.T) {
738+
e := New()
739+
740+
handler := func(ec Context) error {
741+
return ec.(*context).FileFS(tc.whenFile, tc.whenFS)
742+
}
743+
744+
req := httptest.NewRequest(http.MethodGet, "/match.png", nil)
745+
rec := httptest.NewRecorder()
746+
c := e.NewContext(req, rec)
747+
748+
err := handler(c)
749+
750+
testify.Equal(t, tc.expectStatus, rec.Code)
751+
if tc.expectError != "" {
752+
testify.EqualError(t, err, tc.expectError)
753+
} else {
754+
testify.NoError(t, err)
755+
}
756+
757+
body := rec.Body.Bytes()
758+
if len(body) > len(tc.expectStartsWith) {
759+
body = body[:len(tc.expectStartsWith)]
760+
}
761+
testify.Equal(t, tc.expectStartsWith, body)
762+
})
763+
}
764+
}
765+
642766
func TestContextRedirect(t *testing.T) {
643767
e := New()
644768
req := httptest.NewRequest(http.MethodGet, "/", nil)

echo.go

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
"errors"
4444
"fmt"
4545
"io"
46+
"io/fs"
4647
"io/ioutil"
4748
stdLog "log"
4849
"net"
@@ -52,6 +53,7 @@ import (
5253
"path/filepath"
5354
"reflect"
5455
"runtime"
56+
"strings"
5557
"sync"
5658
"time"
5759

@@ -96,6 +98,9 @@ type (
9698
Logger Logger
9799
IPExtractor IPExtractor
98100
ListenerNetwork string
101+
// Filesystem is file system used by Static and File handlers to access files.
102+
// Defaults to os.DirFS(".")
103+
Filesystem fs.FS
99104
}
100105

101106
// Route contains a handler and information for matching against requests.
@@ -328,6 +333,7 @@ func New() (e *Echo) {
328333
colorer: color.New(),
329334
maxParam: new(int),
330335
ListenerNetwork: "tcp",
336+
Filesystem: newDefaultFS(),
331337
}
332338
e.Server.Handler = e
333339
e.TLSServer.Handler = e
@@ -499,48 +505,57 @@ func (e *Echo) Match(methods []string, path string, handler HandlerFunc, middlew
499505
return routes
500506
}
501507

502-
// Static registers a new route with path prefix to serve static files from the
503-
// provided root directory.
504-
func (e *Echo) Static(prefix, root string) *Route {
505-
if root == "" {
506-
root = "." // For security we want to restrict to CWD.
508+
// Static registers a new route with path prefix to serve static files from the provided root directory.
509+
func (e *Echo) Static(pathPrefix, root string) *Route {
510+
subFs, err := subFS(e.Filesystem, root)
511+
if err != nil {
512+
// happens when `root` contains invalid path according to `fs.ValidPath` rules and we are unable to create FS
513+
panic(fmt.Errorf("invalid root given to echo.Static, err %w", err))
507514
}
508-
return e.static(prefix, root, e.GET)
515+
return e.Add(
516+
http.MethodGet,
517+
pathPrefix+"*",
518+
StaticDirectoryHandler(subFs, false),
519+
)
509520
}
510521

511-
func (common) static(prefix, root string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route) *Route {
512-
h := func(c Context) error {
513-
p, err := url.PathUnescape(c.Param("*"))
514-
if err != nil {
515-
return err
522+
// StaticFS registers a new route with path prefix to serve static files from the provided file system.
523+
func (e *Echo) StaticFS(pathPrefix string, fileSystem fs.FS) *Route {
524+
return e.Add(
525+
http.MethodGet,
526+
pathPrefix+"*",
527+
StaticDirectoryHandler(fileSystem, false),
528+
)
529+
}
530+
531+
// StaticDirectoryHandler creates handler function to serve files from provided file system
532+
// When disablePathUnescaping is set then file name from path is not unescaped and is served as is.
533+
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc {
534+
return func(c Context) error {
535+
p := c.Param("*")
536+
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
537+
tmpPath, err := url.PathUnescape(p)
538+
if err != nil {
539+
return fmt.Errorf("failed to unescape path variable: %w", err)
540+
}
541+
p = tmpPath
516542
}
517543

518-
name := filepath.Join(root, filepath.Clean("/"+p)) // "/"+ for security
519-
fi, err := os.Stat(name)
544+
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
545+
name := filepath.Clean(strings.TrimPrefix(p, "/"))
546+
fi, err := fs.Stat(fileSystem, name)
520547
if err != nil {
521-
// The access path does not exist
522-
return NotFoundHandler(c)
548+
return ErrNotFound
523549
}
524550

525551
// If the request is for a directory and does not end with "/"
526552
p = c.Request().URL.Path // path must not be empty.
527-
if fi.IsDir() && p[len(p)-1] != '/' {
553+
if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
528554
// Redirect to ends with "/"
529555
return c.Redirect(http.StatusMovedPermanently, p+"/")
530556
}
531-
return c.File(name)
532-
}
533-
// Handle added routes based on trailing slash:
534-
// /prefix => exact route "/prefix" + any route "/prefix/*"
535-
// /prefix/ => only any route "/prefix/*"
536-
if prefix != "" {
537-
if prefix[len(prefix)-1] == '/' {
538-
// Only add any route for intentional trailing slash
539-
return get(prefix+"*", h)
540-
}
541-
get(prefix, h)
557+
return fsFile(c, name, fileSystem)
542558
}
543-
return get(prefix+"/*", h)
544559
}
545560

546561
func (common) file(path, file string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route,
@@ -555,6 +570,18 @@ func (e *Echo) File(path, file string, m ...MiddlewareFunc) *Route {
555570
return e.file(path, file, e.GET, m...)
556571
}
557572

573+
// FileFS registers a new route with path to serve file from the provided file system.
574+
func (e *Echo) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
575+
return e.GET(path, StaticFileHandler(file, filesystem), m...)
576+
}
577+
578+
// StaticFileHandler creates handler function to serve file from provided file system
579+
func StaticFileHandler(file string, filesystem fs.FS) HandlerFunc {
580+
return func(c Context) error {
581+
return fsFile(c, file, filesystem)
582+
}
583+
}
584+
558585
func (e *Echo) add(host, method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
559586
name := handlerName(handler)
560587
router := e.findRouter(host)
@@ -1007,3 +1034,38 @@ func applyMiddleware(h HandlerFunc, middleware ...MiddlewareFunc) HandlerFunc {
10071034
}
10081035
return h
10091036
}
1037+
1038+
// defaultFS emulates os.Open behaviour with filesystem opened by `os.DirFs`. Difference between `os.Open` and `fs.Open`
1039+
// is that FS does not allow to open path that start with `..` or `/` etc. For example previously you could have `../images`
1040+
// in your application but `fs := os.DirFS("./")` would not allow you to use `fs.Open("../images")` and this would break
1041+
// all old applications that rely on being able to traverse up from current executable run path.
1042+
// NB: private because you really should use fs.FS implementation instances
1043+
type defaultFS struct {
1044+
prefix string
1045+
fs fs.FS
1046+
}
1047+
1048+
func newDefaultFS() *defaultFS {
1049+
dir, _ := os.Getwd()
1050+
return &defaultFS{
1051+
prefix: dir,
1052+
fs: os.DirFS(dir),
1053+
}
1054+
}
1055+
1056+
func (fs defaultFS) Open(name string) (fs.File, error) {
1057+
return fs.fs.Open(name)
1058+
}
1059+
1060+
func subFS(currentFs fs.FS, root string) (fs.FS, error) {
1061+
if dFS, ok := currentFs.(*defaultFS); ok {
1062+
// we need to make exception for `defaultFS` instances as it interprets root prefix differently from fs.FS to
1063+
// allow cases when root is given as `../somepath` which is not valid for fs.FS
1064+
root = filepath.Join(dFS.prefix, root)
1065+
return &defaultFS{
1066+
prefix: root,
1067+
fs: os.DirFS(root),
1068+
}, nil
1069+
}
1070+
return fs.Sub(currentFs, filepath.Clean(root))
1071+
}

0 commit comments

Comments
 (0)