diff --git a/cmd/serve/http/http.go b/cmd/serve/http/http.go index 0360336dc..6dcd40f73 100644 --- a/cmd/serve/http/http.go +++ b/cmd/serve/http/http.go @@ -131,9 +131,13 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, dirRemote stri // Make the entries for display directory := serve.NewDirectory(dirRemote, s.HTMLTemplate) for _, node := range dirEntries { - directory.AddEntry(node.Path(), node.IsDir()) + directory.AddHTMLEntry(node.Path(), node.IsDir(), node.Size(), node.ModTime()) } + sortParm := r.URL.Query().Get("sort") + orderParm := r.URL.Query().Get("order") + directory.ProcessQueryParams(sortParm, orderParm) + directory.Serve(w, r) } diff --git a/cmd/serve/http/testdata/golden/index.html b/cmd/serve/http/testdata/golden/index.html index 8afb9b697..2ddb71e39 100644 --- a/cmd/serve/http/testdata/golden/index.html +++ b/cmd/serve/http/testdata/golden/index.html @@ -6,8 +6,8 @@

Directory listing of /

-one%.txt
three/
+one%.txt
two.txt
diff --git a/cmd/serve/httplib/httpflags/httpflags.go b/cmd/serve/httplib/httpflags/httpflags.go index 49f672548..073715504 100644 --- a/cmd/serve/httplib/httpflags/httpflags.go +++ b/cmd/serve/httplib/httpflags/httpflags.go @@ -27,6 +27,7 @@ func AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string, Opt *httplib.Options) flags.StringVarP(flagSet, &Opt.BasicUser, prefix+"user", "", Opt.BasicUser, "User name for authentication.") flags.StringVarP(flagSet, &Opt.BasicPass, prefix+"pass", "", Opt.BasicPass, "Password for authentication.") flags.StringVarP(flagSet, &Opt.BaseURL, prefix+"baseurl", "", Opt.BaseURL, "Prefix for URLs - leave blank for root.") + flags.StringVarP(flagSet, &Opt.Template, prefix+"template", "", Opt.Template, "User Specified Template.") } diff --git a/cmd/serve/httplib/httplib.go b/cmd/serve/httplib/httplib.go index d2839fa81..26d195043 100644 --- a/cmd/serve/httplib/httplib.go +++ b/cmd/serve/httplib/httplib.go @@ -52,6 +52,27 @@ inserts leading and trailing "/" on --baseurl, so --baseurl "rclone", --baseurl "/rclone" and --baseurl "/rclone/" are all treated identically. +--template allows a user to specify a custom markup template for http +and webdav serve functions. The server exports the following markup +to be used within the template to server pages: +.Name The full path of a file/directory. +.Title "Directory listing of .Name". +.Sort The current sort used. This is changble via ?sort= parameter + Sort Options: namedirfist,name,size,time (defailt namedirfirst) +.Order The current ordering used. This is changable via ?order= paramter + Order Options: asc,desc (default asc) +.Query Currently unused. +.Breacrumb Allows for creating a relative navigation +-- .Link The relative to the root link of the Text. +-- .Text The Name of the directory. +.Entries Information about a specific file/directory. +-- .URL The 'url' of an entry. +-- .Leaf Currently same as 'URL' but intended to be 'just' the name. +-- .IsDir Boolean for if an entry is a directory or not. +-- .Size Size in Bytes of the entry. +-- .ModTime The UTC timestamp of an entry. + + #### Authentication By default this will serve files without needing a login. @@ -101,6 +122,7 @@ type Options struct { BasicUser string // single username for basic auth if not using Htpasswd BasicPass string // password for BasicUser Auth AuthFn `json:"-"` // custom Auth (not set by command line flags) + Template string // User specified template } // AuthFn if used will be used to authenticate user, pass. If an error @@ -281,7 +303,7 @@ func NewServer(handler http.Handler, opt *Options) *Server { s.httpServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert } - htmlTemplate, templateErr := data.GetTemplate() + htmlTemplate, templateErr := data.GetTemplate(s.Opt.Template) if templateErr != nil { log.Fatalf(templateErr.Error()) } diff --git a/cmd/serve/httplib/serve/data/assets_vfsdata.go b/cmd/serve/httplib/serve/data/assets_vfsdata.go index 88d14efdd..cb8723368 100644 --- a/cmd/serve/httplib/serve/data/assets_vfsdata.go +++ b/cmd/serve/httplib/serve/data/assets_vfsdata.go @@ -21,11 +21,11 @@ var Assets = func() http.FileSystem { fs := vfsgen۰FS{ "/": &vfsgen۰DirInfo{ name: "/", - modTime: time.Date(2018, 12, 16, 6, 54, 42, 894445775, time.UTC), + modTime: time.Date(2020, 5, 4, 15, 36, 2, 723307530, time.UTC), }, "/index.html": &vfsgen۰CompressedFileInfo{ name: "index.html", - modTime: time.Date(2018, 12, 16, 6, 54, 42, 790442328, time.UTC), + modTime: time.Date(2020, 5, 4, 15, 36, 2, 527302371, time.UTC), uncompressedSize: 226, compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x5c\x8f\x31\xcf\x83\x20\x10\x86\x77\x7e\xc5\x7d\xc4\xf5\x93\xb8\x35\x0d\xb0\xb4\x6e\x26\x6d\x1a\x3b\x74\x3c\xeb\x29\x24\x4a\x13\xa4\x43\x43\xf8\xef\x0d\xea\xd4\x09\xee\x79\xef\x9e\xcb\xc9\xbf\xf3\xe5\xd4\x3e\xae\x35\x98\x30\x4f\x9a\xc9\xfc\xc0\x84\x6e\x54\x9c\x1c\xcf\x80\xb0\xd7\x4c\xce\x14\x10\x9e\x06\xfd\x42\x41\xf1\x77\x18\xfe\x0f\x39\x0d\x36\x4c\xa4\x63\x84\xb2\xcd\x3f\x48\x49\x8a\x8d\x31\x29\xf6\xd1\xee\xd5\x7f\xb2\xa8\xfa\xe9\x33\x95\x66\x31\x82\x47\x37\x12\x14\x16\x8e\x0a\xca\xda\x05\x6f\x69\xc9\x39\x82\xf1\x34\x28\x1e\x23\x14\xb6\xbc\xdf\x1a\x48\x89\xeb\xad\x6a\x08\x87\xd5\x81\x5a\x76\x1e\xc4\x2a\x22\xd7\xaf\x6c\xdf\x27\xb6\x8b\xbe\x01\x00\x00\xff\xff\x92\x2e\x35\x75\xe2\x00\x00\x00"), diff --git a/cmd/serve/httplib/serve/data/data.go b/cmd/serve/httplib/serve/data/data.go index d34fb2963..e976c58cb 100644 --- a/cmd/serve/httplib/serve/data/data.go +++ b/cmd/serve/httplib/serve/data/data.go @@ -11,22 +11,33 @@ import ( "github.com/rclone/rclone/fs" ) -// GetTemplate returns the HTML template for serving directories via HTTP -func GetTemplate() (tpl *template.Template, err error) { - templateFile, err := Assets.Open("index.html") - if err != nil { - return nil, errors.Wrap(err, "get template open") +// GetTemplate returns the HTML template for serving directories via HTTP/Webdav +func GetTemplate(tmpl string) (tpl *template.Template, err error) { + var templateString string + if tmpl == "" { + templateFile, err := Assets.Open("index.html") + if err != nil { + return nil, errors.Wrap(err, "get template open") + } + + defer fs.CheckClose(templateFile, &err) + + templateBytes, err := ioutil.ReadAll(templateFile) + if err != nil { + return nil, errors.Wrap(err, "get template read") + } + + templateString = string(templateBytes) + + } else { + templateFile, err := ioutil.ReadFile(tmpl) + if err != nil { + return nil, errors.Wrap(err, "get template open") + } + + templateString = string(templateFile) } - defer fs.CheckClose(templateFile, &err) - - templateBytes, err := ioutil.ReadAll(templateFile) - if err != nil { - return nil, errors.Wrap(err, "get template read") - } - - var templateString = string(templateBytes) - tpl, err = template.New("index").Parse(templateString) if err != nil { return nil, errors.Wrap(err, "get template parse") diff --git a/cmd/serve/httplib/serve/dir.go b/cmd/serve/httplib/serve/dir.go index 601fc0152..c0801145f 100644 --- a/cmd/serve/httplib/serve/dir.go +++ b/cmd/serve/httplib/serve/dir.go @@ -7,6 +7,9 @@ import ( "net/http" "net/url" "path" + "sort" + "strings" + "time" "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs/accounting" @@ -15,26 +18,59 @@ import ( // DirEntry is a directory entry type DirEntry struct { - remote string - URL string - Leaf string + remote string + URL string + Leaf string + IsDir bool + Size int64 + ModTime time.Time } // Directory represents a directory type Directory struct { DirRemote string Title string + Name string Entries []DirEntry Query string HTMLTemplate *template.Template + Breadcrumb []Crumb + Sort string + Order string +} + +// Crumb is a breadcrumb entry +type Crumb struct { + Link string + Text string } // NewDirectory makes an empty Directory func NewDirectory(dirRemote string, htmlTemplate *template.Template) *Directory { + var breadcrumb []Crumb + + // skip trailing slash + lpath := "/" + dirRemote + if lpath[len(lpath)-1] == '/' { + lpath = lpath[:len(lpath)-1] + } + + parts := strings.Split(lpath, "/") + for i := range parts { + txt := parts[i] + if i == 0 && parts[i] == "" { + txt = "/" + } + lnk := strings.Repeat("../", len(parts)-i-1) + breadcrumb = append(breadcrumb, Crumb{Link: lnk, Text: txt}) + } + d := &Directory{ DirRemote: dirRemote, Title: fmt.Sprintf("Directory listing of /%s", dirRemote), + Name: fmt.Sprintf("/%s", dirRemote), HTMLTemplate: htmlTemplate, + Breadcrumb: breadcrumb, } return d } @@ -48,6 +84,27 @@ func (d *Directory) SetQuery(queryParams url.Values) *Directory { return d } +// AddHTMLEntry adds an entry to that directory +func (d *Directory) AddHTMLEntry(remote string, isDir bool, size int64, modTime time.Time) { + leaf := path.Base(remote) + if leaf == "." { + leaf = "" + } + urlRemote := leaf + if isDir { + leaf += "/" + urlRemote += "/" + } + d.Entries = append(d.Entries, DirEntry{ + remote: remote, + URL: rest.URLPathEscape(urlRemote) + d.Query, + Leaf: leaf, + IsDir: isDir, + Size: size, + ModTime: modTime, + }) +} + // AddEntry adds an entry to that directory func (d *Directory) AddEntry(remote string, isDir bool) { leaf := path.Base(remote) @@ -75,6 +132,95 @@ func Error(what interface{}, w http.ResponseWriter, text string, err error) { } } +// ProcessQueryParams takes and sorts/orders based on the request sort/order parameters and defailt is namedirfist/asc +func (d *Directory) ProcessQueryParams(sortParm string, orderParm string) *Directory { + d.Sort = sortParm + d.Order = orderParm + + var toSort sort.Interface + + switch d.Sort { + case sortByName: + toSort = byName(*d) + case sortByNameDirFirst: + toSort = byNameDirFirst(*d) + case sortBySize: + toSort = bySize(*d) + case sortByTime: + toSort = byTime(*d) + default: + toSort = byNameDirFirst(*d) + } + if d.Order == "desc" && toSort != nil { + toSort = sort.Reverse(toSort) + } + if toSort != nil { + sort.Sort(toSort) + } + + return d + +} + +type byName Directory +type byNameDirFirst Directory +type bySize Directory +type byTime Directory + +func (d byName) Len() int { return len(d.Entries) } +func (d byName) Swap(i, j int) { d.Entries[i], d.Entries[j] = d.Entries[j], d.Entries[i] } + +func (d byName) Less(i, j int) bool { + return strings.ToLower(d.Entries[i].Leaf) < strings.ToLower(d.Entries[j].Leaf) +} + +func (d byNameDirFirst) Len() int { return len(d.Entries) } +func (d byNameDirFirst) Swap(i, j int) { d.Entries[i], d.Entries[j] = d.Entries[j], d.Entries[i] } + +func (d byNameDirFirst) Less(i, j int) bool { + // sort by name if both are dir or file + if d.Entries[i].IsDir == d.Entries[j].IsDir { + return strings.ToLower(d.Entries[i].Leaf) < strings.ToLower(d.Entries[j].Leaf) + } + // sort dir ahead of file + return d.Entries[i].IsDir +} + +func (d bySize) Len() int { return len(d.Entries) } +func (d bySize) Swap(i, j int) { d.Entries[i], d.Entries[j] = d.Entries[j], d.Entries[i] } + +func (d bySize) Less(i, j int) bool { + const directoryOffset = -1 << 31 // = -math.MinInt32 + + iSize, jSize := d.Entries[i].Size, d.Entries[j].Size + + // directory sizes depend on the file system; to + // provide a consistent experience, put them up front + // and sort them by name + if d.Entries[i].IsDir { + iSize = directoryOffset + } + if d.Entries[j].IsDir { + jSize = directoryOffset + } + if d.Entries[i].IsDir && d.Entries[j].IsDir { + return strings.ToLower(d.Entries[i].Leaf) < strings.ToLower(d.Entries[j].Leaf) + } + + return iSize < jSize +} + +func (d byTime) Len() int { return len(d.Entries) } +func (d byTime) Swap(i, j int) { d.Entries[i], d.Entries[j] = d.Entries[j], d.Entries[i] } +func (d byTime) Less(i, j int) bool { return d.Entries[i].ModTime.Before(d.Entries[j].ModTime) } + +const ( + sortByName = "name" + sortByNameDirFirst = "namedirfirst" + sortBySize = "size" + sortByTime = "time" +) + // Serve serves a directory func (d *Directory) Serve(w http.ResponseWriter, r *http.Request) { // Account the transfer diff --git a/cmd/serve/httplib/serve/dir_test.go b/cmd/serve/httplib/serve/dir_test.go index 87a8fbd07..cb49c1074 100644 --- a/cmd/serve/httplib/serve/dir_test.go +++ b/cmd/serve/httplib/serve/dir_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/rclone/rclone/cmd/serve/httplib/serve/data" "github.com/stretchr/testify/assert" @@ -15,7 +16,7 @@ import ( ) func GetTemplate(t *testing.T) *template.Template { - htmlTemplate, err := data.GetTemplate() + htmlTemplate, err := data.GetTemplate("") require.NoError(t, err) return htmlTemplate } @@ -35,6 +36,32 @@ func TestSetQuery(t *testing.T) { assert.Equal(t, "", d.Query) } +func TestAddHTMLEntry(t *testing.T) { + var modtime = time.Now() + var d = NewDirectory("z", GetTemplate(t)) + d.AddHTMLEntry("", true, 0, modtime) + d.AddHTMLEntry("dir", true, 0, modtime) + d.AddHTMLEntry("a/b/c/d.txt", false, 64, modtime) + d.AddHTMLEntry("a/b/c/colon:colon.txt", false, 64, modtime) + d.AddHTMLEntry("\"quotes\".txt", false, 64, modtime) + assert.Equal(t, []DirEntry{ + {remote: "", URL: "/", Leaf: "/", IsDir: true, Size: 0, ModTime: modtime}, + {remote: "dir", URL: "dir/", Leaf: "dir/", IsDir: true, Size: 0, ModTime: modtime}, + {remote: "a/b/c/d.txt", URL: "d.txt", Leaf: "d.txt", IsDir: false, Size: 64, ModTime: modtime}, + {remote: "a/b/c/colon:colon.txt", URL: "./colon:colon.txt", Leaf: "colon:colon.txt", IsDir: false, Size: 64, ModTime: modtime}, + {remote: "\"quotes\".txt", URL: "%22quotes%22.txt", Leaf: "\"quotes\".txt", Size: 64, IsDir: false, ModTime: modtime}, + }, d.Entries) + + // Now test with a query parameter + d = NewDirectory("z", GetTemplate(t)).SetQuery(url.Values{"potato": []string{"42"}}) + d.AddHTMLEntry("file", false, 64, modtime) + d.AddHTMLEntry("dir", true, 0, modtime) + assert.Equal(t, []DirEntry{ + {remote: "file", URL: "file?potato=42", Leaf: "file", IsDir: false, Size: 64, ModTime: modtime}, + {remote: "dir", URL: "dir/?potato=42", Leaf: "dir/", IsDir: true, Size: 0, ModTime: modtime}, + }, d.Entries) +} + func TestAddEntry(t *testing.T) { var d = NewDirectory("z", GetTemplate(t)) d.AddEntry("", true) diff --git a/cmd/serve/webdav/testdata/golden/index.html b/cmd/serve/webdav/testdata/golden/index.html index 8afb9b697..2ddb71e39 100644 --- a/cmd/serve/webdav/testdata/golden/index.html +++ b/cmd/serve/webdav/testdata/golden/index.html @@ -6,8 +6,8 @@

Directory listing of /

-one%.txt
three/
+one%.txt
two.txt
diff --git a/cmd/serve/webdav/webdav.go b/cmd/serve/webdav/webdav.go index c6cdf244e..5d7af1e05 100644 --- a/cmd/serve/webdav/webdav.go +++ b/cmd/serve/webdav/webdav.go @@ -204,6 +204,7 @@ func (w *WebDAV) serveDir(rw http.ResponseWriter, r *http.Request, dirRemote str } dir := node.(*vfs.Dir) dirEntries, err := dir.ReadDirAll() + if err != nil { serve.Error(dirRemote, rw, "Failed to list directory", err) return @@ -212,9 +213,13 @@ func (w *WebDAV) serveDir(rw http.ResponseWriter, r *http.Request, dirRemote str // Make the entries for display directory := serve.NewDirectory(dirRemote, w.HTMLTemplate) for _, node := range dirEntries { - directory.AddEntry(node.Path(), node.IsDir()) + directory.AddHTMLEntry(node.Path(), node.IsDir(), node.Size(), node.ModTime()) } + sortParm := r.URL.Query().Get("sort") + orderParm := r.URL.Query().Get("order") + directory.ProcessQueryParams(sortParm, orderParm) + directory.Serve(rw, r) }