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 }