feat: use cobra to provide subcommands, move sources to lib (#506)

- Use cobra in order to provide subcommands `serve` and `db`.
  - Subdir `cmd` is removed.
  - Subdir `cli` is created, which is a standard cobra structure.
- Sources related to the core are moved to subdir `lib`.
- #497 and #504 are merged.
- Deprecated flags are added. See https://github.com/filebrowser/filebrowser/pull/497#discussion_r209428120.
- [`viper.BindPFlags`](https://godoc.org/github.com/spf13/viper#BindPFlags) is used in order to reduce the verbosity in `serve.go`.


Former-commit-id: 4b37ad82e91e01f7718cd389469814674bdf7032 [formerly c84d7fcf9c362b2aa1f9e5b57196152f53835e61] [formerly 2fef43c0382f3cc7d13e0297ccb467e38fac6982 [formerly 69a3f853bd2821d2c52a435277aaac68a468d39b]]
Former-commit-id: 2f7dc1b8ee6735382cedae2053f40c546c21de45 [formerly b438417178b47ad5f7caf9cb728f4a5011a09f5e]
Former-commit-id: 07bc58ab2e1ab10c30be8d0a5e760288bfc4d4dc
This commit is contained in:
1138-4EB
2018-08-21 22:42:03 +01:00
parent 93acf4a6f7
commit 0536d8342c
35 changed files with 535 additions and 840 deletions

26
lib/bolt/config.go Normal file
View File

@@ -0,0 +1,26 @@
package bolt
import (
"github.com/asdine/storm"
fb "github.com/filebrowser/filebrowser/lib"
)
// ConfigStore is a configuration store.
type ConfigStore struct {
DB *storm.DB
}
// Get gets a configuration from the database to an interface.
func (c ConfigStore) Get(name string, to interface{}) error {
err := c.DB.Get("config", name, to)
if err == storm.ErrNotFound {
return fb.ErrNotExist
}
return err
}
// Save saves a configuration from an interface to the database.
func (c ConfigStore) Save(name string, from interface{}) error {
return c.DB.Set("config", name, from)
}

66
lib/bolt/share.go Normal file
View File

@@ -0,0 +1,66 @@
package bolt
import (
"github.com/asdine/storm"
"github.com/asdine/storm/q"
fb "github.com/filebrowser/filebrowser/lib"
)
// ShareStore is a shareable links store.
type ShareStore struct {
DB *storm.DB
}
// Get gets a Share Link from an hash.
func (s ShareStore) Get(hash string) (*fb.ShareLink, error) {
var v fb.ShareLink
err := s.DB.One("Hash", hash, &v)
if err == storm.ErrNotFound {
return nil, fb.ErrNotExist
}
return &v, err
}
// GetPermanent gets the permanent link from a path.
func (s ShareStore) GetPermanent(path string) (*fb.ShareLink, error) {
var v fb.ShareLink
err := s.DB.Select(q.Eq("Path", path), q.Eq("Expires", false)).First(&v)
if err == storm.ErrNotFound {
return nil, fb.ErrNotExist
}
return &v, err
}
// GetByPath gets all the links for a specific path.
func (s ShareStore) GetByPath(hash string) ([]*fb.ShareLink, error) {
var v []*fb.ShareLink
err := s.DB.Find("Path", hash, &v)
if err == storm.ErrNotFound {
return v, fb.ErrNotExist
}
return v, err
}
// Gets retrieves all the shareable links.
func (s ShareStore) Gets() ([]*fb.ShareLink, error) {
var v []*fb.ShareLink
err := s.DB.All(&v)
if err == storm.ErrNotFound {
return v, fb.ErrNotExist
}
return v, err
}
// Save stores a Share Link on the database.
func (s ShareStore) Save(l *fb.ShareLink) error {
return s.DB.Save(l)
}
// Delete deletes a Share Link from the database.
func (s ShareStore) Delete(hash string) error {
return s.DB.DeleteStruct(&fb.ShareLink{Hash: hash})
}

90
lib/bolt/users.go Normal file
View File

@@ -0,0 +1,90 @@
package bolt
import (
"reflect"
"github.com/asdine/storm"
fb "github.com/filebrowser/filebrowser/lib"
)
// UsersStore is a users store.
type UsersStore struct {
DB *storm.DB
}
// Get gets a user with a certain id from the database.
func (u UsersStore) Get(id int, builder fb.FSBuilder) (*fb.User, error) {
var us fb.User
err := u.DB.One("ID", id, &us)
if err == storm.ErrNotFound {
return nil, fb.ErrNotExist
}
if err != nil {
return nil, err
}
us.FileSystem = builder(us.Scope)
return &us, nil
}
// GetByUsername gets a user with a certain username from the database.
func (u UsersStore) GetByUsername(username string, builder fb.FSBuilder) (*fb.User, error) {
var us fb.User
err := u.DB.One("Username", username, &us)
if err == storm.ErrNotFound {
return nil, fb.ErrNotExist
}
if err != nil {
return nil, err
}
us.FileSystem = builder(us.Scope)
return &us, nil
}
// Gets gets all the users from the database.
func (u UsersStore) Gets(builder fb.FSBuilder) ([]*fb.User, error) {
var us []*fb.User
err := u.DB.All(&us)
if err == storm.ErrNotFound {
return nil, fb.ErrNotExist
}
if err != nil {
return us, err
}
for _, user := range us {
user.FileSystem = builder(user.Scope)
}
return us, err
}
// Update updates the whole user object or only certain fields.
func (u UsersStore) Update(us *fb.User, fields ...string) error {
if len(fields) == 0 {
return u.Save(us)
}
for _, field := range fields {
val := reflect.ValueOf(us).Elem().FieldByName(field).Interface()
if err := u.DB.UpdateField(us, field, val); err != nil {
return err
}
}
return nil
}
// Save saves a user to the database.
func (u UsersStore) Save(us *fb.User) error {
return u.DB.Save(us)
}
// Delete deletes a user from the database.
func (u UsersStore) Delete(id int) error {
return u.DB.DeleteStruct(&fb.User{ID: id})
}

77
lib/doc.go Normal file
View File

@@ -0,0 +1,77 @@
/*
Package filebrowser provides a web interface to access your files
wherever you are. To use this package as a middleware for your app,
you'll need to import both File Browser and File Browser HTTP packages.
import (
fm "github.com/filebrowser/filebrowser"
h "github.com/filebrowser/filebrowser/http"
)
Then, you should create a new FileBrowser object with your options. In this
case, I'm using BoltDB (via Storm package) as a Store. So, you'll also need
to import "github.com/filebrowser/filebrowser/bolt".
db, _ := storm.Open("bolt.db")
m := &fm.FileBrowser{
NoAuth: false,
Auth: {
Method: "default",
LoginHeader: "X-Fowarded-User"
},
DefaultUser: &fm.User{
AllowCommands: true,
AllowEdit: true,
AllowNew: true,
AllowPublish: true,
Commands: []string{"git"},
Rules: []*fm.Rule{},
Locale: "en",
CSS: "",
Scope: ".",
FileSystem: fileutils.Dir("."),
},
Store: &fm.Store{
Config: bolt.ConfigStore{DB: db},
Users: bolt.UsersStore{DB: db},
Share: bolt.ShareStore{DB: db},
},
NewFS: func(scope string) fm.FileSystem {
return fileutils.Dir(scope)
},
}
The credentials for the first user are always 'admin' for both the user and
the password, and they can be changed later through the settings. The first
user is always an Admin and has all of the permissions set to 'true'.
Then, you should set the Prefix URL and the Base URL, using the following
functions:
m.SetBaseURL("/")
m.SetPrefixURL("/")
The Prefix URL is a part of the path that is already stripped from the
r.URL.Path variable before the request arrives to File Browser's handler.
This is a function that will rarely be used. You can see one example on Caddy
filemanager plugin.
The Base URL is the URL path where you want File Browser to be available in. If
you want to be available at the root path, you should call:
m.SetBaseURL("/")
But if you want to access it at '/admin', you would call:
m.SetBaseURL("/admin")
Now, that you already have a File Browser instance created, you just need to
add it to your handlers using m.ServeHTTP which is compatible to http.Handler.
We also have a m.ServeWithErrorsHTTP that returns the status code and an error.
One simple implementation for this, at port 80, in the root of the domain, would be:
http.ListenAndServe(":80", h.Handler(m))
*/
package lib

487
lib/file.go Normal file
View File

@@ -0,0 +1,487 @@
package lib
import (
"bytes"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"hash"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gohugoio/hugo/parser"
"github.com/maruel/natural"
)
// File contains the information about a particular file or directory.
type File struct {
// Indicates the Kind of view on the front-end (Listing, editor or preview).
Kind string `json:"kind"`
// The name of the file.
Name string `json:"name"`
// The Size of the file.
Size int64 `json:"size"`
// The absolute URL.
URL string `json:"url"`
// The extension of the file.
Extension string `json:"extension"`
// The last modified time.
ModTime time.Time `json:"modified"`
// The File Mode.
Mode os.FileMode `json:"mode"`
// Indicates if this file is a directory.
IsDir bool `json:"isDir"`
// Absolute path.
Path string `json:"path"`
// Relative path to user's virtual File System.
VirtualPath string `json:"virtualPath"`
// Indicates the file content type: video, text, image, music or blob.
Type string `json:"type"`
// Stores the content of a text file.
Content string `json:"content,omitempty"`
*Listing `json:",omitempty"`
Metadata string `json:"metadata,omitempty"`
Language string `json:"language,omitempty"`
}
// A Listing is the context used to fill out a template.
type Listing struct {
// The items (files and folders) in the path.
Items []*File `json:"items"`
// The number of directories in the Listing.
NumDirs int `json:"numDirs"`
// The number of files (items that aren't directories) in the Listing.
NumFiles int `json:"numFiles"`
// Which sorting order is used.
Sort string `json:"sort"`
// And which order.
Order string `json:"order"`
}
// GetInfo gets the file information and, in case of error, returns the
// respective HTTP error code
func GetInfo(url *url.URL, c *FileBrowser, u *User) (*File, error) {
var err error
i := &File{
URL: "/files" + url.String(),
VirtualPath: url.Path,
Path: filepath.Join(u.Scope, url.Path),
}
info, err := u.FileSystem.Stat(url.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)
if i.IsDir && !strings.HasSuffix(i.URL, "/") {
i.URL += "/"
}
return i, nil
}
// GetListing gets the information about a specific directory and its files.
func (i *File) GetListing(u *User, r *http.Request) error {
// Gets the directory information using the Virtual File System of
// the user configuration.
f, err := u.FileSystem.OpenFile(i.VirtualPath, os.O_RDONLY, 0)
if err != nil {
return err
}
defer f.Close()
// Reads the directory and gets the information about the files.
files, err := f.Readdir(-1)
if err != nil {
return err
}
var (
fileinfos []*File
dirCount, fileCount int
)
baseurl, err := url.PathUnescape(i.URL)
if err != nil {
return err
}
for _, f := range files {
name := f.Name()
allowed := u.Allowed("/" + name)
if !allowed {
continue
}
if strings.HasPrefix(f.Mode().String(), "L") {
// It's a symbolic link. We try to follow it. If it doesn't work,
// we stay with the link information instead if the target's.
info, err := os.Stat(f.Name())
if err == nil {
f = info
}
}
if f.IsDir() {
name += "/"
dirCount++
} else {
fileCount++
}
// Absolute URL
url := url.URL{Path: baseurl + name}
i := &File{
Name: f.Name(),
Size: f.Size(),
ModTime: f.ModTime(),
Mode: f.Mode(),
IsDir: f.IsDir(),
URL: url.String(),
Extension: filepath.Ext(name),
VirtualPath: filepath.Join(i.VirtualPath, name),
Path: filepath.Join(i.Path, name),
}
i.GetFileType(false)
fileinfos = append(fileinfos, i)
}
i.Listing = &Listing{
Items: fileinfos,
NumDirs: dirCount,
NumFiles: fileCount,
}
return nil
}
// GetEditor gets the editor based on a Info struct
func (i *File) GetEditor() error {
i.Language = editorLanguage(i.Extension)
// If the editor will hold only content, leave now.
if editorMode(i.Language) == "content" {
return nil
}
// If the file doesn't have any kind of metadata, leave now.
if !hasRune(i.Content) {
return nil
}
buffer := bytes.NewBuffer([]byte(i.Content))
page, err := parser.ReadFrom(buffer)
// If there is an error, just ignore it and return nil.
// This way, the file can be served for editing.
if err != nil {
return nil
}
i.Content = strings.TrimSpace(string(page.Content()))
i.Metadata = strings.TrimSpace(string(page.FrontMatter()))
return nil
}
// GetFileType obtains the mimetype and converts it to a simple
// type nomenclature.
func (i *File) GetFileType(checkContent bool) error {
var content []byte
var err error
// Tries to get the file mimetype using its extension.
mimetype := mime.TypeByExtension(i.Extension)
if mimetype == "" && checkContent {
file, err := os.Open(i.Path)
if err != nil {
return err
}
defer file.Close()
// Only the first 512 bytes are used to sniff the content type.
buffer := make([]byte, 512)
_, err = file.Read(buffer)
if err != nil && err != io.EOF {
return err
}
// Tries to get the file mimetype using its first
// 512 bytes.
mimetype = http.DetectContentType(buffer)
}
if strings.HasPrefix(mimetype, "video") {
i.Type = "video"
return nil
}
if strings.HasPrefix(mimetype, "audio") {
i.Type = "audio"
return nil
}
if strings.HasPrefix(mimetype, "image") {
i.Type = "image"
return nil
}
if strings.HasPrefix(mimetype, "text") {
i.Type = "text"
goto End
}
if strings.HasPrefix(mimetype, "application/javascript") {
i.Type = "text"
goto End
}
// If the type isn't text (and is blob for example), it will check some
// common types that are mistaken not to be text.
if isInTextExtensions(i.Name) {
i.Type = "text"
} else {
i.Type = "blob"
}
End:
// If the file type is text, save its content.
if i.Type == "text" {
if len(content) == 0 {
content, err = ioutil.ReadFile(i.Path)
if err != nil {
return err
}
}
i.Content = string(content)
}
return nil
}
// Checksum retrieves the checksum of a file.
func (i File) Checksum(algo string) (string, error) {
file, err := os.Open(i.Path)
if err != nil {
return "", err
}
defer file.Close()
var h hash.Hash
switch algo {
case "md5":
h = md5.New()
case "sha1":
h = sha1.New()
case "sha256":
h = sha256.New()
case "sha512":
h = sha512.New()
default:
return "", ErrInvalidOption
}
_, err = io.Copy(h, file)
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// CanBeEdited checks if the extension of a file is supported by the editor
func (i File) CanBeEdited() bool {
return i.Type == "text"
}
// 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 "modified":
sort.Sort(sort.Reverse(byModified(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 "modified":
sort.Sort(byModified(l))
default:
sort.Sort(byName(l))
return
}
}
}
// Implement sorting for Listing
type byName Listing
type bySize Listing
type byModified 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 natural.Less(l.Items[i].Name, 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 Modified
func (l byModified) Len() int {
return len(l.Items)
}
func (l byModified) Swap(i, j int) {
l.Items[i], l.Items[j] = l.Items[j], l.Items[i]
}
func (l byModified) Less(i, j int) bool {
iModified, jModified := l.Items[i].ModTime, l.Items[j].ModTime
return iModified.Sub(jModified) < 0
}
// textExtensions is the sorted list of text extensions which
// can be edited.
var textExtensions = []string{
".ad", ".ada", ".adoc", ".asciidoc",
".bas", ".bash", ".bat",
".c", ".cc", ".cmd", ".conf", ".cpp", ".cr", ".cs", ".css", ".csv",
".d",
".f", ".f90",
".h", ".hh", ".hpp", ".htaccess", ".html",
".ini",
".java", ".js", ".json",
".markdown", ".md", ".mdown", ".mmark",
".nim",
".php", ".pl", ".ps1", ".py",
".rss", ".rst", ".rtf",
".sass", ".scss", ".sh", ".sty",
".tex", ".tml", ".toml", ".txt",
".vala", ".vapi",
".xml",
".yaml", ".yml",
"Caddyfile",
}
// isInTextExtensions checks if a file can be edited by its extensions.
func isInTextExtensions(name string) bool {
search := filepath.Ext(name)
if search == "" {
search = name
}
i := sort.SearchStrings(textExtensions, search)
return i < len(textExtensions) && textExtensions[i] == search
}
// hasRune checks if the file has the frontmatter rune
func hasRune(file string) bool {
return strings.HasPrefix(file, "---") ||
strings.HasPrefix(file, "+++") ||
strings.HasPrefix(file, "{")
}
func editorMode(language string) string {
switch language {
case "markdown", "asciidoc", "rst":
return "content+metadata"
}
return "content"
}
func editorLanguage(mode string) string {
mode = strings.TrimPrefix(mode, ".")
switch mode {
case "md", "markdown", "mdown", "mmark":
mode = "markdown"
case "yml":
mode = "yaml"
case "asciidoc", "adoc", "ad":
mode = "asciidoc"
case "rst":
mode = "rst"
case "html", "htm", "xml":
mode = "htmlmixed"
case "js":
mode = "javascript"
case "go":
mode = "golang"
case "":
mode = "text"
}
return mode
}

574
lib/filebrowser.go Normal file
View File

@@ -0,0 +1,574 @@
package lib
import (
"crypto/rand"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"reflect"
"regexp"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
rice "github.com/GeertJohan/go.rice"
"github.com/hacdias/fileutils"
"github.com/mholt/caddy"
"github.com/robfig/cron"
)
const (
// Version is the current File Browser version.
Version = "(untracked)"
ListViewMode = "list"
MosaicViewMode = "mosaic"
)
var (
ErrExist = errors.New("the resource already exists")
ErrNotExist = errors.New("the resource does not exist")
ErrEmptyRequest = errors.New("request body is empty")
ErrEmptyPassword = errors.New("password is empty")
ErrEmptyUsername = errors.New("username is empty")
ErrEmptyScope = errors.New("scope is empty")
ErrWrongDataType = errors.New("wrong data type")
ErrInvalidUpdateField = errors.New("invalid field to update")
ErrInvalidOption = errors.New("invalid option")
)
// ReCaptcha settings.
type ReCaptcha struct {
Host string
Key string
Secret string
}
// Auth settings.
type Auth struct {
// Define if which of the following authentication mechansims should be used:
// - 'default', which requires a user and a password.
// - 'proxy', which requires a valid user and the user name has to be provided through an
// http header.
// - 'none', which allows anyone to access the filebrowser instance.
Method string
// If 'Method' is set to 'proxy' the header configured below is used to identify the user.
Header string
}
// FileBrowser is a file manager instance. It should be creating using the
// 'New' function and not directly.
type FileBrowser struct {
// Cron job to manage schedulings.
Cron *cron.Cron
// The key used to sign the JWT tokens.
Key []byte
// The static assets.
Assets *rice.Box
// The Store is used to manage users, shareable links and
// other stuff that is saved on the database.
Store *Store
// PrefixURL is a part of the URL that is already trimmed from the request URL before it
// arrives to our handlers. It may be useful when using File Browser as a middleware
// such as in caddy-filemanager plugin. It is only useful in certain situations.
PrefixURL string
// BaseURL is the path where the GUI will be accessible. It musn't end with
// a trailing slash and mustn't contain PrefixURL, if set. It shouldn't be
// edited directly. Use SetBaseURL.
BaseURL string
// Authentication configuration.
Auth *Auth
// ReCaptcha host, key and secret.
ReCaptcha *ReCaptcha
// StaticGen is the static websit generator handler.
StaticGen StaticGen
// The Default User needed to build the New User page.
DefaultUser *User
// A map of events to a slice of commands.
Commands map[string][]string
// Global stylesheet.
CSS string
// NewFS should build a new file system for a given path.
NewFS FSBuilder
}
var commandEvents = []string{
"before_save",
"after_save",
"before_publish",
"after_publish",
"before_copy",
"after_copy",
"before_rename",
"after_rename",
"before_upload",
"after_upload",
"before_delete",
"after_delete",
}
// Command is a command function.
type Command func(r *http.Request, m *FileBrowser, u *User) error
// FSBuilder is the File System Builder.
type FSBuilder func(scope string) FileSystem
// Setup loads the configuration from the database and configures
// the Assets and the Cron job. It must always be run after
// creating a File Browser object.
func (m *FileBrowser) Setup() error {
// Creates a new File Browser instance with the Users
// map and Assets box.
m.Assets = rice.MustFindBox("../frontend/dist")
m.Cron = cron.New()
// Tries to get the encryption key from the database.
// If it doesn't exist, create a new one of 256 bits.
err := m.Store.Config.Get("key", &m.Key)
if err != nil && err == ErrNotExist {
var bytes []byte
bytes, err = GenerateRandomBytes(64)
if err != nil {
return err
}
m.Key = bytes
err = m.Store.Config.Save("key", m.Key)
}
if err != nil {
return err
}
// Get the global CSS.
err = m.Store.Config.Get("css", &m.CSS)
if err != nil && err == ErrNotExist {
err = m.Store.Config.Save("css", "")
}
if err != nil {
return err
}
// Tries to get the event commands from the database.
// If they don't exist, initialize them.
err = m.Store.Config.Get("commands", &m.Commands)
if err == nil {
// Add hypothetically new command handlers.
for _, command := range commandEvents {
if _, ok := m.Commands[command]; ok {
continue
}
m.Commands[command] = []string{}
}
}
if err != nil && err == ErrNotExist {
m.Commands = map[string][]string{}
// Initialize the command handlers.
for _, command := range commandEvents {
m.Commands[command] = []string{}
}
err = m.Store.Config.Save("commands", m.Commands)
}
if err != nil {
return err
}
// Tries to fetch the users from the database.
users, err := m.Store.Users.Gets(m.NewFS)
if err != nil && err != ErrNotExist {
return err
}
// If there are no users in the database, it creates a new one
// based on 'base' User that must be provided by the function caller.
if len(users) == 0 {
u := *m.DefaultUser
u.Username = "admin"
// Hashes the password.
u.Password, err = HashPassword("admin")
if err != nil {
return err
}
// The first user must be an administrator.
u.Admin = true
u.AllowCommands = true
u.AllowNew = true
u.AllowEdit = true
u.AllowPublish = true
// Saves the user to the database.
if err := m.Store.Users.Save(&u); err != nil {
return err
}
}
m.DefaultUser.Username = ""
m.DefaultUser.Password = ""
m.Cron.AddFunc("@hourly", m.ShareCleaner)
m.Cron.Start()
return nil
}
// RootURL returns the actual URL where
// File Browser interface can be accessed.
func (m FileBrowser) RootURL() string {
return m.PrefixURL + m.BaseURL
}
// SetPrefixURL updates the prefixURL of a File
// Manager object.
func (m *FileBrowser) SetPrefixURL(url string) {
url = strings.TrimPrefix(url, "/")
url = strings.TrimSuffix(url, "/")
url = "/" + url
m.PrefixURL = strings.TrimSuffix(url, "/")
}
// SetBaseURL updates the baseURL of a File Browser
// object.
func (m *FileBrowser) SetBaseURL(url string) {
url = strings.TrimPrefix(url, "/")
url = strings.TrimSuffix(url, "/")
url = "/" + url
m.BaseURL = strings.TrimSuffix(url, "/")
}
// Attach attaches a static generator to the current File Browser.
func (m *FileBrowser) Attach(s StaticGen) error {
if reflect.TypeOf(s).Kind() != reflect.Ptr {
return errors.New("data should be a pointer to interface, not interface")
}
err := s.Setup()
if err != nil {
return err
}
m.StaticGen = s
err = m.Store.Config.Get("staticgen_"+s.Name(), s)
if err == ErrNotExist {
return m.Store.Config.Save("staticgen_"+s.Name(), s)
}
return err
}
// ShareCleaner removes sharing links that are no longer active.
// This function is set to run periodically.
func (m FileBrowser) ShareCleaner() {
// Get all links.
links, err := m.Store.Share.Gets()
if err != nil {
log.Print(err)
return
}
// Find the expired ones.
for i := range links {
if links[i].Expires && links[i].ExpireDate.Before(time.Now()) {
err = m.Store.Share.Delete(links[i].Hash)
if err != nil {
log.Print(err)
}
}
}
}
// Runner runs the commands for a certain event type.
func (m FileBrowser) Runner(event string, path string, destination string, user *User) error {
commands := []string{}
// Get the commands from the File Browser instance itself.
if val, ok := m.Commands[event]; ok {
commands = append(commands, val...)
}
// Execute the commands.
for _, command := range commands {
args := strings.Split(command, " ")
nonblock := false
if len(args) > 1 && args[len(args)-1] == "&" {
// Run command in background; non-blocking
nonblock = true
args = args[:len(args)-1]
}
command, args, err := caddy.SplitCommandAndArgs(strings.Join(args, " "))
if err != nil {
return err
}
cmd := exec.Command(command, args...)
cmd.Env = append(os.Environ(), fmt.Sprintf("FILE=%s", path))
cmd.Env = append(cmd.Env, fmt.Sprintf("ROOT=%s", user.Scope))
cmd.Env = append(cmd.Env, fmt.Sprintf("TRIGGER=%s", event))
cmd.Env = append(cmd.Env, fmt.Sprintf("USERNAME=%s", user.Username))
if destination != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("DESTINATION=%s", destination))
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if nonblock {
log.Printf("[INFO] Nonblocking Command:\"%s %s\"", command, strings.Join(args, " "))
if err := cmd.Start(); err != nil {
return err
}
continue
}
log.Printf("[INFO] Blocking Command:\"%s %s\"", command, strings.Join(args, " "))
if err := cmd.Run(); err != nil {
return err
}
}
return nil
}
// DefaultUser is used on New, when no 'base' user is provided.
var DefaultUser = User{
AllowCommands: true,
AllowEdit: true,
AllowNew: true,
AllowPublish: true,
LockPassword: false,
Commands: []string{},
Rules: []*Rule{},
CSS: "",
Admin: true,
Locale: "",
Scope: ".",
FileSystem: fileutils.Dir("."),
ViewMode: "mosaic",
}
// User contains the configuration for each user.
type User struct {
// ID is the required primary key with auto increment0
ID int `storm:"id,increment"`
// Tells if this user is an admin.
Admin bool `json:"admin"`
// These indicate if the user can perform certain actions.
AllowCommands bool `json:"allowCommands"` // Execute commands
AllowEdit bool `json:"allowEdit"` // Edit/rename files
AllowNew bool `json:"allowNew"` // Create files and folders
AllowPublish bool `json:"allowPublish"` // Publish content (to use with static gen)
// Prevents the user to change its password.
LockPassword bool `json:"lockPassword"`
// Commands is the list of commands the user can execute.
Commands []string `json:"commands"`
// Custom styles for this user.
CSS string `json:"css"`
// FileSystem is the virtual file system the user has access.
FileSystem FileSystem `json:"-"`
// Locale is the language of the user.
Locale string `json:"locale"`
// The hashed password. This never reaches the front-end because it's temporarily
// emptied during JSON marshall.
Password string `json:"password"`
// Rules is an array of access and deny rules.
Rules []*Rule `json:"rules"`
// Scope is the path the user has access to.
Scope string `json:"filesystem"`
// Username is the user username used to login.
Username string `json:"username" storm:"index,unique"`
// User view mode for files and folders.
ViewMode string `json:"viewMode"`
}
// 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
}
// Rule is a dissalow/allow rule.
type Rule struct {
// Regex indicates if this rule uses Regular Expressions or not.
Regex bool `json:"regex"`
// Allow indicates if this is an allow rule. Set 'false' to be a disallow rule.
Allow bool `json:"allow"`
// Path is the corresponding URL path for this rule.
Path string `json:"path"`
// Regexp is the regular expression. Only use this when 'Regex' was set to true.
Regexp *Regexp `json:"regexp"`
}
// Regexp is a regular expression wrapper around native regexp.
type Regexp struct {
Raw string `json:"raw"`
regexp *regexp.Regexp
}
// MatchString checks if this string matches the regular expression.
func (r *Regexp) MatchString(s string) bool {
if r.regexp == nil {
r.regexp = regexp.MustCompile(r.Raw)
}
return r.regexp.MatchString(s)
}
// ShareLink is the information needed to build a shareable link.
type ShareLink struct {
Hash string `json:"hash" storm:"id,index"`
Path string `json:"path" storm:"index"`
Expires bool `json:"expires"`
ExpireDate time.Time `json:"expireDate"`
}
// Store is a collection of the stores needed to get
// and save information.
type Store struct {
Users UsersStore
Config ConfigStore
Share ShareStore
}
// UsersStore is the interface to manage users.
type UsersStore interface {
Get(id int, builder FSBuilder) (*User, error)
GetByUsername(username string, builder FSBuilder) (*User, error)
Gets(builder FSBuilder) ([]*User, error)
Save(u *User) error
Update(u *User, fields ...string) error
Delete(id int) error
}
// ConfigStore is the interface to manage configuration.
type ConfigStore interface {
Get(name string, to interface{}) error
Save(name string, from interface{}) error
}
// ShareStore is the interface to manage share links.
type ShareStore interface {
Get(hash string) (*ShareLink, error)
GetPermanent(path string) (*ShareLink, error)
GetByPath(path string) ([]*ShareLink, error)
Gets() ([]*ShareLink, error)
Save(s *ShareLink) error
Delete(hash string) error
}
// StaticGen is a static website generator.
type StaticGen interface {
SettingsPath() string
Name() string
Setup() error
Hook(c *Context, w http.ResponseWriter, r *http.Request) (int, error)
Preview(c *Context, w http.ResponseWriter, r *http.Request) (int, error)
Publish(c *Context, w http.ResponseWriter, r *http.Request) (int, error)
}
// FileSystem is the interface to work with the file system.
type FileSystem interface {
Mkdir(name string, perm os.FileMode) error
OpenFile(name string, flag int, perm os.FileMode) (*os.File, error)
RemoveAll(name string) error
Rename(oldName, newName string) error
Stat(name string) (os.FileInfo, error)
Copy(src, dst string) error
}
// Context contains the needed information to make handlers work.
type Context struct {
*FileBrowser
User *User
File *File
// On API handlers, Router is the APi handler we want.
Router string
}
// HashPassword generates an hash from a password using bcrypt.
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPasswordHash compares a password with an hash to check if they match.
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// GenerateRandomBytes returns securely generated random bytes.
// It will return an fm.Error if the system's secure random
// number generator fails to function correctly, in which
// case the caller should not continue.
func GenerateRandomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
// Note that err == nil only if we read len(b) bytes.
if err != nil {
return nil, err
}
return b, nil
}

218
lib/http/auth.go Normal file
View File

@@ -0,0 +1,218 @@
package http
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/dgrijalva/jwt-go/request"
fb "github.com/filebrowser/filebrowser/lib"
)
const reCaptchaAPI = "/recaptcha/api/siteverify"
type cred struct {
Password string `json:"password"`
Username string `json:"username"`
ReCaptcha string `json:"recaptcha"`
}
// reCaptcha checks the reCaptcha code.
func reCaptcha(host, secret, response string) (bool, error) {
body := url.Values{}
body.Set("secret", secret)
body.Add("response", response)
client := &http.Client{}
resp, err := client.Post(host+reCaptchaAPI, "application/x-www-form-urlencoded", strings.NewReader(body.Encode()))
if err != nil {
return false, err
}
if resp.StatusCode != http.StatusOK {
return false, nil
}
var data struct {
Success bool `json:"success"`
}
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return false, err
}
return data.Success, nil
}
// authHandler processes the authentication for the user.
func authHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if c.Auth.Method == "none" {
// NoAuth instances shouldn't call this method.
return 0, nil
}
if c.Auth.Method == "proxy" {
// Receive the Username from the Header and check if it exists.
u, err := c.Store.Users.GetByUsername(r.Header.Get(c.Auth.Header), c.NewFS)
if err != nil {
return http.StatusForbidden, nil
}
c.User = u
return printToken(c, w)
}
// Receive the credentials from the request and unmarshal them.
var cred cred
if r.Body == nil {
return http.StatusForbidden, nil
}
err := json.NewDecoder(r.Body).Decode(&cred)
if err != nil {
return http.StatusForbidden, err
}
// If ReCaptcha is enabled, check the code.
if len(c.ReCaptcha.Secret) > 0 {
ok, err := reCaptcha(c.ReCaptcha.Host, c.ReCaptcha.Secret, cred.ReCaptcha)
if err != nil {
return http.StatusForbidden, err
}
if !ok {
return http.StatusForbidden, nil
}
}
// Checks if the user exists.
u, err := c.Store.Users.GetByUsername(cred.Username, c.NewFS)
if err != nil {
return http.StatusForbidden, nil
}
// Checks if the password is correct.
if !fb.CheckPasswordHash(cred.Password, u.Password) {
return http.StatusForbidden, nil
}
c.User = u
return printToken(c, w)
}
// renewAuthHandler is used when the front-end already has a JWT token
// and is checking if it is up to date. If so, updates its info.
func renewAuthHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
ok, u := validateAuth(c, r)
if !ok {
return http.StatusForbidden, nil
}
c.User = u
return printToken(c, w)
}
// claims is the JWT claims.
type claims struct {
fb.User
jwt.StandardClaims
}
// printToken prints the final JWT token to the user.
func printToken(c *fb.Context, w http.ResponseWriter) (int, error) {
// Creates a copy of the user and removes it password
// hash so it never arrives to the user.
u := fb.User{}
u = *c.User
u.Password = ""
// Builds the claims.
claims := claims{
u,
jwt.StandardClaims{
ExpiresAt: time.Now().Add(time.Hour * 24).Unix(),
Issuer: "File Browser",
},
}
// Creates the token and signs it.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(c.Key)
if err != nil {
return http.StatusInternalServerError, err
}
// Writes the token.
w.Header().Set("Content-Type", "cty")
w.Write([]byte(signed))
return 0, nil
}
type extractor []string
func (e extractor) ExtractToken(r *http.Request) (string, error) {
token, _ := request.AuthorizationHeaderExtractor.ExtractToken(r)
// Checks if the token isn't empty and if it contains two dots.
// The former prevents incompatibility with URLs that previously
// used basic auth.
if token != "" && strings.Count(token, ".") == 2 {
return token, nil
}
cookie, err := r.Cookie("auth")
if err != nil {
return "", request.ErrNoTokenInRequest
}
return cookie.Value, nil
}
// validateAuth is used to validate the authentication and returns the
// User if it is valid.
func validateAuth(c *fb.Context, r *http.Request) (bool, *fb.User) {
if c.Auth.Method == "none" {
c.User = c.DefaultUser
return true, c.User
}
// If proxy auth is used do not verify the JWT token if the header is provided.
if c.Auth.Method == "proxy" {
u, err := c.Store.Users.GetByUsername(r.Header.Get(c.Auth.Header), c.NewFS)
if err != nil {
return false, nil
}
c.User = u
return true, c.User
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
return c.Key, nil
}
var claims claims
token, err := request.ParseFromRequestWithClaims(r,
extractor{},
&claims,
keyFunc,
)
if err != nil || !token.Valid {
return false, nil
}
u, err := c.Store.Users.Get(claims.User.ID, c.NewFS)
if err != nil {
return false, nil
}
c.User = u
return true, u
}

102
lib/http/download.go Normal file
View File

@@ -0,0 +1,102 @@
package http
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
fb "github.com/filebrowser/filebrowser/lib"
"github.com/hacdias/fileutils"
"github.com/mholt/archiver"
)
// downloadHandler creates an archive in one of the supported formats (zip, tar,
// tar.gz or tar.bz2) and sends it to be downloaded.
func downloadHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// If the file isn't a directory, serve it using http.ServeFile. We display it
// inline if it is requested.
if !c.File.IsDir {
return downloadFileHandler(c, w, r)
}
query := r.URL.Query().Get("format")
files := []string{}
names := strings.Split(r.URL.Query().Get("files"), ",")
// If there are files in the query, sanitize their names.
// Otherwise, just append the current path.
if len(names) != 0 {
for _, name := range names {
// Unescape the name.
name, err := url.QueryUnescape(name)
if err != nil {
return http.StatusInternalServerError, err
}
// Clean the slashes.
name = fileutils.SlashClean(name)
files = append(files, filepath.Join(c.File.Path, name))
}
} else {
files = append(files, c.File.Path)
}
var (
extension string
ar archiver.Archiver
)
switch query {
// If the format is true, just set it to "zip".
case "zip", "true", "":
extension, ar = ".zip", archiver.Zip
case "tar":
extension, ar = ".tar", archiver.Tar
case "targz":
extension, ar = ".tar.gz", archiver.TarGz
case "tarbz2":
extension, ar = ".tar.bz2", archiver.TarBz2
case "tarxz":
extension, ar = ".tar.xz", archiver.TarXZ
default:
return http.StatusNotImplemented, nil
}
// Defines the file name.
name := c.File.Name
if name == "." || name == "" {
name = "archive"
}
name += extension
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(name))
err := ar.Write(w, files)
return 0, err
}
func downloadFileHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
file, err := os.Open(c.File.Path)
if err != nil {
return http.StatusInternalServerError, err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return http.StatusInternalServerError, err
}
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(c.File.Name))
}
http.ServeContent(w, r, stat.Name(), stat.ModTime(), file)
return 0, nil
}

348
lib/http/http.go Normal file
View File

@@ -0,0 +1,348 @@
package http
import (
"encoding/json"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
fb "github.com/filebrowser/filebrowser/lib"
)
// Handler returns a function compatible with http.HandleFunc.
func Handler(m *fb.FileBrowser) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code, err := serve(&fb.Context{
FileBrowser: m,
User: nil,
File: nil,
}, w, r)
if code >= 400 {
w.WriteHeader(code)
txt := http.StatusText(code)
log.Printf("%v: %v %v\n", r.URL.Path, code, txt)
w.Write([]byte(txt + "\n"))
}
if err != nil {
log.Print(err)
}
})
}
// serve is the main entry point of this HTML application.
func serve(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Checks if the URL contains the baseURL and strips it. Otherwise, it just
// returns a 404 fb.Error because we're not supposed to be here!
p := strings.TrimPrefix(r.URL.Path, c.BaseURL)
if len(p) >= len(r.URL.Path) && c.BaseURL != "" {
return http.StatusNotFound, nil
}
r.URL.Path = p
// Check if this request is made to the service worker. If so,
// pass it through a template to add the needed variables.
if r.URL.Path == "/sw.js" {
return renderFile(c, w, "sw.js")
}
// Checks if this request is made to the static assets folder. If so, and
// if it is a GET request, returns with the asset. Otherwise, returns
// a status not implemented.
if matchURL(r.URL.Path, "/static") {
if r.Method != http.MethodGet {
return http.StatusNotImplemented, nil
}
return staticHandler(c, w, r)
}
// Checks if this request is made to the API and directs to the
// API handler if so.
if matchURL(r.URL.Path, "/api") {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
return apiHandler(c, w, r)
}
// If it is a request to the preview and a static website generator is
// active, build the preview.
if strings.HasPrefix(r.URL.Path, "/preview") && c.StaticGen != nil {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/preview")
return c.StaticGen.Preview(c, w, r)
}
if strings.HasPrefix(r.URL.Path, "/share/") {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/share/")
return sharePage(c, w, r)
}
// Any other request should show the index.html file.
w.Header().Set("x-frame-options", "SAMEORIGIN")
w.Header().Set("x-content-type-options", "nosniff")
w.Header().Set("x-xss-protection", "1; mode=block")
return renderFile(c, w, "index.html")
}
// staticHandler handles the static assets path.
func staticHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path != "/static/manifest.json" {
http.FileServer(c.Assets.HTTPBox()).ServeHTTP(w, r)
return 0, nil
}
return renderFile(c, w, "static/manifest.json")
}
// apiHandler is the main entry point for the /api endpoint.
func apiHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path == "/auth/get" {
return authHandler(c, w, r)
}
if r.URL.Path == "/auth/renew" {
return renewAuthHandler(c, w, r)
}
valid, _ := validateAuth(c, r)
if !valid {
return http.StatusForbidden, nil
}
c.Router, r.URL.Path = splitURL(r.URL.Path)
if !c.User.Allowed(r.URL.Path) {
return http.StatusForbidden, nil
}
if c.StaticGen != nil {
// If we are using the 'magic url' for the settings,
// we should redirect the request for the acutual path.
if r.URL.Path == "/settings" {
r.URL.Path = c.StaticGen.SettingsPath()
}
// Executes the Static website generator hook.
code, err := c.StaticGen.Hook(c, w, r)
if code != 0 || err != nil {
return code, err
}
}
if c.Router == "checksum" || c.Router == "download" || c.Router == "subtitle" || c.Router == "subtitles" {
var err error
c.File, err = fb.GetInfo(r.URL, c.FileBrowser, c.User)
if err != nil {
return ErrorToHTTP(err, false), err
}
}
var code int
var err error
switch c.Router {
case "download":
code, err = downloadHandler(c, w, r)
case "checksum":
code, err = checksumHandler(c, w, r)
case "command":
code, err = command(c, w, r)
case "search":
code, err = search(c, w, r)
case "resource":
code, err = resourceHandler(c, w, r)
case "users":
code, err = usersHandler(c, w, r)
case "settings":
code, err = settingsHandler(c, w, r)
case "share":
code, err = shareHandler(c, w, r)
case "subtitles":
code, err = subtitlesHandler(c, w, r)
case "subtitle":
code, err = subtitleHandler(c, w, r)
default:
code = http.StatusNotFound
}
return code, err
}
// serveChecksum calculates the hash of a file. Supports MD5, SHA1, SHA256 and SHA512.
func checksumHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
query := r.URL.Query().Get("algo")
val, err := c.File.Checksum(query)
if err == fb.ErrInvalidOption {
return http.StatusBadRequest, err
} else if err != nil {
return http.StatusInternalServerError, err
}
w.Write([]byte(val))
return 0, nil
}
// splitURL splits the path and returns everything that stands
// before the first slash and everything that goes after.
func splitURL(path string) (string, string) {
if path == "" {
return "", ""
}
path = strings.TrimPrefix(path, "/")
i := strings.Index(path, "/")
if i == -1 {
return "", path
}
return path[0:i], path[i:]
}
// renderFile renders a file using a template with some needed variables.
func renderFile(c *fb.Context, w http.ResponseWriter, file string) (int, error) {
tpl := template.Must(template.New("file").Parse(c.Assets.MustString(file)))
var contentType string
switch filepath.Ext(file) {
case ".html":
contentType = "text/html"
case ".js":
contentType = "application/javascript"
case ".json":
contentType = "application/json"
default:
contentType = "text"
}
w.Header().Set("Content-Type", contentType+"; charset=utf-8")
data := map[string]interface{}{
"baseurl": c.RootURL(),
"NoAuth": c.Auth.Method == "none",
"Version": fb.Version,
"CSS": template.CSS(c.CSS),
"ReCaptcha": c.ReCaptcha.Key != "" && c.ReCaptcha.Secret != "",
"ReCaptchaHost": c.ReCaptcha.Host,
"ReCaptchaKey": c.ReCaptcha.Key,
}
if c.StaticGen != nil {
data["staticgen"] = c.StaticGen.Name()
}
err := tpl.Execute(w, data)
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
// sharePage build the share page.
func sharePage(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
s, err := c.Store.Share.Get(r.URL.Path)
if err == fb.ErrNotExist {
w.WriteHeader(http.StatusNotFound)
return renderFile(c, w, "static/share/404.html")
}
if err != nil {
return http.StatusInternalServerError, err
}
if s.Expires && s.ExpireDate.Before(time.Now()) {
c.Store.Share.Delete(s.Hash)
w.WriteHeader(http.StatusNotFound)
return renderFile(c, w, "static/share/404.html")
}
r.URL.Path = s.Path
info, err := os.Stat(s.Path)
if err != nil {
c.Store.Share.Delete(s.Hash)
return ErrorToHTTP(err, false), err
}
c.File = &fb.File{
Path: s.Path,
Name: info.Name(),
ModTime: info.ModTime(),
Mode: info.Mode(),
IsDir: info.IsDir(),
Size: info.Size(),
}
dl := r.URL.Query().Get("dl")
if dl == "" || dl == "0" {
tpl := template.Must(template.New("file").Parse(c.Assets.MustString("static/share/index.html")))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := tpl.Execute(w, map[string]interface{}{
"baseurl": c.RootURL(),
"File": c.File,
})
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
return downloadHandler(c, w, r)
}
// renderJSON prints the JSON version of data to the browser.
func renderJSON(w http.ResponseWriter, data interface{}) (int, error) {
marsh, err := json.Marshal(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 0, nil
}
// matchURL checks if the first URL matches the second.
func matchURL(first, second string) bool {
first = strings.ToLower(first)
second = strings.ToLower(second)
return strings.HasPrefix(first, second)
}
// ErrorToHTTP converts errors to HTTP Status Code.
func ErrorToHTTP(err error, gone bool) int {
switch {
case err == nil:
return http.StatusOK
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.StatusConflict
default:
return http.StatusInternalServerError
}
}

386
lib/http/resource.go Normal file
View File

@@ -0,0 +1,386 @@
package http
import (
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
fb "github.com/filebrowser/filebrowser/lib"
"github.com/hacdias/fileutils"
)
// sanitizeURL sanitizes the URL to prevent path transversal
// using fileutils.SlashClean and adds the trailing slash bar.
func sanitizeURL(url string) string {
path := fileutils.SlashClean(url)
if strings.HasSuffix(url, "/") && path != "/" {
return path + "/"
}
return path
}
func resourceHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
r.URL.Path = sanitizeURL(r.URL.Path)
switch r.Method {
case http.MethodGet:
return resourceGetHandler(c, w, r)
case http.MethodDelete:
return resourceDeleteHandler(c, w, r)
case http.MethodPut:
// Before save command handler.
path := filepath.Join(c.User.Scope, r.URL.Path)
if err := c.Runner("before_save", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
code, err := resourcePostPutHandler(c, w, r)
if code != http.StatusOK {
return code, err
}
// After save command handler.
if err := c.Runner("after_save", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
return code, err
case http.MethodPatch:
return resourcePatchHandler(c, w, r)
case http.MethodPost:
return resourcePostPutHandler(c, w, r)
}
return http.StatusNotImplemented, nil
}
func resourceGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Gets the information of the directory/file.
f, err := fb.GetInfo(r.URL, c.FileBrowser, c.User)
if err != nil {
return ErrorToHTTP(err, false), err
}
// If it's a dir and the path doesn't end with a trailing slash,
// add a trailing slash to the path.
if f.IsDir && !strings.HasSuffix(r.URL.Path, "/") {
r.URL.Path = r.URL.Path + "/"
}
// If it is a dir, go and serve the listing.
if f.IsDir {
c.File = f
return listingHandler(c, w, r)
}
// Tries to get the file type.
if err = f.GetFileType(true); err != nil {
return ErrorToHTTP(err, true), err
}
// Serve a preview if the file can't be edited or the
// user has no permission to edit this file. Otherwise,
// just serve the editor.
if !f.CanBeEdited() || !c.User.AllowEdit {
f.Kind = "preview"
return renderJSON(w, f)
}
f.Kind = "editor"
// Tries to get the editor data.
if err = f.GetEditor(); err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, f)
}
func listingHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
f := c.File
f.Kind = "listing"
// Tries to get the listing data.
if err := f.GetListing(c.User, r); err != nil {
return ErrorToHTTP(err, true), err
}
listing := f.Listing
// Defines the cookie scope.
cookieScope := c.RootURL()
if cookieScope == "" {
cookieScope = "/"
}
// Copy the query values into the Listing struct
if sort, order, err := handleSortOrder(w, r, cookieScope); err == nil {
listing.Sort = sort
listing.Order = order
} else {
return http.StatusBadRequest, err
}
listing.ApplySort()
return renderJSON(w, f)
}
func resourceDeleteHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Prevent the removal of the root directory.
if r.URL.Path == "/" || !c.User.AllowEdit {
return http.StatusForbidden, nil
}
// Fire the before trigger.
if err := c.Runner("before_delete", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
// Remove the file or folder.
err := c.User.FileSystem.RemoveAll(r.URL.Path)
if err != nil {
return ErrorToHTTP(err, true), err
}
// Fire the after trigger.
if err := c.Runner("after_delete", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
func resourcePostPutHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.AllowNew && r.Method == http.MethodPost {
return http.StatusForbidden, nil
}
if !c.User.AllowEdit && r.Method == http.MethodPut {
return http.StatusForbidden, nil
}
// Discard any invalid upload before returning to avoid connection
// reset error.
defer func() {
io.Copy(ioutil.Discard, r.Body)
}()
// Checks if the current request is for a directory and not a file.
if strings.HasSuffix(r.URL.Path, "/") {
// If the method is PUT, we return 405 Method not Allowed, because
// POST should be used instead.
if r.Method == http.MethodPut {
return http.StatusMethodNotAllowed, nil
}
// Otherwise we try to create the directory.
err := c.User.FileSystem.Mkdir(r.URL.Path, 0775)
return ErrorToHTTP(err, false), err
}
// If using POST method, we are trying to create a new file so it is not
// desirable to override an already existent file. Thus, we check
// if the file already exists. If so, we just return a 409 Conflict.
if r.Method == http.MethodPost && r.Header.Get("Action") != "override" {
if _, err := c.User.FileSystem.Stat(r.URL.Path); err == nil {
return http.StatusConflict, errors.New("There is already a file on that path")
}
}
// Fire the before trigger.
if err := c.Runner("before_upload", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
// Create/Open the file.
f, err := c.User.FileSystem.OpenFile(r.URL.Path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0775)
if err != nil {
return ErrorToHTTP(err, false), err
}
defer f.Close()
// Copies the new content for the file.
_, err = io.Copy(f, r.Body)
if err != nil {
return ErrorToHTTP(err, false), err
}
// Gets the info about the file.
fi, err := f.Stat()
if err != nil {
return ErrorToHTTP(err, false), err
}
// Check if this instance has a Static Generator and handles publishing
// or scheduling if it's the case.
if c.StaticGen != nil {
code, err := resourcePublishSchedule(c, w, r)
if code != 0 {
return code, err
}
}
// Writes the ETag Header.
etag := fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size())
w.Header().Set("ETag", etag)
// Fire the after trigger.
if err := c.Runner("after_upload", r.URL.Path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
func resourcePublishSchedule(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
publish := r.Header.Get("Publish")
schedule := r.Header.Get("Schedule")
if publish != "true" && schedule == "" {
return 0, nil
}
if !c.User.AllowPublish {
return http.StatusForbidden, nil
}
if publish == "true" {
return resourcePublish(c, w, r)
}
t, err := time.Parse("2006-01-02T15:04", schedule)
if err != nil {
return http.StatusInternalServerError, err
}
c.Cron.AddFunc(t.Format("05 04 15 02 01 *"), func() {
_, err := resourcePublish(c, w, r)
if err != nil {
log.Print(err)
}
})
return http.StatusOK, nil
}
func resourcePublish(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
path := filepath.Join(c.User.Scope, r.URL.Path)
// Before save command handler.
if err := c.Runner("before_publish", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
code, err := c.StaticGen.Publish(c, w, r)
if err != nil {
return code, err
}
// Executed the before publish command.
if err := c.Runner("before_publish", path, "", c.User); err != nil {
return http.StatusInternalServerError, err
}
return code, nil
}
// resourcePatchHandler is the entry point for resource handler.
func resourcePatchHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.AllowEdit {
return http.StatusForbidden, nil
}
dst := r.Header.Get("Destination")
action := r.Header.Get("Action")
dst, err := url.QueryUnescape(dst)
if err != nil {
return ErrorToHTTP(err, true), err
}
src := r.URL.Path
if dst == "/" || src == "/" {
return http.StatusForbidden, nil
}
if action == "copy" {
// Fire the after trigger.
if err := c.Runner("before_copy", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}
// Copy the file.
err = c.User.FileSystem.Copy(src, dst)
// Fire the after trigger.
if err := c.Runner("after_copy", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}
} else {
// Fire the after trigger.
if err := c.Runner("before_rename", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}
// Rename the file.
err = c.User.FileSystem.Rename(src, dst)
// Fire the after trigger.
if err := c.Runner("after_rename", src, dst, c.User); err != nil {
return http.StatusInternalServerError, err
}
}
return ErrorToHTTP(err, true), err
}
// 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, err error) {
sort = r.URL.Query().Get("sort")
order = r.URL.Query().Get("order")
// 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":
http.SetCookie(w, &http.Cookie{
Name: "sort",
Value: sort,
MaxAge: 31536000,
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,
MaxAge: 31536000,
Path: scope,
Secure: r.TLS != nil,
})
}
return
}

146
lib/http/settings.go Normal file
View File

@@ -0,0 +1,146 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"reflect"
fb "github.com/filebrowser/filebrowser/lib"
"github.com/mitchellh/mapstructure"
)
type modifySettingsRequest struct {
modifyRequest
Data struct {
CSS string `json:"css"`
Commands map[string][]string `json:"commands"`
StaticGen map[string]interface{} `json:"staticGen"`
} `json:"data"`
}
type option struct {
Variable string `json:"variable"`
Name string `json:"name"`
Value interface{} `json:"value"`
}
func parsePutSettingsRequest(r *http.Request) (*modifySettingsRequest, error) {
// Checks if the request body is empty.
if r.Body == nil {
return nil, fb.ErrEmptyRequest
}
// Parses the request body and checks if it's well formed.
mod := &modifySettingsRequest{}
err := json.NewDecoder(r.Body).Decode(mod)
if err != nil {
return nil, err
}
// Checks if the request type is right.
if mod.What != "settings" {
return nil, fb.ErrWrongDataType
}
return mod, nil
}
func settingsHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path != "" && r.URL.Path != "/" {
return http.StatusNotFound, nil
}
switch r.Method {
case http.MethodGet:
return settingsGetHandler(c, w, r)
case http.MethodPut:
return settingsPutHandler(c, w, r)
}
return http.StatusMethodNotAllowed, nil
}
type settingsGetRequest struct {
CSS string `json:"css"`
Commands map[string][]string `json:"commands"`
StaticGen []option `json:"staticGen"`
}
func settingsGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.Admin {
return http.StatusForbidden, nil
}
result := &settingsGetRequest{
Commands: c.Commands,
StaticGen: []option{},
CSS: c.CSS,
}
if c.StaticGen != nil {
t := reflect.TypeOf(c.StaticGen).Elem()
for i := 0; i < t.NumField(); i++ {
if t.Field(i).Name[0] == bytes.ToLower([]byte{t.Field(i).Name[0]})[0] {
continue
}
result.StaticGen = append(result.StaticGen, option{
Variable: t.Field(i).Name,
Name: t.Field(i).Tag.Get("name"),
Value: reflect.ValueOf(c.StaticGen).Elem().FieldByName(t.Field(i).Name).Interface(),
})
}
}
return renderJSON(w, result)
}
func settingsPutHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if !c.User.Admin {
return http.StatusForbidden, nil
}
mod, err := parsePutSettingsRequest(r)
if err != nil {
return http.StatusBadRequest, err
}
// Update the commands.
if mod.Which == "commands" {
if err := c.Store.Config.Save("commands", mod.Data.Commands); err != nil {
return http.StatusInternalServerError, err
}
c.Commands = mod.Data.Commands
return http.StatusOK, nil
}
// Update the global CSS.
if mod.Which == "css" {
if err := c.Store.Config.Save("css", mod.Data.CSS); err != nil {
return http.StatusInternalServerError, err
}
c.CSS = mod.Data.CSS
return http.StatusOK, nil
}
// Update the static generator options.
if mod.Which == "staticGen" {
err = mapstructure.Decode(mod.Data.StaticGen, c.StaticGen)
if err != nil {
return http.StatusInternalServerError, err
}
err = c.Store.Config.Save("staticgen_"+c.StaticGen.Name(), c.StaticGen)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
return http.StatusMethodNotAllowed, nil
}

127
lib/http/share.go Normal file
View File

@@ -0,0 +1,127 @@
package http
import (
"encoding/base64"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
fb "github.com/filebrowser/filebrowser/lib"
)
func shareHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
r.URL.Path = sanitizeURL(r.URL.Path)
switch r.Method {
case http.MethodGet:
return shareGetHandler(c, w, r)
case http.MethodDelete:
return shareDeleteHandler(c, w, r)
case http.MethodPost:
return sharePostHandler(c, w, r)
}
return http.StatusNotImplemented, nil
}
func shareGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
path := filepath.Join(c.User.Scope, r.URL.Path)
s, err := c.Store.Share.GetByPath(path)
if err == fb.ErrNotExist {
return http.StatusNotFound, nil
}
if err != nil {
return http.StatusInternalServerError, err
}
for i, link := range s {
if link.Expires && link.ExpireDate.Before(time.Now()) {
c.Store.Share.Delete(link.Hash)
s = append(s[:i], s[i+1:]...)
}
}
if len(s) == 0 {
return http.StatusNotFound, nil
}
return renderJSON(w, s)
}
func sharePostHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
path := filepath.Join(c.User.Scope, r.URL.Path)
var s *fb.ShareLink
expire := r.URL.Query().Get("expires")
unit := r.URL.Query().Get("unit")
if expire == "" {
var err error
s, err = c.Store.Share.GetPermanent(path)
if err == nil {
w.Write([]byte(c.RootURL() + "/share/" + s.Hash))
return 0, nil
}
}
bytes, err := fb.GenerateRandomBytes(6)
if err != nil {
return http.StatusInternalServerError, err
}
str := base64.URLEncoding.EncodeToString(bytes)
s = &fb.ShareLink{
Path: path,
Hash: str,
Expires: expire != "",
}
if expire != "" {
num, err := strconv.Atoi(expire)
if err != nil {
return http.StatusInternalServerError, err
}
var add time.Duration
switch unit {
case "seconds":
add = time.Second * time.Duration(num)
case "minutes":
add = time.Minute * time.Duration(num)
case "days":
add = time.Hour * 24 * time.Duration(num)
default:
add = time.Hour * time.Duration(num)
}
s.ExpireDate = time.Now().Add(add)
}
if err := c.Store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
return renderJSON(w, s)
}
func shareDeleteHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
s, err := c.Store.Share.Get(strings.TrimPrefix(r.URL.Path, "/"))
if err == fb.ErrNotExist {
return http.StatusNotFound, nil
}
if err != nil {
return http.StatusInternalServerError, err
}
err = c.Store.Share.Delete(s.Hash)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}

83
lib/http/subtitle.go Normal file
View File

@@ -0,0 +1,83 @@
package http
import (
"bytes"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"regexp"
fb "github.com/filebrowser/filebrowser/lib"
)
func subtitlesHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
files, err := readDir(filepath.Dir(c.File.Path))
if err != nil {
return http.StatusInternalServerError, err
}
subtitles := make([]map[string]string, 0)
for _, file := range files {
ext := filepath.Ext(file.Name())
if ext == ".vtt" || ext == ".srt" {
sub := make(map[string]string)
sub["src"] = filepath.Dir(c.File.Path) + "/" + file.Name()
sub["kind"] = "subtitles"
sub["label"] = file.Name()
subtitles = append(subtitles, sub)
}
}
return renderJSON(w, subtitles)
}
func subtitleHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
str, err := cleanSubtitle(c.File.Path)
if err != nil {
return http.StatusInternalServerError, err
}
file, err := os.Open(c.File.Path)
if err != nil {
return http.StatusInternalServerError, err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return http.StatusInternalServerError, err
}
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("Content-Type", "text/vtt")
http.ServeContent(w, r, stat.Name(), stat.ModTime(), bytes.NewReader([]byte(str)))
return 0, nil
}
func cleanSubtitle(filename string) (string, error) {
b, err := ioutil.ReadFile(filename)
if err != nil {
return "", err
}
str := string(b) // convert content to a 'string'
ext := filepath.Ext(filename)
if ext == ".srt" {
re := regexp.MustCompile("([0-9]{2}:[0-9]{2}:[0-9]{2}),([0-9]{3})")
str = "WEBVTT\n\n" + re.ReplaceAllString(str, "$1.$2")
}
return str, err
}
func readDir(dirname string) ([]os.FileInfo, error) {
f, err := os.Open(dirname)
if err != nil {
return nil, err
}
list, err := f.Readdir(-1)
f.Close()
if err != nil {
return nil, err
}
return list, nil
}

383
lib/http/users.go Normal file
View File

@@ -0,0 +1,383 @@
package http
import (
"encoding/json"
"errors"
"net/http"
"os"
"sort"
"strconv"
"strings"
fb "github.com/filebrowser/filebrowser/lib"
)
type modifyRequest struct {
What string `json:"what"` // Answer to: what data type?
Which string `json:"which"` // Answer to: which field?
}
type modifyUserRequest struct {
modifyRequest
Data *fb.User `json:"data"`
}
// usersHandler is the entry point of the users API. It's just a router
// to send the request to its
func usersHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// If the user isn't admin and isn't making a PUT
// request, then return forbidden.
if !c.User.Admin && r.Method != http.MethodPut {
return http.StatusForbidden, nil
}
switch r.Method {
case http.MethodGet:
return usersGetHandler(c, w, r)
case http.MethodPost:
return usersPostHandler(c, w, r)
case http.MethodDelete:
return usersDeleteHandler(c, w, r)
case http.MethodPut:
return usersPutHandler(c, w, r)
}
return http.StatusNotImplemented, nil
}
// getUserID returns the id from the user which is present
// in the request url. If the url is invalid and doesn't
// contain a valid ID, it returns an fb.Error.
func getUserID(r *http.Request) (int, error) {
// Obtains the ID in string from the URL and converts
// it into an integer.
sid := strings.TrimPrefix(r.URL.Path, "/")
sid = strings.TrimSuffix(sid, "/")
id, err := strconv.Atoi(sid)
if err != nil {
return http.StatusBadRequest, err
}
return id, nil
}
// getUser returns the user which is present in the request
// body. If the body is empty or the JSON is invalid, it
// returns an fb.Error.
func getUser(c *fb.Context, r *http.Request) (*fb.User, string, error) {
// Checks if the request body is empty.
if r.Body == nil {
return nil, "", fb.ErrEmptyRequest
}
// Parses the request body and checks if it's well formed.
mod := &modifyUserRequest{}
err := json.NewDecoder(r.Body).Decode(mod)
if err != nil {
return nil, "", err
}
// Checks if the request type is right.
if mod.What != "user" {
return nil, "", fb.ErrWrongDataType
}
mod.Data.FileSystem = c.NewFS(mod.Data.Scope)
return mod.Data, mod.Which, nil
}
func usersGetHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Request for the default user data.
if r.URL.Path == "/base" {
return renderJSON(w, c.DefaultUser)
}
// Request for the listing of users.
if r.URL.Path == "/" {
users, err := c.Store.Users.Gets(c.NewFS)
if err != nil {
return http.StatusInternalServerError, err
}
for _, u := range users {
// Removes the user password so it won't
// be sent to the front-end.
u.Password = ""
}
sort.Slice(users, func(i, j int) bool {
return users[i].ID < users[j].ID
})
return renderJSON(w, users)
}
id, err := getUserID(r)
if err != nil {
return http.StatusInternalServerError, err
}
u, err := c.Store.Users.Get(id, c.NewFS)
if err == fb.ErrExist {
return http.StatusNotFound, err
}
if err != nil {
return http.StatusInternalServerError, err
}
u.Password = ""
return renderJSON(w, u)
}
func usersPostHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path != "/" {
return http.StatusMethodNotAllowed, nil
}
u, _, err := getUser(c, r)
if err != nil {
return http.StatusBadRequest, err
}
// Checks if username isn't empty.
if u.Username == "" {
return http.StatusBadRequest, fb.ErrEmptyUsername
}
// Checks if scope isn't empty.
if u.Scope == "" {
return http.StatusBadRequest, fb.ErrEmptyScope
}
// Checks if password isn't empty.
if u.Password == "" {
return http.StatusBadRequest, fb.ErrEmptyPassword
}
// Initialize rules if they're not initialized.
if u.Rules == nil {
u.Rules = []*fb.Rule{}
}
// If the view mode is empty, initialize with the default one.
if u.ViewMode == "" {
u.ViewMode = c.DefaultUser.ViewMode
}
// Initialize commands if not initialized.
if u.Commands == nil {
u.Commands = []string{}
}
// It's a new user so the ID will be auto created.
if u.ID != 0 {
u.ID = 0
}
// Checks if the scope exists.
if code, err := checkFS(u.Scope); err != nil {
return code, err
}
// Hashes the password.
pw, err := fb.HashPassword(u.Password)
if err != nil {
return http.StatusInternalServerError, err
}
u.Password = pw
u.ViewMode = fb.MosaicViewMode
// Saves the user to the database.
err = c.Store.Users.Save(u)
if err == fb.ErrExist {
return http.StatusConflict, err
}
if err != nil {
return http.StatusInternalServerError, err
}
// Set the Location header and return.
w.Header().Set("Location", "/settings/users/"+strconv.Itoa(u.ID))
w.WriteHeader(http.StatusCreated)
return 0, nil
}
func checkFS(path string) (int, error) {
info, err := os.Stat(path)
if err != nil {
if !os.IsNotExist(err) {
return http.StatusInternalServerError, err
}
err = os.MkdirAll(path, 0666)
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
if !info.IsDir() {
return http.StatusBadRequest, errors.New("Scope is not a dir")
}
return 0, nil
}
func usersDeleteHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
if r.URL.Path == "/" {
return http.StatusMethodNotAllowed, nil
}
id, err := getUserID(r)
if err != nil {
return http.StatusInternalServerError, err
}
// Deletes the user from the database.
err = c.Store.Users.Delete(id)
if err == fb.ErrNotExist {
return http.StatusNotFound, fb.ErrNotExist
}
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
func usersPutHandler(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// New users should be created on /api/users.
if r.URL.Path == "/" {
return http.StatusMethodNotAllowed, nil
}
// Gets the user ID from the URL and checks if it's valid.
id, err := getUserID(r)
if err != nil {
return http.StatusInternalServerError, err
}
// Checks if the user has permission to access this page.
if !c.User.Admin && id != c.User.ID {
return http.StatusForbidden, nil
}
// Gets the user from the request body.
u, which, err := getUser(c, r)
if err != nil {
return http.StatusBadRequest, err
}
// If we're updating the default user. Only for NoAuth
// implementations. Used to change the viewMode.
if id == 0 && c.Auth.Method == "none" {
c.DefaultUser.ViewMode = u.ViewMode
return http.StatusOK, nil
}
// Updates the CSS and locale.
if which == "partial" {
c.User.CSS = u.CSS
c.User.Locale = u.Locale
c.User.ViewMode = u.ViewMode
err = c.Store.Users.Update(c.User, "CSS", "Locale", "ViewMode")
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
// Updates the Password.
if which == "password" {
if u.Password == "" {
return http.StatusBadRequest, fb.ErrEmptyPassword
}
if id == c.User.ID && c.User.LockPassword {
return http.StatusForbidden, nil
}
c.User.Password, err = fb.HashPassword(u.Password)
if err != nil {
return http.StatusInternalServerError, err
}
err = c.Store.Users.Update(c.User, "Password")
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
// If can only be all.
if which != "all" {
return http.StatusBadRequest, fb.ErrInvalidUpdateField
}
// Checks if username isn't empty.
if u.Username == "" {
return http.StatusBadRequest, fb.ErrEmptyUsername
}
// Checks if filesystem isn't empty.
if u.Scope == "" {
return http.StatusBadRequest, fb.ErrEmptyScope
}
// Checks if the scope exists.
if code, err := checkFS(u.Scope); err != nil {
return code, err
}
// Initialize rules if they're not initialized.
if u.Rules == nil {
u.Rules = []*fb.Rule{}
}
// Initialize commands if not initialized.
if u.Commands == nil {
u.Commands = []string{}
}
// Gets the current saved user from the in-memory map.
suser, err := c.Store.Users.Get(id, c.NewFS)
if err == fb.ErrNotExist {
return http.StatusNotFound, nil
}
if err != nil {
return http.StatusInternalServerError, err
}
u.ID = id
// Changes the password if the request wants it.
if u.Password != "" {
pw, err := fb.HashPassword(u.Password)
if err != nil {
return http.StatusInternalServerError, err
}
u.Password = pw
} else {
u.Password = suser.Password
}
// Updates the whole User struct because we always are supposed
// to send a new entire object.
err = c.Store.Users.Update(u)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}

340
lib/http/websockets.go Normal file
View File

@@ -0,0 +1,340 @@
package http
import (
"bytes"
"encoding/json"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
fb "github.com/filebrowser/filebrowser/lib"
"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(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Upgrades the connection to a websocket and checks for fb.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 c.User.Commands {
if regexp.MustCompile(cmd).MatchString(command[0]) {
allowed = true
break
}
}
if !allowed {
err = conn.WriteMessage(websocket.TextMessage, cmdNotAllowed)
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
// Check if the program is installed on the computer.
if _, err = exec.LookPath(command[0]); err != nil {
err = conn.WriteMessage(websocket.TextMessage, cmdNotImplemented)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusNotImplemented, nil
}
// Gets the path and initializes a buffer.
path := c.User.Scope + "/" + r.URL.Path
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 fb.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
}
var (
typeRegexp = regexp.MustCompile(`type:(\w+)`)
)
type condition func(path string) bool
type searchOptions struct {
CaseSensitive bool
Conditions []condition
Terms []string
}
func extensionCondition(extension string) condition {
return func(path string) bool {
return filepath.Ext(path) == "."+extension
}
}
func imageCondition(path string) bool {
extension := filepath.Ext(path)
mimetype := mime.TypeByExtension(extension)
return strings.HasPrefix(mimetype, "image")
}
func audioCondition(path string) bool {
extension := filepath.Ext(path)
mimetype := mime.TypeByExtension(extension)
return strings.HasPrefix(mimetype, "audio")
}
func videoCondition(path string) bool {
extension := filepath.Ext(path)
mimetype := mime.TypeByExtension(extension)
return strings.HasPrefix(mimetype, "video")
}
func parseSearch(value string) *searchOptions {
opts := &searchOptions{
CaseSensitive: strings.Contains(value, "case:sensitive"),
Conditions: []condition{},
Terms: []string{},
}
// removes the options from the value
value = strings.Replace(value, "case:insensitive", "", -1)
value = strings.Replace(value, "case:sensitive", "", -1)
value = strings.TrimSpace(value)
types := typeRegexp.FindAllStringSubmatch(value, -1)
for _, t := range types {
if len(t) == 1 {
continue
}
switch t[1] {
case "image":
opts.Conditions = append(opts.Conditions, imageCondition)
case "audio", "music":
opts.Conditions = append(opts.Conditions, audioCondition)
case "video":
opts.Conditions = append(opts.Conditions, videoCondition)
default:
opts.Conditions = append(opts.Conditions, extensionCondition(t[1]))
}
}
if len(types) > 0 {
// Remove the fields from the search value.
value = typeRegexp.ReplaceAllString(value, "")
}
// If it's canse insensitive, put everything in lowercase.
if !opts.CaseSensitive {
value = strings.ToLower(value)
}
// Remove the spaces from the search value.
value = strings.TrimSpace(value)
if value == "" {
return opts
}
// 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(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Upgrades the connection to a websocket and checks for fb.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.TrimPrefix(r.URL.Path, "/")
scope = "/" + scope
scope = c.User.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.CaseSensitive {
path = strings.ToLower(path)
}
path = strings.TrimPrefix(path, scope)
path = strings.TrimPrefix(path, "/")
path = strings.Replace(path, "\\", "/", -1)
// Only execute if there are conditions to meet.
if len(search.Conditions) > 0 {
match := false
for _, t := range search.Conditions {
if t(path) {
match = true
break
}
}
// If doesn't meet the condition, go to the next.
if !match {
return nil
}
}
if len(search.Terms) > 0 {
is := false
// Checks if matches the terms and if it is allowed.
for _, term := range search.Terms {
if is {
break
}
if strings.Contains(path, term) {
if !c.User.Allowed(path) {
return nil
}
is = true
}
}
if !is {
return nil
}
}
response, _ := json.Marshal(map[string]interface{}{
"dir": f.IsDir(),
"path": path,
})
return conn.WriteMessage(websocket.TextMessage, response)
})
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}

192
lib/staticgen/hugo.go Normal file
View File

@@ -0,0 +1,192 @@
package staticgen
import (
"errors"
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
fb "github.com/filebrowser/filebrowser/lib"
"github.com/hacdias/varutils"
)
var (
errUnsupportedFileType = errors.New("The type of the provided file isn't supported for this action")
)
// Hugo is the Hugo static website generator.
type Hugo struct {
// Website root
Root string `name:"Website Root"`
// Public folder
Public string `name:"Public Directory"`
// Hugo executable path
Exe string `name:"Hugo Executable"`
// Hugo arguments
Args []string `name:"Hugo Arguments"`
// Indicates if we should clean public before a new publish.
CleanPublic bool `name:"Clean Public"`
// previewPath is the temporary path for a preview
previewPath string
}
// SettingsPath retrieves the correct settings path.
func (h Hugo) SettingsPath() string {
var frontmatter string
var err error
if _, err = os.Stat(filepath.Join(h.Root, "config.yaml")); err == nil {
frontmatter = "yaml"
}
if _, err = os.Stat(filepath.Join(h.Root, "config.json")); err == nil {
frontmatter = "json"
}
if _, err = os.Stat(filepath.Join(h.Root, "config.toml")); err == nil {
frontmatter = "toml"
}
if frontmatter == "" {
return "/settings"
}
return "/config." + frontmatter
}
// Name is the plugin's name.
func (h Hugo) Name() string {
return "hugo"
}
// Hook is the pre-api handler.
func (h Hugo) Hook(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// If we are not using HTTP Post, we shall return Method Not Allowed
// since we are only working with this method.
if r.Method != http.MethodPost {
return 0, nil
}
if c.Router != "resource" {
return 0, nil
}
// We only care about creating new files from archetypes here. So...
if r.Header.Get("Archetype") == "" {
return 0, nil
}
if !c.User.AllowNew {
return http.StatusForbidden, nil
}
filename := filepath.Clean(r.URL.Path)
filename = strings.TrimPrefix(filename, string(filepath.Separator))
archetype := r.Header.Get("archetype")
ext := filepath.Ext(filename)
// If the request isn't for a markdown file, we can't
// handle it.
if ext != ".markdown" && ext != ".md" {
return http.StatusBadRequest, errUnsupportedFileType
}
// Tries to create a new file based on this archetype.
args := []string{"new", filename, "--kind", archetype}
if err := runCommand(h.Exe, args, h.Root); err != nil {
return http.StatusInternalServerError, err
}
// Writes the location of the new file to the Header.
w.Header().Set("Location", "/files/content/"+filename)
return http.StatusCreated, nil
}
// Publish publishes a post.
func (h Hugo) Publish(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
filename := filepath.Join(c.User.Scope, r.URL.Path)
// We only run undraft command if it is a file.
if strings.HasSuffix(filename, ".md") && strings.HasSuffix(filename, ".markdown") {
if err := h.undraft(filename); err != nil {
return http.StatusInternalServerError, err
}
}
// Regenerates the file
h.run(false)
return 0, nil
}
// Preview handles the preview path.
func (h *Hugo) Preview(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Get a new temporary path if there is none.
if h.previewPath == "" {
path, err := ioutil.TempDir("", "")
if err != nil {
return http.StatusInternalServerError, err
}
h.previewPath = path
}
// Build the arguments to execute Hugo: change the base URL,
// build the drafts and update the destination.
args := h.Args
args = append(args, "--baseURL", c.RootURL()+"/preview/")
args = append(args, "--buildDrafts")
args = append(args, "--destination", h.previewPath)
// Builds the preview.
if err := runCommand(h.Exe, args, h.Root); err != nil {
return http.StatusInternalServerError, err
}
// Serves the temporary path with the preview.
http.FileServer(http.Dir(h.previewPath)).ServeHTTP(w, r)
return 0, nil
}
func (h Hugo) run(force bool) {
// If the CleanPublic option is enabled, clean it.
if h.CleanPublic {
os.RemoveAll(h.Public)
}
// Prevent running if watching is enabled
if b, pos := varutils.StringInSlice("--watch", h.Args); b && !force {
if len(h.Args) > pos && h.Args[pos+1] != "false" {
return
}
if len(h.Args) == pos+1 {
return
}
}
if err := runCommand(h.Exe, h.Args, h.Root); err != nil {
log.Println(err)
}
}
func (h Hugo) undraft(file string) error {
args := []string{"undraft", file}
if err := runCommand(h.Exe, args, h.Root); err != nil && !strings.Contains(err.Error(), "not a Draft") {
return err
}
return nil
}
// Setup sets up the plugin.
func (h *Hugo) Setup() error {
var err error
h.Exe, err = exec.LookPath("hugo")
return err
}

125
lib/staticgen/jekyll.go Normal file
View File

@@ -0,0 +1,125 @@
package staticgen
import (
"io/ioutil"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
fb "github.com/filebrowser/filebrowser/lib"
)
// Jekyll is the Jekyll static website generator.
type Jekyll struct {
// Website root
Root string `name:"Website Root"`
// Public folder
Public string `name:"Public Directory"`
// Jekyll executable path
Exe string `name:"Executable"`
// Jekyll arguments
Args []string `name:"Arguments"`
// Indicates if we should clean public before a new publish.
CleanPublic bool `name:"Clean Public"`
// previewPath is the temporary path for a preview
previewPath string
}
// Name is the plugin's name.
func (j Jekyll) Name() string {
return "jekyll"
}
// SettingsPath retrieves the correct settings path.
func (j Jekyll) SettingsPath() string {
return "/_config.yml"
}
// Hook is the pre-api handler.
func (j Jekyll) Hook(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
return 0, nil
}
// Publish publishes a post.
func (j Jekyll) Publish(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
filename := filepath.Join(c.User.Scope, r.URL.Path)
// We only run undraft command if it is a file.
if err := j.undraft(filename); err != nil {
return http.StatusInternalServerError, err
}
// Regenerates the file
j.run()
return 0, nil
}
// Preview handles the preview path.
func (j *Jekyll) Preview(c *fb.Context, w http.ResponseWriter, r *http.Request) (int, error) {
// Get a new temporary path if there is none.
if j.previewPath == "" {
path, err := ioutil.TempDir("", "")
if err != nil {
return http.StatusInternalServerError, err
}
j.previewPath = path
}
// Build the arguments to execute Hugo: change the base URL,
// build the drafts and update the destination.
args := j.Args
args = append(args, "--baseurl", c.RootURL()+"/preview/")
args = append(args, "--drafts")
args = append(args, "--destination", j.previewPath)
// Builds the preview.
if err := runCommand(j.Exe, args, j.Root); err != nil {
return http.StatusInternalServerError, err
}
// Serves the temporary path with the preview.
http.FileServer(http.Dir(j.previewPath)).ServeHTTP(w, r)
return 0, nil
}
func (j Jekyll) run() {
// If the CleanPublic option is enabled, clean it.
if j.CleanPublic {
os.RemoveAll(j.Public)
}
if err := runCommand(j.Exe, j.Args, j.Root); err != nil {
log.Println(err)
}
}
func (j Jekyll) undraft(file string) error {
if !strings.Contains(file, "_drafts") {
return nil
}
return os.Rename(file, strings.Replace(file, "_drafts", "_posts", 1))
}
// Setup sets up the plugin.
func (j *Jekyll) Setup() error {
var err error
if j.Exe, err = exec.LookPath("jekyll"); err != nil {
return err
}
if len(j.Args) == 0 {
j.Args = []string{"build"}
}
if j.Args[0] != "build" {
j.Args = append([]string{"build"}, j.Args...)
}
return nil
}

View File

@@ -0,0 +1,19 @@
package staticgen
import (
"errors"
"os/exec"
)
// runCommand executes an external command
func runCommand(command string, args []string, path string) error {
cmd := exec.Command(command, args...)
cmd.Dir = path
out, err := cmd.CombinedOutput()
if err != nil {
return errors.New(string(out))
}
return nil
}