1
mirror of https://github.com/rclone/rclone synced 2024-11-29 07:55:12 +01:00

serve http, serve webdav: Added a --template flag for user defined markup

This commit is contained in:
calistri 2020-04-30 14:24:11 -04:00 committed by Nick Craig-Wood
parent dcf945ed58
commit 4362ca7bb9
10 changed files with 241 additions and 25 deletions

View File

@ -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)
}

View File

@ -6,8 +6,8 @@
</head>
<body>
<h1>Directory listing of /</h1>
<a href="one%25.txt">one%.txt</a><br />
<a href="three/">three/</a><br />
<a href="one%25.txt">one%.txt</a><br />
<a href="two.txt">two.txt</a><br />
</body>
</html>

View File

@ -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.")
}

View File

@ -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())
}

View File

@ -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"),

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -6,8 +6,8 @@
</head>
<body>
<h1>Directory listing of /</h1>
<a href="one%25.txt">one%.txt</a><br />
<a href="three/">three/</a><br />
<a href="one%25.txt">one%.txt</a><br />
<a href="two.txt">two.txt</a><br />
</body>
</html>

View File

@ -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)
}