308 lines
8.3 KiB
Go
308 lines
8.3 KiB
Go
package megauploader
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/c2h5oh/datasize"
|
|
"github.com/pkg/errors"
|
|
glob "github.com/ryanuber/go-glob"
|
|
)
|
|
|
|
// helpers {{{
|
|
|
|
// Globs is an array of globs with convenience methods
|
|
type Globs []string
|
|
|
|
// Has check if any of the Glob matches with needle
|
|
func (gs Globs) Has(needle string) bool {
|
|
for _, g := range gs {
|
|
if glob.Glob(g, needle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ExtensionList is a list of extensions; you MUST NOT include the dot. That is, it must be
|
|
// {"mp3", "ogg", "aac"}
|
|
// not
|
|
// {".docx", ".odt"}
|
|
type ExtensionList []string
|
|
|
|
// Matches check if the provided filename matches any of the extension
|
|
func (el *ExtensionList) Matches(needle string) bool {
|
|
if len(*el) == 0 {
|
|
return true
|
|
}
|
|
n := strings.ToLower(needle)
|
|
for _, ext := range *el {
|
|
if strings.HasSuffix(n, fmt.Sprintf(".%s", strings.ToLower(ext))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// }}}
|
|
|
|
// Config contains the whole configuration
|
|
type Config struct {
|
|
Global Global `yaml:"global"`
|
|
UserHome *UserHome `yaml:"userhome"`
|
|
Shares []Share `yaml:"shares"`
|
|
}
|
|
|
|
// Pretty returns a pretty-printed representation of the Config data
|
|
func (c Config) Pretty() string {
|
|
return fmt.Sprintf("GLOBAL\n%+v\nHOME\n%+v\nSHARES\n%+v\n", c.Global, *(c.UserHome), c.Shares)
|
|
}
|
|
|
|
// Validate checks the config for blocking inconsistencies
|
|
//
|
|
// while at the moment this does few checks, expect it to act on the conservative side
|
|
//
|
|
// returns a slice of errors; so check with
|
|
// if errs := conf.Validate(); len(errs) != 0
|
|
func (c Config) Validate() []error {
|
|
errs := make([]error, 0)
|
|
names := make([]string, len(c.Shares))
|
|
if c.Shares != nil {
|
|
for i, s := range c.Shares {
|
|
names[i] = s.Name
|
|
}
|
|
sort.Strings(names)
|
|
i := 0
|
|
for i < len(names)-1 {
|
|
if names[i] == names[i+1] {
|
|
errs = append(errs, fmt.Errorf("Duplicate '%s' shares", names[i]))
|
|
}
|
|
i++
|
|
}
|
|
|
|
for i, s := range c.Shares {
|
|
// shareRef is a human-understandable,
|
|
// 1-indexed reference to this share
|
|
shareRef := fmt.Sprintf("Share %d(%s)", i+1, s.Name)
|
|
if s.Name == "" {
|
|
errs = append(errs, fmt.Errorf("%s has no name", shareRef))
|
|
}
|
|
if s.Dir == "" {
|
|
errs = append(errs, fmt.Errorf("%s has no dir", shareRef))
|
|
} else {
|
|
var fi os.FileInfo
|
|
var err error
|
|
if fi, err = os.Stat(s.Dir); err != nil {
|
|
errs = append(errs, fmt.Errorf("error accessing dir %s for %s", s.Dir, shareRef))
|
|
} else {
|
|
if !fi.IsDir() {
|
|
errs = append(errs, fmt.Errorf("dir %s for %s is not a dir", s.Dir, shareRef))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if c.UserHome != nil && c.UserHome.BasePath == "" {
|
|
errs = append(errs, errors.New("UserHome requires a BasePath: fill it or omit it"))
|
|
}
|
|
|
|
if (c.Global.ViewPathBase == "") != (c.Global.ViewURLBase == "") {
|
|
errs = append(errs, errors.New("viewpathbase and viewurlbase must be both set, or none"))
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// Global is the global section of the configuration
|
|
type Global struct {
|
|
Excluded Globs `yaml:"excluded"`
|
|
ViewURLBase string `yaml:"viewurlbase"`
|
|
ViewPathBase string `yaml:"viewpathbase"`
|
|
RoutePrefix string `yaml:"routeprefix"` // if you want to proxy megauploader as a sub-location, that's for you
|
|
}
|
|
|
|
// UserHome specifies rules for generating home shares on the fly
|
|
type UserHome struct {
|
|
BasePath string `yaml:"basepath"`
|
|
Create bool `yaml:"create"`
|
|
}
|
|
|
|
// A Share is a directory where users can upload files
|
|
type Share struct {
|
|
Name string `yaml:"name"`
|
|
Dir string `yaml:"dir"`
|
|
Description string `yaml:"description"`
|
|
Authorized Globs `yaml:"authorized"`
|
|
SizeLimit datasize.ByteSize `yaml:"sizelimit"`
|
|
Extensions ExtensionList `yaml:"extensions"`
|
|
}
|
|
|
|
// IsAuthorized checks whether username is autorized to access the Share
|
|
func (s Share) IsAuthorized(username string) bool {
|
|
return s.Authorized.Has(username)
|
|
}
|
|
|
|
// Upload let you create a new file in the share
|
|
//
|
|
// If a file with the same name exists, Upload will try to "increment"
|
|
// it. If no valid filename is found, an error will be returned.
|
|
//
|
|
// FIXME: distinguish between validation errors and server errors
|
|
func (s Share) Upload(buf io.Reader, filename string) (string, error) {
|
|
if !s.Extensions.Matches(filename) {
|
|
return "", fmt.Errorf("Extension not admitted for this share")
|
|
}
|
|
fpath := filepath.Join(s.Dir, filename)
|
|
incrementable := newFileIncrement(fpath)
|
|
var dst *os.File
|
|
var err error
|
|
var realFileName string
|
|
|
|
try := 0
|
|
for try < 10 {
|
|
realFileName = incrementable.Increment(try)
|
|
dst, err = os.OpenFile(realFileName,
|
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
|
|
if err == nil {
|
|
break
|
|
}
|
|
try++
|
|
}
|
|
if try == 10 {
|
|
return "", fmt.Errorf("Can't create file %s", filename)
|
|
}
|
|
defer dst.Close()
|
|
_, err = io.Copy(dst, buf)
|
|
return realFileName, nil
|
|
}
|
|
|
|
// GetViewURL retrieves the view URL for the specified path
|
|
func (g *Global) GetViewURL(dir string) (url.URL, error) {
|
|
rel, err := filepath.Rel(g.ViewPathBase, dir)
|
|
if err != nil {
|
|
return url.URL{}, errors.Wrapf(err, "Error getting view URL for '%s'", dir)
|
|
}
|
|
if strings.HasPrefix(rel, "../") {
|
|
return url.URL{}, fmt.Errorf("Error getting view URL for '%s'", dir)
|
|
}
|
|
u, err := url.Parse(g.ViewURLBase)
|
|
if err != nil {
|
|
return url.URL{}, fmt.Errorf("Invalid view URL %s", g.ViewURLBase)
|
|
}
|
|
u.Path = path.Join(u.Path, rel)
|
|
return *u, nil
|
|
}
|
|
|
|
// GetShareURL retrieves the view URL for the share identified by sharename
|
|
func (c *Config) GetShareURL(sharename string) (url.URL, error) {
|
|
if c.Global.ViewPathBase == "" || c.Global.ViewURLBase == "" {
|
|
return url.URL{}, errors.New("View URL not configured")
|
|
}
|
|
s, err := c.GetShare(sharename)
|
|
if err != nil {
|
|
return url.URL{}, err
|
|
}
|
|
return c.Global.GetViewURL(s.Dir)
|
|
}
|
|
|
|
// GetAuthShares returns an array of all the shares the user is authorized to use, including its home.
|
|
func (c *Config) GetAuthShares(username string) []Share {
|
|
shares := make([]Share, 0)
|
|
if c.Global.Excluded.Has(username) {
|
|
return shares
|
|
}
|
|
for _, s := range c.Shares {
|
|
if s.IsAuthorized(username) {
|
|
shares = append(shares, s)
|
|
}
|
|
}
|
|
home, err := c.GetHome(username)
|
|
if err == nil {
|
|
shares = append(shares, *home)
|
|
}
|
|
return shares
|
|
}
|
|
|
|
// GetAuthShare returns a valid share for a certain user.
|
|
//
|
|
// It includes checking for permissions and dynamically-generated home.
|
|
//
|
|
// FIXME: can't tell 'unauthorized' from 'not found'
|
|
func (c *Config) GetAuthShare(sharename string, username string) (Share, error) {
|
|
share, err := c.GetShare(sharename)
|
|
if err == nil {
|
|
if share.IsAuthorized(username) {
|
|
return share, nil
|
|
}
|
|
return Share{}, errors.New("Unauthorized")
|
|
}
|
|
home, homeerr := c.GetHome(username)
|
|
if homeerr != nil || home.Name != sharename {
|
|
return Share{}, errors.New("Cannot find home")
|
|
}
|
|
// assuming user is always authorized on its dynamically-generated home
|
|
return *home, nil
|
|
}
|
|
|
|
func (c *Config) GetShare(name string) (Share, error) {
|
|
for _, share := range c.Shares {
|
|
if share.Name == name {
|
|
return share, nil
|
|
}
|
|
}
|
|
return Share{}, errors.New("share not found")
|
|
}
|
|
|
|
// GetHome returns the home share for a user, if available
|
|
// It implements authorization check
|
|
func (c *Config) GetHome(username string) (*Share, error) {
|
|
// explicit share gets precedence
|
|
sharename := fmt.Sprintf("home_%s", username)
|
|
share, err := c.GetShare(sharename)
|
|
if err == nil {
|
|
if share.IsAuthorized(username) {
|
|
return &share, nil
|
|
}
|
|
return nil, fmt.Errorf("Home share for %s is forbidden to its owner", username)
|
|
}
|
|
if c.Global.Excluded.Has(username) {
|
|
return nil, fmt.Errorf("User %s has no home", username)
|
|
}
|
|
s := new(Share)
|
|
s.Name = sharename
|
|
s.Authorized = Globs{username}
|
|
s.Dir = filepath.Join(c.UserHome.BasePath, username)
|
|
stat, err := os.Stat(s.Dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) { //doesn't exist
|
|
if c.UserHome.Create {
|
|
os.Mkdir(s.Dir, os.ModeDir)
|
|
} else {
|
|
return nil, fmt.Errorf("User %s home is non-existent (and we won't create it)", username)
|
|
}
|
|
} else {
|
|
return nil, errors.Wrapf(err, "access to %s'shome failed", username)
|
|
}
|
|
} else {
|
|
if !stat.IsDir() {
|
|
return nil, fmt.Errorf("%s's home exists but is not a directory", username)
|
|
}
|
|
}
|
|
s.Description = fmt.Sprintf("%s's home", username)
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// URLFetcher is a WIP section of the config to define rules for fetching URLs instead of uploading
|
|
type URLFetcher struct {
|
|
Enabled bool
|
|
AllowedURLs Globs
|
|
SizeLimit datasize.ByteSize
|
|
}
|