feat: 207 multi-status with HTML directory listing (#354)

Co-authored-by: Henrique Dias <mail@hacdias.com>
This commit is contained in:
Manuel Martins
2026-10-03 10:33:07 +02:00
committed by GitHub
co-authored by Henrique Dias
parent 83463594ba
commit 67f345369f
9 changed files with 1113 additions and 2 deletions
+2
View File
@@ -0,0 +1,2 @@
# Embedded templates must render identically on every platform.
*.html text eol=lf
+18 -1
View File
@@ -75,7 +75,7 @@ For clients that use it, the server also supports partial `PUT` requests with `C
## Configuration
The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with
all the options available, as well as what they mean.
all the options available, as well as what they mean. Top-level keys use camelCase and keys nested inside a section use snake_case.
```yaml
address: 0.0.0.0
@@ -191,6 +191,23 @@ cors:
- PUT
exposed_headers: []
# HTML Directory Listing configuration
listing:
# Serve an HTML listing on GET requests to a collection, instead of the
# PROPFIND response. Sort with ?C=N|M|S (name, modified, size) and ?O=A|D
# (ascending, descending). Default is 'false'.
enabled: true
# Hide the parent directory (../) link. Default is 'false'.
hide_parent_dir: false
# Show the "Index of <path>" title. Ignored with a custom header. Default is 'true'.
show_path: true
# Custom HTML replacing the page shell: header up to and including <body>,
# footer from </body>. Both must be set, inline or from a file.
# header: "<!DOCTYPE html><html><head><title>WebDAV</title></head><body>"
# header_file: /etc/webdav/header.html
# footer: "</body></html>"
# footer_file: /etc/webdav/footer.html
# You define here the list of users.
# Basic authentication is automatically be configured when users are detected
# below, else there will be no authentication.
+54
View File
@@ -40,6 +40,7 @@ type Config struct {
BehindProxy bool
Log Log
CORS CORS
Listing Listing
Users []User
}
@@ -92,6 +93,13 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"})
v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"})
v.SetDefault("Listing.Enabled", false)
v.SetDefault("Listing.Hide_Parent_Dir", false)
v.SetDefault("Listing.Show_Path", true)
v.SetDefault("Listing.Header", "")
v.SetDefault("Listing.Header_File", "")
v.SetDefault("Listing.Footer", "")
v.SetDefault("Listing.Footer_File", "")
// Read and unmarshal configuration
err := v.ReadInConfig()
@@ -238,6 +246,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("invalid config: %w", err)
}
err = c.Listing.Load()
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
for i := range c.Users {
err := c.Users[i].Validate(c.NoPassword)
if err != nil {
@@ -391,3 +404,44 @@ type CORS struct {
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
}
// Listing.Header and Footer, when set, replace the entire outer HTML
// document shell (doctype, <html>, <head>, <body> open/close) instead of
// being inserted as fragments inside a default shell: Header must contain
// the full opening, up to and including <body>, and Footer must contain the
// full closing, i.e. </body></html>. They must be configured together.
type Listing struct {
Enabled bool
HideParentDir bool `mapstructure:"hide_parent_dir"`
ShowPath bool `mapstructure:"show_path"`
Header string
HeaderFile string `mapstructure:"header_file"`
Footer string
FooterFile string `mapstructure:"footer_file"`
}
var errHeaderFooterMismatch = errors.New("listing: header and footer must both be set, or both left empty")
func (bl *Listing) Load() error {
if bl.HeaderFile != "" {
content, err := os.ReadFile(bl.HeaderFile)
if err != nil {
return fmt.Errorf("failed to read header file: %w", err)
}
bl.Header = string(content)
}
if bl.FooterFile != "" {
content, err := os.ReadFile(bl.FooterFile)
if err != nil {
return fmt.Errorf("failed to read footer file: %w", err)
}
bl.Footer = string(content)
}
if (bl.Header == "") != (bl.Footer == "") {
return errHeaderFooterMismatch
}
return nil
}
+37
View File
@@ -55,9 +55,46 @@ func TestConfigDefaults(t *testing.T) {
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
require.False(t, cfg.Listing.Enabled)
require.False(t, cfg.Listing.HideParentDir)
require.True(t, cfg.Listing.ShowPath)
require.False(t, cfg.CORS.AllowPrivateNetwork)
}
func TestConfigListingHeaderFooterMustBeSetTogether(t *testing.T) {
t.Parallel()
writeAndParseConfigWithError(t, `
listing:
header: "<html><body>"
`, ".yml", "header and footer must both be set")
writeAndParseConfigWithError(t, `
listing:
footer: "</body></html>"
`, ".yml", "header and footer must both be set")
cfg := writeAndParseConfig(t, `
listing:
header: "<html><body>"
footer: "</body></html>"
`, ".yml")
require.NoError(t, cfg.Validate())
}
func TestConfigListingEnvOverrides(t *testing.T) {
t.Setenv("WD_LISTING_HIDE_PARENT_DIR", "true")
t.Setenv("WD_LISTING_HEADER", "<html><body>")
t.Setenv("WD_LISTING_FOOTER", "</body></html>")
cfg := writeAndParseConfig(t, "", ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.Listing.HideParentDir)
require.Equal(t, "<html><body>", cfg.Listing.Header)
require.Equal(t, "</body></html>", cfg.Listing.Footer)
}
func TestConfigCascade(t *testing.T) {
t.Parallel()
+46 -1
View File
@@ -3,6 +3,8 @@ package lib
import (
"net/http"
"os"
"path"
"strings"
"github.com/rs/cors"
"go.uber.org/zap"
@@ -18,6 +20,7 @@ type handlerUser struct {
type Handler struct {
noPassword bool
behindProxy bool
listing Listing
user *handlerUser
users map[string]*handlerUser
}
@@ -33,6 +36,7 @@ func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{
noPassword: c.NoPassword,
behindProxy: c.BehindProxy,
listing: c.Listing,
user: newHandlerUser(User{UserPermissions: c.UserPermissions}, c, ls, logFunc),
users: map[string]*handlerUser{},
}
@@ -247,10 +251,51 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// message body, the semantics of HEAD are unmodified when applied to
// collection resources.
//
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
// GET (or HEAD), when applied to collection, will serve directory listing
// if listing is enabled, otherwise return same as PROPFIND method.
if r.Method == "GET" || r.Method == "HEAD" {
info, err := user.fs.Stat(r.Context(), req.path)
if err == nil && info.IsDir() {
if h.listing.Enabled {
// The listing uses relative links, which only resolve correctly
// under a trailing slash. The target is relative so http.Redirect
// cleans it, keeping a path such as "//host" from leaving the site.
if !strings.HasSuffix(r.URL.Path, "/") {
target := "./" + path.Base(r.URL.EscapedPath()) + "/"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
return
}
sorting := ParseListingSortQuery(r.URL.Query())
html, err := RenderListing(
r.Context(),
user.fs,
req.path,
sorting,
h.listing,
)
if err != nil {
lZap.Error("failed to render directory listing", zap.Error(err))
http.Error(w, "Failed to render directory listing", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if r.Method == "GET" {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(html)); err != nil {
lZap.Error("failed to write directory listing", zap.Error(err))
}
} else {
// HEAD: write headers only
w.WriteHeader(http.StatusOK)
}
return
}
r.Method = "PROPFIND"
if r.Header.Get("Depth") == "" {
+267
View File
@@ -1761,3 +1761,270 @@ rules:
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
func TestServerListingEnabled(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"file1.txt": []byte("content1"),
"file2.txt": []byte("content2"),
"subdir/test": []byte("test"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: R
listing:
enabled: true
`, dir))
defer srv.Close()
// Test GET request to root - should return HTML
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
require.Contains(t, bodyStr, "<!DOCTYPE html>")
require.Contains(t, bodyStr, "file1.txt")
require.Contains(t, bodyStr, "file2.txt")
require.Contains(t, bodyStr, "subdir")
require.Contains(t, bodyStr, `<table id="list">`)
}
func TestServerListingHideParentDir(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"subdir/file.txt": []byte("content"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: R
listing:
enabled: true
hide_parent_dir: true
`, dir))
defer srv.Close()
resp, err := http.Get(srv.URL + "/subdir/")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
require.NotContains(t, bodyStr, `href="../"`)
require.Contains(t, bodyStr, `td colspan="2" class="link"`)
require.Contains(t, bodyStr, `td class="size"`)
require.Contains(t, bodyStr, `td class="date"`)
}
func TestServerListingDisabled(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"file1.txt": []byte("content1"),
})
for name, extra := range map[string]string{
"Default": "",
"Explicit": "listing:\n enabled: false\n",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
srv := makeTestServer(t, fmt.Sprintf("directory: %s\npermissions: R\n%s", dir, extra))
defer srv.Close()
// Test GET request to root - should return 207 Multi-Status (PROPFIND)
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusMultiStatus, resp.StatusCode)
require.Contains(t, resp.Header.Get("Content-Type"), "text/xml")
})
}
}
func TestServerListingWithHeader(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("content"),
})
customHeader := "<!DOCTYPE html><html><head></head><body><h1>Welcome to my WebDAV Server</h1>"
customFooter := "</body></html>"
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: R
listing:
enabled: true
header: %q
footer: %q
`, dir, customHeader, customFooter))
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
require.Contains(t, bodyStr, customHeader)
require.NotContains(t, bodyStr, "Index of")
}
func TestServerListingRedirectsToTrailingSlash(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"subdir/file.txt": []byte("content"),
"evil.com/file.txt": []byte("content"),
"with space/file.txt": []byte("content"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: R
listing:
enabled: true
`, dir))
defer srv.Close()
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
for requestPath, location := range map[string]string{
"/subdir": "/subdir/",
"/subdir?C=S&O=A": "/subdir/?C=S&O=A",
"//evil.com": "/evil.com/",
"/with%20space": "/with%20space/",
} {
resp, err := client.Get(srv.URL + requestPath)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusMovedPermanently, resp.StatusCode, requestPath)
require.Equal(t, location, resp.Header.Get("Location"), requestPath)
}
}
func TestServerListingWithFooter(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("content"),
})
customHeader := "<!DOCTYPE html><html><head></head><body>"
customFooter := "<p>Copyright 2026 - My Company</p></body></html>"
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: R
listing:
enabled: true
header: %q
footer: %q
`, dir, customHeader, customFooter))
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
require.Contains(t, bodyStr, customFooter)
}
func TestServerListingSortQuery(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"small.txt": []byte("1"),
"medium.txt": []byte("12345"),
"large.txt": []byte("123456789"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: R
listing:
enabled: true
`, dir))
defer srv.Close()
resp, err := http.Get(srv.URL + "/?C=S&O=D")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
largePos := strings.Index(bodyStr, "large.txt")
mediumPos := strings.Index(bodyStr, "medium.txt")
smallPos := strings.Index(bodyStr, "small.txt")
require.True(t, largePos >= 0 && mediumPos >= 0 && smallPos >= 0)
require.True(t, largePos < mediumPos && mediumPos < smallPos)
}
func TestServerListingHidesForbiddenEntries(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"public.txt": []byte("public"),
"secret/flag.txt": []byte("flag"),
"hidden.txt": []byte("hidden"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: R
listing:
enabled: true
rules:
- path: /secret/
permissions: none
- path: /hidden.txt
permissions: none
`, dir))
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
bodyStr := string(body)
require.Contains(t, bodyStr, "public.txt")
require.NotContains(t, bodyStr, "secret")
require.NotContains(t, bodyStr, "hidden.txt")
resp, err = http.Get(srv.URL + "/secret/")
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
+299
View File
@@ -0,0 +1,299 @@
package lib
import (
"bytes"
"context"
_ "embed"
"fmt"
"html/template"
"net/url"
"os"
"path"
"sort"
"strings"
"time"
"golang.org/x/net/webdav"
)
//go:embed listing.html
var listingTemplateSource string
var listingTemplate = template.Must(template.New("listing").Parse(listingTemplateSource))
type ListingSortField string
type ListingSortOrder string
const (
listingSortByName ListingSortField = "name"
listingSortByDate ListingSortField = "date"
listingSortBySize ListingSortField = "size"
listingSortAsc ListingSortOrder = "asc"
listingSortDesc ListingSortOrder = "desc"
)
type ListingSortOptions struct {
Field ListingSortField
Order ListingSortOrder
}
// ParseListingSortQuery reads the fancyindex-style ?C=<column>&O=<order> query params.
// Defaults to sorting by date, descending, when no query params are given.
func ParseListingSortQuery(query url.Values) ListingSortOptions {
field, ok := listingSortFieldFromCode(query.Get("C"))
if !ok {
field = listingSortByDate
}
order, ok := listingSortOrderFromCode(query.Get("O"))
if !ok {
order = listingSortDesc
}
return ListingSortOptions{
Field: field,
Order: order,
}
}
type FileEntry struct {
Name string
IsDir bool
Size int64
ModTime time.Time
}
// listingPage is the data handed to listing.html.
type listingPage struct {
Path string
ShowPath bool
ShowParent bool
// Header and Footer come from the operator's configuration and are
// therefore trusted: they are the only values rendered without escaping.
Header template.HTML
Footer template.HTML
NameColumn listingColumn
SizeColumn listingColumn
DateColumn listingColumn
Entries []listingEntry
}
type listingColumn struct {
Href string
Label string
}
type listingEntry struct {
Name string
Href string
IsDir bool
Size string
ModTime string
}
func RenderListing(ctx context.Context, fs webdav.FileSystem, dirPath string, sorting ListingSortOptions, listing Listing) (string, error) {
file, err := fs.OpenFile(ctx, dirPath, os.O_RDONLY, 0)
if err != nil {
return "", fmt.Errorf("failed to open directory: %w", err)
}
defer func() { _ = file.Close() }()
entries, err := file.Readdir(-1)
if err != nil {
return "", fmt.Errorf("failed to read directory: %w", err)
}
// Convert to FileEntry and sort according to query options.
var files []FileEntry
for _, entry := range entries {
files = append(files, FileEntry{
Name: entry.Name(),
IsDir: entry.IsDir(),
Size: entry.Size(),
ModTime: entry.ModTime(),
})
}
sortFileEntries(files, sorting)
page := listingPage{
Path: normalizeCollectionPath(dirPath),
ShowPath: listing.ShowPath,
ShowParent: dirPath != "/" && !listing.HideParentDir,
Header: template.HTML(listing.Header),
Footer: template.HTML(listing.Footer),
NameColumn: listingSortColumn("File Name", listingSortByName, sorting),
SizeColumn: listingSortColumn("File Size", listingSortBySize, sorting),
DateColumn: listingSortColumn("Date", listingSortByDate, sorting),
}
for _, entry := range files {
size := "-"
if !entry.IsDir {
size = formatSize(entry.Size)
}
page.Entries = append(page.Entries, listingEntry{
Name: entry.Name,
Href: fileEntryLink(entry),
IsDir: entry.IsDir,
Size: size,
ModTime: entry.ModTime.Format("02-Jan-2006 15:04"),
})
}
var buf bytes.Buffer
if err := listingTemplate.Execute(&buf, page); err != nil {
return "", fmt.Errorf("failed to render directory listing: %w", err)
}
return buf.String(), nil
}
func sortFileEntries(files []FileEntry, sorting ListingSortOptions) {
sort.SliceStable(files, func(i, j int) bool {
left := files[i]
right := files[j]
// Directories always come before files, regardless of sort order.
if left.IsDir != right.IsDir {
return left.IsDir
}
less := false
equal := false
switch sorting.Field {
case listingSortByDate:
less = left.ModTime.Before(right.ModTime)
equal = left.ModTime.Equal(right.ModTime)
case listingSortBySize:
less = left.Size < right.Size
equal = left.Size == right.Size
default:
leftName := strings.ToLower(left.Name)
rightName := strings.ToLower(right.Name)
less = leftName < rightName
equal = leftName == rightName
}
if equal {
return strings.ToLower(left.Name) < strings.ToLower(right.Name)
}
if sorting.Order == listingSortDesc {
return !less
}
return less
})
}
func listingSortLink(field ListingSortField, current ListingSortOptions) string {
order := listingSortAsc
if current.Field == field {
if current.Order == listingSortAsc {
order = listingSortDesc
} else {
order = listingSortAsc
}
}
return fmt.Sprintf("?C=%s&O=%s", listingSortFieldToCode(field), listingSortOrderToCode(order))
}
func listingSortLabel(label string, field ListingSortField, current ListingSortOptions) string {
if current.Field != field {
return label
}
if current.Order == listingSortDesc {
return label + " ↓"
}
return label + " ↑"
}
func listingSortColumn(label string, field ListingSortField, current ListingSortOptions) listingColumn {
return listingColumn{
Href: listingSortLink(field, current),
Label: listingSortLabel(label, field, current),
}
}
func normalizeCollectionPath(p string) string {
clean := path.Clean("/" + strings.TrimSpace(p))
if clean != "/" {
clean += "/"
}
return clean
}
// fileEntryLink builds a relative link to an entry. url.URL.String escapes the
// name and prefixes it with "./" when it contains a colon, so that a name such
// as "javascript:alert(1)" can never be read as a URL scheme.
func fileEntryLink(entry FileEntry) string {
link := (&url.URL{Path: entry.Name}).String()
if entry.IsDir {
return link + "/"
}
return link
}
// listingSortFieldToCode maps a field to fancyindex's column code (N=name, M=modified, S=size).
func listingSortFieldToCode(field ListingSortField) string {
switch field {
case listingSortByDate:
return "M"
case listingSortBySize:
return "S"
default:
return "N"
}
}
func listingSortFieldFromCode(code string) (ListingSortField, bool) {
switch strings.ToUpper(code) {
case "N":
return listingSortByName, true
case "M":
return listingSortByDate, true
case "S":
return listingSortBySize, true
default:
return "", false
}
}
// listingSortOrderToCode maps an order to fancyindex's code (A=ascending, D=descending).
func listingSortOrderToCode(order ListingSortOrder) string {
if order == listingSortDesc {
return "D"
}
return "A"
}
func listingSortOrderFromCode(code string) (ListingSortOrder, bool) {
switch strings.ToUpper(code) {
case "A":
return listingSortAsc, true
case "D":
return listingSortDesc, true
default:
return "", false
}
}
// formatSize mimics nginx-fancyindex's IEC binary units (e.g. "13.8 MiB").
func formatSize(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
}
+24
View File
@@ -0,0 +1,24 @@
{{- if .Header}}{{.Header}}{{else}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Index of {{.Path}}</title>
<style>
body { font-family: sans-serif; margin: 1rem; }
table { border-collapse: collapse; width: 100%; table-layout: fixed; }
th, td { padding: 0.2rem 0.6rem; text-align: left; white-space: nowrap; }
td.link { width: 100%; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
</style>
</head>
<body>{{end}}
{{- if and .ShowPath (not .Header)}}<h1>Index of {{.Path}}</h1>{{end}}
<table id="list"><thead><tr><th colspan="2"><a href="{{.NameColumn.Href}}">{{.NameColumn.Label}}</a></th><th class="size"><a href="{{.SizeColumn.Href}}">{{.SizeColumn.Label}}</a></th><th class="date"><a href="{{.DateColumn.Href}}">{{.DateColumn.Label}}</a></th></tr></thead>
<tbody>
{{- if .ShowParent}}<tr><td colspan="2" class="link"><a href="../" title="..">../</a></td><td class="size">-</td><td class="date">-</td></tr>
{{end}}
{{- range .Entries}}<tr><td colspan="2" class="link"><a href="{{.Href}}" title="{{.Name}}">{{.Name}}{{if .IsDir}}/{{end}}</a></td><td class="size">{{.Size}}</td><td class="date">{{.ModTime}}</td></tr>
{{end -}}
</tbody>
</table>
{{- if .Footer}}{{.Footer}}{{else}}</body>
</html>{{end -}}
+366
View File
@@ -0,0 +1,366 @@
package lib
import (
"context"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/webdav"
)
// makeListingFS builds an in-memory file system from a map of names to
// contents, where a name ending in "/" is a directory. The in-memory file
// system accepts names that some platforms reject (e.g. ":" or "<" on
// Windows), so hostile names can be tested everywhere.
func makeListingFS(t *testing.T, m map[string]string) webdav.FileSystem {
ctx := context.Background()
fs := webdav.NewMemFS()
for name, data := range m {
if strings.HasSuffix(name, "/") {
require.NoError(t, fs.Mkdir(ctx, "/"+strings.TrimSuffix(name, "/"), 0o755))
continue
}
f, err := fs.OpenFile(ctx, "/"+name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
require.NoError(t, err)
_, err = f.Write([]byte(data))
require.NoError(t, err)
require.NoError(t, f.Close())
}
return fs
}
func renderListing(t *testing.T, fs webdav.FileSystem, dirPath string, sorting ListingSortOptions, listing Listing) string {
html, err := RenderListing(context.Background(), fs, dirPath, sorting, listing)
require.NoError(t, err)
return html
}
var sortByNameAsc = ListingSortOptions{Field: listingSortByName, Order: listingSortAsc}
func TestParseListingSortQuery(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
got := ParseListingSortQuery(url.Values{})
if got.Field != listingSortByDate || got.Order != listingSortDesc {
t.Fatalf("unexpected defaults: %+v", got)
}
})
t.Run("valid values", func(t *testing.T) {
got := ParseListingSortQuery(url.Values{"C": {"S"}, "O": {"D"}})
if got.Field != listingSortBySize || got.Order != listingSortDesc {
t.Fatalf("unexpected parsed values: %+v", got)
}
})
t.Run("invalid values fallback", func(t *testing.T) {
got := ParseListingSortQuery(url.Values{"C": {"invalid"}, "O": {"nope"}})
if got.Field != listingSortByDate || got.Order != listingSortDesc {
t.Fatalf("unexpected fallback values: %+v", got)
}
})
}
func TestRenderDirectoryListingBasic(t *testing.T) {
fs := makeListingFS(t, map[string]string{
"file1.txt": "content1",
"subdir/": "",
})
html := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true})
if !strings.Contains(html, "<!DOCTYPE html>") || !strings.Contains(html, `<table id="list">`) {
t.Fatal("expected basic html table output")
}
if !strings.Contains(html, "<title>Index of /</title>") || !strings.Contains(html, "<h1>Index of /</h1>") {
t.Fatal("expected index title and heading")
}
if !strings.Contains(html, `<a href="subdir/" title="subdir">subdir/</a>`) {
t.Fatal("expected directory entry")
}
if !strings.Contains(html, `<a href="file1.txt" title="file1.txt">file1.txt</a>`) {
t.Fatal("expected file entry")
}
if !strings.HasSuffix(html, "</tbody>\n</table></body>\n</html>") {
t.Fatal("expected default document closing")
}
}
func TestRenderDirectoryListingHeaderFooter(t *testing.T) {
fs := makeListingFS(t, nil)
html := renderListing(t, fs, "/", sortByNameAsc, Listing{
Header: "<!DOCTYPE html><html><head><title>Custom</title></head><body><h1>HEADER</h1>",
Footer: "<p>FOOTER</p></body></html>",
ShowPath: true,
})
if !strings.HasPrefix(html, "<!DOCTYPE html><html><head><title>Custom</title></head><body><h1>HEADER</h1>") {
t.Fatal("expected header to be rendered unescaped at the start")
}
if !strings.HasSuffix(html, "</table><p>FOOTER</p></body></html>") {
t.Fatal("expected footer to be rendered unescaped at the end")
}
if strings.Count(html, "<html>") != 1 || strings.Count(html, "</html>") != 1 {
t.Fatal("expected exactly one html open/close tag when header/footer own the shell")
}
if strings.Count(html, "<body>") != 1 || strings.Count(html, "</body>") != 1 {
t.Fatal("expected exactly one body open/close tag when header/footer own the shell")
}
if strings.Contains(html, `<html lang="en">`) {
t.Fatal("expected default shell to be skipped when a custom header is set")
}
}
func TestRenderDirectoryListingSortLinks(t *testing.T) {
fs := makeListingFS(t, nil)
html := renderListing(t, fs, "/", ListingSortOptions{Field: listingSortByDate, Order: listingSortAsc}, Listing{ShowPath: true})
if !strings.Contains(html, `href="?C=N&amp;O=A"`) {
t.Fatal("expected name sort link")
}
if !strings.Contains(html, `href="?C=M&amp;O=D"`) {
t.Fatal("expected toggled date sort link")
}
if !strings.Contains(html, `href="?C=S&amp;O=A"`) {
t.Fatal("expected size sort link")
}
}
func TestRenderDirectoryListingEscapingAndLinks(t *testing.T) {
fs := makeListingFS(t, map[string]string{
"a b&c<d>.txt": "x",
"mydir/": "",
})
html := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true})
if !strings.Contains(html, `title="a b&amp;c&lt;d&gt;.txt">a b&amp;c&lt;d&gt;.txt</a>`) {
t.Fatal("expected escaped display name")
}
if !strings.Contains(html, `href="a%20b&amp;c%3Cd%3E.txt"`) {
t.Fatal("expected url-escaped href")
}
if !strings.Contains(html, `href="mydir/"`) {
t.Fatal("expected trailing slash for directory links")
}
}
func TestRenderDirectoryListingHostileNames(t *testing.T) {
fs := makeListingFS(t, map[string]string{
"javascript:alert(window.location)": "x",
"javascript:alert(document.cookie)/": "",
"<script>alert(1)<": "x",
`"><img src=x onerror=alert(1)>`: "x",
"?C=N&O=A#frag": "x",
})
html := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true})
t.Run("URL schemes", func(t *testing.T) {
require.NotContains(t, html, `href="javascript:`)
require.Contains(t, html, `href="./javascript:alert%28window.location%29"`)
require.Contains(t, html, `href="./javascript:alert%28document.cookie%29/"`)
})
t.Run("Markup", func(t *testing.T) {
require.NotContains(t, html, "<script>")
require.NotContains(t, html, "<img")
require.Contains(t, html, `title="&lt;script&gt;alert(1)&lt;">&lt;script&gt;alert(1)&lt;</a>`)
require.Contains(t, html, `title="&#34;&gt;&lt;img src=x onerror=alert(1)&gt;"`)
})
t.Run("Query and fragment", func(t *testing.T) {
require.Contains(t, html, `href="%3FC=N&amp;O=A%23frag"`)
})
}
func TestRenderDirectoryListingEscapesPath(t *testing.T) {
fs := makeListingFS(t, map[string]string{
"<b>dir/": "",
})
html := renderListing(t, fs, "/<b>dir", sortByNameAsc, Listing{ShowPath: true})
require.NotContains(t, html, "<b>")
require.Contains(t, html, "<title>Index of /&lt;b&gt;dir/</title>")
require.Contains(t, html, "<h1>Index of /&lt;b&gt;dir/</h1>")
}
func TestRenderDirectoryListingParentDirectory(t *testing.T) {
fs := makeListingFS(t, map[string]string{
"child/": "",
})
rootHTML := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true})
if strings.Contains(rootHTML, `href="../"`) {
t.Fatal("root should not contain parent link")
}
childHTML := renderListing(t, fs, "/child", sortByNameAsc, Listing{ShowPath: true})
if !strings.Contains(childHTML, `href="../"`) {
t.Fatal("non-root should contain parent link")
}
if !strings.Contains(childHTML, `<tbody><tr><td colspan="2" class="link"><a href="../" title="..">../</a></td>`) {
t.Fatal("parent link should use fancyindex-style link cell")
}
hiddenChildHTML := renderListing(t, fs, "/child", sortByNameAsc, Listing{HideParentDir: true, ShowPath: true})
if strings.Contains(hiddenChildHTML, `href="../"`) {
t.Fatal("non-root should not contain parent link when hideParentDir is true")
}
}
func TestRenderDirectoryListingShowPath(t *testing.T) {
fs := makeListingFS(t, nil)
shown := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true})
if !strings.Contains(shown, "<h1>Index of") {
t.Fatal("expected default title when showPath is true")
}
hidden := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: false})
if strings.Contains(hidden, "<h1>Index of") {
t.Fatal("expected no title when showPath is false and no custom header is set")
}
withHeader := renderListing(t, fs, "/", sortByNameAsc, Listing{
Header: "<!DOCTYPE html><html><head></head><body><h1>Custom</h1>",
Footer: "</body></html>",
ShowPath: false,
})
if !strings.Contains(withHeader, "<h1>Custom</h1>") {
t.Fatal("custom header should still render regardless of showPath")
}
}
func TestRenderDirectoryListingSortByName(t *testing.T) {
fs := makeListingFS(t, map[string]string{
"zebra.txt": "z",
"apple.txt": "a",
"banana.txt": "b",
})
html := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true})
apple := strings.Index(html, "apple.txt")
banana := strings.Index(html, "banana.txt")
zebra := strings.Index(html, "zebra.txt")
require.Less(t, apple, banana, "expected alphabetical order")
require.Less(t, banana, zebra, "expected alphabetical order")
}
func TestRenderDirectoryListingFancyindexClasses(t *testing.T) {
fs := makeListingFS(t, map[string]string{
"file.txt": "content",
})
html := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true})
if !strings.Contains(html, `<th colspan="2"><a href="?C=N&amp;O=D">File Name ↑</a></th>`) {
t.Fatal("expected filename header to span two columns")
}
if !strings.Contains(html, `<th class="size"><a href="?C=S&amp;O=A">File Size</a></th>`) {
t.Fatal("expected size header class")
}
if !strings.Contains(html, `<th class="date"><a href="?C=M&amp;O=A">Date</a></th>`) {
t.Fatal("expected date header class")
}
if !strings.Contains(html, `<td colspan="2" class="link"><a href="file.txt" title="file.txt">file.txt</a></td>`) {
t.Fatal("expected file link cell to use fancyindex-style class")
}
if !strings.Contains(html, `<td class="size">7 B</td>`) {
t.Fatal("expected size cell class")
}
if !strings.Contains(html, `<td class="date">`) {
t.Fatal("expected date cell class")
}
if strings.Contains(html, `td class="name"`) || strings.Contains(html, `a class="link"`) {
t.Fatal("expected old listing classes to be removed")
}
}
func TestSortFileEntriesByDateAndSize(t *testing.T) {
now := time.Now()
files := []FileEntry{
{Name: "a", Size: 10, ModTime: now.Add(-time.Hour)},
{Name: "b", Size: 30, ModTime: now.Add(-3 * time.Hour)},
{Name: "c", Size: 20, ModTime: now.Add(-2 * time.Hour)},
}
sortFileEntries(files, ListingSortOptions{Field: listingSortByDate, Order: listingSortAsc})
if files[0].Name != "b" || files[2].Name != "a" {
t.Fatal("expected ascending date order")
}
sortFileEntries(files, ListingSortOptions{Field: listingSortByDate, Order: listingSortDesc})
if files[0].Name != "a" || files[2].Name != "b" {
t.Fatal("expected descending date order")
}
sortFileEntries(files, ListingSortOptions{Field: listingSortBySize, Order: listingSortAsc})
if files[0].Name != "a" || files[2].Name != "b" {
t.Fatal("expected ascending size order")
}
sortFileEntries(files, ListingSortOptions{Field: listingSortBySize, Order: listingSortDesc})
if files[0].Name != "b" || files[2].Name != "a" {
t.Fatal("expected descending size order")
}
}
func TestSortFileEntriesDirectoriesFirst(t *testing.T) {
now := time.Now()
files := []FileEntry{
{Name: "b-file", IsDir: false, Size: 30, ModTime: now.Add(-3 * time.Hour)},
{Name: "a-dir", IsDir: true, Size: 0, ModTime: now.Add(-time.Hour)},
{Name: "c-file", IsDir: false, Size: 10, ModTime: now.Add(-2 * time.Hour)},
{Name: "z-dir", IsDir: true, Size: 0, ModTime: now.Add(-4 * time.Hour)},
}
combos := []ListingSortOptions{
{Field: listingSortByName, Order: listingSortAsc},
{Field: listingSortByName, Order: listingSortDesc},
{Field: listingSortByDate, Order: listingSortAsc},
{Field: listingSortByDate, Order: listingSortDesc},
{Field: listingSortBySize, Order: listingSortAsc},
{Field: listingSortBySize, Order: listingSortDesc},
}
for _, sorting := range combos {
entries := append([]FileEntry(nil), files...)
sortFileEntries(entries, sorting)
if !entries[0].IsDir || !entries[1].IsDir {
t.Fatalf("expected directories first for %+v, got %+v", sorting, entries)
}
if entries[2].IsDir || entries[3].IsDir {
t.Fatalf("expected files after directories for %+v, got %+v", sorting, entries)
}
}
}
func TestFormatSize(t *testing.T) {
tests := []struct {
size int64
expected string
}{
{0, "0 B"},
{1, "1 B"},
{1023, "1023 B"},
{1024, "1.0 KiB"},
{1536, "1.5 KiB"},
{1024 * 1024, "1.0 MiB"},
}
for _, tc := range tests {
if got := formatSize(tc.size); got != tc.expected {
t.Fatalf("formatSize(%d)=%q want %q", tc.size, got, tc.expected)
}
}
}