mirror of
https://github.com/hacdias/webdav.git
synced 2026-10-19 16:15:39 +00:00
300 lines
7.0 KiB
Go
300 lines
7.0 KiB
Go
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])
|
|
}
|