2017-11-03 00:15:13 +01:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2018-07-12 09:58:39 +02:00
|
|
|
|
"bufio"
|
|
|
|
|
"bytes"
|
2018-04-16 17:40:54 +02:00
|
|
|
|
"encoding/hex"
|
2018-04-25 22:33:50 +02:00
|
|
|
|
"fmt"
|
2018-12-24 19:30:07 +01:00
|
|
|
|
"github.com/pkg/errors"
|
2017-11-03 00:15:13 +01:00
|
|
|
|
"html/template"
|
2018-07-12 09:58:39 +02:00
|
|
|
|
"io"
|
2017-11-03 00:15:13 +01:00
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
2018-07-12 09:58:39 +02:00
|
|
|
|
"os/signal"
|
|
|
|
|
"path"
|
|
|
|
|
"regexp"
|
|
|
|
|
"sync"
|
|
|
|
|
"syscall"
|
2018-06-19 17:49:46 +02:00
|
|
|
|
"time"
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
"github.com/Wessie/appdirs"
|
2017-11-03 00:15:13 +01:00
|
|
|
|
"github.com/dustin/go-humanize"
|
|
|
|
|
"github.com/gorilla/mux"
|
2018-06-19 17:49:46 +02:00
|
|
|
|
"github.com/gorilla/schema"
|
2018-07-12 09:58:39 +02:00
|
|
|
|
"github.com/jessevdk/go-flags"
|
2017-11-03 00:15:13 +01:00
|
|
|
|
"go.uber.org/zap"
|
|
|
|
|
"go.uber.org/zap/zapcore"
|
2018-07-12 09:58:39 +02:00
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-04-16 17:40:54 +02:00
|
|
|
|
"github.com/boramalper/magnetico/pkg/persistence"
|
2017-11-03 00:15:13 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const N_TORRENTS = 20
|
|
|
|
|
|
2018-12-25 16:35:11 +01:00
|
|
|
|
var compiledOn string
|
|
|
|
|
|
2018-06-19 17:49:46 +02:00
|
|
|
|
// Set a Decoder instance as a package global, because it caches
|
|
|
|
|
// meta-data about structs, and an instance can be shared safely.
|
|
|
|
|
var decoder = schema.NewDecoder()
|
|
|
|
|
|
2017-11-03 00:15:13 +01:00
|
|
|
|
var templates map[string]*template.Template
|
|
|
|
|
var database persistence.Database
|
|
|
|
|
|
2018-08-03 14:40:04 +02:00
|
|
|
|
var opts struct {
|
2018-07-12 09:58:39 +02:00
|
|
|
|
Addr string
|
|
|
|
|
Database string
|
2018-08-03 14:40:04 +02:00
|
|
|
|
Credentials map[string][]byte // TODO: encapsulate credentials and mutex for safety
|
2018-07-12 09:58:39 +02:00
|
|
|
|
CredentialsRWMutex sync.RWMutex
|
|
|
|
|
CredentialsPath string
|
2018-12-25 16:35:11 +01:00
|
|
|
|
Verbosity int
|
2018-06-19 17:49:46 +02:00
|
|
|
|
}
|
|
|
|
|
|
2017-11-03 00:15:13 +01:00
|
|
|
|
func main() {
|
|
|
|
|
loggerLevel := zap.NewAtomicLevel()
|
|
|
|
|
// Logging levels: ("debug", "info", "warn", "error", "dpanic", "panic", and "fatal").
|
|
|
|
|
logger := zap.New(zapcore.NewCore(
|
|
|
|
|
zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()),
|
|
|
|
|
zapcore.Lock(os.Stderr),
|
|
|
|
|
loggerLevel,
|
|
|
|
|
))
|
|
|
|
|
defer logger.Sync()
|
|
|
|
|
zap.ReplaceGlobals(logger)
|
|
|
|
|
|
2018-09-21 14:00:23 +02:00
|
|
|
|
zap.L().Info("magneticow v0.7.0-beta1 has been started.")
|
2018-12-25 16:35:11 +01:00
|
|
|
|
zap.L().Info("Copyright (C) 2018 Mert Bora ALPER <bora@boramalper.org>.")
|
2017-11-03 00:15:13 +01:00
|
|
|
|
zap.L().Info("Dedicated to Cemile Binay, in whose hands I thrived.")
|
2018-12-25 16:35:11 +01:00
|
|
|
|
zap.S().Infof("Compiled on %s", compiledOn)
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
if err := parseFlags(); err != nil {
|
|
|
|
|
zap.L().Error("Error while initializing", zap.Error(err))
|
|
|
|
|
return
|
|
|
|
|
}
|
2018-06-19 17:49:46 +02:00
|
|
|
|
|
2018-12-25 16:35:11 +01:00
|
|
|
|
switch opts.Verbosity {
|
|
|
|
|
case 0:
|
|
|
|
|
loggerLevel.SetLevel(zap.WarnLevel)
|
|
|
|
|
case 1:
|
|
|
|
|
loggerLevel.SetLevel(zap.InfoLevel)
|
|
|
|
|
default: // Default: i.e. in case of 2 or more.
|
|
|
|
|
// TODO: print the caller (function)'s name and line number!
|
|
|
|
|
loggerLevel.SetLevel(zap.DebugLevel)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
zap.ReplaceGlobals(logger)
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
// Reload credentials when you receive SIGHUP
|
|
|
|
|
sighupChan := make(chan os.Signal, 1)
|
|
|
|
|
signal.Notify(sighupChan, syscall.SIGHUP)
|
|
|
|
|
go func() {
|
|
|
|
|
for range sighupChan {
|
|
|
|
|
opts.CredentialsRWMutex.Lock()
|
|
|
|
|
if opts.Credentials == nil {
|
|
|
|
|
zap.L().Warn("Ignoring SIGHUP since `no-auth` was supplied")
|
|
|
|
|
continue
|
|
|
|
|
}
|
2018-06-19 17:49:46 +02:00
|
|
|
|
|
2018-08-03 14:40:04 +02:00
|
|
|
|
opts.Credentials = make(map[string][]byte) // Clear opts.Credentials
|
2018-07-12 09:58:39 +02:00
|
|
|
|
opts.CredentialsRWMutex.Unlock()
|
2018-08-03 14:40:04 +02:00
|
|
|
|
if err := loadCred(opts.CredentialsPath); err != nil { // Reload credentials
|
2018-07-12 09:58:39 +02:00
|
|
|
|
zap.L().Warn("couldn't load credentials", zap.Error(err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
2018-06-19 17:49:46 +02:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
router := mux.NewRouter()
|
|
|
|
|
router.HandleFunc("/",
|
|
|
|
|
BasicAuth(rootHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/api/v0.1/files/{infohash:[a-f0-9]{40}}",
|
|
|
|
|
BasicAuth(apiFilesInfohashHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/api/v0.1/statistics",
|
|
|
|
|
BasicAuth(apiStatisticsHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/api/v0.1/torrents",
|
|
|
|
|
BasicAuth(apiTorrentsHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/api/v0.1/torrents/{infohash:[a-f0-9]{40}}",
|
|
|
|
|
BasicAuth(apiTorrentsInfohashHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/feed",
|
|
|
|
|
BasicAuth(feedHandler, "magneticow"))
|
|
|
|
|
router.PathPrefix("/static").HandlerFunc(
|
|
|
|
|
BasicAuth(staticHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/statistics",
|
|
|
|
|
BasicAuth(statisticsHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/torrents",
|
|
|
|
|
BasicAuth(torrentsHandler, "magneticow"))
|
|
|
|
|
router.HandleFunc("/torrents/{infohash:[a-f0-9]{40}}",
|
|
|
|
|
BasicAuth(torrentsInfohashHandler, "magneticow"))
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
|
|
|
|
templateFunctions := template.FuncMap{
|
|
|
|
|
"add": func(augend int, addends int) int {
|
|
|
|
|
return augend + addends
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
"subtract": func(minuend int, subtrahend int) int {
|
|
|
|
|
return minuend - subtrahend
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
"bytesToHex": func(bytes []byte) string {
|
|
|
|
|
return hex.EncodeToString(bytes)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
"unixTimeToYearMonthDay": func(s int64) string {
|
|
|
|
|
tm := time.Unix(s, 0)
|
|
|
|
|
// > Format and Parse use example-based layouts. Usually you’ll use a constant from time
|
|
|
|
|
// > for these layouts, but you can also supply custom layouts. Layouts must use the
|
|
|
|
|
// > reference time Mon Jan 2 15:04:05 MST 2006 to show the pattern with which to
|
|
|
|
|
// > format/parse a given time/string. The example time must be exactly as shown: the
|
|
|
|
|
// > year 2006, 15 for the hour, Monday for the day of the week, etc.
|
|
|
|
|
// https://gobyexample.com/time-formatting-parsing
|
|
|
|
|
// Why you gotta be so weird Go?
|
|
|
|
|
return tm.Format("02/01/2006")
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
"humanizeSize": func(s uint64) string {
|
|
|
|
|
return humanize.IBytes(s)
|
|
|
|
|
},
|
2018-04-25 22:33:50 +02:00
|
|
|
|
|
2018-06-29 17:58:57 +02:00
|
|
|
|
"humanizeSizeF": func(s int64) string {
|
|
|
|
|
if s < 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return humanize.IBytes(uint64(s))
|
|
|
|
|
},
|
|
|
|
|
|
2018-04-25 22:33:50 +02:00
|
|
|
|
"comma": func(s uint) string {
|
|
|
|
|
return humanize.Comma(int64(s))
|
|
|
|
|
},
|
2017-11-03 00:15:13 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
templates = make(map[string]*template.Template)
|
2018-07-01 17:16:17 +02:00
|
|
|
|
templates["feed"] = template.Must(template.New("feed").Funcs(templateFunctions).Parse(string(mustAsset("templates/feed.xml"))))
|
2018-04-25 22:33:50 +02:00
|
|
|
|
templates["homepage"] = template.Must(template.New("homepage").Funcs(templateFunctions).Parse(string(mustAsset("templates/homepage.html"))))
|
2018-06-29 17:58:57 +02:00
|
|
|
|
templates["torrent"] = template.Must(template.New("torrent").Funcs(templateFunctions).Parse(string(mustAsset("templates/torrent.html"))))
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
|
|
|
|
var err error
|
2018-07-12 09:58:39 +02:00
|
|
|
|
database, err = persistence.MakeDatabase(opts.Database, logger)
|
2017-11-03 00:15:13 +01:00
|
|
|
|
if err != nil {
|
2018-12-24 19:30:07 +01:00
|
|
|
|
zap.L().Fatal("could not access to database", zap.Error(err))
|
2017-11-03 00:15:13 +01:00
|
|
|
|
}
|
|
|
|
|
|
2018-06-19 17:49:46 +02:00
|
|
|
|
decoder.IgnoreUnknownKeys(false)
|
|
|
|
|
decoder.ZeroEmpty(true)
|
|
|
|
|
|
2018-08-03 10:28:50 +02:00
|
|
|
|
zap.S().Infof("magneticow is ready to serve on %s!", opts.Addr)
|
2018-07-12 09:58:39 +02:00
|
|
|
|
err = http.ListenAndServe(opts.Addr, router)
|
2018-06-19 17:49:46 +02:00
|
|
|
|
if err != nil {
|
|
|
|
|
zap.L().Error("ListenAndServe error", zap.Error(err))
|
|
|
|
|
}
|
2017-11-03 00:15:13 +01:00
|
|
|
|
}
|
|
|
|
|
|
2018-06-19 17:49:46 +02:00
|
|
|
|
// TODO: I think there is a standard lib. function for this
|
2018-04-25 22:33:50 +02:00
|
|
|
|
func respondError(w http.ResponseWriter, statusCode int, format string, a ...interface{}) {
|
|
|
|
|
w.WriteHeader(statusCode)
|
|
|
|
|
w.Write([]byte(fmt.Sprintf(format, a...)))
|
|
|
|
|
}
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
func mustAsset(name string) []byte {
|
|
|
|
|
data, err := Asset(name)
|
2017-11-03 00:15:13 +01:00
|
|
|
|
if err != nil {
|
2018-12-24 19:30:07 +01:00
|
|
|
|
zap.L().Panic("Could NOT access the requested resource! THIS IS A BUG, PLEASE REPORT",
|
|
|
|
|
zap.String("name", name), zap.Error(err))
|
2017-11-03 00:15:13 +01:00
|
|
|
|
}
|
2018-07-12 09:58:39 +02:00
|
|
|
|
return data
|
|
|
|
|
}
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
func parseFlags() error {
|
|
|
|
|
var cmdFlags struct {
|
2018-08-03 14:40:04 +02:00
|
|
|
|
Addr string `short:"a" long:"addr" description:"Address (host:port) to serve on" default:":8080"`
|
|
|
|
|
Database string `short:"d" long:"database" description:"URL of the (magneticod) database"`
|
|
|
|
|
Cred string `short:"c" long:"credentials" description:"Path to the credentials file"`
|
|
|
|
|
NoAuth bool ` long:"no-auth" description:"Disables authorisation"`
|
2018-12-25 16:35:11 +01:00
|
|
|
|
|
|
|
|
|
Verbose []bool `short:"v" long:"verbose" description:"Increases verbosity."`
|
2018-06-29 17:58:57 +02:00
|
|
|
|
}
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
if _, err := flags.Parse(&cmdFlags); err != nil {
|
|
|
|
|
return err
|
2018-06-29 17:58:57 +02:00
|
|
|
|
}
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
if cmdFlags.Cred != "" && cmdFlags.NoAuth {
|
|
|
|
|
return fmt.Errorf("`credentials` and `no-auth` cannot be supplied together")
|
2018-06-29 17:58:57 +02:00
|
|
|
|
}
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
opts.Addr = cmdFlags.Addr
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
if cmdFlags.Database == "" {
|
2018-08-03 10:28:50 +02:00
|
|
|
|
opts.Database =
|
|
|
|
|
"sqlite3://" +
|
2018-08-03 14:40:04 +02:00
|
|
|
|
appdirs.UserDataDir("magneticod", "", "", false) +
|
|
|
|
|
"/database.sqlite3" +
|
|
|
|
|
"?_journal_mode=WAL" // https://github.com/mattn/go-sqlite3#connection-string
|
2018-08-03 10:28:50 +02:00
|
|
|
|
} else {
|
|
|
|
|
opts.Database = cmdFlags.Database
|
2018-07-01 17:16:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
if cmdFlags.Cred == "" && !cmdFlags.NoAuth {
|
|
|
|
|
opts.CredentialsPath = path.Join(
|
|
|
|
|
appdirs.UserConfigDir("magneticow", "", "", false),
|
|
|
|
|
"credentials",
|
|
|
|
|
)
|
2018-07-01 17:16:17 +02:00
|
|
|
|
} else {
|
2018-07-12 09:58:39 +02:00
|
|
|
|
opts.CredentialsPath = cmdFlags.Cred
|
2018-07-01 17:16:17 +02:00
|
|
|
|
}
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
if opts.CredentialsPath != "" {
|
|
|
|
|
opts.Credentials = make(map[string][]byte)
|
|
|
|
|
if err := loadCred(opts.CredentialsPath); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
opts.Credentials = nil
|
2018-07-01 17:16:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
2018-12-25 16:35:11 +01:00
|
|
|
|
opts.Verbosity = len(cmdFlags.Verbose)
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
return nil
|
|
|
|
|
}
|
2018-07-01 17:16:17 +02:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
func loadCred(cred string) error {
|
|
|
|
|
file, err := os.Open(cred)
|
2018-07-01 17:16:17 +02:00
|
|
|
|
if err != nil {
|
2018-07-12 09:58:39 +02:00
|
|
|
|
return err
|
2018-07-01 17:16:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
opts.CredentialsRWMutex.Lock()
|
|
|
|
|
defer opts.CredentialsRWMutex.Unlock()
|
2017-11-03 00:15:13 +01:00
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
reader := bufio.NewReader(file)
|
|
|
|
|
for lineno := 1; true; lineno++ {
|
|
|
|
|
line, err := reader.ReadBytes('\n')
|
|
|
|
|
if err != nil {
|
|
|
|
|
if err == io.EOF {
|
2018-08-03 14:40:04 +02:00
|
|
|
|
break
|
2018-07-12 09:58:39 +02:00
|
|
|
|
}
|
2018-12-24 19:30:07 +01:00
|
|
|
|
return errors.Wrapf(err, "while reading line %d", lineno)
|
2018-07-12 09:58:39 +02:00
|
|
|
|
}
|
|
|
|
|
|
2018-08-03 14:40:04 +02:00
|
|
|
|
line = line[:len(line)-1] // strip '\n'
|
2018-07-12 09:58:39 +02:00
|
|
|
|
|
|
|
|
|
/* The following regex checks if the line satisfies the following conditions:
|
|
|
|
|
*
|
|
|
|
|
* <USERNAME>:<BCRYPT HASH>
|
|
|
|
|
*
|
|
|
|
|
* where
|
|
|
|
|
* <USERNAME> must start with a small-case a-z character, might contain non-consecutive
|
|
|
|
|
* underscores in-between, and consists of small-case a-z characters and digits 0-9.
|
|
|
|
|
*
|
|
|
|
|
* <BCRYPT HASH> is the output of the well-known bcrypt function.
|
|
|
|
|
*/
|
|
|
|
|
re := regexp.MustCompile(`^[a-z](?:_?[a-z0-9])*:\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}$`)
|
|
|
|
|
if !re.Match(line) {
|
|
|
|
|
return fmt.Errorf("on line %d: format should be: <USERNAME>:<BCRYPT HASH>", lineno)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tokens := bytes.Split(line, []byte(":"))
|
|
|
|
|
opts.Credentials[string(tokens[0])] = tokens[1]
|
2017-11-03 00:15:13 +01:00
|
|
|
|
}
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// BasicAuth wraps a handler requiring HTTP basic auth for it using the given
|
|
|
|
|
// username and password and the specified realm, which shouldn't contain quotes.
|
|
|
|
|
//
|
|
|
|
|
// Most web browser display a dialog with something like:
|
|
|
|
|
//
|
|
|
|
|
// The website says: "<realm>"
|
|
|
|
|
//
|
|
|
|
|
// Which is really stupid so you may want to set the realm to a message rather than
|
|
|
|
|
// an actual realm.
|
|
|
|
|
//
|
|
|
|
|
// Source: https://stackoverflow.com/a/39591234/4466589
|
|
|
|
|
func BasicAuth(handler http.HandlerFunc, realm string) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
username, password, ok := r.BasicAuth()
|
2018-08-03 14:40:04 +02:00
|
|
|
|
if !ok { // No credentials provided
|
2018-07-12 09:58:39 +02:00
|
|
|
|
authenticate(w, realm)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
opts.CredentialsRWMutex.RLock()
|
|
|
|
|
hashedPassword, ok := opts.Credentials[username]
|
|
|
|
|
opts.CredentialsRWMutex.RUnlock()
|
2018-08-03 14:40:04 +02:00
|
|
|
|
if !ok { // User not found
|
2018-07-12 09:58:39 +02:00
|
|
|
|
authenticate(w, realm)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2018-08-03 14:40:04 +02:00
|
|
|
|
if err := bcrypt.CompareHashAndPassword(hashedPassword, []byte(password)); err != nil { // Wrong password
|
2018-07-12 09:58:39 +02:00
|
|
|
|
authenticate(w, realm)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handler(w, r)
|
2017-11-03 00:15:13 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2018-07-12 09:58:39 +02:00
|
|
|
|
func authenticate(w http.ResponseWriter, realm string) {
|
|
|
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
|
|
|
|
|
w.WriteHeader(401)
|
2018-12-24 19:30:07 +01:00
|
|
|
|
_, _ = w.Write([]byte("Unauthorised.\n"))
|
2017-11-03 00:15:13 +01:00
|
|
|
|
}
|