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