package main import ( "bufio" "bytes" "encoding/hex" "fmt" "html/template" "io" "log" "net/http" "os" "os/signal" "path" "regexp" "sync" "syscall" "time" "github.com/Wessie/appdirs" "github.com/dustin/go-humanize" "github.com/gorilla/mux" "github.com/gorilla/schema" "github.com/jessevdk/go-flags" "go.uber.org/zap" "go.uber.org/zap/zapcore" "golang.org/x/crypto/bcrypt" "github.com/boramalper/magnetico/pkg/persistence" ) const N_TORRENTS = 20 // 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() var templates map[string]*template.Template var database persistence.Database var opts struct { Addr string Database string Credentials map[string][]byte // TODO: encapsulate credentials and mutex for safety CredentialsRWMutex sync.RWMutex CredentialsPath string } 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) zap.L().Info("magneticow v0.7.0 has been started.") zap.L().Info("Copyright (C) 2017 Mert Bora ALPER .") zap.L().Info("Dedicated to Cemile Binay, in whose hands I thrived.") if err := parseFlags(); err != nil { zap.L().Error("Error while initializing", zap.Error(err)) return } // 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 } opts.Credentials = make(map[string][]byte) // Clear opts.Credentials opts.CredentialsRWMutex.Unlock() if err := loadCred(opts.CredentialsPath); err != nil { // Reload credentials zap.L().Warn("couldn't load credentials", zap.Error(err)) } } }() 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")) 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) }, "humanizeSizeF": func(s int64) string { if s < 0 { return "" } return humanize.IBytes(uint64(s)) }, "comma": func(s uint) string { return humanize.Comma(int64(s)) }, } templates = make(map[string]*template.Template) templates["feed"] = template.Must(template.New("feed").Funcs(templateFunctions).Parse(string(mustAsset("templates/feed.xml")))) templates["homepage"] = template.Must(template.New("homepage").Funcs(templateFunctions).Parse(string(mustAsset("templates/homepage.html")))) templates["torrent"] = template.Must(template.New("torrent").Funcs(templateFunctions).Parse(string(mustAsset("templates/torrent.html")))) var err error database, err = persistence.MakeDatabase(opts.Database, logger) if err != nil { panic(err.Error()) } decoder.IgnoreUnknownKeys(false) decoder.ZeroEmpty(true) zap.S().Infof("magneticow is ready to serve on %s!", opts.Addr) err = http.ListenAndServe(opts.Addr, router) if err != nil { zap.L().Error("ListenAndServe error", zap.Error(err)) } } // TODO: I think there is a standard lib. function for this func respondError(w http.ResponseWriter, statusCode int, format string, a ...interface{}) { w.WriteHeader(statusCode) w.Write([]byte(fmt.Sprintf(format, a...))) } func mustAsset(name string) []byte { data, err := Asset(name) if err != nil { log.Panicf("Could NOT access the requested resource `%s`: %s (please inform us, this is a BUG!)", name, err.Error()) } return data } func parseFlags() error { var cmdFlags struct { 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"` } if _, err := flags.Parse(&cmdFlags); err != nil { return err } if cmdFlags.Cred != "" && cmdFlags.NoAuth { return fmt.Errorf("`credentials` and `no-auth` cannot be supplied together") } opts.Addr = cmdFlags.Addr if cmdFlags.Database == "" { opts.Database = "sqlite3://" + appdirs.UserDataDir("magneticod", "", "", false) + "/database.sqlite3" + "?_journal_mode=WAL" // https://github.com/mattn/go-sqlite3#connection-string } else { opts.Database = cmdFlags.Database } if cmdFlags.Cred == "" && !cmdFlags.NoAuth { opts.CredentialsPath = path.Join( appdirs.UserConfigDir("magneticow", "", "", false), "credentials", ) } else { opts.CredentialsPath = cmdFlags.Cred } fmt.Printf("%v credpath %s\n", cmdFlags.NoAuth, opts.CredentialsPath) if opts.CredentialsPath != "" { opts.Credentials = make(map[string][]byte) if err := loadCred(opts.CredentialsPath); err != nil { return err } } else { opts.Credentials = nil } return nil } func loadCred(cred string) error { file, err := os.Open(cred) if err != nil { return err } opts.CredentialsRWMutex.Lock() defer opts.CredentialsRWMutex.Unlock() reader := bufio.NewReader(file) for lineno := 1; true; lineno++ { line, err := reader.ReadBytes('\n') if err != nil { if err == io.EOF { break } return fmt.Errorf("error while reading line %d: %s", lineno, err.Error()) } line = line[:len(line)-1] // strip '\n' /* The following regex checks if the line satisfies the following conditions: * * : * * where * 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. * * 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: :", lineno) } tokens := bytes.Split(line, []byte(":")) opts.Credentials[string(tokens[0])] = tokens[1] } 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: "" // // 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() if !ok { // No credentials provided authenticate(w, realm) return } opts.CredentialsRWMutex.RLock() hashedPassword, ok := opts.Credentials[username] opts.CredentialsRWMutex.RUnlock() if !ok { // User not found authenticate(w, realm) return } if err := bcrypt.CompareHashAndPassword(hashedPassword, []byte(password)); err != nil { // Wrong password authenticate(w, realm) return } handler(w, r) } } func authenticate(w http.ResponseWriter, realm string) { w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`) w.WriteHeader(401) if _, err := w.Write([]byte("Unauthorised.\n")); err != nil { panic(err.Error()) } }