mirror of
https://github.com/hacdias/webdav.git
synced 2026-10-19 16:15:39 +00:00
feat: 207 multi-status with HTML directory listing (#354)
Co-authored-by: Henrique Dias <mail@hacdias.com>
This commit is contained in:
co-authored by
Henrique Dias
parent
83463594ba
commit
67f345369f
@@ -0,0 +1,2 @@
|
||||
# Embedded templates must render identically on every platform.
|
||||
*.html text eol=lf
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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") == "" {
|
||||
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
@@ -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 -}}
|
||||
@@ -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&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<d>.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</a>`) {
|
||||
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)/": "",
|
||||
"<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="<script>alert(1)<"><script>alert(1)<</a>`)
|
||||
require.Contains(t, html, `title=""><img src=x onerror=alert(1)>"`)
|
||||
})
|
||||
|
||||
t.Run("Query and fragment", func(t *testing.T) {
|
||||
require.Contains(t, html, `href="%3FC=N&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 /<b>dir/</title>")
|
||||
require.Contains(t, html, "<h1>Index of /<b>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&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&O=A">File Size</a></th>`) {
|
||||
t.Fatal("expected size header class")
|
||||
}
|
||||
if !strings.Contains(html, `<th class="date"><a href="?C=M&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user