diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7e85d34 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Embedded templates must render identically on every platform. +*.html text eol=lf diff --git a/README.md b/README.md index ea3d348..64a1bbf 100644 --- a/README.md +++ b/README.md @@ -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 " title. Ignored with a custom header. Default is 'true'. + show_path: true + # Custom HTML replacing the page shell: header up to and including , + # footer from . Both must be set, inline or from a file. + # header: "WebDAV" + # header_file: /etc/webdav/header.html + # footer: "" + # 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. diff --git a/lib/config.go b/lib/config.go index 4b2da91..4e2773b 100644 --- a/lib/config.go +++ b/lib/config.go @@ -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, , , open/close) instead of +// being inserted as fragments inside a default shell: Header must contain +// the full opening, up to and including , and Footer must contain the +// full closing, i.e. . 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 +} diff --git a/lib/config_test.go b/lib/config_test.go index 4853e83..b0c1298 100644 --- a/lib/config_test.go +++ b/lib/config_test.go @@ -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: "" +`, ".yml", "header and footer must both be set") + + writeAndParseConfigWithError(t, ` +listing: + footer: "" +`, ".yml", "header and footer must both be set") + + cfg := writeAndParseConfig(t, ` +listing: + header: "" + footer: "" +`, ".yml") + require.NoError(t, cfg.Validate()) +} + +func TestConfigListingEnvOverrides(t *testing.T) { + t.Setenv("WD_LISTING_HIDE_PARENT_DIR", "true") + t.Setenv("WD_LISTING_HEADER", "") + t.Setenv("WD_LISTING_FOOTER", "") + + cfg := writeAndParseConfig(t, "", ".yml") + require.NoError(t, cfg.Validate()) + + require.True(t, cfg.Listing.HideParentDir) + require.Equal(t, "", cfg.Listing.Header) + require.Equal(t, "", cfg.Listing.Footer) +} + func TestConfigCascade(t *testing.T) { t.Parallel() diff --git a/lib/handler.go b/lib/handler.go index dd9d32e..90f4341 100644 --- a/lib/handler.go +++ b/lib/handler.go @@ -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") == "" { diff --git a/lib/handler_test.go b/lib/handler_test.go index b2847af..ed39379 100644 --- a/lib/handler_test.go +++ b/lib/handler_test.go @@ -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, "") + require.Contains(t, bodyStr, "file1.txt") + require.Contains(t, bodyStr, "file2.txt") + require.Contains(t, bodyStr, "subdir") + require.Contains(t, bodyStr, ``) +} + +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 := "

Welcome to my WebDAV Server

" + customFooter := "" + 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 := "" + customFooter := "

Copyright 2026 - My Company

" + 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) +} diff --git a/lib/listing.go b/lib/listing.go new file mode 100644 index 0000000..97e9e56 --- /dev/null +++ b/lib/listing.go @@ -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=&O= 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]) +} diff --git a/lib/listing.html b/lib/listing.html new file mode 100644 index 0000000..5aea44a --- /dev/null +++ b/lib/listing.html @@ -0,0 +1,24 @@ +{{- if .Header}}{{.Header}}{{else}} + + + +Index of {{.Path}} + + +{{end}} +{{- if and .ShowPath (not .Header)}}

Index of {{.Path}}

{{end}} +
+ +{{- if .ShowParent}} +{{end}} +{{- range .Entries}} +{{end -}} + +
{{.NameColumn.Label}}{{.SizeColumn.Label}}{{.DateColumn.Label}}
--
{{.Size}}{{.ModTime}}
+{{- if .Footer}}{{.Footer}}{{else}} +{{end -}} diff --git a/lib/listing_test.go b/lib/listing_test.go new file mode 100644 index 0000000..a0d6dbd --- /dev/null +++ b/lib/listing_test.go @@ -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, "") || !strings.Contains(html, ``) { + t.Fatal("expected basic html table output") + } + if !strings.Contains(html, "Index of /") || !strings.Contains(html, "

Index of /

") { + t.Fatal("expected index title and heading") + } + if !strings.Contains(html, `subdir/`) { + t.Fatal("expected directory entry") + } + if !strings.Contains(html, `file1.txt`) { + t.Fatal("expected file entry") + } + if !strings.HasSuffix(html, "\n
\n") { + t.Fatal("expected default document closing") + } +} + +func TestRenderDirectoryListingHeaderFooter(t *testing.T) { + fs := makeListingFS(t, nil) + html := renderListing(t, fs, "/", sortByNameAsc, Listing{ + Header: "Custom

HEADER

", + Footer: "

FOOTER

", + ShowPath: true, + }) + + if !strings.HasPrefix(html, "Custom

HEADER

") { + t.Fatal("expected header to be rendered unescaped at the start") + } + if !strings.HasSuffix(html, "

FOOTER

") { + t.Fatal("expected footer to be rendered unescaped at the end") + } + if strings.Count(html, "") != 1 || strings.Count(html, "") != 1 { + t.Fatal("expected exactly one html open/close tag when header/footer own the shell") + } + if strings.Count(html, "") != 1 || strings.Count(html, "") != 1 { + t.Fatal("expected exactly one body open/close tag when header/footer own the shell") + } + if strings.Contains(html, ``) { + 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&O=A"`) { + t.Fatal("expected name sort link") + } + if !strings.Contains(html, `href="?C=M&O=D"`) { + t.Fatal("expected toggled date sort link") + } + if !strings.Contains(html, `href="?C=S&O=A"`) { + t.Fatal("expected size sort link") + } +} + +func TestRenderDirectoryListingEscapingAndLinks(t *testing.T) { + fs := makeListingFS(t, map[string]string{ + "a b&c.txt": "x", + "mydir/": "", + }) + + html := renderListing(t, fs, "/", sortByNameAsc, Listing{ShowPath: true}) + + if !strings.Contains(html, `title="a b&c<d>.txt">a b&c<d>.txt`) { + t.Fatal("expected escaped display name") + } + if !strings.Contains(html, `href="a%20b&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)/": "", + "