pare che fa
This commit is contained in:
commit
c640ec47ac
16 changed files with 839 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
rice-box.go
|
||||||
|
*.yaml
|
63
README.md
Normal file
63
README.md
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
MEGAROR upload
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Un uploader fatto apposta per ondarossa
|
||||||
|
|
||||||
|
Cose principali:
|
||||||
|
|
||||||
|
* pesca l'utente da degli header HTTP (sì, il server proxy deve fare le cose giuste)
|
||||||
|
* autorizza delle cose in base a dei file di permessi semplici. Esempio /megaror/spot -> tizio@ondarossa.info,
|
||||||
|
*.ondarossa.net, tipa@ondarossa.info
|
||||||
|
* associa path tipo /megaror/redattor@/boyska a https://megaror.ondarossa.info/redattor@/boyska. Ovvero gli
|
||||||
|
dai un view-url-base e un view-path-base e lui compone le cose giuste.
|
||||||
|
* fa graceful degradation così non killa i trasferimenti in corso
|
||||||
|
* logga decentemente
|
||||||
|
* (da approfondire) scarica da URL, ma con possibilità di filtrare quali url sì e quali no (ie: solo archive.org)
|
||||||
|
* (da approfondire) manda mail quando un file viene caricato
|
||||||
|
|
||||||
|
Configurazione. Un file YAML in cui l'unità di base è lo "share". Ogni share ha
|
||||||
|
```
|
||||||
|
type Share struct {
|
||||||
|
Name OBBLIGATORIO
|
||||||
|
Dir OBBLIGATORIA
|
||||||
|
Description
|
||||||
|
Authorized lista di glob di utenti autorizzati.
|
||||||
|
SizeLimit esempio 20M
|
||||||
|
ExtList lista di estensioni esempio ogg,oga,mp3,aac
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
type Global struct {
|
||||||
|
Excluded []Glob
|
||||||
|
ViewURLBase string
|
||||||
|
ViewPathBase string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sezione globale: si possono escludere alcune persone globalmente dal servizio. Esempio: molesto@ondarossa.info, worm@ondarossa.info. Se un utente è bloccato globalmente ma è dentro l'Authorized di un certo share, è autorizzato.
|
||||||
|
|
||||||
|
```
|
||||||
|
type UserHome {
|
||||||
|
PathPrefix string
|
||||||
|
Create bool
|
||||||
|
}
|
||||||
|
func (*uh UserHome) GetShare(username string) (Share)
|
||||||
|
```
|
||||||
|
|
||||||
|
Share per-utente: si può creare una share "dinamica" per ogni utente (home) tipo /megaror/Redazione/redattor@/%h
|
||||||
|
Se c'è una share con nome uguale a home_tizio@ondarossa.info si possono cambiare le impostazioni per quella
|
||||||
|
home. Ovvero una share statica sovrascrive una share dinamica.
|
||||||
|
Ad esempio per dire che home_leditanellapresa@ondarossa.info è scrivibile anche da nerd@ondarossa.info si può
|
||||||
|
creare la share esplicitamente e mettere tutti i desiderati (incluso il "proprietario", non c'è automatismo)
|
||||||
|
dentro Authorized
|
||||||
|
Un utente bloccato globalmente non ha la home, a meno che la sezione non sia creata manualmente e l'utente
|
||||||
|
venga autorizzato.
|
||||||
|
|
||||||
|
```
|
||||||
|
type URLFetcher struct {
|
||||||
|
Enabled bool
|
||||||
|
AllowedURLs []Glob
|
||||||
|
SizeLimit ???
|
||||||
|
}
|
||||||
|
```
|
38
cmd/megauploader/main.go
Normal file
38
cmd/megauploader/main.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.lattuga.net/boyska/megauploader"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dump := flag.Bool("dump", false, "Do not run; dump the configuration as it is loaded instead")
|
||||||
|
cfgfile := flag.String("cfg", "/etc/megauploader/config.yaml", "Location of the configuration file")
|
||||||
|
addr := flag.String("addr", "localhost:8000", "Listen address")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg, err := megauploader.ParseConf(*cfgfile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if errs := cfg.Validate(); len(errs) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Configuration errors:")
|
||||||
|
for _, e := range errs {
|
||||||
|
fmt.Fprintln(os.Stderr, " -", e)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *dump {
|
||||||
|
fmt.Println(cfg.Pretty())
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu := megauploader.MegaUploader{Conf: cfg}
|
||||||
|
mu.SetupRoutes()
|
||||||
|
http.ListenAndServe(*addr, nil)
|
||||||
|
}
|
304
conf.go
Normal file
304
conf.go
Normal file
|
@ -0,0 +1,304 @@
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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
|
||||||
|
}
|
38
conf_test.go
Normal file
38
conf_test.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
package megauploader
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
type TestCase struct {
|
||||||
|
G Global
|
||||||
|
P string // path
|
||||||
|
E string // expected
|
||||||
|
}
|
||||||
|
|
||||||
|
var good []TestCase = []TestCase{
|
||||||
|
// simplest case
|
||||||
|
TestCase{G: Global{ViewPathBase: "/", ViewURLBase: "http://localhost/"}, P: "/", E: "http://localhost/"},
|
||||||
|
// prefix == path
|
||||||
|
TestCase{G: Global{ViewPathBase: "/asd/", ViewURLBase: "http://localhost/"}, P: "/asd/", E: "http://localhost/"},
|
||||||
|
// path has something more
|
||||||
|
TestCase{G: Global{ViewPathBase: "/asd/", ViewURLBase: "http://localhost/"}, P: "/asd/foo", E: "http://localhost/foo"},
|
||||||
|
// URL has a path
|
||||||
|
TestCase{G: Global{ViewPathBase: "/asd/", ViewURLBase: "http://localhost/bla/"}, P: "/asd/foo", E: "http://localhost/bla/foo"},
|
||||||
|
// removing slashes does not affect the result
|
||||||
|
TestCase{G: Global{ViewPathBase: "/asd", ViewURLBase: "http://localhost/bla"}, P: "/asd/foo", E: "http://localhost/bla/foo"},
|
||||||
|
// URL has port
|
||||||
|
TestCase{G: Global{ViewPathBase: "/asd/", ViewURLBase: "http://localhost:1234/"}, P: "/asd/foo", E: "http://localhost:1234/foo"},
|
||||||
|
// URL has params
|
||||||
|
TestCase{G: Global{ViewPathBase: "/asd/", ViewURLBase: "http://localhost:1234/?k=v"}, P: "/asd/foo", E: "http://localhost:1234/foo?k=v"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewURL(t *testing.T) {
|
||||||
|
for _, test := range good {
|
||||||
|
val, err := test.G.GetShareURL(test.P)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if val != test.E {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26
confload.go
Normal file
26
confload.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
package megauploader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
yaml "gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseConf will read and load a config file
|
||||||
|
func ParseConf(filename string) (Config, error) {
|
||||||
|
buf, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, errors.Wrap(err, "Error opening config file")
|
||||||
|
}
|
||||||
|
defer buf.Close()
|
||||||
|
content, err := ioutil.ReadAll(buf)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, errors.Wrap(err, "Error reading config file")
|
||||||
|
}
|
||||||
|
var c Config
|
||||||
|
err = yaml.UnmarshalStrict(content, &c)
|
||||||
|
return c, err
|
||||||
|
}
|
18
doc.go
Normal file
18
doc.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// megauploader is a very opinionated uploader. It is focused on very simple authorization schemes and the
|
||||||
|
// idea of multiple "shares". A share is basically a directory where users can put things. Each share can list
|
||||||
|
// authorized users (wildcards supported).
|
||||||
|
//
|
||||||
|
// There's also some support for "dynamic" share. MegaUploader has a native concept of user "home": a
|
||||||
|
// directory that is dynamically calculated for each user. There are both global settings and possibility of
|
||||||
|
// overriding dynamic calculation
|
||||||
|
//
|
||||||
|
// Upload is supported, while view is not. MegaUploader relies on a separate "file browse" URL.
|
||||||
|
//
|
||||||
|
// Security
|
||||||
|
//
|
||||||
|
// Absolutely do *not* expose it publicly; in fact, for authentication it relies on a proxy that will
|
||||||
|
// set the X-Forwarded-User accordingly. This is nice if you want to reuse your webserver authentication
|
||||||
|
// modules without rewriting them for this application
|
||||||
|
// Requests that do not have X-Forwarded-User are an error; configure your webserver properly to avoid that a
|
||||||
|
// malicious user can forge that header!
|
||||||
|
package megauploader
|
27
fileutils.go
Normal file
27
fileutils.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package megauploader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileIncrement struct {
|
||||||
|
pre string
|
||||||
|
post string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi fileIncrement) Increment(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return fmt.Sprintf("%s%s", fi.pre, fi.post)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%d%s", fi.pre, i, fi.post)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileIncrement(fn string) fileIncrement {
|
||||||
|
idx := strings.Index(fn, ".")
|
||||||
|
if idx != -1 {
|
||||||
|
return fileIncrement{pre: fn[0:idx], post: fn[idx:]}
|
||||||
|
} else {
|
||||||
|
return fileIncrement{pre: fn[0:], post: ""}
|
||||||
|
}
|
||||||
|
}
|
31
fileutils_test.go
Normal file
31
fileutils_test.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
package megauploader
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNoExtZero(t *testing.T) {
|
||||||
|
if newFileIncrement("foo").Increment(0) != "foo" {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestNoExtMore(t *testing.T) {
|
||||||
|
if newFileIncrement("foo").Increment(1) != "foo-1" {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
if newFileIncrement("foo").Increment(2) != "foo-2" {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtZero(t *testing.T) {
|
||||||
|
if newFileIncrement("foo.ogg").Increment(0) != "foo.ogg" {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestExtMore(t *testing.T) {
|
||||||
|
if newFileIncrement("foo.ogg").Increment(1) != "foo-1.ogg" {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
if newFileIncrement("foo.ogg").Increment(2) != "foo-2.ogg" {
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
129
http.go
Normal file
129
http.go
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
package megauploader
|
||||||
|
|
||||||
|
//go:generate rice embed-go
|
||||||
|
|
||||||
|
// this file contains all the web-related code for MegaUploader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
rice "github.com/GeertJohan/go.rice"
|
||||||
|
"github.com/c2h5oh/datasize"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MegaUploader acts as controller for all the application. Since this is inherently a web-focused
|
||||||
|
// application, it will include http utils
|
||||||
|
type MegaUploader struct {
|
||||||
|
Conf Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRoutes adds API routes
|
||||||
|
func (mu *MegaUploader) SetupRoutes() {
|
||||||
|
http.HandleFunc("/", requireUserMiddleware(mu.home))
|
||||||
|
http.HandleFunc("/upload/", requireUserMiddleware(mu.uploadUI))
|
||||||
|
static := rice.MustFindBox("res/static")
|
||||||
|
http.HandleFunc("/static/", requireUserMiddleware(
|
||||||
|
http.StripPrefix("/static/", http.FileServer(static.HTTPBox())).ServeHTTP,
|
||||||
|
))
|
||||||
|
http.HandleFunc("/api/share", requireUserMiddleware(mu.listShares))
|
||||||
|
http.HandleFunc("/api/share/", requireUserMiddleware(mu.getShare))
|
||||||
|
http.HandleFunc("/api/upload/", requireUserMiddleware(mu.upload))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUser(r *http.Request) (string, error) {
|
||||||
|
user := r.Header.Get("X-Forwarded-User")
|
||||||
|
if user == "" {
|
||||||
|
return "", fmt.Errorf("User not set")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireUserMiddleware(inner func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, err := getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Only logged-in users are authorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inner(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mu *MegaUploader) listShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := getUser(r) // user is set: checked by middleware
|
||||||
|
shares := mu.Conf.GetAuthShares(user)
|
||||||
|
serialized, err := json.Marshal(shares)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-type", "application/json")
|
||||||
|
w.Write(serialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mu *MegaUploader) getShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sharename := r.URL.Path[strings.LastIndexByte(r.URL.Path, '/')+1:]
|
||||||
|
share, err := mu.Conf.GetShare(sharename)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Share '%s' not found: %s", sharename, err), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := getUser(r) // user is set: checked by middleware
|
||||||
|
if !share.IsAuthorized(user) {
|
||||||
|
http.Error(w, "You are not authorized to this share", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serialized, err := json.Marshal(share)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-type", "application/json")
|
||||||
|
w.Write(serialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// on success, redirect to view URL
|
||||||
|
func (mu *MegaUploader) upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := getUser(r) // user is set: checked by middleware
|
||||||
|
sharename := r.URL.Path[strings.LastIndexByte(r.URL.Path, '/')+1:]
|
||||||
|
|
||||||
|
share, err := mu.Conf.GetAuthShare(sharename, user)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Share '%s' not found: %s", sharename, err), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sizelimit := uint64(20 * datasize.MB)
|
||||||
|
if share.SizeLimit.Bytes() > 0 {
|
||||||
|
sizelimit = share.SizeLimit.Bytes()
|
||||||
|
}
|
||||||
|
err = r.ParseMultipartForm(int64(sizelimit))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "No file uploaded", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fname, err := share.Upload(file, filepath.Base(header.Filename))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
http.Error(w, "Error uploading", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fname = filepath.Base(fname)
|
||||||
|
u, err := mu.Conf.GetShareURL(sharename)
|
||||||
|
if err != nil {
|
||||||
|
w.Write([]byte(fname))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.Path = path.Join(u.Path, fname)
|
||||||
|
w.Write([]byte(u.String()))
|
||||||
|
}
|
79
httpui.go
Normal file
79
httpui.go
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
package megauploader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
rice "github.com/GeertJohan/go.rice"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate rice embed-go
|
||||||
|
|
||||||
|
// loadTmpl return the required template
|
||||||
|
// It is provided to centralize handling of templates; it will care about
|
||||||
|
// riceboxes and other quirks that templates might need
|
||||||
|
func (mu *MegaUploader) loadTmpl(tmplname string) (*template.Template, error) {
|
||||||
|
str := rice.MustFindBox("res/templates").MustString(tmplname)
|
||||||
|
tmpl := template.Must(
|
||||||
|
template.New(strings.Split(tmplname, ".")[0]).Funcs(template.FuncMap{
|
||||||
|
"pretty": func(data interface{}) string {
|
||||||
|
var out bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&out)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
err := enc.Encode(data)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
|
},
|
||||||
|
}).Parse(str),
|
||||||
|
)
|
||||||
|
str = rice.MustFindBox("res/templates").MustString("base.html.tmpl")
|
||||||
|
tmpl = template.Must(tmpl.Parse(str))
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mu *MegaUploader) home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tmpl, err := mu.loadTmpl("index.html.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, _ := getUser(r)
|
||||||
|
shares := mu.Conf.GetAuthShares(user)
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
tmpl.Execute(w, map[string]interface{}{
|
||||||
|
"Shares": shares,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mu *MegaUploader) uploadUI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := getUser(r) // user is set: checked by middleware
|
||||||
|
sharename := r.URL.Path[strings.LastIndexByte(r.URL.Path, '/')+1:]
|
||||||
|
|
||||||
|
share, err := mu.Conf.GetAuthShare(sharename, user)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Share '%s' not found", sharename), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := mu.loadTmpl("upload.html.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewurl, err := mu.Conf.GetShareURL(sharename)
|
||||||
|
if err != nil {
|
||||||
|
viewurl = url.URL{}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
tmpl.Execute(w, map[string]interface{}{
|
||||||
|
"Share": share,
|
||||||
|
"ViewURL": viewurl.String(),
|
||||||
|
})
|
||||||
|
}
|
1
res/static/dropzone/dropzone.min.css
vendored
Normal file
1
res/static/dropzone/dropzone.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
res/static/dropzone/dropzone.min.js
vendored
Normal file
2
res/static/dropzone/dropzone.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
res/templates/base.html.tmpl
Normal file
14
res/templates/base.html.tmpl
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
{{block "head" .}}
|
||||||
|
<meta name="generator" value="megauploader" />
|
||||||
|
{{end}}
|
||||||
|
{{block "styles" .}}{{end}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "body" .}}
|
||||||
|
{{block "scripts" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
21
res/templates/index.html.tmpl
Normal file
21
res/templates/index.html.tmpl
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{{define "body"}}
|
||||||
|
<h1>MegaUploader</h1>
|
||||||
|
|
||||||
|
Carica un file
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{{range .Shares}}
|
||||||
|
<li><span class="share-link">
|
||||||
|
<a href="/upload/{{.Name}}">{{.Name}}</a>
|
||||||
|
</span>
|
||||||
|
<span class="share-description">{{.Description}}</span>
|
||||||
|
{{ if ne .SizeLimit.Bytes 0 }}
|
||||||
|
<span class="share-maxsize">
|
||||||
|
(Max {{.SizeLimit.HR}})</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="share-maxsize"></span>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
46
res/templates/upload.html.tmpl
Normal file
46
res/templates/upload.html.tmpl
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
{{define "body"}}
|
||||||
|
<h1>MegaUploader - {{.Share.Name}}</h1>
|
||||||
|
|
||||||
|
<div>Carica un file su {{.Share.Name}}
|
||||||
|
{{if .ViewURL}}(<a href="{{.ViewURL}}">Guarda cosa c'è già</a>){{end}}
|
||||||
|
</div>
|
||||||
|
<div>Ricorda:
|
||||||
|
<ul>
|
||||||
|
<li>{{if ne .Share.SizeLimit 0}}
|
||||||
|
Max {{.Share.SizeLimit.HR}}
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{if ne (len .Share.Extensions) 0}}
|
||||||
|
<li>Solo file di tipo
|
||||||
|
{{range .Share.Extensions}}
|
||||||
|
<tt>{{.}}</tt>,
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<pre id="responses"></pre>
|
||||||
|
<form id="uploadForm" class="dropzone" method="POST" action="/api/upload/{{.Share.Name}}">
|
||||||
|
<noscript>
|
||||||
|
<input type="file" name="file" />
|
||||||
|
</noscript>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- TODO: area per trascinare file -->
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script type="text/javascript" src="/static/dropzone/dropzone.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
Dropzone.options.uploadForm = {
|
||||||
|
init: function() {
|
||||||
|
this.on("success", function(file, response) {
|
||||||
|
document.getElementById("responses").textContent += response + "\n";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
{{define "styles"}}
|
||||||
|
<link href="/static/dropzone/dropzone.min.css" rel="stylesheet" />
|
||||||
|
{{end}}
|
Loading…
Reference in a new issue