diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..63339e38 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# 4 space indentation +[*.go] +indent_style = tab +indent_size = 4 diff --git a/.gitignore b/.gitignore index 8e47e6a4..e976ef51 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -.old \ No newline at end of file +_embed \ No newline at end of file diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 00000000..ca2e8b73 --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,33 @@ +package assets + +import ( + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/hacdias/filemanager" +) + +// BaseURL is the url of the assets +const BaseURL = "/_filemanagerinternal" + +// Serve provides the needed assets for the front-end +func Serve(w http.ResponseWriter, r *http.Request, c *filemanager.Config) (int, error) { + // gets the filename to be used with Assets function + filename := strings.Replace(r.URL.Path, c.BaseURL+BaseURL, "public", 1) + file, err := Asset(filename) + if err != nil { + return http.StatusNotFound, nil + } + + // Get the file extension and its mimetype + extension := filepath.Ext(filename) + mediatype := mime.TypeByExtension(extension) + + // Write the header with the Content-Type and write the file + // content to the buffer + w.Header().Set("Content-Type", mediatype) + w.Write(file) + return 200, nil +} diff --git a/assets/binary.go.REMOVED.git-id b/assets/binary.go.REMOVED.git-id new file mode 100644 index 00000000..8fc30cca --- /dev/null +++ b/assets/binary.go.REMOVED.git-id @@ -0,0 +1 @@ +4c30378a214b5b33410a74961df51cbc21bd6122 \ No newline at end of file diff --git a/cmd/filemanager/main.go b/cmd/filemanager/main.go new file mode 100644 index 00000000..4a0b536e --- /dev/null +++ b/cmd/filemanager/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "net/http" + "regexp" + "strings" + + "golang.org/x/net/webdav" + + "github.com/hacdias/filemanager" + handlers "github.com/hacdias/filemanager/http" +) + +var cfg *filemanager.Config + +func handler(w http.ResponseWriter, r *http.Request) { + handlers.ServeHTTP(w, r, cfg) +} + +func main() { + cfg = &filemanager.Config{User: &filemanager.User{}} + cfg.Scope = "." + cfg.FileSystem = webdav.Dir(cfg.Scope) + cfg.BaseURL = "/" + cfg.HugoEnabled = false + cfg.Users = map[string]*filemanager.User{} + cfg.AllowCommands = true + cfg.AllowEdit = true + cfg.AllowNew = true + cfg.Commands = []string{"git", "svn", "hg"} + cfg.BeforeSave = func(r *http.Request, c *filemanager.Config, u *filemanager.User) error { return nil } + cfg.AfterSave = func(r *http.Request, c *filemanager.Config, u *filemanager.User) error { return nil } + cfg.Rules = []*filemanager.Rule{{ + Regex: true, + Allow: false, + Regexp: regexp.MustCompile("\\/\\..+"), + }} + + cfg.BaseURL = strings.TrimPrefix(cfg.BaseURL, "/") + cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/") + cfg.BaseURL = "/" + cfg.BaseURL + cfg.WebDavURL = "" + + if cfg.BaseURL == "/" { + cfg.BaseURL = "" + } + + if cfg.WebDavURL == "" { + cfg.WebDavURL = "webdav" + } + + cfg.PrefixURL = "" + cfg.WebDavURL = cfg.BaseURL + "/" + strings.TrimPrefix(cfg.WebDavURL, "/") + cfg.Handler = &webdav.Handler{ + Prefix: cfg.WebDavURL, + FileSystem: cfg.FileSystem, + LockSystem: webdav.NewMemLS(), + } + + http.HandleFunc("/", handler) + http.ListenAndServe(":8080", nil) +} \ No newline at end of file diff --git a/file.go b/file.go new file mode 100644 index 00000000..009b8449 --- /dev/null +++ b/file.go @@ -0,0 +1,161 @@ +package filemanager + +import ( + "io/ioutil" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + humanize "github.com/dustin/go-humanize" +) + +// FileInfo contains the information about a particular file or directory. +type FileInfo struct { + Name string + Size int64 + URL string + Extension string + ModTime time.Time + Mode os.FileMode + IsDir bool + Path string // Relative path to Caddyfile + VirtualPath string // Relative path to u.FileSystem + Mimetype string + Content []byte + Type string + UserAllowed bool // Indicates if the user has enough permissions +} + +// GetInfo retrieves the file information and the error, if there is any. +func GetInfo(url *url.URL, c *Config, u *User) (*FileInfo, error) { + var err error + + i := &FileInfo{URL: c.PrefixURL + url.Path} + i.VirtualPath = strings.Replace(url.Path, c.BaseURL, "", 1) + i.VirtualPath = strings.TrimPrefix(i.VirtualPath, "/") + i.VirtualPath = "/" + i.VirtualPath + + i.Path = u.Scope + i.VirtualPath + i.Path = filepath.Clean(i.Path) + + info, err := os.Stat(i.Path) + if err != nil { + return i, err + } + + i.Name = info.Name() + i.ModTime = info.ModTime() + i.Mode = info.Mode() + i.IsDir = info.IsDir() + i.Size = info.Size() + i.Extension = filepath.Ext(i.Name) + return i, nil +} + +var textExtensions = [...]string{ + ".md", ".markdown", ".mdown", ".mmark", + ".asciidoc", ".adoc", ".ad", + ".rst", + ".json", ".toml", ".yaml", ".csv", ".xml", ".rss", ".conf", ".ini", + ".tex", ".sty", + ".css", ".sass", ".scss", + ".js", + ".html", + ".txt", ".rtf", + ".sh", ".bash", ".ps1", ".bat", ".cmd", + ".php", ".pl", ".py", + "Caddyfile", + ".c", ".cc", ".h", ".hh", ".cpp", ".hpp", ".f90", + ".f", ".bas", ".d", ".ada", ".nim", ".cr", ".java", ".cs", ".vala", ".vapi", +} + +// RetrieveFileType obtains the mimetype and a simplified internal Type +// using the first 512 bytes from the file. +func (i *FileInfo) RetrieveFileType() error { + i.Mimetype = mime.TypeByExtension(i.Extension) + + if i.Mimetype == "" { + err := i.Read() + if err != nil { + return err + } + + i.Mimetype = http.DetectContentType(i.Content) + } + + if strings.HasPrefix(i.Mimetype, "video") { + i.Type = "video" + return nil + } + + if strings.HasPrefix(i.Mimetype, "audio") { + i.Type = "audio" + return nil + } + + if strings.HasPrefix(i.Mimetype, "image") { + i.Type = "image" + return nil + } + + if strings.HasPrefix(i.Mimetype, "text") { + i.Type = "text" + return nil + } + + if strings.HasPrefix(i.Mimetype, "application/javascript") { + i.Type = "text" + return nil + } + + // If the type isn't text (and is blob for example), it will check some + // common types that are mistaken not to be text. + for _, extension := range textExtensions { + if strings.HasSuffix(i.Name, extension) { + i.Type = "text" + return nil + } + } + + i.Type = "blob" + return nil +} + +// Reads the file. +func (i *FileInfo) Read() error { + if len(i.Content) != 0 { + return nil + } + + var err error + i.Content, err = ioutil.ReadFile(i.Path) + if err != nil { + return err + } + return nil +} + +// StringifyContent returns the string version of Raw +func (i FileInfo) StringifyContent() string { + return string(i.Content) +} + +// HumanSize returns the size of the file as a human-readable string +// in IEC format (i.e. power of 2 or base 1024). +func (i FileInfo) HumanSize() string { + return humanize.IBytes(uint64(i.Size)) +} + +// HumanModTime returns the modified time of the file as a human-readable string. +func (i FileInfo) HumanModTime(format string) string { + return i.ModTime.Format(format) +} + +// CanBeEdited checks if the extension of a file is supported by the editor +func (i FileInfo) CanBeEdited() bool { + return i.Type == "text" +} diff --git a/filemanager.go b/filemanager.go new file mode 100644 index 00000000..783eadf7 --- /dev/null +++ b/filemanager.go @@ -0,0 +1,77 @@ +package filemanager + +import ( + "net/http" + "regexp" + "strings" + + "golang.org/x/net/webdav" +) + +// CommandFunc ... +type CommandFunc func(r *http.Request, c *Config, u *User) error + +// Config is a configuration for browsing in a particular path. +type Config struct { + *User + PrefixURL string + BaseURL string + WebDavURL string + HugoEnabled bool // Enables the Hugo plugin for File Manager + Users map[string]*User + BeforeSave CommandFunc + AfterSave CommandFunc +} + +// AbsoluteURL ... +func (c Config) AbsoluteURL() string { + return c.PrefixURL + c.BaseURL +} + +// AbsoluteWebdavURL ... +func (c Config) AbsoluteWebdavURL() string { + return c.PrefixURL + c.WebDavURL +} + +// Rule is a dissalow/allow rule +type Rule struct { + Regex bool + Allow bool + Path string + Regexp *regexp.Regexp +} + +// User contains the configuration for each user +type User struct { + Scope string `json:"-"` // Path the user have access + FileSystem webdav.FileSystem `json:"-"` // The virtual file system the user have access + Handler *webdav.Handler `json:"-"` // The WebDav HTTP Handler + StyleSheet string `json:"-"` // Costum stylesheet + AllowNew bool // Can create files and folders + AllowEdit bool // Can edit/rename files + AllowCommands bool // Can execute commands + Commands []string // Available Commands + Rules []*Rule `json:"-"` // Access rules +} + +// Allowed checks if the user has permission to access a directory/file +func (u User) Allowed(url string) bool { + var rule *Rule + i := len(u.Rules) - 1 + + for i >= 0 { + rule = u.Rules[i] + + if rule.Regex { + if rule.Regexp.MatchString(url) { + return rule.Allow + } + } else if strings.HasPrefix(url, rule.Path) { + return rule.Allow + } + + i-- + } + + return true +} diff --git a/frontmatter/frontmatter.go b/frontmatter/frontmatter.go new file mode 100644 index 00000000..e9e948bf --- /dev/null +++ b/frontmatter/frontmatter.go @@ -0,0 +1,276 @@ +package frontmatter + +import ( + "bytes" + "encoding/json" + "errors" + "log" + "reflect" + "sort" + "strconv" + "strings" + + "gopkg.in/yaml.v2" + + "github.com/BurntSushi/toml" + "github.com/hacdias/filemanager/utils" + + "github.com/spf13/cast" +) + +const ( + mainName = "#MAIN#" + objectType = "object" + arrayType = "array" +) + +var mainTitle = "" + +// Pretty creates a new FrontMatter object +func Pretty(content []byte) (*Content, string, error) { + data, err := Unmarshal(content) + + if err != nil { + return &Content{}, "", err + } + + kind := reflect.ValueOf(data).Kind() + + if kind == reflect.Invalid { + return &Content{}, "", nil + } + + object := new(Block) + object.Type = objectType + object.Name = mainName + + if kind == reflect.Map { + object.Type = objectType + } else if kind == reflect.Slice || kind == reflect.Array { + object.Type = arrayType + } + + return rawToPretty(data, object), mainTitle, nil +} + +// Unmarshal returns the data of the frontmatter +func Unmarshal(content []byte) (interface{}, error) { + mark := rune(content[0]) + var data interface{} + + switch mark { + case '-': + // If it's YAML + if err := yaml.Unmarshal(content, &data); err != nil { + return nil, err + } + case '+': + // If it's TOML + content = bytes.Replace(content, []byte("+"), []byte(""), -1) + if _, err := toml.Decode(string(content), &data); err != nil { + return nil, err + } + case '{', '[': + // If it's JSON + if err := json.Unmarshal(content, &data); err != nil { + return nil, err + } + default: + return nil, errors.New("Invalid frontmatter type") + } + + return data, nil +} + +// Marshal encodes the interface in a specific format +func Marshal(data interface{}, mark rune) ([]byte, error) { + b := new(bytes.Buffer) + + switch mark { + case '+': + enc := toml.NewEncoder(b) + err := enc.Encode(data) + if err != nil { + return nil, err + } + return b.Bytes(), nil + case '{': + by, err := json.MarshalIndent(data, "", " ") + if err != nil { + return nil, err + } + b.Write(by) + _, err = b.Write([]byte("\n")) + if err != nil { + return nil, err + } + return b.Bytes(), nil + case '-': + by, err := yaml.Marshal(data) + if err != nil { + return nil, err + } + b.Write(by) + _, err = b.Write([]byte("...")) + if err != nil { + return nil, err + } + return b.Bytes(), nil + default: + return nil, errors.New("Unsupported Format provided") + } +} + +// Content is the block content +type Content struct { + Other interface{} + Fields []*Block + Arrays []*Block + Objects []*Block +} + +// Block is a block +type Block struct { + Name string + Title string + Type string + HTMLType string + Content *Content + Parent *Block +} + +func rawToPretty(config interface{}, parent *Block) *Content { + objects := []*Block{} + arrays := []*Block{} + fields := []*Block{} + + cnf := map[string]interface{}{} + kind := reflect.TypeOf(config) + + switch kind { + case reflect.TypeOf(map[interface{}]interface{}{}): + for key, value := range config.(map[interface{}]interface{}) { + cnf[key.(string)] = value + } + case reflect.TypeOf([]map[string]interface{}{}): + for index, value := range config.([]map[string]interface{}) { + cnf[strconv.Itoa(index)] = value + } + case reflect.TypeOf([]map[interface{}]interface{}{}): + for index, value := range config.([]map[interface{}]interface{}) { + cnf[strconv.Itoa(index)] = value + } + case reflect.TypeOf([]interface{}{}): + for index, value := range config.([]interface{}) { + cnf[strconv.Itoa(index)] = value + } + default: + cnf = config.(map[string]interface{}) + } + + for name, element := range cnf { + if utils.IsMap(element) { + objects = append(objects, handleObjects(element, parent, name)) + } else if utils.IsSlice(element) { + arrays = append(arrays, handleArrays(element, parent, name)) + } else { + if name == "title" && parent.Name == mainName { + mainTitle = element.(string) + } + fields = append(fields, handleFlatValues(element, parent, name)) + } + } + + sort.Sort(sortByTitle(fields)) + sort.Sort(sortByTitle(arrays)) + sort.Sort(sortByTitle(objects)) + return &Content{ + Fields: fields, + Arrays: arrays, + Objects: objects, + } +} + +type sortByTitle []*Block + +func (f sortByTitle) Len() int { return len(f) } +func (f sortByTitle) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f sortByTitle) Less(i, j int) bool { + return strings.ToLower(f[i].Name) < strings.ToLower(f[j].Name) +} + +func handleObjects(content interface{}, parent *Block, name string) *Block { + c := new(Block) + c.Parent = parent + c.Type = objectType + c.Title = name + + if parent.Name == mainName { + c.Name = c.Title + } else if parent.Type == arrayType { + c.Name = parent.Name + "[" + name + "]" + } else { + c.Name = parent.Name + "." + c.Title + } + + c.Content = rawToPretty(content, c) + return c +} + +func handleArrays(content interface{}, parent *Block, name string) *Block { + c := new(Block) + c.Parent = parent + c.Type = arrayType + c.Title = name + + if parent.Name == mainName { + c.Name = name + } else { + c.Name = parent.Name + "." + name + } + + c.Content = rawToPretty(content, c) + return c +} + +func handleFlatValues(content interface{}, parent *Block, name string) *Block { + c := new(Block) + c.Parent = parent + + switch content.(type) { + case bool: + c.Type = "boolean" + case int, float32, float64: + c.Type = "number" + default: + c.Type = "string" + } + + c.Content = &Content{Other: content} + + switch strings.ToLower(name) { + case "description": + c.HTMLType = "textarea" + case "date", "publishdate": + c.HTMLType = "datetime" + c.Content = &Content{Other: cast.ToTime(content)} + default: + c.HTMLType = "text" + } + + if parent.Type == arrayType { + c.Name = parent.Name + "[]" + c.Title = content.(string) + } else if parent.Type == objectType { + c.Title = name + c.Name = parent.Name + "." + name + + if parent.Name == mainName { + c.Name = name + } + } else { + log.Panic("Parent type not allowed in handleFlatValues.") + } + + return c +} diff --git a/frontmatter/runes.go b/frontmatter/runes.go new file mode 100644 index 00000000..b4ad1dc2 --- /dev/null +++ b/frontmatter/runes.go @@ -0,0 +1,58 @@ +package frontmatter + +import ( + "bytes" + "errors" + "strings" +) + +// HasRune checks if the file has the frontmatter rune +func HasRune(file []byte) bool { + return strings.HasPrefix(string(file), "---") || + strings.HasPrefix(string(file), "+++") || + strings.HasPrefix(string(file), "{") +} + +// AppendRune appends the frontmatter rune to a file +func AppendRune(frontmatter []byte, mark rune) []byte { + frontmatter = bytes.TrimSpace(frontmatter) + + switch mark { + case '-': + return []byte("---\n" + string(frontmatter) + "\n---") + case '+': + return []byte("+++\n" + string(frontmatter) + "\n+++") + case '{': + return []byte("{\n" + string(frontmatter) + "\n}") + } + + return frontmatter +} + +// RuneToStringFormat converts the rune to a string with the format +func RuneToStringFormat(mark rune) (string, error) { + switch mark { + case '-': + return "yaml", nil + case '+': + return "toml", nil + case '{', '}': + return "json", nil + default: + return "", errors.New("Unsupported format type") + } +} + +// StringFormatToRune converts the format name to its rune +func StringFormatToRune(format string) (rune, error) { + switch format { + case "yaml": + return '-', nil + case "toml": + return '+', nil + case "json": + return '{', nil + default: + return '0', errors.New("Unsupported format type") + } +} diff --git a/frontmatter/runes_test.go b/frontmatter/runes_test.go new file mode 100644 index 00000000..6d120948 --- /dev/null +++ b/frontmatter/runes_test.go @@ -0,0 +1,131 @@ +package frontmatter + +import "testing" + +type hasRuneTest struct { + File []byte + Return bool +} + +var testHasRune = []hasRuneTest{ + hasRuneTest{ + File: []byte(`--- +Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Sed auctor libero eget ante fermentum commodo. +---`), + Return: true, + }, + hasRuneTest{ + File: []byte(`+++ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Sed auctor libero eget ante fermentum commodo. ++++`), + Return: true, + }, + hasRuneTest{ + File: []byte(`{ + "json": "Lorem ipsum dolor sit amet" +}`), + Return: true, + }, + hasRuneTest{ + File: []byte(`+`), + Return: false, + }, + hasRuneTest{ + File: []byte(`++`), + Return: false, + }, + hasRuneTest{ + File: []byte(`-`), + Return: false, + }, + hasRuneTest{ + File: []byte(`--`), + Return: false, + }, + hasRuneTest{ + File: []byte(`Lorem ipsum`), + Return: false, + }, +} + +func TestHasRune(t *testing.T) { + for _, test := range testHasRune { + if HasRune(test.File) != test.Return { + t.Error("Incorrect value on HasRune") + } + } +} + +type appendRuneTest struct { + Before []byte + After []byte + Mark rune +} + +var testAppendRuneTest = []appendRuneTest{} + +func TestAppendRune(t *testing.T) { + for i, test := range testAppendRuneTest { + if !compareByte(AppendRune(test.Before, test.Mark), test.After) { + t.Errorf("Incorrect value on AppendRune of Test %d", i) + } + } +} + +func compareByte(a, b []byte) bool { + if a == nil && b == nil { + return true + } + + if a == nil || b == nil { + return false + } + + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +var testRuneToStringFormat = map[rune]string{ + '-': "yaml", + '+': "toml", + '{': "json", + '}': "json", + '1': "", + 'a': "", +} + +func TestRuneToStringFormat(t *testing.T) { + for mark, format := range testRuneToStringFormat { + val, _ := RuneToStringFormat(mark) + if val != format { + t.Errorf("Incorrect value on RuneToStringFormat of %v; want: %s; got: %s", mark, format, val) + } + } +} + +var testStringFormatToRune = map[string]rune{ + "yaml": '-', + "toml": '+', + "json": '{', + "lorem": '0', +} + +func TestStringFormatToRune(t *testing.T) { + for format, mark := range testStringFormatToRune { + val, _ := StringFormatToRune(format) + if val != mark { + t.Errorf("Incorrect value on StringFormatToRune of %s; want: %v; got: %v", format, mark, val) + } + } +} diff --git a/http/checksum.go b/http/checksum.go new file mode 100644 index 00000000..e4dcb82c --- /dev/null +++ b/http/checksum.go @@ -0,0 +1,53 @@ +package http + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + e "errors" + "hash" + "io" + "net/http" + "os" + + fm "github.com/hacdias/filemanager" + "github.com/hacdias/filemanager/utils" +) + +// checksum calculates the hash of a filemanager. Supports MD5, SHA1, SHA256 and SHA512. +func checksum(w http.ResponseWriter, r *http.Request, c *fm.Config, i *fm.FileInfo) (int, error) { + query := r.URL.Query().Get("checksum") + + file, err := os.Open(i.Path) + if err != nil { + return utils.ErrorToHTTPCode(err, true), err + } + + defer file.Close() + + var h hash.Hash + + switch query { + case "md5": + h = md5.New() + case "sha1": + h = sha1.New() + case "sha256": + h = sha256.New() + case "sha512": + h = sha512.New() + default: + return http.StatusBadRequest, e.New("Unknown HASH type") + } + + _, err = io.Copy(h, file) + if err != nil { + return http.StatusInternalServerError, err + } + + val := hex.EncodeToString(h.Sum(nil)) + w.Write([]byte(val)) + return http.StatusOK, nil +} diff --git a/http/command.go b/http/command.go new file mode 100644 index 00000000..1c1a8ce9 --- /dev/null +++ b/http/command.go @@ -0,0 +1,136 @@ +package http + +import ( + "bytes" + "net/http" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/gorilla/websocket" + fm "github.com/hacdias/filemanager" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +var ( + cmdNotImplemented = []byte("Command not implemented.") + cmdNotAllowed = []byte("Command not allowed.") +) + +// command handles the requests for VCS related commands: git, svn and mercurial +func command(w http.ResponseWriter, r *http.Request, c *fm.Config, u *fm.User) (int, error) { + // Upgrades the connection to a websocket and checks for errors. + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return 0, err + } + defer conn.Close() + + var ( + message []byte + command []string + ) + + // Starts an infinite loop until a valid command is captured. + for { + _, message, err = conn.ReadMessage() + if err != nil { + return http.StatusInternalServerError, err + } + + command = strings.Split(string(message), " ") + if len(command) != 0 { + break + } + } + + // Check if the command is allowed + allowed := false + + for _, cmd := range u.Commands { + if cmd == command[0] { + allowed = true + } + } + + if !allowed { + err = conn.WriteMessage(websocket.BinaryMessage, cmdNotAllowed) + if err != nil { + return http.StatusInternalServerError, err + } + + return 0, nil + } + + // Check if the program is talled is installed on the computer. + if _, err = exec.LookPath(command[0]); err != nil { + err = conn.WriteMessage(websocket.BinaryMessage, cmdNotImplemented) + if err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusNotImplemented, nil + } + + // Gets the path and initializes a buffer. + path := strings.Replace(r.URL.Path, c.BaseURL, c.Scope, 1) + path = filepath.Clean(path) + buff := new(bytes.Buffer) + + // Sets up the command executation. + cmd := exec.Command(command[0], command[1:]...) + cmd.Dir = path + cmd.Stderr = buff + cmd.Stdout = buff + + // Starts the command and checks for errors. + err = cmd.Start() + if err != nil { + return http.StatusInternalServerError, err + } + + // Set a 'done' variable to check whetever the command has already finished + // running or not. This verification is done using a goroutine that uses the + // method .Wait() from the command. + done := false + go func() { + err = cmd.Wait() + done = true + }() + + // Function to print the current information on the buffer to the connection. + print := func() error { + by := buff.Bytes() + if len(by) > 0 { + err = conn.WriteMessage(websocket.TextMessage, by) + if err != nil { + return err + } + } + + return nil + } + + // While the command hasn't finished running, continue sending the output + // to the client in intervals of 100 milliseconds. + for !done { + if err = print(); err != nil { + return http.StatusInternalServerError, err + } + + time.Sleep(100 * time.Millisecond) + } + + // After the command is done executing, send the output one more time to the + // browser to make sure it gets the latest information. + if err = print(); err != nil { + return http.StatusInternalServerError, err + } + + return 0, nil +} diff --git a/http/download.go b/http/download.go new file mode 100644 index 00000000..b1848e47 --- /dev/null +++ b/http/download.go @@ -0,0 +1,96 @@ +package http + +import ( + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + fm "github.com/hacdias/filemanager" + "github.com/mholt/archiver" +) + +// download creates an archive in one of the supported formats (zip, tar, +// tar.gz or tar.bz2) and sends it to be downloaded. +func download(w http.ResponseWriter, r *http.Request, c *fm.Config, i *fm.FileInfo) (int, error) { + query := r.URL.Query().Get("download") + + if !i.IsDir { + w.Header().Set("Content-Disposition", "attachment; filename="+i.Name) + http.ServeFile(w, r, i.Path) + return 0, nil + } + + files := []string{} + names := strings.Split(r.URL.Query().Get("files"), ",") + + if len(names) != 0 { + for _, name := range names { + name, err := url.QueryUnescape(name) + + if err != nil { + return http.StatusInternalServerError, err + } + + files = append(files, filepath.Join(i.Path, name)) + } + + } else { + files = append(files, i.Path) + } + + if query == "true" { + query = "zip" + } + + var ( + extension string + temp string + err error + tempfile string + ) + + temp, err = ioutil.TempDir("", "") + if err != nil { + return http.StatusInternalServerError, err + } + + defer os.RemoveAll(temp) + tempfile = filepath.Join(temp, "temp") + + switch query { + case "zip": + extension, err = ".zip", archiver.Zip.Make(tempfile, files) + case "tar": + extension, err = ".tar", archiver.Tar.Make(tempfile, files) + case "targz": + extension, err = ".tar.gz", archiver.TarGz.Make(tempfile, files) + case "tarbz2": + extension, err = ".tar.bz2", archiver.TarBz2.Make(tempfile, files) + case "tarxz": + extension, err = ".tar.xz", archiver.TarXZ.Make(tempfile, files) + default: + return http.StatusNotImplemented, nil + } + + if err != nil { + return http.StatusInternalServerError, err + } + + file, err := os.Open(temp + "/temp") + if err != nil { + return http.StatusInternalServerError, err + } + + name := i.Name + if name == "." || name == "" { + name = "download" + } + + w.Header().Set("Content-Disposition", "attachment; filename="+name+extension) + io.Copy(w, file) + return http.StatusOK, nil +} diff --git a/http/editor.go b/http/editor.go new file mode 100644 index 00000000..724a6884 --- /dev/null +++ b/http/editor.go @@ -0,0 +1,121 @@ +package http + +import ( + "bytes" + "errors" + "net/http" + "path/filepath" + "strings" + + fm "github.com/hacdias/filemanager" + "github.com/hacdias/filemanager/frontmatter" + "github.com/spf13/hugo/parser" +) + +// Editor contains the information for the editor page +type Editor struct { + Class string + Mode string + Visual bool + Content string + FrontMatter struct { + Content *frontmatter.Content + Rune rune + } +} + +// getEditor gets the editor based on a FileInfo struct +func getEditor(r *http.Request, i *fm.FileInfo) (*Editor, error) { + var err error + + // Create a new editor variable and set the mode + e := new(Editor) + e.Mode = editorMode(i.Name) + e.Class = editorClass(e.Mode) + + if e.Class == "frontmatter-only" || e.Class == "complete" { + e.Visual = true + } + + if r.URL.Query().Get("visual") == "false" { + e.Class = "content-only" + } + + hasRune := frontmatter.HasRune(i.Content) + + if e.Class == "frontmatter-only" && !hasRune { + e.FrontMatter.Rune, err = frontmatter.StringFormatToRune(e.Mode) + if err != nil { + goto Error + } + i.Content = frontmatter.AppendRune(i.Content, e.FrontMatter.Rune) + hasRune = true + } + + if e.Class == "frontmatter-only" && hasRune { + e.FrontMatter.Content, _, err = frontmatter.Pretty(i.Content) + if err != nil { + goto Error + } + } + + if e.Class == "complete" && hasRune { + var page parser.Page + // Starts a new buffer and parses the file using Hugo's functions + buffer := bytes.NewBuffer(i.Content) + page, err = parser.ReadFrom(buffer) + + if err != nil { + goto Error + } + + // Parses the page content and the frontmatter + e.Content = strings.TrimSpace(string(page.Content())) + e.FrontMatter.Rune = rune(i.Content[0]) + e.FrontMatter.Content, _, err = frontmatter.Pretty(page.FrontMatter()) + } + + if e.Class == "complete" && !hasRune { + err = errors.New("Complete but without rune") + } + +Error: + if e.Class == "content-only" || err != nil { + e.Class = "content-only" + e.Content = i.StringifyContent() + } + + return e, nil +} + +func editorClass(mode string) string { + switch mode { + case "json", "toml", "yaml": + return "frontmatter-only" + case "markdown", "asciidoc", "rst": + return "complete" + } + + return "content-only" +} + +func editorMode(filename string) string { + mode := strings.TrimPrefix(filepath.Ext(filename), ".") + + switch mode { + case "md", "markdown", "mdown", "mmark": + mode = "markdown" + case "asciidoc", "adoc", "ad": + mode = "asciidoc" + case "rst": + mode = "rst" + case "html", "htm": + mode = "html" + case "js": + mode = "javascript" + case "go": + mode = "golang" + } + + return mode +} diff --git a/http/http.go b/http/http.go new file mode 100644 index 00000000..b438dd49 --- /dev/null +++ b/http/http.go @@ -0,0 +1,173 @@ +package http + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "strings" + + fm "github.com/hacdias/filemanager" + "github.com/hacdias/filemanager/assets" + "github.com/hacdias/filemanager/page" + "github.com/hacdias/filemanager/utils" + "github.com/hacdias/filemanager/wrapper" + "github.com/mholt/caddy/caddyhttp/httpserver" +) + +// ServeHTTP starts FileManager. +func ServeHTTP(w http.ResponseWriter, r *http.Request, c *fm.Config) (int, error) { + var ( + fi *fm.FileInfo + user *fm.User + code int + err error + ) + + // Checks if the URL matches the Assets URL. Returns the asset if the + // method is GET and Status Forbidden otherwise. + if strings.HasPrefix(r.URL.Path, c.BaseURL+assets.BaseURL) { + if r.Method == http.MethodGet { + return assets.Serve(w, r, c) + } + + return http.StatusForbidden, nil + } + + // Obtains the user. + username, _, _ := r.BasicAuth() + if _, ok := c.Users[username]; ok { + user = c.Users[username] + } else { + user = c.User + } + + // Checks if the request URL is for the WebDav server + if httpserver.Path(r.URL.Path).Matches(c.WebDavURL) { + // Checks for user permissions relatively to this PATH + if !user.Allowed(strings.TrimPrefix(r.URL.Path, c.WebDavURL)) { + return http.StatusForbidden, nil + } + + switch r.Method { + case "GET", "HEAD": + // Excerpt from RFC4918, section 9.4: + // + // GET, when applied to a collection, may return the contents of an + // "index.html" resource, a human-readable view of the contents of + // the collection, or something else altogether. + // + // It was decided on https://github.com/hacdias/caddy-filemanager/issues/85 + // that GET, for collections, will return the same as PROPFIND method. + path := strings.Replace(r.URL.Path, c.WebDavURL, "", 1) + path = user.Scope + "/" + path + path = filepath.Clean(path) + + var i os.FileInfo + i, err = os.Stat(path) + if err != nil { + // Is there any error? WebDav will handle it... no worries. + break + } + + if i.IsDir() { + r.Method = "PROPFIND" + + if r.Method == "HEAD" { + w = wrapper.NewResponseWriterNoBody(w) + } + } + case "PROPPATCH", "MOVE", "PATCH", "PUT", "DELETE": + if !user.AllowEdit { + return http.StatusForbidden, nil + } + case "MKCOL", "COPY": + if !user.AllowNew { + return http.StatusForbidden, nil + } + } + + // Preprocess the PUT request if it's the case + if r.Method == http.MethodPut { + if err = c.BeforeSave(r, c, user); err != nil { + return http.StatusInternalServerError, err + } + + if preProccessPUT(w, r, c, user) != nil { + return http.StatusInternalServerError, err + } + } + + c.Handler.ServeHTTP(w, r) + if err = c.AfterSave(r, c, user); err != nil { + return http.StatusInternalServerError, err + } + + return 0, nil + } + + w.Header().Set("x-frame-options", "SAMEORIGIN") + w.Header().Set("x-content-type", "nosniff") + w.Header().Set("x-xss-protection", "1; mode=block") + + // Checks if the User is allowed to access this file + if !user.Allowed(strings.TrimPrefix(r.URL.Path, c.BaseURL)) { + if r.Method == http.MethodGet { + return page.PrintErrorHTML( + w, http.StatusForbidden, + errors.New("You don't have permission to access this page"), + ) + } + + return http.StatusForbidden, nil + } + + if r.URL.Query().Get("search") != "" { + return search(w, r, c, user) + } + + if r.URL.Query().Get("command") != "" { + return command(w, r, c, user) + } + + if r.Method == http.MethodGet { + // Gets the information of the directory/file + fi, err = fm.GetInfo(r.URL, c, user) + code = utils.ErrorToHTTPCode(err, false) + if err != nil { + if r.Method == http.MethodGet { + return page.PrintErrorHTML(w, code, err) + } + return code, err + } + + // If it's a dir and the path doesn't end with a trailing slash, + // redirect the user. + if fi.IsDir && !strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, c.PrefixURL+r.URL.Path+"/", http.StatusTemporaryRedirect) + return 0, nil + } + + switch { + case r.URL.Query().Get("download") != "": + code, err = download(w, r, c, fi) + case r.URL.Query().Get("raw") == "true" && !fi.IsDir: + http.ServeFile(w, r, fi.Path) + code, err = 0, nil + case !fi.IsDir && r.URL.Query().Get("checksum") != "": + code, err = checksum(w, r, c, fi) + case fi.IsDir: + code, err = serveListing(w, r, c, user, fi) + default: + code, err = serveSingle(w, r, c, user, fi) + } + + if err != nil { + code, err = page.PrintErrorHTML(w, code, err) + } + + return code, err + } + + return http.StatusNotImplemented, nil +} diff --git a/http/listing.go b/http/listing.go new file mode 100644 index 00000000..3cea7645 --- /dev/null +++ b/http/listing.go @@ -0,0 +1,147 @@ +package http + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + fm "github.com/hacdias/filemanager" + "github.com/hacdias/filemanager/page" + "github.com/hacdias/filemanager/utils" + "github.com/mholt/caddy/caddyhttp/httpserver" +) + +// serveListing presents the user with a listage of a directory folder. +func serveListing(w http.ResponseWriter, r *http.Request, c *fm.Config, u *fm.User, i *fm.FileInfo) (int, error) { + var err error + + // Loads the content of the directory + listing, err := fm.GetListing(u, i.VirtualPath, c.PrefixURL+r.URL.Path) + if err != nil { + return utils.ErrorToHTTPCode(err, true), err + } + + listing.Context = httpserver.Context{ + Root: http.Dir(u.Scope), + Req: r, + URL: r.URL, + } + + cookieScope := c.BaseURL + if cookieScope == "" { + cookieScope = "/" + } + + // Copy the query values into the Listing struct + var limit int + listing.Sort, listing.Order, limit, err = handleSortOrder(w, r, cookieScope) + if err != nil { + return http.StatusBadRequest, err + } + + listing.ApplySort() + + if limit > 0 && limit <= len(listing.Items) { + listing.Items = listing.Items[:limit] + listing.ItemsLimitedTo = limit + } + + if strings.Contains(r.Header.Get("Accept"), "application/json") { + marsh, err := json.Marshal(listing.Items) + if err != nil { + return http.StatusInternalServerError, err + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if _, err := w.Write(marsh); err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusOK, nil + } + + displayMode := r.URL.Query().Get("display") + + if displayMode == "" { + if displayCookie, err := r.Cookie("display"); err == nil { + displayMode = displayCookie.Value + } + } + + if displayMode == "" || (displayMode != "mosaic" && displayMode != "list") { + displayMode = "mosaic" + } + + http.SetCookie(w, &http.Cookie{ + Name: "display", + Value: displayMode, + Path: cookieScope, + Secure: r.TLS != nil, + }) + + page := &page.Page{ + Minimal: r.Header.Get("Minimal") == "true", + Info: &page.Info{ + Name: listing.Name, + Path: i.VirtualPath, + IsDir: true, + User: u, + Config: c, + Display: displayMode, + Data: listing, + }, + } + + return page.PrintAsHTML(w, "listing") +} + +// handleSortOrder gets and stores for a Listing the 'sort' and 'order', +// and reads 'limit' if given. The latter is 0 if not given. Sets cookies. +func handleSortOrder(w http.ResponseWriter, r *http.Request, scope string) (sort string, order string, limit int, err error) { + sort = r.URL.Query().Get("sort") + order = r.URL.Query().Get("order") + limitQuery := r.URL.Query().Get("limit") + + // If the query 'sort' or 'order' is empty, use defaults or any values + // previously saved in Cookies. + switch sort { + case "": + sort = "name" + if sortCookie, sortErr := r.Cookie("sort"); sortErr == nil { + sort = sortCookie.Value + } + case "name", "size", "type": + http.SetCookie(w, &http.Cookie{ + Name: "sort", + Value: sort, + Path: scope, + Secure: r.TLS != nil, + }) + } + + switch order { + case "": + order = "asc" + if orderCookie, orderErr := r.Cookie("order"); orderErr == nil { + order = orderCookie.Value + } + case "asc", "desc": + http.SetCookie(w, &http.Cookie{ + Name: "order", + Value: order, + Path: scope, + Secure: r.TLS != nil, + }) + } + + if limitQuery != "" { + limit, err = strconv.Atoi(limitQuery) + // If the 'limit' query can't be interpreted as a number, return err. + if err != nil { + return + } + } + + return +} diff --git a/http/put.go b/http/put.go new file mode 100644 index 00000000..23d0418b --- /dev/null +++ b/http/put.go @@ -0,0 +1,144 @@ +package http + +import ( + "bytes" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/hacdias/filemanager" + "github.com/hacdias/filemanager/frontmatter" +) + +// preProccessPUT is used to update a file that was edited +func preProccessPUT( + w http.ResponseWriter, + r *http.Request, + c *filemanager.Config, + u *filemanager.User, +) (err error) { + var ( + data = map[string]interface{}{} + file []byte + kind string + rawBuffer = new(bytes.Buffer) + ) + + kind = r.Header.Get("kind") + rawBuffer.ReadFrom(r.Body) + + if kind != "" { + err = json.Unmarshal(rawBuffer.Bytes(), &data) + + if err != nil { + return + } + } + + switch kind { + case "frontmatter-only": + if file, err = ParseFrontMatterOnlyFile(data, r.URL.Path); err != nil { + return + } + case "content-only": + mainContent := data["content"].(string) + mainContent = strings.TrimSpace(mainContent) + file = []byte(mainContent) + case "complete": + var mark rune + + if v := r.Header.Get("Rune"); v != "" { + var n int + n, err = strconv.Atoi(v) + if err != nil { + return err + } + + mark = rune(n) + } + + if file, err = ParseCompleteFile(data, r.URL.Path, mark); err != nil { + return + } + default: + file = rawBuffer.Bytes() + } + + // Overwrite the request Body + r.Body = ioutil.NopCloser(bytes.NewReader(file)) + return +} + +// ParseFrontMatterOnlyFile parses a frontmatter only file +func ParseFrontMatterOnlyFile(data interface{}, filename string) ([]byte, error) { + frontmatter := strings.TrimPrefix(filepath.Ext(filename), ".") + f, err := ParseFrontMatter(data, frontmatter) + fString := string(f) + + // If it's toml or yaml, strip frontmatter identifier + if frontmatter == "toml" { + fString = strings.TrimSuffix(fString, "+++\n") + fString = strings.TrimPrefix(fString, "+++\n") + } + + if frontmatter == "yaml" { + fString = strings.TrimSuffix(fString, "---\n") + fString = strings.TrimPrefix(fString, "---\n") + } + + f = []byte(fString) + return f, err +} + +// ParseFrontMatter is the frontmatter parser +func ParseFrontMatter(data interface{}, front string) ([]byte, error) { + var mark rune + + switch front { + case "toml": + mark = '+' + case "json": + mark = '{' + case "yaml": + mark = '-' + default: + return nil, errors.New("Unsupported Format provided") + } + + return frontmatter.Marshal(data, mark) +} + +// ParseCompleteFile parses a complete file +func ParseCompleteFile(data map[string]interface{}, filename string, mark rune) ([]byte, error) { + mainContent := "" + + if _, ok := data["content"]; ok { + // The main content of the file + mainContent = data["content"].(string) + mainContent = "\n\n" + strings.TrimSpace(mainContent) + "\n" + + // Removes the main content from the rest of the frontmatter + delete(data, "content") + } + + if _, ok := data["date"]; ok { + data["date"] = data["date"].(string) + ":00" + } + + front, err := frontmatter.Marshal(data, mark) + if err != nil { + return []byte{}, err + } + + front = frontmatter.AppendRune(front, mark) + + // Generates the final file + f := new(bytes.Buffer) + f.Write(front) + f.Write([]byte(mainContent)) + return f.Bytes(), nil +} diff --git a/http/search.go b/http/search.go new file mode 100644 index 00000000..3604404f --- /dev/null +++ b/http/search.go @@ -0,0 +1,118 @@ +package http + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gorilla/websocket" + fm "github.com/hacdias/filemanager" +) + +type searchOptions struct { + CaseInsensitive bool + Terms []string +} + +func parseSearch(value string) *searchOptions { + opts := &searchOptions{ + CaseInsensitive: strings.Contains(value, "case:insensitive"), + } + + // removes the options from the value + value = strings.Replace(value, "case:insensitive", "", -1) + value = strings.Replace(value, "case:sensitive", "", -1) + value = strings.TrimSpace(value) + + if opts.CaseInsensitive { + value = strings.ToLower(value) + } + + // if the value starts with " and finishes what that character, we will + // only search for that term + if value[0] == '"' && value[len(value)-1] == '"' { + unique := strings.TrimPrefix(value, "\"") + unique = strings.TrimSuffix(unique, "\"") + + opts.Terms = []string{unique} + return opts + } + + opts.Terms = strings.Split(value, " ") + return opts +} + +// search ... +func search(w http.ResponseWriter, r *http.Request, c *fm.Config, u *fm.User) (int, error) { + // Upgrades the connection to a websocket and checks for errors. + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return 0, err + } + defer conn.Close() + + var ( + value string + search *searchOptions + message []byte + ) + + // Starts an infinite loop until a valid command is captured. + for { + _, message, err = conn.ReadMessage() + if err != nil { + return http.StatusInternalServerError, err + } + + if len(message) != 0 { + value = string(message) + break + } + } + + search = parseSearch(value) + scope := strings.Replace(r.URL.Path, c.BaseURL, "", 1) + scope = strings.TrimPrefix(scope, "/") + scope = "/" + scope + scope = u.Scope + scope + scope = strings.Replace(scope, "\\", "/", -1) + scope = filepath.Clean(scope) + + err = filepath.Walk(scope, func(path string, f os.FileInfo, err error) error { + if search.CaseInsensitive { + path = strings.ToLower(path) + } + + path = strings.Replace(path, "\\", "/", -1) + is := false + + for _, term := range search.Terms { + if is { + break + } + + if strings.Contains(path, term) { + if !u.Allowed(path) { + return nil + } + + is = true + } + } + + if !is { + return nil + } + + path = strings.TrimPrefix(path, scope) + path = strings.TrimPrefix(path, "/") + return conn.WriteMessage(websocket.TextMessage, []byte(path)) + }) + + if err != nil { + return http.StatusInternalServerError, err + } + + return http.StatusOK, nil +} diff --git a/http/single.go b/http/single.go new file mode 100644 index 00000000..e4d75b6d --- /dev/null +++ b/http/single.go @@ -0,0 +1,54 @@ +package http + +import ( + "net/http" + "strings" + + fm "github.com/hacdias/filemanager" + "github.com/hacdias/filemanager/page" + "github.com/hacdias/filemanager/utils" +) + +// serveSingle serves a single file in an editor (if it is editable), shows the +// plain file, or downloads it if it can't be shown. +func serveSingle(w http.ResponseWriter, r *http.Request, c *fm.Config, u *fm.User, i *fm.FileInfo) (int, error) { + var err error + + if err = i.RetrieveFileType(); err != nil { + return utils.ErrorToHTTPCode(err, true), err + } + + p := &page.Page{ + Info: &page.Info{ + Name: i.Name, + Path: i.VirtualPath, + IsDir: false, + Data: i, + User: u, + Config: c, + }, + } + + // If the request accepts JSON, we send the file information. + if strings.Contains(r.Header.Get("Accept"), "application/json") { + return p.PrintAsJSON(w) + } + + if i.Type == "text" { + if err = i.Read(); err != nil { + return utils.ErrorToHTTPCode(err, true), err + } + } + + if i.CanBeEdited() && u.AllowEdit { + p.Data, err = getEditor(r, i) + p.Editor = true + if err != nil { + return http.StatusInternalServerError, err + } + + return p.PrintAsHTML(w, "frontmatter", "editor") + } + + return p.PrintAsHTML(w, "single") +} diff --git a/listing.go b/listing.go new file mode 100644 index 00000000..b272c2af --- /dev/null +++ b/listing.go @@ -0,0 +1,184 @@ +package filemanager + +import ( + "context" + "net/url" + "os" + "path" + "sort" + "strings" + + "github.com/mholt/caddy/caddyhttp/httpserver" +) + +// 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 relatively to a File System + Path string + // 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 + // If ≠0 then Items have been limited to that many elements + ItemsLimitedTo int + httpserver.Context `json:"-"` +} + +// GetListing gets the information about a specific directory and its files. +func GetListing(u *User, filePath string, baseURL string) (*Listing, error) { + // Gets the directory information using the Virtual File System of + // the user configuration. + file, err := u.FileSystem.OpenFile(context.TODO(), filePath, os.O_RDONLY, 0) + if err != nil { + return nil, err + } + defer file.Close() + + // Reads the directory and gets the information about the files. + files, err := file.Readdir(-1) + if err != nil { + return nil, err + } + + var ( + fileinfos []FileInfo + dirCount, fileCount int + ) + + for _, f := range files { + name := f.Name() + allowed := u.Allowed("/" + name) + + if !allowed { + continue + } + + if f.IsDir() { + name += "/" + dirCount++ + } else { + fileCount++ + } + + // Absolute URL + url := url.URL{Path: baseURL + name} + + i := FileInfo{ + Name: f.Name(), + Size: f.Size(), + ModTime: f.ModTime(), + Mode: f.Mode(), + IsDir: f.IsDir(), + URL: url.String(), + UserAllowed: allowed, + } + i.RetrieveFileType() + + fileinfos = append(fileinfos, i) + } + + return &Listing{ + Name: path.Base(filePath), + Path: filePath, + Items: fileinfos, + NumDirs: dirCount, + NumFiles: fileCount, + }, nil +} + +// ApplySort applies the sort order using .Order and .Sort +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: + sort.Sort(byName(l)) + return + } + } +} + +// 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 { + if l.Items[i].IsDir && !l.Items[j].IsDir { + return true + } + + if !l.Items[i].IsDir && l.Items[j].IsDir { + return false + } + + 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] +} + +const directoryOffset = -1 << 31 // = math.MinInt32 +func (l bySize) Less(i, j int) bool { + iSize, jSize := l.Items[i].Size, l.Items[j].Size + if l.Items[i].IsDir { + iSize = directoryOffset + iSize + } + if l.Items[j].IsDir { + jSize = directoryOffset + jSize + } + return iSize < jSize +} + +// 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) +} diff --git a/page/error.go b/page/error.go new file mode 100644 index 00000000..cdd470f7 --- /dev/null +++ b/page/error.go @@ -0,0 +1,65 @@ +package page + +import ( + "net/http" + "strconv" + "strings" +) + +const errTemplate = ` + +
+Try reloading the page or hitting the back button. If this error persists, it seems that you may have found a bug! Please create an issue at hacdias/caddy-filemanager repository on GitHub with the code below.
+ +CODE
+