Repository: labstack/echo
Pull request #1572 (2020-05-13 16:44:17)

The directory path does not end with '/', it needs to be redirected
https://github.com/labstack/echo/issues/1533#issue-580533874

**Reference golang source code**

https://github.com/golang/go/blob/cf9b4f63a57b4360be700831781885fc6cf5a0b1/src/net/http/fs.go#L575-L612

```go
	if redirect {
		// redirect to canonical path: / at end of directory url
		// r.URL.Path always begins with /
		url := r.URL.Path
		if d.IsDir() {
			if url[len(url)-1] != '/' {
				localRedirect(w, r, path.Base(url)+"/")
				return
			}
		} else {
			if url[len(url)-1] == '/' {
				localRedirect(w, r, "../"+path.Base(url))
				return
			}
		}
	}


	if d.IsDir() {
		url := r.URL.Path
		// redirect if the directory name doesn't end in a slash
		if url == "" || url[len(url)-1] != '/' {
			localRedirect(w, r, path.Base(url)+"/")
			return
		}


		// use contents of index.html for directory, if present
		index := strings.TrimSuffix(name, "/") + indexPage
		ff, err := fs.Open(index)
		if err == nil {
			defer ff.Close()
			dd, err := ff.Stat()
			if err == nil {
				name = index
				d = dd
				f = ff
			}
		}
	}
```


Patch:
diff --git a/echo.go b/echo.go
index af93354..888d4c0 100644
--- a/echo.go
+++ b/echo.go
@@ -48,6 +48,7 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"os"
 	"path"
 	"path/filepath"
 	"reflect"
@@ -479,7 +480,20 @@ func (common) static(prefix, root string, get func(string, HandlerFunc, ...Middl
 		if err != nil {
 			return err
 		}
+
 		name := filepath.Join(root, path.Clean("/"+p)) // "/"+ for security
+		fi, err := os.Stat(name)
+		if err != nil {
+			// The access path does not exist
+			return NotFoundHandler(c)
+		}
+
+		// 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] != '/' {
+			// Redirect to ends with "/"
+			return c.Redirect(http.StatusMovedPermanently, p+"/")
+		}
 		return c.File(name)
 	}
 	if prefix == "/" {
@@ -504,11 +518,7 @@ func (e *Echo) add(host, method, path string, handler HandlerFunc, middleware ..
 	name := handlerName(handler)
 	router := e.findRouter(host)
 	router.Add(method, path, func(c Context) error {
-		h := handler
-		// Chain middleware
-		for i := len(middleware) - 1; i >= 0; i-- {
-			h = middleware[i](h)
-		}
+		h := applyMiddleware(handler, middleware...)
 		return h(c)
 	})
 	r := &Route{
@@ -795,6 +805,11 @@ func (he *HTTPError) SetInternal(err error) *HTTPError {
 	return he
 }
 
+// Unwrap satisfies the Go 1.13 error wrapper interface.
+func (he *HTTPError) Unwrap() error {
+	return he.Internal
+}
+
 // WrapHandler wraps `http.Handler` into `echo.HandlerFunc`.
 func WrapHandler(h http.Handler) HandlerFunc {
 	return func(c Context) error {
diff --git a/go.mod b/go.mod
index b3ac080..ed220b7 100644
--- a/go.mod
+++ b/go.mod
@@ -10,5 +10,5 @@ require (
 	github.com/valyala/fasttemplate v1.1.0
 	golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d
 	golang.org/x/net v0.0.0-20200226121028-0de0cce0169b
-	golang.org/x/text v0.3.2 // indirect
+	golang.org/x/text v0.3.3 // indirect
 )
diff --git a/go.sum b/go.sum
index 8e7e54c..11ef341 100644
--- a/go.sum
+++ b/go.sum
@@ -43,6 +43,8 @@ golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
diff --git a/middleware/compress.go b/middleware/compress.go
index 89da16e..dd97d98 100644
--- a/middleware/compress.go
+++ b/middleware/compress.go
@@ -119,3 +119,10 @@ func (w *gzipResponseWriter) Flush() {
 func (w *gzipResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
 	return w.ResponseWriter.(http.Hijacker).Hijack()
 }
+
+func (w *gzipResponseWriter) Push(target string, opts *http.PushOptions) error {
+	if p, ok := w.ResponseWriter.(http.Pusher); ok {
+		return p.Push(target, opts)
+	}
+	return http.ErrNotSupported
+}
diff --git a/middleware/proxy.go b/middleware/proxy.go
index 1956e91..a9b91f6 100644
--- a/middleware/proxy.go
+++ b/middleware/proxy.go
@@ -45,6 +45,9 @@ type (
 		// Examples: If custom TLS certificates are required.
 		Transport http.RoundTripper
 
+		// ModifyResponse defines function to modify response from ProxyTarget.
+		ModifyResponse func(*http.Response) error
+
 		rewriteRegex map[*regexp.Regexp]string
 	}
 
diff --git a/middleware/proxy_1_11.go b/middleware/proxy_1_11.go
index 12b7568..a439278 100644
--- a/middleware/proxy_1_11.go
+++ b/middleware/proxy_1_11.go
@@ -20,5 +20,6 @@ func proxyHTTP(tgt *ProxyTarget, c echo.Context, config ProxyConfig) http.Handle
 		c.Set("_error", echo.NewHTTPError(http.StatusBadGateway, fmt.Sprintf("remote %s unreachable, could not forward: %v", desc, err)))
 	}
 	proxy.Transport = config.Transport
+	proxy.ModifyResponse = config.ModifyResponse
 	return proxy
 }


Test patch:
diff --git a/echo_go1.13_test.go b/echo_go1.13_test.go
new file mode 100644
index 0000000..3c488bc
--- /dev/null
+++ b/echo_go1.13_test.go
@@ -0,0 +1,28 @@
+// +build go1.13
+
+package echo
+
+import (
+	"errors"
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestHTTPError_Unwrap(t *testing.T) {
+	t.Run("non-internal", func(t *testing.T) {
+		err := NewHTTPError(http.StatusBadRequest, map[string]interface{}{
+			"code": 12,
+		})
+
+		assert.Nil(t, errors.Unwrap(err))
+	})
+	t.Run("internal", func(t *testing.T) {
+		err := NewHTTPError(http.StatusBadRequest, map[string]interface{}{
+			"code": 12,
+		})
+		err.SetInternal(errors.New("internal error"))
+		assert.Equal(t, "internal error", errors.Unwrap(err).Error())
+	})
+}
diff --git a/echo_test.go b/echo_test.go
index ddbc56f..e1706ef 100644
--- a/echo_test.go
+++ b/echo_test.go
@@ -76,9 +76,17 @@ func TestEchoStatic(t *testing.T) {
 
 	// Directory
 	e.Static("/images", "_fixture/images")
-	c, _ = request(http.MethodGet, "/images", e)
+	c, _ = request(http.MethodGet, "/images/", e)
 	assert.Equal(http.StatusNotFound, c)
 
+	// Directory Redirect
+	e.Static("/", "_fixture")
+	req := httptest.NewRequest(http.MethodGet, "/folder", nil)
+	rec := httptest.NewRecorder()
+	e.ServeHTTP(rec, req)
+	assert.Equal(http.StatusMovedPermanently, rec.Code)
+	assert.Equal("/folder/", rec.HeaderMap["Location"][0])
+
 	// Directory with index.html
 	e.Static("/", "_fixture")
 	c, r := request(http.MethodGet, "/", e)
@@ -86,9 +94,10 @@ func TestEchoStatic(t *testing.T) {
 	assert.Equal(true, strings.HasPrefix(r, "<!doctype html>"))
 
 	// Sub-directory with index.html
-	c, r = request(http.MethodGet, "/folder", e)
+	c, r = request(http.MethodGet, "/folder/", e)
 	assert.Equal(http.StatusOK, c)
 	assert.Equal(true, strings.HasPrefix(r, "<!doctype html>"))
+
 }
 
 func TestEchoFile(t *testing.T) {
@@ -549,7 +558,6 @@ func TestHTTPError(t *testing.T) {
 		})
 
 		assert.Equal(t, "code=400, message=map[code:12]", err.Error())
-
 	})
 	t.Run("internal", func(t *testing.T) {
 		err := NewHTTPError(http.StatusBadRequest, map[string]interface{}{
diff --git a/middleware/proxy_test.go b/middleware/proxy_test.go
index 5ef11bc..b19bf4f 100644
--- a/middleware/proxy_test.go
+++ b/middleware/proxy_test.go
@@ -1,7 +1,9 @@
 package middleware
 
 import (
+	"bytes"
 	"fmt"
+	"io/ioutil"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -104,11 +106,26 @@ func TestProxy(t *testing.T) {
 	e.ServeHTTP(rec, req)
 	assert.Equal(t, "/user/jack/order/1", req.URL.Path)
 	assert.Equal(t, http.StatusOK, rec.Code)
-  req.URL.Path = "/users/jill/orders/T%2FcO4lW%2Ft%2FVp%2F"
+	req.URL.Path = "/users/jill/orders/T%2FcO4lW%2Ft%2FVp%2F"
 	e.ServeHTTP(rec, req)
 	assert.Equal(t, "/user/jill/order/T%2FcO4lW%2Ft%2FVp%2F", req.URL.Path)
 	assert.Equal(t, http.StatusOK, rec.Code)
 
+	// ModifyResponse
+	e = echo.New()
+	e.Use(ProxyWithConfig(ProxyConfig{
+		Balancer: rrb,
+		ModifyResponse: func(res *http.Response) error {
+			res.Body = ioutil.NopCloser(bytes.NewBuffer([]byte("modified")))
+			res.Header.Set("X-Modified", "1")
+			return nil
+		},
+	}))
+	rec = httptest.NewRecorder()
+	e.ServeHTTP(rec, req)
+	assert.Equal(t, "modified", rec.Body.String())
+	assert.Equal(t, "1", rec.Header().Get("X-Modified"))
+
 	// ProxyTarget is set in context
 	contextObserver := func(next echo.HandlerFunc) echo.HandlerFunc {
 		return func(c echo.Context) (err error) {
diff --git a/response_test.go b/response_test.go
index 07776ca..7a9c51c 100644
--- a/response_test.go
+++ b/response_test.go
@@ -19,8 +19,13 @@ func TestResponse(t *testing.T) {
 	res.Before(func() {
 		c.Response().Header().Set(HeaderServer, "echo")
 	})
+	// After
+	res.After(func() {
+		c.Response().Header().Set(HeaderXFrameOptions, "DENY")
+	})
 	res.Write([]byte("test"))
 	assert.Equal(t, "echo", rec.Header().Get(HeaderServer))
+	assert.Equal(t, "DENY", rec.Header().Get(HeaderXFrameOptions))
 }
 
 func TestResponse_Write_FallsBackToDefaultStatus(t *testing.T) {