Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions _fixture/dist/private.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
private file
1 change: 1 addition & 0 deletions _fixture/dist/public/assets/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
readme in assets
1 change: 1 addition & 0 deletions _fixture/dist/public/assets/subfolder/subfolder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
file inside subfolder
1 change: 1 addition & 0 deletions _fixture/dist/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Hello from index</h1>
50 changes: 27 additions & 23 deletions middleware/static.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,13 @@ const directoryListHTMLTemplate = `
</header>
<ul>
{{ range .Files }}
{{ $href := .Name }}{{ if ne $.Name "/" }}{{ $href = print $.Name "/" .Name }}{{ end }}
<li>
{{ if .Dir }}
{{ $name := print .Name "/" }}
<a class="dir" href="{{ $name }}">{{ $name }}</a>
<a class="dir" href="{{ $href }}">{{ $name }}</a>
{{ else }}
<a class="file" href="{{ .Name }}">{{ .Name }}</a>
<a class="file" href="{{ $href }}">{{ .Name }}</a>
<span>{{ .Size }}</span>
{{ end }}
</li>
Expand Down Expand Up @@ -196,14 +197,15 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
// 3. The "/" prefix forces absolute path interpretation, removing ".." components
// 4. Backslashes are treated as literal characters (not path separators), preventing traversal
// See static_windows.go for Go 1.20+ filepath.Clean compatibility notes
name := path.Join(config.Root, path.Clean("/"+p)) // "/"+ for security
requestedPath := path.Clean("/" + p) // "/"+ for security
filePath := path.Join(config.Root, requestedPath)

if config.IgnoreBase {
routePath := path.Base(strings.TrimRight(c.Path(), "/*"))
baseURLPath := path.Base(p)
if baseURLPath == routePath {
i := strings.LastIndex(name, routePath)
name = name[:i] + strings.Replace(name[i:], routePath, "", 1)
i := strings.LastIndex(filePath, routePath)
filePath = filePath[:i] + strings.Replace(filePath[i:], routePath, "", 1)
}
}

Expand All @@ -212,7 +214,7 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
currentFS = c.Echo().Filesystem
}

file, err := currentFS.Open(name)
file, err := currentFS.Open(filePath)
if err != nil {
if !isIgnorableOpenFileError(err) {
return err
Expand Down Expand Up @@ -243,10 +245,10 @@ func (config StaticConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
}

if info.IsDir() {
index, err := currentFS.Open(path.Join(name, config.Index))
index, err := currentFS.Open(path.Join(filePath, config.Index))
if err != nil {
if config.Browse {
return listDir(dirListTemplate, name, currentFS, c.Response())
return listDir(dirListTemplate, requestedPath, filePath, currentFS, c.Response())
}

return next(c)
Expand Down Expand Up @@ -276,34 +278,36 @@ func serveFile(c *echo.Context, file fs.File, info os.FileInfo) error {
return nil
}

func listDir(t *template.Template, name string, filesystem fs.FS, res http.ResponseWriter) error {
func listDir(t *template.Template, requestedPath string, pathInFs string, filesystem fs.FS, res http.ResponseWriter) error {
files, err := fs.ReadDir(filesystem, pathInFs)
if err != nil {
return fmt.Errorf("static middleware failed to read directory for listing: %w", err)
}

// Create directory index
res.Header().Set(echo.HeaderContentType, echo.MIMETextHTMLCharsetUTF8)
data := struct {
Name string
Files []any
}{
Name: name,
Name: requestedPath,
}
err := fs.WalkDir(filesystem, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

info, infoErr := d.Info()
if infoErr != nil {
return fmt.Errorf("static middleware list dir error when getting file info: %w", infoErr)
for _, f := range files {
var size int64
if !f.IsDir() {
info, err := f.Info()
if err != nil {
return fmt.Errorf("static middleware failed to get file info for listing: %w", err)
}
size = info.Size()
}

data.Files = append(data.Files, struct {
Name string
Dir bool
Size string
}{d.Name(), d.IsDir(), format(info.Size())})

return nil
})
if err != nil {
return err
}{f.Name(), f.IsDir(), format(size)})
}

return t.Execute(res, data)
Expand Down
64 changes: 64 additions & 0 deletions middleware/static_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -577,3 +577,67 @@ func TestStatic_CustomFS(t *testing.T) {
})
}
}

func TestStatic_DirectoryBrowsing(t *testing.T) {
var testCases = []struct {
name string
givenConfig StaticConfig
whenURL string
expectContains string
expectNotContains []string
expectCode int
}{
{
name: "ok, should return index.html contents from Root=public folder",
givenConfig: StaticConfig{
Root: "public",
Filesystem: os.DirFS("../_fixture/dist"),
Browse: true,
},
whenURL: "/",
expectCode: http.StatusOK,
expectContains: `<h1>Hello from index</h1>`,
},
{
name: "ok, should return only subfolder folder listing from Root=public/assets",
givenConfig: StaticConfig{
Root: "public",
Filesystem: os.DirFS("../_fixture/dist"),
Browse: true,
},
whenURL: "/assets",
expectCode: http.StatusOK,
expectContains: `<a class="file" href="/assets/readme.md">readme.md</a>`,
expectNotContains: []string{
`<h1>Hello from index</h1>`, // should see the listing, not index.html contents
`private.txt`, // file from the parent folder
`subfolder.md`, // file from subfolder
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
e := echo.New()

middlewareFunc, err := tc.givenConfig.ToMiddleware()
assert.NoError(t, err)

e.Use(middlewareFunc)

req := httptest.NewRequest(http.MethodGet, tc.whenURL, nil)
rec := httptest.NewRecorder()

e.ServeHTTP(rec, req)

assert.Equal(t, tc.expectCode, rec.Code)

responseBody := rec.Body.String()
if tc.expectContains != "" {
assert.Contains(t, responseBody, tc.expectContains, "body should contain: "+tc.expectContains)
}
for _, notContains := range tc.expectNotContains {
assert.NotContains(t, responseBody, notContains, "body should NOT contain: "+notContains)
}
})
}
}
Loading