Skip to content

Commit

Permalink
Improve filesystem support. Add field echo.Filesystem, methods: echo.…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
aldas committed Jan 8, 2022
1 parent 6f6befe commit bf0adea
Show file tree
Hide file tree
Showing 6 changed files with 601 additions and 47 deletions.
31 changes: 22 additions & 9 deletions context.go
Expand Up @@ -3,13 +3,14 @@ package echo
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"io/fs"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -569,27 +570,39 @@ func (c *context) Stream(code int, contentType string, r io.Reader) (err error)
return
}

func (c *context) File(file string) (err error) {
f, err := os.Open(file)
func (c *context) File(file string) error {
return fsFile(c, file, c.echo.Filesystem)
}

func (c *context) FileFS(file string, filesystem fs.FS) error {
return fsFile(c, file, filesystem)
}

func fsFile(c Context, file string, filesystem fs.FS) error {
f, err := filesystem.Open(file)
if err != nil {
return NotFoundHandler(c)
return ErrNotFound
}
defer f.Close()

fi, _ := f.Stat()
if fi.IsDir() {
file = filepath.Join(file, indexPage)
f, err = os.Open(file)
f, err = filesystem.Open(file)
if err != nil {
return NotFoundHandler(c)
return ErrNotFound
}
defer f.Close()
if fi, err = f.Stat(); err != nil {
return
return err
}
}
http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), f)
return
ff, ok := f.(io.ReadSeeker)
if !ok {
return errors.New("file does not implement io.ReadSeeker")
}
http.ServeContent(c.Response(), c.Request(), fi.Name(), fi.ModTime(), ff)
return nil
}

func (c *context) Attachment(file, name string) error {
Expand Down
124 changes: 124 additions & 0 deletions context_test.go
Expand Up @@ -8,11 +8,13 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"math"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"text/template"
Expand Down Expand Up @@ -639,6 +641,128 @@ func TestContextMultipartForm(t *testing.T) {
}
}

func TestContext_File(t *testing.T) {
var testCases = []struct {
name string
whenFile string
whenFS fs.FS
expectStatus int
expectStartsWith []byte
expectError string
}{
{
name: "ok, from default file system",
whenFile: "_fixture/images/walle.png",
whenFS: nil,
expectStatus: http.StatusOK,
expectStartsWith: []byte{0x89, 0x50, 0x4e},
},
{
name: "ok, from custom file system",
whenFile: "walle.png",
whenFS: os.DirFS("_fixture/images"),
expectStatus: http.StatusOK,
expectStartsWith: []byte{0x89, 0x50, 0x4e},
},
{
name: "nok, not existent file",
whenFile: "not.png",
whenFS: os.DirFS("_fixture/images"),
expectStatus: http.StatusOK,
expectStartsWith: nil,
expectError: "code=404, message=Not Found",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
e := New()
if tc.whenFS != nil {
e.Filesystem = tc.whenFS
}

handler := func(ec Context) error {
return ec.(*context).File(tc.whenFile)
}

req := httptest.NewRequest(http.MethodGet, "/match.png", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

err := handler(c)

testify.Equal(t, tc.expectStatus, rec.Code)
if tc.expectError != "" {
testify.EqualError(t, err, tc.expectError)
} else {
testify.NoError(t, err)
}

body := rec.Body.Bytes()
if len(body) > len(tc.expectStartsWith) {
body = body[:len(tc.expectStartsWith)]
}
testify.Equal(t, tc.expectStartsWith, body)
})
}
}

func TestContext_FileFS(t *testing.T) {
var testCases = []struct {
name string
whenFile string
whenFS fs.FS
expectStatus int
expectStartsWith []byte
expectError string
}{
{
name: "ok",
whenFile: "walle.png",
whenFS: os.DirFS("_fixture/images"),
expectStatus: http.StatusOK,
expectStartsWith: []byte{0x89, 0x50, 0x4e},
},
{
name: "nok, not existent file",
whenFile: "not.png",
whenFS: os.DirFS("_fixture/images"),
expectStatus: http.StatusOK,
expectStartsWith: nil,
expectError: "code=404, message=Not Found",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
e := New()

handler := func(ec Context) error {
return ec.(*context).FileFS(tc.whenFile, tc.whenFS)
}

req := httptest.NewRequest(http.MethodGet, "/match.png", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

err := handler(c)

testify.Equal(t, tc.expectStatus, rec.Code)
if tc.expectError != "" {
testify.EqualError(t, err, tc.expectError)
} else {
testify.NoError(t, err)
}

body := rec.Body.Bytes()
if len(body) > len(tc.expectStartsWith) {
body = body[:len(tc.expectStartsWith)]
}
testify.Equal(t, tc.expectStartsWith, body)
})
}
}

func TestContextRedirect(t *testing.T) {
e := New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
Expand Down
118 changes: 90 additions & 28 deletions echo.go
Expand Up @@ -43,6 +43,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"io/ioutil"
stdLog "log"
"net"
Expand All @@ -52,6 +53,7 @@ import (
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -96,6 +98,9 @@ type (
Logger Logger
IPExtractor IPExtractor
ListenerNetwork string
// Filesystem is file system used by Static and File handlers to access files.
// Defaults to os.DirFS(".")
Filesystem fs.FS
}

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

// Static registers a new route with path prefix to serve static files from the
// provided root directory.
func (e *Echo) Static(prefix, root string) *Route {
if root == "" {
root = "." // For security we want to restrict to CWD.
// Static registers a new route with path prefix to serve static files from the provided root directory.
func (e *Echo) Static(pathPrefix, root string) *Route {
subFs, err := subFS(e.Filesystem, root)
if err != nil {
// happens when `root` contains invalid path according to `fs.ValidPath` rules and we are unable to create FS
panic(fmt.Errorf("invalid root given to echo.Static, err %w", err))
}
return e.static(prefix, root, e.GET)
return e.Add(
http.MethodGet,
pathPrefix+"*",
StaticDirectoryHandler(subFs, false),
)
}

func (common) static(prefix, root string, get func(string, HandlerFunc, ...MiddlewareFunc) *Route) *Route {
h := func(c Context) error {
p, err := url.PathUnescape(c.Param("*"))
if err != nil {
return err
// StaticFS registers a new route with path prefix to serve static files from the provided file system.
func (e *Echo) StaticFS(pathPrefix string, fileSystem fs.FS) *Route {
return e.Add(
http.MethodGet,
pathPrefix+"*",
StaticDirectoryHandler(fileSystem, false),
)
}

// StaticDirectoryHandler creates handler function to serve files from provided file system
// When disablePathUnescaping is set then file name from path is not unescaped and is served as is.
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) HandlerFunc {
return func(c Context) error {
p := c.Param("*")
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
tmpPath, err := url.PathUnescape(p)
if err != nil {
return fmt.Errorf("failed to unescape path variable: %w", err)
}
p = tmpPath
}

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

// If the request is for a directory and does not end with "/"
p = c.Request().URL.Path // path must not be empty.
if fi.IsDir() && p[len(p)-1] != '/' {
if fi.IsDir() && len(p) > 0 && p[len(p)-1] != '/' {
// Redirect to ends with "/"
return c.Redirect(http.StatusMovedPermanently, p+"/")
}
return c.File(name)
}
// Handle added routes based on trailing slash:
// /prefix => exact route "/prefix" + any route "/prefix/*"
// /prefix/ => only any route "/prefix/*"
if prefix != "" {
if prefix[len(prefix)-1] == '/' {
// Only add any route for intentional trailing slash
return get(prefix+"*", h)
}
get(prefix, h)
return fsFile(c, name, fileSystem)
}
return get(prefix+"/*", h)
}

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

// FileFS registers a new route with path to serve file from the provided file system.
func (e *Echo) FileFS(path, file string, filesystem fs.FS, m ...MiddlewareFunc) *Route {
return e.GET(path, StaticFileHandler(file, filesystem), m...)
}

// StaticFileHandler creates handler function to serve file from provided file system
func StaticFileHandler(file string, filesystem fs.FS) HandlerFunc {
return func(c Context) error {
return fsFile(c, file, filesystem)
}
}

func (e *Echo) add(host, method, path string, handler HandlerFunc, middleware ...MiddlewareFunc) *Route {
name := handlerName(handler)
router := e.findRouter(host)
Expand Down Expand Up @@ -1007,3 +1034,38 @@ func applyMiddleware(h HandlerFunc, middleware ...MiddlewareFunc) HandlerFunc {
}
return h
}

// defaultFS emulates os.Open behaviour with filesystem opened by `os.DirFs`. Difference between `os.Open` and `fs.Open`
// is that FS does not allow to open path that start with `..` or `/` etc. For example previously you could have `../images`
// in your application but `fs := os.DirFS("./")` would not allow you to use `fs.Open("../images")` and this would break
// all old applications that rely on being able to traverse up from current executable run path.
// NB: private because you really should use fs.FS implementation instances
type defaultFS struct {
prefix string
fs fs.FS
}

func newDefaultFS() *defaultFS {
dir, _ := os.Getwd()
return &defaultFS{
prefix: dir,
fs: os.DirFS(dir),
}
}

func (fs defaultFS) Open(name string) (fs.File, error) {
return fs.fs.Open(name)
}

func subFS(currentFs fs.FS, root string) (fs.FS, error) {
if dFS, ok := currentFs.(*defaultFS); ok {
// we need to make exception for `defaultFS` instances as it interprets root prefix differently from fs.FS to
// allow cases when root is given as `../somepath` which is not valid for fs.FS
root = filepath.Join(dFS.prefix, root)
return &defaultFS{
prefix: root,
fs: os.DirFS(root),
}, nil
}
return fs.Sub(currentFs, filepath.Clean(root))
}

0 comments on commit bf0adea

Please sign in to comment.