From 9c36dea485ddd16a90f0a615e20baddaee6b9cde Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 24 Jun 2017 12:12:15 +0100 Subject: [PATCH] updates --- editor.go | 120 ++++++++++++++++ error.go | 65 +++++++++ filemanager.go | 98 +++++++++++++ frontmatter/frontmatter.go | 276 ++++++++++++++++++++++++++++++++++++ frontmatter/runes.go | 58 ++++++++ frontmatter/runes_test.go | 131 +++++++++++++++++ http.go | 167 ++++++++++++++++++++++ http_assets.go | 49 +++++++ http_checksum.go | 50 +++++++ http_command.go | 135 ++++++++++++++++++ http_download.go | 95 +++++++++++++ http_listing.go | 144 +++++++++++++++++++ http_put.go | 138 ++++++++++++++++++ http_search.go | 117 +++++++++++++++ http_single.go | 50 +++++++ http_utils.go | 50 +++++++ info.go | 163 +++++++++++++++++++++ listing.go | 184 ++++++++++++++++++++++++ page.go | 168 ++++++++++++++++++++++ variables/types.go | 13 ++ variables/types_test.go | 49 +++++++ variables/variables.go | 47 ++++++ variables/variables_test.go | 41 ++++++ 23 files changed, 2408 insertions(+) create mode 100644 editor.go create mode 100644 error.go create mode 100644 frontmatter/frontmatter.go create mode 100644 frontmatter/runes.go create mode 100644 frontmatter/runes_test.go create mode 100644 http.go create mode 100644 http_assets.go create mode 100644 http_checksum.go create mode 100644 http_command.go create mode 100644 http_download.go create mode 100644 http_listing.go create mode 100644 http_put.go create mode 100644 http_search.go create mode 100644 http_single.go create mode 100644 http_utils.go create mode 100644 info.go create mode 100644 listing.go create mode 100644 page.go create mode 100644 variables/types.go create mode 100644 variables/types_test.go create mode 100644 variables/variables.go create mode 100644 variables/variables_test.go diff --git a/editor.go b/editor.go new file mode 100644 index 00000000..d2b591d2 --- /dev/null +++ b/editor.go @@ -0,0 +1,120 @@ +package filemanager + +import ( + "bytes" + "errors" + "net/http" + "path/filepath" + "strings" + + "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 Info struct +func GetEditor(r *http.Request, i *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/error.go b/error.go new file mode 100644 index 00000000..1a77cc97 --- /dev/null +++ b/error.go @@ -0,0 +1,65 @@ +package filemanager + +import ( + "net/http" + "strconv" + "strings" +) + +const errTemplate = ` + + + TITLE + + + + + + +
+

TITLE

+ +

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 +
+` + +// PrintErrorHTML prints the error page +func PrintErrorHTML(w http.ResponseWriter, code int, err error) (int, error) { + tpl := errTemplate + tpl = strings.Replace(tpl, "TITLE", strconv.Itoa(code)+" "+http.StatusText(code), -1) + tpl = strings.Replace(tpl, "CODE", err.Error(), -1) + + _, err = w.Write([]byte(tpl)) + + if err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} diff --git a/filemanager.go b/filemanager.go index 2e5b8ff3..f65c3362 100644 --- a/filemanager.go +++ b/filemanager.go @@ -1 +1,99 @@ package filemanager + +import ( + "net/http" + "regexp" + "strings" + + rice "github.com/GeertJohan/go.rice" + "golang.org/x/net/webdav" +) + +// FileManager is a file manager instance. +type FileManager struct { + *User `json:"-"` + Assets *Assets `json:"-"` + + // PrefixURL is a part of the URL that is trimmed from the http.Request.URL before + // it arrives to our handlers. It may be useful when using FileManager as a middleware + // such as in caddy-filemanager plugin. + PrefixURL string + + // BaseURL is the path where the GUI will be accessible. + BaseURL string + + // WebDavURL is the path where the WebDAV will be accessible. It can be set to "/" + // in order to override the GUI and only use the WebDAV. + WebDavURL string + + // Users is a map with the different configurations for each user. + Users map[string]*User `json:"-"` + + // TODO: event-based? + BeforeSave CommandFunc `json:"-"` + AfterSave CommandFunc `json:"-"` +} + +// 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 + Rules []*Rule `json:"-"` // Access rules + 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 +} + +// Assets are the static and front-end assets, such as JS, CSS and HTML templates. +type Assets struct { + requiredJS rice.Box // JS that is always required to have in order to be usable. + Templates rice.Box + CSS rice.Box + JS rice.Box +} + +// Rule is a dissalow/allow rule. +type Rule struct { + Regex bool + Allow bool + Path string + Regexp *regexp.Regexp +} + +// CommandFunc ... +type CommandFunc func(r *http.Request, c *FileManager, u *User) error + +// AbsoluteURL ... +func (m FileManager) AbsoluteURL() string { + return m.PrefixURL + m.BaseURL +} + +// AbsoluteWebdavURL ... +func (m FileManager) AbsoluteWebdavURL() string { + return m.PrefixURL + m.WebDavURL +} + +// 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..ba28af20 --- /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/variables" + + "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 variables.IsMap(element) { + objects = append(objects, handleObjects(element, parent, name)) + } else if variables.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.go b/http.go new file mode 100644 index 00000000..cfe4b852 --- /dev/null +++ b/http.go @@ -0,0 +1,167 @@ +package filemanager + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/mholt/caddy/caddyhttp/httpserver" +) + +// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. +func (c *FileManager) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { + var ( + fi *FileInfo + code int + err error + user *User + ) + + // Checks if the URL matches the Assets URL. Returns the asset if the + // method is GET and Status Forbidden otherwise. + if httpserver.Path(r.URL.Path).Matches(c.BaseURL + AssetsURL) { + if r.Method == http.MethodGet { + return serveAssets(w, r, c) + } + + return http.StatusForbidden, nil + } + + 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 = 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 put(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 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 = GetInfo(r.URL, c, user) + if err != nil { + if r.Method == http.MethodGet { + return PrintErrorHTML(w, code, err) + } + code = errorToHTTP(err, false) + 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, 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, 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 = PrintErrorHTML(w, code, err) + } + + return code, err + } + + return http.StatusNotImplemented, nil +} diff --git a/http_assets.go b/http_assets.go new file mode 100644 index 00000000..b15513d6 --- /dev/null +++ b/http_assets.go @@ -0,0 +1,49 @@ +package filemanager + +import ( + "errors" + "mime" + "net/http" + "path/filepath" + "strings" +) + +// AssetsURL is the url of the assets +const AssetsURL = "/_filemanagerinternal" + +// Serve provides the needed assets for the front-end +func serveAssets(w http.ResponseWriter, r *http.Request, m *FileManager) (int, error) { + // gets the filename to be used with Assets function + filename := strings.Replace(r.URL.Path, m.BaseURL+AssetsURL, "", 1) + + var file []byte + var err error + + switch { + case strings.HasPrefix(filename, "/css"): + filename = strings.Replace(filename, "/css/", "", 1) + file, err = m.Assets.CSS.Bytes(filename) + case strings.HasPrefix(filename, "/js"): + filename = strings.Replace(filename, "/js/", "", 1) + file, err = m.Assets.requiredJS.Bytes(filename) + case strings.HasPrefix(filename, "/vendor"): + filename = strings.Replace(filename, "/vendor/", "", 1) + file, err = m.Assets.JS.Bytes(filename) + default: + err = errors.New("not found") + } + + 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/http_checksum.go b/http_checksum.go new file mode 100644 index 00000000..eeeddcbc --- /dev/null +++ b/http_checksum.go @@ -0,0 +1,50 @@ +package filemanager + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + e "errors" + "hash" + "io" + "net/http" + "os" +) + +// checksum calculates the hash of a file. Supports MD5, SHA1, SHA256 and SHA512. +func checksum(w http.ResponseWriter, r *http.Request, i *FileInfo) (int, error) { + query := r.URL.Query().Get("checksum") + + file, err := os.Open(i.Path) + if err != nil { + return errorToHTTP(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..eea6fa48 --- /dev/null +++ b/http_command.go @@ -0,0 +1,135 @@ +package filemanager + +import ( + "bytes" + "net/http" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +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 *FileManager, u *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..dc028dc2 --- /dev/null +++ b/http_download.go @@ -0,0 +1,95 @@ +package filemanager + +import ( + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "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, i *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_listing.go b/http_listing.go new file mode 100644 index 00000000..c4ea51b1 --- /dev/null +++ b/http_listing.go @@ -0,0 +1,144 @@ +package filemanager + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "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 *FileManager, u *User, i *FileInfo) (int, error) { + var err error + + // Loads the content of the directory + listing, err := GetListing(u, i.VirtualPath, c.PrefixURL+r.URL.Path) + if err != nil { + return errorToHTTP(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{ + Minimal: r.Header.Get("Minimal") == "true", + PageInfo: &PageInfo{ + 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..67164119 --- /dev/null +++ b/http_put.go @@ -0,0 +1,138 @@ +package filemanager + +import ( + "bytes" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/hacdias/filemanager/frontmatter" +) + +// put is used to update a file that was edited +func put(w http.ResponseWriter, r *http.Request, c *FileManager, u *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..e35918c8 --- /dev/null +++ b/http_search.go @@ -0,0 +1,117 @@ +package filemanager + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gorilla/websocket" +) + +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 searches for a file or directory. +func search(w http.ResponseWriter, r *http.Request, c *FileManager, u *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..87f01315 --- /dev/null +++ b/http_single.go @@ -0,0 +1,50 @@ +package filemanager + +import ( + "net/http" + "strings" +) + +// 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 *FileManager, u *User, i *FileInfo) (int, error) { + var err error + + if err = i.RetrieveFileType(); err != nil { + return errorToHTTP(err, true), err + } + + p := &Page{ + PageInfo: &PageInfo{ + 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 errorToHTTP(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/http_utils.go b/http_utils.go new file mode 100644 index 00000000..cbb9fa3a --- /dev/null +++ b/http_utils.go @@ -0,0 +1,50 @@ +package filemanager + +import ( + "net/http" + "os" +) + +// responseWriterNoBody is a wrapper used to suprress the body of the response +// to a request. Mainly used for HEAD requests. +type responseWriterNoBody struct { + http.ResponseWriter +} + +// newResponseWriterNoBody creates a new responseWriterNoBody. +func newResponseWriterNoBody(w http.ResponseWriter) *responseWriterNoBody { + return &responseWriterNoBody{w} +} + +// Header executes the Header method from the http.ResponseWriter. +func (w responseWriterNoBody) Header() http.Header { + return w.ResponseWriter.Header() +} + +// Write suprresses the body. +func (w responseWriterNoBody) Write(data []byte) (int, error) { + return 0, nil +} + +// WriteHeader writes the header to the http.ResponseWriter. +func (w responseWriterNoBody) WriteHeader(statusCode int) { + w.ResponseWriter.WriteHeader(statusCode) +} + +// errorToHTTP converts errors to HTTP Status Code. +func errorToHTTP(err error, gone bool) int { + switch { + case os.IsPermission(err): + return http.StatusForbidden + case os.IsNotExist(err): + if !gone { + return http.StatusNotFound + } + + return http.StatusGone + case os.IsExist(err): + return http.StatusGone + default: + return http.StatusInternalServerError + } +} diff --git a/info.go b/info.go new file mode 100644 index 00000000..741f03bd --- /dev/null +++ b/info.go @@ -0,0 +1,163 @@ +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 Current Working Directory + VirtualPath string // Relative path to user's virtual File System + Mimetype string + Type string + UserAllowed bool // Indicates if the user has enough permissions + + content []byte +} + +// GetInfo gets the file information and, in case of error, returns the +// respective HTTP error code +func GetInfo(url *url.URL, c *FileManager, 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/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.go b/page.go new file mode 100644 index 00000000..b65a7365 --- /dev/null +++ b/page.go @@ -0,0 +1,168 @@ +package filemanager + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "html/template" + "log" + "net/http" + "strings" + + "github.com/hacdias/filemanager/variables" +) + +// Page contains the informations and functions needed to show the Page +type Page struct { + *PageInfo + Minimal bool +} + +// PageInfo contains the information of a Page +type PageInfo struct { + Name string + Path string + IsDir bool + User *User + Config *FileManager + Data interface{} + Editor bool + Display string +} + +// BreadcrumbMapItem ... +type BreadcrumbMapItem struct { + Name string + URL string +} + +// BreadcrumbMap returns p.Path where every element is a map +// of URLs and path segment names. +func (i PageInfo) BreadcrumbMap() []BreadcrumbMapItem { + result := []BreadcrumbMapItem{} + + if len(i.Path) == 0 { + return result + } + + // skip trailing slash + lpath := i.Path + if lpath[len(lpath)-1] == '/' { + lpath = lpath[:len(lpath)-1] + } + + parts := strings.Split(lpath, "/") + for i, part := range parts { + if i == len(parts)-1 { + continue + } + + if i == 0 && part == "" { + result = append([]BreadcrumbMapItem{{ + Name: "/", + URL: "/", + }}, result...) + continue + } + + result = append([]BreadcrumbMapItem{{ + Name: part, + URL: strings.Join(parts[:i+1], "/") + "/", + }}, result...) + } + + return result +} + +// PreviousLink returns the path of the previous folder +func (i PageInfo) PreviousLink() string { + path := strings.TrimSuffix(i.Path, "/") + path = strings.TrimPrefix(path, "/") + path = i.Config.AbsoluteURL() + "/" + path + path = path[0 : len(path)-len(i.Name)] + + if len(path) < len(i.Config.AbsoluteURL()+"/") { + return "" + } + + return path +} + +// Create the functions map, then the template, check for erros and +// execute the template if there aren't errors +var functions = template.FuncMap{ + "Defined": variables.FieldInStruct, + "CSS": func(s string) template.CSS { + return template.CSS(s) + }, + "Marshal": func(v interface{}) template.JS { + a, _ := json.Marshal(v) + return template.JS(a) + }, + "EncodeBase64": func(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) + }, +} + +// PrintAsHTML formats the page in HTML and executes the template +func (p Page) PrintAsHTML(w http.ResponseWriter, templates ...string) (int, error) { + + if p.Minimal { + templates = append(templates, "minimal") + } else { + templates = append(templates, "base") + } + + var tpl *template.Template + + // For each template, add it to the the tpl variable + for i, t := range templates { + // Get the template from the assets + Page, err := p.Config.Assets.Templates.String(t + ".tmpl") + + // Check if there is some error. If so, the template doesn't exist + if err != nil { + log.Print(err) + return http.StatusInternalServerError, err + } + + // If it's the first iteration, creates a new template and add the + // functions map + if i == 0 { + tpl, err = template.New(t).Funcs(functions).Parse(Page) + } else { + tpl, err = tpl.Parse(string(Page)) + } + + if err != nil { + log.Print(err) + return http.StatusInternalServerError, err + } + } + + buf := &bytes.Buffer{} + err := tpl.Execute(buf, p.PageInfo) + + if err != nil { + return http.StatusInternalServerError, err + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, err = buf.WriteTo(w) + return http.StatusOK, err +} + +// PrintAsJSON prints the current Page information in JSON +func (p Page) PrintAsJSON(w http.ResponseWriter) (int, error) { + marsh, err := json.MarshalIndent(p.PageInfo.Data, "", " ") + 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 +} diff --git a/variables/types.go b/variables/types.go new file mode 100644 index 00000000..ee43dad3 --- /dev/null +++ b/variables/types.go @@ -0,0 +1,13 @@ +package variables + +import "reflect" + +// IsMap checks if some variable is a map +func IsMap(sth interface{}) bool { + return reflect.ValueOf(sth).Kind() == reflect.Map +} + +// IsSlice checks if some variable is a slice +func IsSlice(sth interface{}) bool { + return reflect.ValueOf(sth).Kind() == reflect.Slice +} diff --git a/variables/types_test.go b/variables/types_test.go new file mode 100644 index 00000000..9955b9b2 --- /dev/null +++ b/variables/types_test.go @@ -0,0 +1,49 @@ +package variables + +import "testing" + +type interfaceToBool struct { + Value interface{} + Result bool +} + +var testIsMap = []*interfaceToBool{ + &interfaceToBool{"teste", false}, + &interfaceToBool{453478, false}, + &interfaceToBool{-984512, false}, + &interfaceToBool{true, false}, + &interfaceToBool{map[string]bool{}, true}, + &interfaceToBool{map[int]bool{}, true}, + &interfaceToBool{map[interface{}]bool{}, true}, + &interfaceToBool{[]string{}, false}, +} + +func TestIsMap(t *testing.T) { + for _, test := range testIsMap { + if IsMap(test.Value) != test.Result { + t.Errorf("Incorrect value on IsMap for %v; want: %v; got: %v", test.Value, test.Result, !test.Result) + } + } +} + +var testIsSlice = []*interfaceToBool{ + &interfaceToBool{"teste", false}, + &interfaceToBool{453478, false}, + &interfaceToBool{-984512, false}, + &interfaceToBool{true, false}, + &interfaceToBool{map[string]bool{}, false}, + &interfaceToBool{map[int]bool{}, false}, + &interfaceToBool{map[interface{}]bool{}, false}, + &interfaceToBool{[]string{}, true}, + &interfaceToBool{[]int{}, true}, + &interfaceToBool{[]bool{}, true}, + &interfaceToBool{[]interface{}{}, true}, +} + +func TestIsSlice(t *testing.T) { + for _, test := range testIsSlice { + if IsSlice(test.Value) != test.Result { + t.Errorf("Incorrect value on IsSlice for %v; want: %v; got: %v", test.Value, test.Result, !test.Result) + } + } +} diff --git a/variables/variables.go b/variables/variables.go new file mode 100644 index 00000000..37782c74 --- /dev/null +++ b/variables/variables.go @@ -0,0 +1,47 @@ +package variables + +import ( + "errors" + "log" + "reflect" +) + +// Dict allows to send more than one variable into a template. +func Dict(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, errors.New("invalid dict call") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, errors.New("dict keys must be strings") + } + dict[key] = values[i+1] + } + + return dict, nil +} + +// FieldInStruct checks if variable is defined in a struct. +func FieldInStruct(data interface{}, field string) bool { + t := reflect.Indirect(reflect.ValueOf(data)).Type() + + if t.Kind() != reflect.Struct { + log.Print("Non-struct type not allowed.") + return false + } + + _, b := t.FieldByName(field) + return b +} + +// StringInSlice checks if a slice contains a string. +func StringInSlice(a string, list []string) (bool, int) { + for i, b := range list { + if b == a { + return true, i + } + } + return false, 0 +} diff --git a/variables/variables_test.go b/variables/variables_test.go new file mode 100644 index 00000000..95dcd5ab --- /dev/null +++ b/variables/variables_test.go @@ -0,0 +1,41 @@ +package variables + +import "testing" + +type testFieldInStructData struct { + f1 string + f2 bool + f3 int + f4 func() +} + +type testFieldInStruct struct { + data interface{} + field string + result bool +} + +var testFieldInStructCases = []testFieldInStruct{ + {testFieldInStructData{}, "f1", true}, + {testFieldInStructData{}, "f2", true}, + {testFieldInStructData{}, "f3", true}, + {testFieldInStructData{}, "f4", true}, + {testFieldInStructData{}, "f5", false}, + {[]string{}, "", false}, + {map[string]int{"oi": 4}, "", false}, + {"asa", "", false}, + {"int", "", false}, +} + +func TestFieldInStruct(t *testing.T) { + for _, pair := range testFieldInStructCases { + v := FieldInStruct(pair.data, pair.field) + if v != pair.result { + t.Error( + "For", pair.data, + "expected", pair.result, + "got", v, + ) + } + } +}