mirror of
https://github.com/caddyserver/caddy.git
synced 2024-12-30 22:34:15 -05:00
ef95173827
As discussed with @mholt I have dropped the old LinkedPath function and replaced it within the browse template with the new BreadcrumbMap function. Visually it looks exactly the same as before, now the template functionality is just more powerful. Signed-off-by: Thomas Boerger <tboerger@suse.de>
363 lines
9.5 KiB
Go
363 lines
9.5 KiB
Go
// Package browse provides middleware for listing files in a directory
|
|
// when directory path is requested instead of a specific file.
|
|
package browse
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/mholt/caddy/middleware"
|
|
)
|
|
|
|
// Browse is an http.Handler that can show a file listing when
|
|
// directories in the given paths are specified.
|
|
type Browse struct {
|
|
Next middleware.Handler
|
|
Root string
|
|
Configs []Config
|
|
IgnoreIndexes bool
|
|
}
|
|
|
|
// Config is a configuration for browsing in a particular path.
|
|
type Config struct {
|
|
PathScope string
|
|
Variables interface{}
|
|
Template *template.Template
|
|
}
|
|
|
|
// A Listing is the context used to fill out a template.
|
|
type Listing struct {
|
|
// The name of the directory (the last element of the path)
|
|
Name string
|
|
|
|
// The full path of the request
|
|
Path string
|
|
|
|
// Whether the parent directory is browsable
|
|
CanGoUp bool
|
|
|
|
// The items (files and folders) in the path
|
|
Items []FileInfo
|
|
|
|
// The number of directories in the listing
|
|
NumDirs int
|
|
|
|
// The number of files (items that aren't directories) in the listing
|
|
NumFiles int
|
|
|
|
// Which sorting order is used
|
|
Sort string
|
|
|
|
// And which order
|
|
Order string
|
|
|
|
// Optional custom variables for use in browse templates
|
|
User interface{}
|
|
|
|
middleware.Context
|
|
}
|
|
|
|
// BreadcrumbMap returns l.Path where every element is a map
|
|
// of URLs and path segment names.
|
|
func (l Listing) BreadcrumbMap() map[string]string {
|
|
result := map[string]string{}
|
|
|
|
if len(l.Path) == 0 {
|
|
return result
|
|
}
|
|
|
|
// skip trailing slash
|
|
lpath := l.Path
|
|
if lpath[len(lpath)-1] == '/' {
|
|
lpath = lpath[:len(lpath)-1]
|
|
}
|
|
|
|
parts := strings.Split(lpath, "/")
|
|
for i, part := range parts {
|
|
if i == 0 && part == "" {
|
|
// Leading slash (root)
|
|
result["/"] = "/"
|
|
continue
|
|
}
|
|
result[strings.Join(parts[:i+1], "/")] = part
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// FileInfo is the info about a particular file or directory
|
|
type FileInfo struct {
|
|
IsDir bool
|
|
Name string
|
|
Size int64
|
|
URL string
|
|
ModTime time.Time
|
|
Mode os.FileMode
|
|
}
|
|
|
|
// HumanSize returns the size of the file as a human-readable string
|
|
// in IEC format (i.e. power of 2 or base 1024).
|
|
func (fi FileInfo) HumanSize() string {
|
|
return humanize.IBytes(uint64(fi.Size))
|
|
}
|
|
|
|
// HumanModTime returns the modified time of the file as a human-readable string.
|
|
func (fi FileInfo) HumanModTime(format string) string {
|
|
return fi.ModTime.Format(format)
|
|
}
|
|
|
|
// Implement sorting for Listing
|
|
type byName Listing
|
|
type bySize Listing
|
|
type byTime Listing
|
|
|
|
// By Name
|
|
func (l byName) Len() int { return len(l.Items) }
|
|
func (l byName) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] }
|
|
|
|
// Treat upper and lower case equally
|
|
func (l byName) Less(i, j int) bool {
|
|
return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name)
|
|
}
|
|
|
|
// By Size
|
|
func (l bySize) Len() int { return len(l.Items) }
|
|
func (l bySize) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] }
|
|
func (l bySize) Less(i, j int) bool { return l.Items[i].Size < l.Items[j].Size }
|
|
|
|
// By Time
|
|
func (l byTime) Len() int { return len(l.Items) }
|
|
func (l byTime) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] }
|
|
func (l byTime) Less(i, j int) bool { return l.Items[i].ModTime.Before(l.Items[j].ModTime) }
|
|
|
|
// Add sorting method to "Listing"
|
|
// it will apply what's in ".Sort" and ".Order"
|
|
func (l Listing) applySort() {
|
|
// Check '.Order' to know how to sort
|
|
if l.Order == "desc" {
|
|
switch l.Sort {
|
|
case "name":
|
|
sort.Sort(sort.Reverse(byName(l)))
|
|
case "size":
|
|
sort.Sort(sort.Reverse(bySize(l)))
|
|
case "time":
|
|
sort.Sort(sort.Reverse(byTime(l)))
|
|
default:
|
|
// If not one of the above, do nothing
|
|
return
|
|
}
|
|
} else { // If we had more Orderings we could add them here
|
|
switch l.Sort {
|
|
case "name":
|
|
sort.Sort(byName(l))
|
|
case "size":
|
|
sort.Sort(bySize(l))
|
|
case "time":
|
|
sort.Sort(byTime(l))
|
|
default:
|
|
// If not one of the above, do nothing
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func directoryListing(files []os.FileInfo, r *http.Request, canGoUp bool, root string, ignoreIndexes bool, vars interface{}) (Listing, error) {
|
|
var fileinfos []FileInfo
|
|
var dirCount, fileCount int
|
|
var urlPath = r.URL.Path
|
|
|
|
for _, f := range files {
|
|
name := f.Name()
|
|
|
|
// Directory is not browsable if it contains index file
|
|
if !ignoreIndexes {
|
|
for _, indexName := range middleware.IndexPages {
|
|
if name == indexName {
|
|
return Listing{}, errors.New("Directory contains index file, not browsable!")
|
|
}
|
|
}
|
|
}
|
|
|
|
if f.IsDir() {
|
|
name += "/"
|
|
dirCount++
|
|
} else {
|
|
fileCount++
|
|
}
|
|
|
|
url := url.URL{Path: "./" + name} // prepend with "./" to fix paths with ':' in the name
|
|
|
|
fileinfos = append(fileinfos, FileInfo{
|
|
IsDir: f.IsDir(),
|
|
Name: f.Name(),
|
|
Size: f.Size(),
|
|
URL: url.String(),
|
|
ModTime: f.ModTime(),
|
|
Mode: f.Mode(),
|
|
})
|
|
}
|
|
|
|
return Listing{
|
|
Name: path.Base(urlPath),
|
|
Path: urlPath,
|
|
CanGoUp: canGoUp,
|
|
Items: fileinfos,
|
|
NumDirs: dirCount,
|
|
NumFiles: fileCount,
|
|
Context: middleware.Context{
|
|
Root: http.Dir(root),
|
|
Req: r,
|
|
URL: r.URL,
|
|
},
|
|
User: vars,
|
|
}, nil
|
|
}
|
|
|
|
// ServeHTTP implements the middleware.Handler interface.
|
|
func (b Browse) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) {
|
|
filename := b.Root + r.URL.Path
|
|
info, err := os.Stat(filename)
|
|
if err != nil {
|
|
return b.Next.ServeHTTP(w, r)
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
return b.Next.ServeHTTP(w, r)
|
|
}
|
|
|
|
// See if there's a browse configuration to match the path
|
|
for _, bc := range b.Configs {
|
|
if !middleware.Path(r.URL.Path).Matches(bc.PathScope) {
|
|
continue
|
|
}
|
|
|
|
// Browsing navigation gets messed up if browsing a directory
|
|
// that doesn't end in "/" (which it should, anyway)
|
|
if r.URL.Path[len(r.URL.Path)-1] != '/' {
|
|
http.Redirect(w, r, r.URL.Path+"/", http.StatusTemporaryRedirect)
|
|
return 0, nil
|
|
}
|
|
|
|
// Load directory contents
|
|
file, err := os.Open(b.Root + r.URL.Path)
|
|
if err != nil {
|
|
if os.IsPermission(err) {
|
|
return http.StatusForbidden, err
|
|
}
|
|
return http.StatusNotFound, err
|
|
}
|
|
defer file.Close()
|
|
|
|
files, err := file.Readdir(-1)
|
|
if err != nil {
|
|
return http.StatusForbidden, err
|
|
}
|
|
|
|
// Determine if user can browse up another folder
|
|
var canGoUp bool
|
|
curPath := strings.TrimSuffix(r.URL.Path, "/")
|
|
for _, other := range b.Configs {
|
|
if strings.HasPrefix(path.Dir(curPath), other.PathScope) {
|
|
canGoUp = true
|
|
break
|
|
}
|
|
}
|
|
// Assemble listing of directory contents
|
|
listing, err := directoryListing(files, r, canGoUp, b.Root, b.IgnoreIndexes, bc.Variables)
|
|
if err != nil { // directory isn't browsable
|
|
continue
|
|
}
|
|
|
|
// Get the query vales and store them in the Listing struct
|
|
listing.Sort, listing.Order = r.URL.Query().Get("sort"), r.URL.Query().Get("order")
|
|
|
|
// If the query 'sort' or 'order' is empty, check the cookies
|
|
if listing.Sort == "" {
|
|
sortCookie, sortErr := r.Cookie("sort")
|
|
// if there's no sorting values in the cookies, default to "name" and "asc"
|
|
if sortErr != nil {
|
|
listing.Sort = "name"
|
|
} else { // if we have values in the cookies, use them
|
|
listing.Sort = sortCookie.Value
|
|
}
|
|
} else { // save the query value of 'sort' and 'order' as cookies
|
|
http.SetCookie(w, &http.Cookie{Name: "sort", Value: listing.Sort, Path: "/"})
|
|
http.SetCookie(w, &http.Cookie{Name: "order", Value: listing.Order, Path: "/"})
|
|
}
|
|
|
|
if listing.Order == "" {
|
|
orderCookie, orderErr := r.Cookie("order")
|
|
// if there's no sorting values in the cookies, default to "name" and "asc"
|
|
if orderErr != nil {
|
|
listing.Order = "asc"
|
|
} else { // if we have values in the cookies, use them
|
|
listing.Order = orderCookie.Value
|
|
}
|
|
} else { // save the query value of 'sort' and 'order' as cookies
|
|
http.SetCookie(w, &http.Cookie{Name: "order", Value: listing.Order, Path: "/"})
|
|
}
|
|
|
|
// Apply the sorting
|
|
listing.applySort()
|
|
|
|
var buf bytes.Buffer
|
|
// check if we should provide json
|
|
acceptHeader := strings.Join(r.Header["Accept"], ",")
|
|
if strings.Contains(strings.ToLower(acceptHeader), "application/json") {
|
|
var marsh []byte
|
|
// check if we are limited
|
|
if limitQuery := r.URL.Query().Get("limit"); limitQuery != "" {
|
|
limit, err := strconv.Atoi(limitQuery)
|
|
if err != nil { // if the 'limit' query can't be interpreted as a number, return err
|
|
return http.StatusBadRequest, err
|
|
}
|
|
// if `limit` is equal or less than len(listing.Items) and bigger than 0, list them
|
|
if limit <= len(listing.Items) && limit > 0 {
|
|
marsh, err = json.Marshal(listing.Items[:limit])
|
|
} else { // if the 'limit' query is empty, or has the wrong value, list everything
|
|
marsh, err = json.Marshal(listing.Items)
|
|
}
|
|
if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
} else { // there's no 'limit' query, list them all
|
|
marsh, err = json.Marshal(listing.Items)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
}
|
|
|
|
// write the marshaled json to buf
|
|
if _, err = buf.Write(marsh); err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
} else { // there's no 'application/json' in the 'Accept' header, browse normally
|
|
err = bc.Template.Execute(&buf, listing)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
}
|
|
|
|
buf.WriteTo(w)
|
|
|
|
return http.StatusOK, nil
|
|
}
|
|
|
|
// Didn't qualify; pass-thru
|
|
return b.Next.ServeHTTP(w, r)
|
|
}
|