wings/server/filesystem.go
2019-04-07 15:11:56 -07:00

320 lines
9.0 KiB
Go

package server
import (
"bytes"
"encoding/json"
"errors"
"github.com/gabriel-vasile/mimetype"
"go.uber.org/zap"
"io"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Error returned when there is a bad path provided to one of the FS calls.
var InvalidPathResolution = errors.New("invalid path resolution")
type Filesystem struct {
// The root directory where all of the server data is contained. By default
// this is going to be /srv/daemon-data but can vary depending on the system.
Root string
// The server object associated with this Filesystem.
Server *Server
}
// Returns the root path that contains all of a server's data.
func (fs *Filesystem) Path() string {
return filepath.Join(fs.Root, fs.Server.Uuid)
}
// Normalizes a directory being passed in to ensure the user is not able to escape
// from their data directory. After normalization if the directory is still within their home
// path it is returned. If they managed to "escape" an error will be returned.
//
// This logic is actually copied over from the SFTP server code. Ideally that eventually
// either gets ported into this application, or is able to make use of this package.
func (fs *Filesystem) SafePath(p string) (string, error) {
var nonExistentPathResolution string
// Calling filpath.Clean on the joined directory will resolve it to the absolute path,
// removing any ../ type of resolution arguments, and leaving us with a direct path link.
r := filepath.Clean(filepath.Join(fs.Path(), p))
// At the same time, evaluate the symlink status and determine where this file or folder
// is truly pointing to.
p, err := filepath.EvalSymlinks(r)
if err != nil && !os.IsNotExist(err) {
return "", err
} else if os.IsNotExist(err) {
// The requested directory doesn't exist, so at this point we need to iterate up the
// path chain until we hit a directory that _does_ exist and can be validated.
parts := strings.Split(filepath.Dir(r), "/")
var try string
// Range over all of the path parts and form directory pathings from the end
// moving up until we have a valid resolution or we run out of paths to try.
for k := range parts {
try = strings.Join(parts[:(len(parts) - k)], "/")
if !strings.HasPrefix(try, fs.Path()) {
break
}
t, err := filepath.EvalSymlinks(try)
if err == nil {
nonExistentPathResolution = t
break
}
}
}
// If the new path doesn't start with their root directory there is clearly an escape
// attempt going on, and we should NOT resolve this path for them.
if nonExistentPathResolution != "" {
if !strings.HasPrefix(nonExistentPathResolution, fs.Path()) {
return "", InvalidPathResolution
}
// If the nonExistentPathResoltion variable is not empty then the initial path requested
// did not exist and we looped through the pathway until we found a match. At this point
// we've confirmed the first matched pathway exists in the root server directory, so we
// can go ahead and just return the path that was requested initially.
return r, nil
}
// If the requested directory from EvalSymlinks begins with the server root directory go
// ahead and return it. If not we'll return an error which will block any further action
// on the file.
if strings.HasPrefix(p, fs.Path()) {
return p, nil
}
return "", InvalidPathResolution
}
// Determines if the directory a file is trying to be added to has enough space available
// for the file to be written to.
//
// Because determining the amount of space being used by a server is a taxing operation we
// will load it all up into a cache and pull from that as long as the key is not expired.
func (fs *Filesystem) HasSpaceAvailable() bool {
var space = fs.Server.Build.DiskSpace
// If space is -1 or 0 just return true, means they're allowed unlimited.
if space <= 0 {
return true
}
var size int64
if x, exists := fs.Server.Cache().Get("disk_used"); exists {
size = x.(int64)
}
// If there is no size its either because there is no data (in which case running this function
// will have effectively no impact), or there is nothing in the cache, in which case we need to
// grab the size of their data directory. This is a taxing operation, so we want to store it in
// the cache once we've gotten it.
if size == 0 {
if size, err := fs.DirectorySize("/"); err != nil {
zap.S().Warnw("failed to determine directory size", zap.String("server", fs.Server.Uuid), zap.Error(err))
} else {
fs.Server.Cache().Set("disk_used", size, time.Minute*5)
}
}
// Determine if their folder size, in bytes, is smaller than the amount of space they've
// been allocated.
return (size / 1024.0 / 1024.0) <= space
}
// Determines the directory size of a given location by running parallel tasks to iterate
// through all of the folders. Returns the size in bytes. This can be a fairly taxing operation
// on locations with tons of files, so it is recommended that you cache the output.
func (fs *Filesystem) DirectorySize(dir string) (int64, error) {
var size int64
var wg sync.WaitGroup
cleaned, err := fs.SafePath(dir)
if err != nil {
return 0, err
}
files, err := ioutil.ReadDir(cleaned)
if err != nil {
return 0, err
}
// Iterate over all of the files and directories. If it is a file, immediately add its size
// to the total size being returned. If we're dealing with a directory, call this function
// on a seperate thread until we have gotten the size of everything nested within the given
// directory.
for _, f := range files {
if f.IsDir() {
wg.Add(1)
go func(p string) {
defer wg.Done()
s, _ := fs.DirectorySize(p)
size += s
}(filepath.Join(cleaned, f.Name()))
} else {
size += f.Size()
}
}
wg.Wait()
return size, nil
}
// Reads a file on the system and returns it as a byte representation in a file
// reader. This is not the most memory efficient usage since it will be reading the
// entirety of the file into memory.
func (fs *Filesystem) Readfile(p string) (io.Reader, error) {
cleaned, err := fs.SafePath(p)
if err != nil {
return nil, err
}
b, err := ioutil.ReadFile(cleaned)
if err != nil {
return nil, err
}
return bytes.NewReader(b), nil
}
// Delete a file or folder from the system. If a folder location is passed in the
// folder and all of its contents are deleted.
func (fs *Filesystem) DeleteFile(p string) error {
cleaned, err := fs.SafePath(p)
if err != nil {
return err
}
return os.RemoveAll(cleaned)
}
// Defines the stat struct object.
type Stat struct {
Info os.FileInfo
Mimetype string
}
func (s *Stat) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Name string `json:"name"`
Created string `json:"created"`
Modified string `json:"modified"`
Mode string `json:"mode"`
Size int64 `json:"size"`
Directory bool `json:"directory"`
File bool `json:"file"`
Symlink bool `json:"symlink"`
Mime string `json:"mime"`
}{
Name: s.Info.Name(),
Created: s.CTime().String(),
Modified: s.Info.ModTime().String(),
Mode: s.Info.Mode().String(),
Size: s.Info.Size(),
Directory: s.Info.IsDir(),
File: !s.Info.IsDir(),
Symlink: s.Info.Mode().Perm()&os.ModeSymlink != 0,
Mime: s.Mimetype,
})
}
// Stats a file or folder and returns the base stat object from go along with the
// MIME data that can be used for editing files.
func (fs *Filesystem) Stat(p string) (*Stat, error) {
cleaned, err := fs.SafePath(p)
if err != nil {
return nil, err
}
s, err := os.Stat(cleaned)
if err != nil {
return nil, err
}
var m = "inode/directory"
if !s.IsDir() {
m, _, err = mimetype.DetectFile(cleaned)
if err != nil {
return nil, err
}
}
st := &Stat{
Info: s,
Mimetype: m,
}
return st, nil
}
// Lists the contents of a given directory and returns stat information about each
// file and folder within it.
func (fs *Filesystem) ListDirectory(p string) ([]*Stat, error) {
cleaned, err := fs.SafePath(p)
if err != nil {
return nil, err
}
files, err := ioutil.ReadDir(cleaned)
if err != nil {
return nil, err
}
var out []*Stat
var wg sync.WaitGroup
// Iterate over all of the files and directories returned and perform an async process
// to get the mime-type for them all.
for _, file := range files {
wg.Add(1)
go func(f os.FileInfo) {
defer wg.Done()
var m = "inode/directory"
if !f.IsDir() {
m, _, _ = mimetype.DetectFile(filepath.Join(cleaned, f.Name()))
}
out = append(out, &Stat{
Info: f,
Mimetype: m,
})
}(file)
}
wg.Wait()
// Sort the output alphabetically to begin with since we've run the output
// through an asynchronous process and the order is gonna be very random.
sort.SliceStable(out, func(i, j int) bool {
if out[i].Info.Name() == out[j].Info.Name() || out[i].Info.Name() < out[j].Info.Name() {
return true
}
return false
})
// Then, sort it so that directories are listed first in the output. Everything
// will continue to be alphabetized at this point.
sort.SliceStable(out, func(i, j int) bool {
return out[i].Info.IsDir()
})
return out, nil
}