diff --git a/Makefile b/Makefile index 1b1f842..e7c720c 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,9 @@ build: deps build/web build/app build/app: go build -o bin/$(SERVICE) cmd/$(SERVICE)/main.go +build/ctl: + go build -o bin/autobrrctl cmd/autobrrctl/main.go + build/web: cd web && yarn build diff --git a/cmd/autobrr/main.go b/cmd/autobrr/main.go index b3870f3..b148c37 100644 --- a/cmd/autobrr/main.go +++ b/cmd/autobrr/main.go @@ -13,8 +13,10 @@ import ( "github.com/autobrr/autobrr/internal/action" "github.com/autobrr/autobrr/internal/announce" + "github.com/autobrr/autobrr/internal/auth" "github.com/autobrr/autobrr/internal/config" "github.com/autobrr/autobrr/internal/database" + "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/download_client" "github.com/autobrr/autobrr/internal/filter" "github.com/autobrr/autobrr/internal/http" @@ -23,10 +25,11 @@ import ( "github.com/autobrr/autobrr/internal/logger" "github.com/autobrr/autobrr/internal/release" "github.com/autobrr/autobrr/internal/server" + "github.com/autobrr/autobrr/internal/user" ) var ( - cfg config.Cfg + cfg domain.Config ) func main() { @@ -62,6 +65,7 @@ func main() { filterRepo = database.NewFilterRepo(db) indexerRepo = database.NewIndexerRepo(db) ircRepo = database.NewIrcRepo(db) + userRepo = database.NewUserRepo(db) ) var ( @@ -72,6 +76,8 @@ func main() { releaseService = release.NewService(actionService) announceService = announce.NewService(filterService, indexerService, releaseService) ircService = irc.NewService(ircRepo, announceService) + userService = user.NewService(userRepo) + authService = auth.NewService(userService) ) addr := fmt.Sprintf("%v:%v", cfg.Host, cfg.Port) @@ -79,7 +85,7 @@ func main() { errorChannel := make(chan error) go func() { - httpServer := http.NewServer(addr, cfg.BaseURL, actionService, downloadClientService, filterService, indexerService, ircService) + httpServer := http.NewServer(addr, cfg.BaseURL, actionService, authService, downloadClientService, filterService, indexerService, ircService) errorChannel <- httpServer.Open() }() diff --git a/cmd/autobrrctl/main.go b/cmd/autobrrctl/main.go new file mode 100644 index 0000000..4cd5c05 --- /dev/null +++ b/cmd/autobrrctl/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "bufio" + "database/sql" + "flag" + "fmt" + "log" + "os" + + "golang.org/x/crypto/ssh/terminal" + _ "modernc.org/sqlite" + + "github.com/autobrr/autobrr/internal/database" + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/argon2id" +) + +const usage = `usage: autobrrctl --config path + + create-user Create user + help Show this help message +` + +func init() { + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), usage) + } +} + +func main() { + var configPath string + flag.StringVar(&configPath, "config", "", "path to configuration file") + flag.Parse() + + if configPath == "" { + log.Fatal("--config required") + } + + // if configPath is set then put database inside that path, otherwise create wherever it's run + var dataSource = database.DataSourceName(configPath, "autobrr.db") + + // open database connection + db, err := sql.Open("sqlite", dataSource) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + if err = database.Migrate(db); err != nil { + log.Fatalf("could not migrate db: %v", err) + } + + userRepo := database.NewUserRepo(db) + + switch cmd := flag.Arg(0); cmd { + case "create-user": + username := flag.Arg(1) + if username == "" { + flag.Usage() + os.Exit(1) + } + + password, err := readPassword() + if err != nil { + log.Fatalf("failed to read password: %v", err) + } + hashed, err := argon2id.CreateHash(string(password), argon2id.DefaultParams) + if err != nil { + log.Fatalf("failed to hash password: %v", err) + } + + user := domain.User{ + Username: username, + Password: hashed, + } + if err := userRepo.Store(user); err != nil { + log.Fatalf("failed to create user: %v", err) + } + default: + flag.Usage() + if cmd != "help" { + os.Exit(1) + } + } +} + +func readPassword() ([]byte, error) { + var password []byte + var err error + fd := int(os.Stdin.Fd()) + + if terminal.IsTerminal(fd) { + fmt.Printf("Password: ") + password, err = terminal.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return nil, err + } + fmt.Printf("\n") + } else { + fmt.Fprintf(os.Stderr, "warning: Reading password from stdin.\n") + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + log.Fatalf("failed to read password from stdin: %v", err) + } + log.Fatalf("failed to read password from stdin: stdin is empty %v", err) + } + password = scanner.Bytes() + + if len(password) == 0 { + return nil, fmt.Errorf("zero length password") + } + } + + return password, nil +} diff --git a/config.toml b/config.toml index 8c94b53..b6f2057 100644 --- a/config.toml +++ b/config.toml @@ -34,3 +34,7 @@ port = 8989 # Options: "ERROR", "DEBUG", "INFO", "WARN" # logLevel = "DEBUG" + +# Session secret +# +sessionSecret = "secret-session-key" diff --git a/go.mod b/go.mod index 6840cba..25f6679 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/anacrolix/torrent v1.29.1 github.com/fluffle/goirc v1.0.3 github.com/go-chi/chi v1.5.4 + github.com/gorilla/sessions v1.2.1 github.com/lib/pq v1.10.2 github.com/pelletier/go-toml v1.6.0 // indirect github.com/pkg/errors v0.9.1 @@ -14,6 +15,7 @@ require ( github.com/spf13/pflag v1.0.3 github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.7.0 + golang.org/x/crypto v0.0.0-20210812204632-0ba0e8f03122 golang.org/x/net v0.0.0-20210427231257-85d9c07bbe3a golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect gopkg.in/irc.v3 v3.1.1 diff --git a/go.sum b/go.sum index d42f5c6..9e95917 100644 --- a/go.sum +++ b/go.sum @@ -310,6 +310,10 @@ github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORR github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosuri/uilive v0.0.0-20170323041506-ac356e6e42cd/go.mod h1:qkLSc0A5EXSP6B04TrN4oQoxqFI7A8XvoXSlJi8cwk8= @@ -729,6 +733,8 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210812204632-0ba0e8f03122 h1:AOT7vJYHE32m61R8d1WlcqhOO1AocesDsKpcMq+UOaA= +golang.org/x/crypto v0.0.0-20210812204632-0ba0e8f03122/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -868,9 +874,11 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..27bb563 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,51 @@ +package auth + +import ( + "errors" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/internal/user" + "github.com/autobrr/autobrr/pkg/argon2id" +) + +type Service interface { + Login(username, password string) (*domain.User, error) +} + +type service struct { + userSvc user.Service +} + +func NewService(userSvc user.Service) Service { + return &service{ + userSvc: userSvc, + } +} + +func (s *service) Login(username, password string) (*domain.User, error) { + if username == "" || password == "" { + return nil, errors.New("bad credentials") + } + + // find user + u, err := s.userSvc.FindByUsername(username) + if err != nil { + return nil, err + } + + if u == nil { + return nil, errors.New("bad credentials") + } + + // compare password from request and the saved password + match, err := argon2id.ComparePasswordAndHash(password, u.Password) + if err != nil { + return nil, errors.New("error checking credentials") + } + + if !match { + return nil, errors.New("bad credentials") + } + + return u, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 2af44bd..5f80615 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,30 +7,21 @@ import ( "path" "path/filepath" + "github.com/autobrr/autobrr/internal/domain" + "github.com/spf13/viper" ) -type Cfg struct { - Host string `toml:"host"` - Port int `toml:"port"` - LogLevel string `toml:"logLevel"` - LogPath string `toml:"logPath"` - BaseURL string `toml:"baseUrl"` -} +var Config domain.Config -var Config Cfg - -func Defaults() Cfg { - hostname, err := os.Hostname() - if err != nil { - hostname = "localhost" - } - return Cfg{ - Host: hostname, - Port: 8989, - LogLevel: "DEBUG", - LogPath: "", - BaseURL: "/", +func Defaults() domain.Config { + return domain.Config{ + Host: "localhost", + Port: 8989, + LogLevel: "DEBUG", + LogPath: "", + BaseURL: "/", + SessionSecret: "secret-session-key", } } @@ -92,7 +83,12 @@ port = 8989 # # Options: "ERROR", "DEBUG", "INFO", "WARN" # -logLevel = "DEBUG"`) +logLevel = "DEBUG" + +# Session secret +# +sessionSecret = "secret-session-key"`) + if err != nil { log.Printf("error writing contents to file: %v %q", configPath, err) return err @@ -105,7 +101,7 @@ logLevel = "DEBUG"`) return nil } -func Read(configPath string) Cfg { +func Read(configPath string) domain.Config { config := Defaults() // or use viper.SetDefault(val, def) diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 6683e66..a8fb6de 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -3,11 +3,18 @@ package database import ( "database/sql" "fmt" - - "github.com/rs/zerolog/log" ) const schema = ` +CREATE TABLE users +( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE indexer ( id INTEGER PRIMARY KEY, @@ -135,8 +142,6 @@ var migrations = []string{ } func Migrate(db *sql.DB) error { - log.Info().Msg("Migrating database...") - var version int if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { return fmt.Errorf("failed to query schema version: %v", err) diff --git a/internal/database/user.go b/internal/database/user.go new file mode 100644 index 0000000..7a105d7 --- /dev/null +++ b/internal/database/user.go @@ -0,0 +1,47 @@ +package database + +import ( + "database/sql" + + "github.com/rs/zerolog/log" + + "github.com/autobrr/autobrr/internal/domain" +) + +type UserRepo struct { + db *sql.DB +} + +func NewUserRepo(db *sql.DB) domain.UserRepo { + return &UserRepo{db: db} +} + +func (r *UserRepo) FindByUsername(username string) (*domain.User, error) { + query := `SELECT username, password FROM users WHERE username = ?` + + row := r.db.QueryRow(query, username) + if err := row.Err(); err != nil { + return nil, err + } + + var user domain.User + + if err := row.Scan(&user.Username, &user.Password); err != nil { + log.Error().Err(err).Msg("could not scan user to struct") + return nil, err + } + + return &user, nil +} + +func (r *UserRepo) Store(user domain.User) error { + query := `INSERT INTO users (username, password) VALUES (?, ?)` + + _, err := r.db.Exec(query, user.Username, user.Password) + if err != nil { + log.Error().Stack().Err(err).Msg("error executing query") + return err + } + + return nil +} diff --git a/internal/domain/config.go b/internal/domain/config.go index 74df8a2..99984d5 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -1,11 +1,10 @@ package domain -type Settings struct { - Host string `toml:"host"` - Debug bool +type Config struct { + Host string `toml:"host"` + Port int `toml:"port"` + LogLevel string `toml:"logLevel"` + LogPath string `toml:"logPath"` + BaseURL string `toml:"baseUrl"` + SessionSecret string `toml:"sessionSecret"` } - -//type AppConfig struct { -// Settings `toml:"settings"` -// Trackers []Tracker `mapstructure:"tracker"` -//} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..3abd7ce --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,11 @@ +package domain + +type UserRepo interface { + FindByUsername(username string) (*User, error) + Store(user User) error +} + +type User struct { + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/internal/http/auth.go b/internal/http/auth.go new file mode 100644 index 0000000..9ea8130 --- /dev/null +++ b/internal/http/auth.go @@ -0,0 +1,86 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi" + "github.com/gorilla/sessions" + + "github.com/autobrr/autobrr/internal/config" + "github.com/autobrr/autobrr/internal/domain" +) + +type authService interface { + Login(username, password string) (*domain.User, error) +} + +type authHandler struct { + encoder encoder + authService authService +} + +var ( + // key will only be valid as long as it's running. + key = []byte(config.Config.SessionSecret) + store = sessions.NewCookieStore(key) +) + +func (h authHandler) Routes(r chi.Router) { + r.Post("/login", h.login) + r.Post("/logout", h.logout) + r.Get("/test", h.test) +} + +func (h authHandler) login(w http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + data domain.User + ) + + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + // encode error + h.encoder.StatusResponse(ctx, w, nil, http.StatusBadRequest) + return + } + + session, _ := store.Get(r, "user_session") + + _, err := h.authService.Login(data.Username, data.Password) + if err != nil { + h.encoder.StatusResponse(ctx, w, nil, http.StatusUnauthorized) + return + } + + // Set user as authenticated + session.Values["authenticated"] = true + session.Save(r, w) + + h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent) +} + +func (h authHandler) logout(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + session, _ := store.Get(r, "user_session") + + // Revoke users authentication + session.Values["authenticated"] = false + session.Save(r, w) + + h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent) +} + +func (h authHandler) test(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + session, _ := store.Get(r, "user_session") + + // Check if user is authenticated + if auth, ok := session.Values["authenticated"].(bool); !ok || !auth { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // send empty response as ok + h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent) +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..93970a7 --- /dev/null +++ b/internal/http/middleware.go @@ -0,0 +1,17 @@ +package http + +import "net/http" + +func IsAuthenticated(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // check session + session, _ := store.Get(r, "user_session") + + // Check if user is authenticated + if auth, ok := session.Values["authenticated"].(bool); !ok || !auth { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/http/service.go b/internal/http/service.go index c8f2f81..f3e6fba 100644 --- a/internal/http/service.go +++ b/internal/http/service.go @@ -15,17 +15,19 @@ type Server struct { address string baseUrl string actionService actionService + authService authService downloadClientService downloadClientService filterService filterService indexerService indexerService ircService ircService } -func NewServer(address string, baseUrl string, actionService actionService, downloadClientSvc downloadClientService, filterSvc filterService, indexerSvc indexerService, ircSvc ircService) Server { +func NewServer(address string, baseUrl string, actionService actionService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, indexerSvc indexerService, ircSvc ircService) Server { return Server{ address: address, baseUrl: baseUrl, actionService: actionService, + authService: authService, downloadClientService: downloadClientSvc, filterService: filterSvc, indexerService: indexerSvc, @@ -62,7 +64,15 @@ func (s Server) Handler() http.Handler { fileSystem.ServeHTTP(w, r) }) + authHandler := authHandler{ + encoder: encoder, + authService: s.authService, + } + + r.Route("/api/auth", authHandler.Routes) + r.Group(func(r chi.Router) { + r.Use(IsAuthenticated) actionHandler := actionHandler{ encoder: encoder, diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 11ac69d..2f7a3f9 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -5,14 +5,14 @@ import ( "os" "time" - "github.com/autobrr/autobrr/internal/config" + "github.com/autobrr/autobrr/internal/domain" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "gopkg.in/natefinch/lumberjack.v2" ) -func Setup(cfg config.Cfg) { +func Setup(cfg domain.Config) { zerolog.TimeFieldFormat = time.RFC3339 switch cfg.LogLevel { diff --git a/internal/user/service.go b/internal/user/service.go new file mode 100644 index 0000000..bc37cd9 --- /dev/null +++ b/internal/user/service.go @@ -0,0 +1,26 @@ +package user + +import "github.com/autobrr/autobrr/internal/domain" + +type Service interface { + FindByUsername(username string) (*domain.User, error) +} + +type service struct { + repo domain.UserRepo +} + +func NewService(repo domain.UserRepo) Service { + return &service{ + repo: repo, + } +} + +func (s *service) FindByUsername(username string) (*domain.User, error) { + user, err := s.repo.FindByUsername(username) + if err != nil { + return nil, err + } + + return user, nil +} diff --git a/pkg/argon2id/argon2id.go b/pkg/argon2id/argon2id.go new file mode 100644 index 0000000..8d41941 --- /dev/null +++ b/pkg/argon2id/argon2id.go @@ -0,0 +1,172 @@ +package argon2id + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +var ( + // ErrInvalidHash in returned by ComparePasswordAndHash if the provided + // hash isn't in the expected format. + ErrInvalidHash = errors.New("argon2id: hash is not in the correct format") + + // ErrIncompatibleVersion in returned by ComparePasswordAndHash if the + // provided hash was created using a different version of Argon2. + ErrIncompatibleVersion = errors.New("argon2id: incompatible version of argon2") +) + +// DefaultParams provides some sane default parameters for hashing passwords. +// +// Follows recommendations given by the Argon2 RFC: +// "The Argon2id variant with t=1 and maximum available memory is RECOMMENDED as a +// default setting for all environments. This setting is secure against side-channel +// attacks and maximizes adversarial costs on dedicated bruteforce hardware."" +// +// The default parameters should generally be used for development/testing purposes +// only. Custom parameters should be set for production applications depending on +// available memory/CPU resources and business requirements. +var DefaultParams = &Params{ + Memory: 64 * 1024, + Iterations: 1, + Parallelism: 2, + SaltLength: 16, + KeyLength: 32, +} + +// Params describes the input parameters used by the Argon2id algorithm. The +// Memory and Iterations parameters control the computational cost of hashing +// the password. The higher these figures are, the greater the cost of generating +// the hash and the longer the runtime. It also follows that the greater the cost +// will be for any attacker trying to guess the password. If the code is running +// on a machine with multiple cores, then you can decrease the runtime without +// reducing the cost by increasing the Parallelism parameter. This controls the +// number of threads that the work is spread across. Important note: Changing the +// value of the Parallelism parameter changes the hash output. +// +// For guidance and an outline process for choosing appropriate parameters see +// https://tools.ietf.org/html/draft-irtf-cfrg-argon2-04#section-4 +type Params struct { + // The amount of memory used by the algorithm (in kibibytes). + Memory uint32 + + // The number of iterations over the memory. + Iterations uint32 + + // The number of threads (or lanes) used by the algorithm. + // Recommended value is between 1 and runtime.NumCPU(). + Parallelism uint8 + + // Length of the random salt. 16 bytes is recommended for password hashing. + SaltLength uint32 + + // Length of the generated key. 16 bytes or more is recommended. + KeyLength uint32 +} + +// CreateHash returns a Argon2id hash of a plain-text password using the +// provided algorithm parameters. The returned hash follows the format used by +// the Argon2 reference C implementation and contains the base64-encoded Argon2id d +// derived key prefixed by the salt and parameters. It looks like this: +// +// $argon2id$v=19$m=65536,t=3,p=2$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG +// +func CreateHash(password string, params *Params) (hash string, err error) { + salt, err := generateRandomBytes(params.SaltLength) + if err != nil { + return "", err + } + + key := argon2.IDKey([]byte(password), salt, params.Iterations, params.Memory, params.Parallelism, params.KeyLength) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + b64Key := base64.RawStdEncoding.EncodeToString(key) + + hash = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, params.Memory, params.Iterations, params.Parallelism, b64Salt, b64Key) + return hash, nil +} + +// ComparePasswordAndHash performs a constant-time comparison between a +// plain-text password and Argon2id hash, using the parameters and salt +// contained in the hash. It returns true if they match, otherwise it returns +// false. +func ComparePasswordAndHash(password, hash string) (match bool, err error) { + match, _, err = CheckHash(password, hash) + return match, err +} + +// CheckHash is like ComparePasswordAndHash, except it also returns the params that the hash was +// created with. This can be useful if you want to update your hash params over time (which you +// should). +func CheckHash(password, hash string) (match bool, params *Params, err error) { + params, salt, key, err := DecodeHash(hash) + if err != nil { + return false, nil, err + } + + otherKey := argon2.IDKey([]byte(password), salt, params.Iterations, params.Memory, params.Parallelism, params.KeyLength) + + keyLen := int32(len(key)) + otherKeyLen := int32(len(otherKey)) + + if subtle.ConstantTimeEq(keyLen, otherKeyLen) == 0 { + return false, params, nil + } + if subtle.ConstantTimeCompare(key, otherKey) == 1 { + return true, params, nil + } + return false, params, nil +} + +func generateRandomBytes(n uint32) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + + return b, nil +} + +// DecodeHash expects a hash created from this package, and parses it to return the params used to +// create it, as well as the salt and key (password hash). +func DecodeHash(hash string) (params *Params, salt, key []byte, err error) { + vals := strings.Split(hash, "$") + if len(vals) != 6 { + return nil, nil, nil, ErrInvalidHash + } + + var version int + _, err = fmt.Sscanf(vals[2], "v=%d", &version) + if err != nil { + return nil, nil, nil, err + } + if version != argon2.Version { + return nil, nil, nil, ErrIncompatibleVersion + } + + params = &Params{} + _, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", ¶ms.Memory, ¶ms.Iterations, ¶ms.Parallelism) + if err != nil { + return nil, nil, nil, err + } + + salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4]) + if err != nil { + return nil, nil, nil, err + } + params.SaltLength = uint32(len(salt)) + + key, err = base64.RawStdEncoding.Strict().DecodeString(vals[5]) + if err != nil { + return nil, nil, nil, err + } + params.KeyLength = uint32(len(key)) + + return params, salt, key, nil +} diff --git a/pkg/argon2id/argon2id_test.go b/pkg/argon2id/argon2id_test.go new file mode 100644 index 0000000..6fe5894 --- /dev/null +++ b/pkg/argon2id/argon2id_test.go @@ -0,0 +1,111 @@ +package argon2id + +import ( + "regexp" + "strings" + "testing" +) + +func TestCreateHash(t *testing.T) { + hashRX, err := regexp.Compile(`^\$argon2id\$v=19\$m=65536,t=1,p=2\$[A-Za-z0-9+/]{22}\$[A-Za-z0-9+/]{43}$`) + if err != nil { + t.Fatal(err) + } + + hash1, err := CreateHash("pa$$word", DefaultParams) + if err != nil { + t.Fatal(err) + } + + if !hashRX.MatchString(hash1) { + t.Errorf("hash %q not in correct format", hash1) + } + + hash2, err := CreateHash("pa$$word", DefaultParams) + if err != nil { + t.Fatal(err) + } + + if strings.Compare(hash1, hash2) == 0 { + t.Error("hashes must be unique") + } +} + +func TestComparePasswordAndHash(t *testing.T) { + hash, err := CreateHash("pa$$word", DefaultParams) + if err != nil { + t.Fatal(err) + } + + match, err := ComparePasswordAndHash("pa$$word", hash) + if err != nil { + t.Fatal(err) + } + + if !match { + t.Error("expected password and hash to match") + } + + match, err = ComparePasswordAndHash("otherPa$$word", hash) + if err != nil { + t.Fatal(err) + } + + if match { + t.Error("expected password and hash to not match") + } +} + +func TestDecodeHash(t *testing.T) { + hash, err := CreateHash("pa$$word", DefaultParams) + if err != nil { + t.Fatal(err) + } + + params, _, _, err := DecodeHash(hash) + if err != nil { + t.Fatal(err) + } + if *params != *DefaultParams { + t.Fatalf("expected %#v got %#v", *DefaultParams, *params) + } +} + +func TestCheckHash(t *testing.T) { + hash, err := CreateHash("pa$$word", DefaultParams) + if err != nil { + t.Fatal(err) + } + + ok, params, err := CheckHash("pa$$word", hash) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected password to match") + } + if *params != *DefaultParams { + t.Fatalf("expected %#v got %#v", *DefaultParams, *params) + } +} + +func TestStrictDecoding(t *testing.T) { + // "bug" valid hash: $argon2id$v=19$m=65536,t=1,p=2$UDk0zEuIzbt0x3bwkf8Bgw$ihSfHWUJpTgDvNWiojrgcN4E0pJdUVmqCEdRZesx9tE + ok, _, err := CheckHash("bug", "$argon2id$v=19$m=65536,t=1,p=2$UDk0zEuIzbt0x3bwkf8Bgw$ihSfHWUJpTgDvNWiojrgcN4E0pJdUVmqCEdRZesx9tE") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected password to match") + } + + // changed one last character of the hash + ok, _, err = CheckHash("bug", "$argon2id$v=19$m=65536,t=1,p=2$UDk0zEuIzbt0x3bwkf8Bgw$ihSfHWUJpTgDvNWiojrgcN4E0pJdUVmqCEdRZesx9tF") + if err == nil { + t.Fatal("Hash validation should fail") + } + + if ok { + t.Fatal("Hash validation should fail") + } +} diff --git a/web/package.json b/web/package.json index 8047e67..20075ff 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "final-form": "^4.20.2", "final-form-arrays": "^3.0.2", "react": "^17.0.2", + "react-cookie": "^4.1.1", "react-dom": "^17.0.2", "react-final-form": "^6.5.3", "react-final-form-arrays": "^3.1.3", diff --git a/web/public/favicon.ico b/web/public/favicon.ico deleted file mode 100644 index a11777c..0000000 Binary files a/web/public/favicon.ico and /dev/null differ diff --git a/web/public/index.html b/web/public/index.html index 42187fb..f753cf3 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -2,15 +2,15 @@ - + - - + + autobrr {{if eq .BaseUrl "/" }} diff --git a/web/public/logo192.png b/web/public/logo192.png deleted file mode 100644 index fc44b0a..0000000 Binary files a/web/public/logo192.png and /dev/null differ diff --git a/web/public/logo512.png b/web/public/logo512.png deleted file mode 100644 index a4e47a6..0000000 Binary files a/web/public/logo512.png and /dev/null differ diff --git a/web/public/static/favicon.ico b/web/public/static/favicon.ico new file mode 100644 index 0000000..d7b1076 Binary files /dev/null and b/web/public/static/favicon.ico differ diff --git a/web/public/static/logo192.png b/web/public/static/logo192.png new file mode 100644 index 0000000..6ffb3b8 Binary files /dev/null and b/web/public/static/logo192.png differ diff --git a/web/public/static/logo512.png b/web/public/static/logo512.png new file mode 100644 index 0000000..1e8a318 Binary files /dev/null and b/web/public/static/logo512.png differ diff --git a/web/public/manifest.json b/web/public/static/manifest.json similarity index 86% rename from web/public/manifest.json rename to web/public/static/manifest.json index 080d6c7..8ce6a96 100644 --- a/web/public/manifest.json +++ b/web/public/static/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "autobrr", + "name": "autobrr", "icons": [ { "src": "favicon.ico", diff --git a/web/public/robots.txt b/web/public/static/robots.txt similarity index 100% rename from web/public/robots.txt rename to web/public/static/robots.txt diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..2bb0936 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import {QueryClient, QueryClientProvider} from "react-query"; +import {BrowserRouter as Router, Route, Switch} from "react-router-dom"; +import Login from "./screens/auth/login"; +import Logout from "./screens/auth/logout"; +import Base from "./screens/Base"; +import {ReactQueryDevtools} from "react-query/devtools"; +import Layout from "./components/Layout"; +import {baseUrl} from "./utils/utils"; + +function Protected() { + return ( + + + + ) +} + +export const queryClient = new QueryClient() + +function App() { + return ( + + + + + + + + + + + ) +}; + +export default App; \ No newline at end of file diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 812046c..131c997 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -1,18 +1,8 @@ import {Action, DownloadClient, Filter, Indexer, Network} from "../domain/interfaces"; +import {baseUrl} from "../utils/utils"; function baseClient(endpoint: string, method: string, { body, ...customConfig}: any = {}) { - let baseUrl = "" - if (window.APP.baseUrl) { - if (window.APP.baseUrl === '/') { - baseUrl = "/" - } else if (window.APP.baseUrl === `{{.BaseUrl}}`) { - baseUrl = "" - } else if (window.APP.baseUrl === "/autobrr/") { - baseUrl = "/autobrr/" - } else { - baseUrl = window.APP.baseUrl - } - } + let baseURL = baseUrl() const headers = {'content-type': 'application/json'} const config = { @@ -28,13 +18,19 @@ function baseClient(endpoint: string, method: string, { body, ...customConfig}: config.body = JSON.stringify(body) } - return window.fetch(`${baseUrl}${endpoint}`, config) + return window.fetch(`${baseURL}${endpoint}`, config) .then(async response => { if (response.status === 401) { // unauthorized // window.location.assign(window.location) - return + return Promise.reject(new Error(response.statusText)) + } + + if (response.status === 403) { + // window.location.assign("/login") + return Promise.reject(new Error(response.statusText)) + // return } if (response.status === 404) { @@ -68,6 +64,11 @@ const appClient = { } const APIClient = { + auth: { + login: (username: string, password: string) => appClient.Post("api/auth/login", {username: username, password: password}), + logout: () => appClient.Post(`api/auth/logout`, null), + test: () => appClient.Get(`api/auth/test`), + }, actions: { create: (action: Action) => appClient.Post("api/actions", action), update: (action: Action) => appClient.Put(`api/actions/${action.id}`, action), diff --git a/web/src/components/FilterActionList.tsx b/web/src/components/FilterActionList.tsx index 1a77230..1aff9e4 100644 --- a/web/src/components/FilterActionList.tsx +++ b/web/src/components/FilterActionList.tsx @@ -5,11 +5,11 @@ import {classNames} from "../styles/utils"; import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid"; import {useToggle} from "../hooks/hooks"; import {useMutation} from "react-query"; -import {queryClient} from ".."; import {Field, Form} from "react-final-form"; import {TextField} from "./inputs"; import DEBUG from "./debug"; import APIClient from "../api/APIClient"; +import {queryClient} from "../App"; interface radioFieldsetOption { label: string; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..365d6e4 --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,38 @@ +import {isLoggedIn} from "../state/state"; +import {useRecoilState} from "recoil"; +import {useEffect, useState} from "react"; +import { Fragment } from "react"; +import {Redirect} from "react-router-dom"; +import APIClient from "../api/APIClient"; + +export default function Layout({auth=false, authFallback="/login", children}: any) { + const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn); + const [loading, setLoading] = useState(auth); + + useEffect(() => { + // check token + APIClient.auth.test() + .then(r => { + setLoggedIn(true); + setLoading(false); + }) + .catch(a => { + setLoading(false); + }) + + }, [setLoggedIn]) + + return ( + + {loading ? null : ( + + {auth && !loggedIn ? : ( + + {children} + + )} + + )} + + ) +} \ No newline at end of file diff --git a/web/src/components/inputs/PasswordField.tsx b/web/src/components/inputs/PasswordField.tsx new file mode 100644 index 0000000..63d08af --- /dev/null +++ b/web/src/components/inputs/PasswordField.tsx @@ -0,0 +1,47 @@ +import { Field } from "react-final-form"; +import React from "react"; +import Error from "./Error"; +import {classNames} from "../../styles/utils"; + +type COL_WIDTHS = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; + +interface Props { + name: string; + label?: string; + placeholder?: string; + columns?: COL_WIDTHS; + className?: string; + autoComplete?: string; +} + +const PasswordField: React.FC = ({ name, label, placeholder, columns , className, autoComplete}) => ( +
+ {label && ( + + )} + ( + + )} + /> +
+ +
+
+) + +export default PasswordField; \ No newline at end of file diff --git a/web/src/components/inputs/TextField.tsx b/web/src/components/inputs/TextField.tsx index ea57919..c5196f4 100644 --- a/web/src/components/inputs/TextField.tsx +++ b/web/src/components/inputs/TextField.tsx @@ -11,9 +11,10 @@ interface Props { placeholder?: string; columns?: COL_WIDTHS; className?: string; + autoComplete?: string; } -const TextField: React.FC = ({ name, label, placeholder, columns , className}) => ( +const TextField: React.FC = ({ name, label, placeholder, columns , className, autoComplete}) => (
= ({ name, label, placeholder, columns , classN {...input} id={name} type="text" + autoComplete={autoComplete} className="mt-2 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-light-blue-500 focus:border-light-blue-500 sm:text-sm" placeholder={placeholder} /> diff --git a/web/src/components/inputs/index.ts b/web/src/components/inputs/index.ts index 62275df..d38d278 100644 --- a/web/src/components/inputs/index.ts +++ b/web/src/components/inputs/index.ts @@ -1,5 +1,6 @@ export { default as TextField } from "./TextField"; export { default as TextFieldWide } from "./TextFieldWide"; +export { default as PasswordField } from "./PasswordField"; export { default as TextAreaWide } from "./TextAreaWide"; export { default as MultiSelectField } from "./MultiSelectField"; export { default as RadioFieldset } from "./RadioFieldset"; diff --git a/web/src/forms/filters/FilterActionAddForm.tsx b/web/src/forms/filters/FilterActionAddForm.tsx index a971dd2..efa1545 100644 --- a/web/src/forms/filters/FilterActionAddForm.tsx +++ b/web/src/forms/filters/FilterActionAddForm.tsx @@ -1,7 +1,7 @@ import React, {Fragment, useEffect } from "react"; import {useMutation} from "react-query"; import {Action, DownloadClient, Filter} from "../../domain/interfaces"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import {sleep} from "../../utils/utils"; import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid"; import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react"; diff --git a/web/src/forms/filters/FilterActionUpdateForm.tsx b/web/src/forms/filters/FilterActionUpdateForm.tsx index 5a9d3a0..2a90bf5 100644 --- a/web/src/forms/filters/FilterActionUpdateForm.tsx +++ b/web/src/forms/filters/FilterActionUpdateForm.tsx @@ -1,7 +1,7 @@ import {Fragment, useEffect} from "react"; import {useMutation} from "react-query"; import {Action, DownloadClient, Filter} from "../../domain/interfaces"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import {sleep} from "../../utils/utils"; import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid"; import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react"; diff --git a/web/src/forms/filters/FilterAddForm.tsx b/web/src/forms/filters/FilterAddForm.tsx index 2dac5d4..0dd0c83 100644 --- a/web/src/forms/filters/FilterAddForm.tsx +++ b/web/src/forms/filters/FilterAddForm.tsx @@ -1,7 +1,7 @@ import React, {Fragment, useEffect} from "react"; import {useMutation} from "react-query"; import {Filter} from "../../domain/interfaces"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import {XIcon} from "@heroicons/react/solid"; import {Dialog, Transition} from "@headlessui/react"; import {Field, Form} from "react-final-form"; diff --git a/web/src/forms/settings/DownloadClientAddForm.tsx b/web/src/forms/settings/DownloadClientAddForm.tsx index 4b9999d..afcd590 100644 --- a/web/src/forms/settings/DownloadClientAddForm.tsx +++ b/web/src/forms/settings/DownloadClientAddForm.tsx @@ -7,7 +7,7 @@ import {classNames} from "../../styles/utils"; import {Field, Form} from "react-final-form"; import DEBUG from "../../components/debug"; import {SwitchGroup} from "../../components/inputs"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import APIClient from "../../api/APIClient"; import {sleep} from "../../utils/utils"; diff --git a/web/src/forms/settings/DownloadClientUpdateForm.tsx b/web/src/forms/settings/DownloadClientUpdateForm.tsx index b713c34..92cd1fd 100644 --- a/web/src/forms/settings/DownloadClientUpdateForm.tsx +++ b/web/src/forms/settings/DownloadClientUpdateForm.tsx @@ -2,7 +2,7 @@ import {Fragment, useRef, useState} from "react"; import {useToggle} from "../../hooks/hooks"; import {useMutation} from "react-query"; import {DownloadClient} from "../../domain/interfaces"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import {Dialog, RadioGroup, Transition} from "@headlessui/react"; import {ExclamationIcon, XIcon} from "@heroicons/react/solid"; import {classNames} from "../../styles/utils"; diff --git a/web/src/forms/settings/IndexerAddForm.tsx b/web/src/forms/settings/IndexerAddForm.tsx index 0f0ba51..6c689aa 100644 --- a/web/src/forms/settings/IndexerAddForm.tsx +++ b/web/src/forms/settings/IndexerAddForm.tsx @@ -1,4 +1,4 @@ -import React, {Fragment, useEffect} from "react"; +import React, {Fragment} from "react"; import {useMutation, useQuery} from "react-query"; import {Indexer} from "../../domain/interfaces"; import {sleep} from "../../utils/utils"; @@ -7,7 +7,7 @@ import {Dialog, Transition} from "@headlessui/react"; import {Field, Form} from "react-final-form"; import DEBUG from "../../components/debug"; import Select from "react-select"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import { SwitchGroup } from "../../components/inputs"; import APIClient from "../../api/APIClient"; diff --git a/web/src/forms/settings/IndexerUpdateForm.tsx b/web/src/forms/settings/IndexerUpdateForm.tsx index df71eaf..05831ad 100644 --- a/web/src/forms/settings/IndexerUpdateForm.tsx +++ b/web/src/forms/settings/IndexerUpdateForm.tsx @@ -7,9 +7,9 @@ import {Dialog, Transition} from "@headlessui/react"; import {Field, Form} from "react-final-form"; import DEBUG from "../../components/debug"; import { SwitchGroup } from "../../components/inputs"; -import {queryClient} from "../../index"; import {useToggle} from "../../hooks/hooks"; import APIClient from "../../api/APIClient"; +import {queryClient} from "../../App"; interface props { isOpen: boolean; diff --git a/web/src/forms/settings/IrcNetworkAddForm.tsx b/web/src/forms/settings/IrcNetworkAddForm.tsx index 3cdc21b..c6edb0c 100644 --- a/web/src/forms/settings/IrcNetworkAddForm.tsx +++ b/web/src/forms/settings/IrcNetworkAddForm.tsx @@ -6,7 +6,7 @@ import {XIcon} from "@heroicons/react/solid"; import {Field, Form} from "react-final-form"; import DEBUG from "../../components/debug"; import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import arrayMutators from "final-form-arrays"; import { FieldArray } from "react-final-form-arrays"; diff --git a/web/src/forms/settings/IrcNetworkUpdateForm.tsx b/web/src/forms/settings/IrcNetworkUpdateForm.tsx index cd159b3..0476a30 100644 --- a/web/src/forms/settings/IrcNetworkUpdateForm.tsx +++ b/web/src/forms/settings/IrcNetworkUpdateForm.tsx @@ -6,7 +6,7 @@ import {XIcon} from "@heroicons/react/solid"; import {Field, Form} from "react-final-form"; import DEBUG from "../../components/debug"; import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs"; -import {queryClient} from "../../index"; +import {queryClient} from "../../App"; import arrayMutators from "final-form-arrays"; import { FieldArray } from "react-final-form-arrays"; diff --git a/web/src/index.tsx b/web/src/index.tsx index d985cab..b3ed6fa 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,17 +1,10 @@ -import React, {useEffect, useState} from 'react'; +import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; -import reportWebVitals from './reportWebVitals'; -import Base from "./screens/Base"; -import {BrowserRouter as Router,} from "react-router-dom"; -import {ReactQueryDevtools} from 'react-query/devtools' -import {QueryClient, QueryClientProvider} from 'react-query' - -import {RecoilRoot, useRecoilState} from 'recoil'; -import {configState} from "./state/state"; -import APIClient from "./api/APIClient"; +import {RecoilRoot} from 'recoil'; import {APP} from "./domain/interfaces"; +import App from "./App"; declare global { interface Window { APP: APP; } @@ -19,43 +12,11 @@ declare global { window.APP = window.APP || {}; -export const queryClient = new QueryClient() - -const ConfigWrapper = () => { - const [config, setConfig] = useRecoilState(configState) - const [loading, setLoading] = useState(true) - - useEffect(() => { - APIClient.config.get().then(res => { - setConfig(res) - setLoading(false) - }) - - }, [setConfig]) - - return ( - - {loading ? null : ( - - - - )} - - - ) -}; - - ReactDOM.render( - + , document.getElementById('root') ); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/web/src/logo.png b/web/src/logo.png new file mode 100644 index 0000000..6ffb3b8 Binary files /dev/null and b/web/src/logo.png differ diff --git a/web/src/screens/Base.tsx b/web/src/screens/Base.tsx index f854ac5..736d7cc 100644 --- a/web/src/screens/Base.tsx +++ b/web/src/screens/Base.tsx @@ -41,77 +41,77 @@ export default function Base() {
- {/*
*/} - {/*
*/} - {/* */} - {/* View notifications*/} - {/*
+
{/* Mobile menu button */} - + diff --git a/web/src/screens/Filters.tsx b/web/src/screens/Filters.tsx index 42b0af2..0d50131 100644 --- a/web/src/screens/Filters.tsx +++ b/web/src/screens/Filters.tsx @@ -17,7 +17,7 @@ import {FilterActionList} from "../components/FilterActionList"; import {DownloadClient, Filter, Indexer} from "../domain/interfaces"; import {useToggle} from "../hooks/hooks"; import {useMutation, useQuery} from "react-query"; -import {queryClient} from "../index"; +import {queryClient} from "../App"; import {CONTAINER_OPTIONS, CODECS_OPTIONS, RESOLUTION_OPTIONS, SOURCES_OPTIONS} from "../domain/constants"; import {Field, Form} from "react-final-form"; import {MultiSelectField, TextField} from "../components/inputs"; @@ -345,7 +345,7 @@ export function FilterDetails() { } if (!data) { - return (

Something went wrong

) + return null } return ( diff --git a/web/src/screens/Settings.tsx b/web/src/screens/Settings.tsx index b9a7838..9ef458e 100644 --- a/web/src/screens/Settings.tsx +++ b/web/src/screens/Settings.tsx @@ -1,21 +1,12 @@ import React from 'react' import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline' -import { - BrowserRouter as Router, - NavLink, - Route, - Switch as RouteSwitch, - useLocation, - useRouteMatch -} from "react-router-dom"; +import {NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch} from "react-router-dom"; import IndexerSettings from "./settings/Indexer"; import IrcSettings from "./settings/Irc"; import ApplicationSettings from "./settings/Application"; import DownloadClientSettings from "./settings/DownloadClient"; import {classNames} from "../styles/utils"; import ActionSettings from "./settings/Action"; -import {useRecoilValue} from "recoil"; -import {configState} from "../state/state"; const subNavigation = [ {name: 'Application', href: '', icon: CogIcon, current: true}, @@ -74,71 +65,48 @@ function SidebarNav({subNavigation, url}: any) { ) } -export function buildPath(...args: string[]): string { - const [first] = args; - const firstTrimmed = first.trim(); - const result = args - .map((part) => part.trim()) - .map((part, i) => { - if (i === 0) { - return part.replace(/[/]*$/g, ''); - } else { - return part.replace(/(^[/]*|[/]*$)/g, ''); - } - }) - .filter((x) => x.length) - .join('/'); - - return firstTrimmed === '/' ? `/${result}` : result; -} - export default function Settings() { - const config = useRecoilValue(configState) - - let { url } = useRouteMatch(); - let p = config.base_url ? buildPath(config.base_url, url) : url + let {url} = useRouteMatch(); return ( - -
-
-
-

Settings

-
-
+
+
+
+

Settings

+
+
-
-
-
- +
+
+
+ - - - - + + + + - - - + + + - - - + + + - - - + + + - - - + + + - -
+
-
- +
+ ) } diff --git a/web/src/screens/auth/login.tsx b/web/src/screens/auth/login.tsx new file mode 100644 index 0000000..8259e0c --- /dev/null +++ b/web/src/screens/auth/login.tsx @@ -0,0 +1,104 @@ +import {useMutation} from "react-query"; +import APIClient from "../../api/APIClient"; +import {Form} from "react-final-form"; +import {PasswordField, TextField} from "../../components/inputs"; +import {useRecoilState} from "recoil"; +import {isLoggedIn} from "../../state/state"; +import {useHistory} from "react-router-dom"; +import {useEffect} from "react"; +import logo from "../../logo.png" + +interface loginData { + username: string; + password: string; +} + +function Login() { + const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn); + let history = useHistory(); + + useEffect(() => { + if(loggedIn) { + // setLoading(false); + history.push('/'); + } else { + // setLoading(false); + } + }, [loggedIn, history]) + + const mutation = useMutation((data: loginData) => APIClient.auth.login(data.username, data.password), { + onSuccess: () => { + setLoggedIn(true); + }, + }) + + const onSubmit = (data: any, form: any) => { + mutation.mutate(data) + form.reset() + } + + return ( +
+
+ logo +
+ +
+
+ +
+ {({handleSubmit, values}) => { + return ( + + + + + {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/*
*/} + + {/* */} + {/*
*/} + +
+ +
+ + ) + }} + +
+
+
+ ) +} + +export default Login; \ No newline at end of file diff --git a/web/src/screens/auth/logout.tsx b/web/src/screens/auth/logout.tsx new file mode 100644 index 0000000..c9c8d9d --- /dev/null +++ b/web/src/screens/auth/logout.tsx @@ -0,0 +1,29 @@ +import APIClient from "../../api/APIClient"; +import {useRecoilState} from "recoil"; +import {isLoggedIn} from "../../state/state"; +import {useEffect} from "react"; +import {useCookies} from "react-cookie"; +import {useHistory} from "react-router-dom"; + +function Logout() { + const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn); + let history = useHistory(); + + const [_, removeCookie] = useCookies(['user_session']); + + useEffect(() => { + APIClient.auth.logout().then(r => { + removeCookie("user_session", "") + setLoggedIn(false); + history.push('/login'); + }) + }, [loggedIn, history, removeCookie, setLoggedIn]) + + return ( +
+

Logged out

+
+ ) +} + +export default Logout; \ No newline at end of file diff --git a/web/src/screens/settings/Application.tsx b/web/src/screens/settings/Application.tsx index 3403881..ada9b33 100644 --- a/web/src/screens/settings/Application.tsx +++ b/web/src/screens/settings/Application.tsx @@ -1,12 +1,25 @@ import React, {useState} from "react"; import {Switch} from "@headlessui/react"; import { classNames } from "../../styles/utils"; -import {useRecoilState} from "recoil"; -import {configState} from "../../state/state"; +// import {useRecoilState} from "recoil"; +// import {configState} from "../../state/state"; +import {useQuery} from "react-query"; +import {Config} from "../../domain/interfaces"; +import APIClient from "../../api/APIClient"; function ApplicationSettings() { const [isDebug, setIsDebug] = useState(true) - const [config] = useRecoilState(configState) + // const [config] = useRecoilState(configState) + + const {isLoading, data} = useQuery(['config'], () => APIClient.config.get(), + { + retry: false, + refetchOnWindowFocus: false, + onError: err => { + console.log(err) + } + }, + ) return (
@@ -18,6 +31,8 @@ function ApplicationSettings() {

+ {!isLoading && data && ( +
+ )}
diff --git a/web/src/state/state.ts b/web/src/state/state.ts index 4dc4568..c174241 100644 --- a/web/src/state/state.ts +++ b/web/src/state/state.ts @@ -9,4 +9,9 @@ export const configState = atom({ log_path: "", log_level: "DEBUG", } -}); \ No newline at end of file +}); + +export const isLoggedIn = atom({ + key: 'isLoggedIn', + default: false, +}) \ No newline at end of file diff --git a/web/src/utils/utils.ts b/web/src/utils/utils.ts index 5a47b9e..76cbfff 100644 --- a/web/src/utils/utils.ts +++ b/web/src/utils/utils.ts @@ -2,3 +2,39 @@ export function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } + +// get baseUrl sent from server rendered index template +export function baseUrl() { + let baseUrl = "" + if (window.APP.baseUrl) { + if (window.APP.baseUrl === "/") { + baseUrl = "/" + } else if (window.APP.baseUrl === "{{.BaseUrl}}") { + baseUrl = "/" + } else if (window.APP.baseUrl === "/autobrr/") { + baseUrl = "/autobrr/" + } else { + baseUrl = window.APP.baseUrl + } + } + + return baseUrl +} + +export function buildPath(...args: string[]): string { + const [first] = args; + const firstTrimmed = first.trim(); + const result = args + .map((part) => part.trim()) + .map((part, i) => { + if (i === 0) { + return part.replace(/[/]*$/g, ''); + } else { + return part.replace(/(^[/]*|[/]*$)/g, ''); + } + }) + .filter((x) => x.length) + .join('/'); + + return firstTrimmed === '/' ? `/${result}` : result; +} diff --git a/web/yarn.lock b/web/yarn.lock index 5767278..54b2cf0 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -1840,6 +1840,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/cookie@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + "@types/eslint@^7.2.6": version "7.28.0" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.28.0.tgz#7e41f2481d301c68e14f483fe10b017753ce8d5a" @@ -1878,6 +1883,14 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.9.tgz#1cfb6d60ef3822c589f18e70f8b12f9a28ce8724" integrity sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ== +"@types/hoist-non-react-statics@^3.0.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/html-minifier-terser@^5.0.0": version "5.1.2" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz#693b316ad323ea97eed6b38ed1a3cc02b1672b57" @@ -3704,6 +3717,11 @@ cookie@0.4.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== +cookie@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + copy-concurrently@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" @@ -5727,7 +5745,7 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.1: +hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -9421,6 +9439,15 @@ react-app-polyfill@^2.0.0: regenerator-runtime "^0.13.7" whatwg-fetch "^3.4.1" +react-cookie@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/react-cookie/-/react-cookie-4.1.1.tgz#832e134ad720e0de3e03deaceaab179c4061a19d" + integrity sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A== + dependencies: + "@types/hoist-non-react-statics" "^3.0.1" + hoist-non-react-statics "^3.0.0" + universal-cookie "^4.0.0" + react-dev-utils@^11.0.3: version "11.0.4" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a" @@ -11322,6 +11349,14 @@ unique-string@^1.0.0: dependencies: crypto-random-string "^1.0.0" +universal-cookie@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/universal-cookie/-/universal-cookie-4.0.4.tgz#06e8b3625bf9af049569ef97109b4bb226ad798d" + integrity sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw== + dependencies: + "@types/cookie" "^0.3.3" + cookie "^0.4.0" + universalify@^0.1.0, universalify@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"