From 40b855bf399ac49510f1782f22360524dc334615 Mon Sep 17 00:00:00 2001 From: Ludvig Lundgren Date: Sat, 14 Aug 2021 14:19:21 +0200 Subject: [PATCH] Feature: Auth (#4) * feat(api): add auth * feat(web): add auth and refactor * refactor(web): baseurl * feat: add autobrrctl cli for user creation * build: move static assets * refactor(web): auth guard and routing * refactor: rename var * fix: remove subrouter * build: update default config --- Makefile | 3 + cmd/autobrr/main.go | 10 +- cmd/autobrrctl/main.go | 117 ++++++++++++ config.toml | 4 + go.mod | 2 + go.sum | 8 + internal/auth/service.go | 51 ++++++ internal/config/config.go | 40 ++-- internal/database/migrate.go | 13 +- internal/database/user.go | 47 +++++ internal/domain/config.go | 15 +- internal/domain/user.go | 11 ++ internal/http/auth.go | 86 +++++++++ internal/http/middleware.go | 17 ++ internal/http/service.go | 12 +- internal/logger/logger.go | 4 +- internal/user/service.go | 26 +++ pkg/argon2id/argon2id.go | 172 ++++++++++++++++++ pkg/argon2id/argon2id_test.go | 111 +++++++++++ web/package.json | 1 + web/public/favicon.ico | Bin 3870 -> 0 bytes web/public/index.html | 6 +- web/public/logo192.png | Bin 5347 -> 0 bytes web/public/logo512.png | Bin 9664 -> 0 bytes web/public/static/favicon.ico | Bin 0 -> 15086 bytes web/public/static/logo192.png | Bin 0 -> 7418 bytes web/public/static/logo512.png | Bin 0 -> 18557 bytes web/public/{ => static}/manifest.json | 4 +- web/public/{ => static}/robots.txt | 0 web/src/App.tsx | 36 ++++ web/src/api/APIClient.ts | 29 +-- web/src/components/FilterActionList.tsx | 2 +- web/src/components/Layout.tsx | 38 ++++ web/src/components/inputs/PasswordField.tsx | 47 +++++ web/src/components/inputs/TextField.tsx | 4 +- web/src/components/inputs/index.ts | 1 + web/src/forms/filters/FilterActionAddForm.tsx | 2 +- .../forms/filters/FilterActionUpdateForm.tsx | 2 +- web/src/forms/filters/FilterAddForm.tsx | 2 +- .../forms/settings/DownloadClientAddForm.tsx | 2 +- .../settings/DownloadClientUpdateForm.tsx | 2 +- web/src/forms/settings/IndexerAddForm.tsx | 4 +- web/src/forms/settings/IndexerUpdateForm.tsx | 2 +- web/src/forms/settings/IrcNetworkAddForm.tsx | 2 +- .../forms/settings/IrcNetworkUpdateForm.tsx | 2 +- web/src/index.tsx | 47 +---- web/src/logo.png | Bin 0 -> 7418 bytes web/src/screens/Base.tsx | 142 +++++++-------- web/src/screens/Filters.tsx | 4 +- web/src/screens/Settings.tsx | 94 ++++------ web/src/screens/auth/login.tsx | 104 +++++++++++ web/src/screens/auth/logout.tsx | 29 +++ web/src/screens/settings/Application.tsx | 28 ++- web/src/state/state.ts | 7 +- web/src/utils/utils.ts | 36 ++++ web/yarn.lock | 37 +++- 56 files changed, 1208 insertions(+), 257 deletions(-) create mode 100644 cmd/autobrrctl/main.go create mode 100644 internal/auth/service.go create mode 100644 internal/database/user.go create mode 100644 internal/domain/user.go create mode 100644 internal/http/auth.go create mode 100644 internal/http/middleware.go create mode 100644 internal/user/service.go create mode 100644 pkg/argon2id/argon2id.go create mode 100644 pkg/argon2id/argon2id_test.go delete mode 100644 web/public/favicon.ico delete mode 100644 web/public/logo192.png delete mode 100644 web/public/logo512.png create mode 100644 web/public/static/favicon.ico create mode 100644 web/public/static/logo192.png create mode 100644 web/public/static/logo512.png rename web/public/{ => static}/manifest.json (86%) rename web/public/{ => static}/robots.txt (100%) create mode 100644 web/src/App.tsx create mode 100644 web/src/components/Layout.tsx create mode 100644 web/src/components/inputs/PasswordField.tsx create mode 100644 web/src/logo.png create mode 100644 web/src/screens/auth/login.tsx create mode 100644 web/src/screens/auth/logout.tsx 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 a11777cc471a4344702741ab1c8a588998b1311a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ 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 fc44b0a3796c0e0a64c3d858ca038bd4570465d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN diff --git a/web/public/static/favicon.ico b/web/public/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d7b10763c4e2baa217435c2c8b277fe9cbbbafc0 GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/web/public/static/logo192.png b/web/public/static/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..6ffb3b83417bb5f2e2be3e2cd884da3d69bb0b1b GIT binary patch literal 7418 zcmX|G1ymK^*L^e+QcA}YBoyiHE|m^J>F!3lyGy!3=?+1&?pkx_ zoOAcx`3A3mRZ(T%*fPC z+Rffb*-cKxz|F#d$Bh{%e7KvmJ`k9tTjAI?EZN)%K+O1cjMA%GM+PR7fAp7LOQzhK2OP@Gy)-aPPAl-21%b z2ebckpTUyB{m(EM<{wfruwOFhSptTX4E(3{%=3>5w(B3^GgVN}zf7JLVf@d`A^p$s ze^gS>{5}6vU;tSBKe^}a62U#s89wViC;u<OaCSOKTJ@+I4r|w>HkW5 zCV}z)TZL_b8P8g)XHZx~I$4o@7r|^ z1^9frm|R-v9GWkoz#{;>b8KOCYu~>CgadlkA;88tuyp~zYB{(C1pT^Y!usz3;0XYv zLi}7Fny9WSnug=3e;1&SHgL*kVJ9e)C>Cj#w|892ob~d;6YXG=> z0ysQ67{0ekhxQ-e1CU4H>;YJV0Bh#}uXpDa06+m?@dUbe4ZyC^hwm+-fjyKqjSA62 zHvq764a=>0`x2nFZ~nV`fBpnG7R-JMA9!oqWRx-KUb+zdXMGI<{gyat`*UXf9OzlS z(n%fh599%sE>LyQA<3_&3k67hSC%12VS1dKoo_#Nz z+q!@*9zXW4U&9j6N*ePlTb$m%_pMqk7~Bu4T^ZTBSw4YwEM3eW-It9X!R`(7?5T;} zo578n)_F))_pWm6aMK*bBy(zf=XUk%aryMAe)`lRXPV2iEA02$;QA$*Wdqpy&&K(q zef~_($~lo){pi*un`_xL>>1E@RFYE$wS=LbBQRlz`QWk6DI i)AT&OgBd_qaAV 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 P00000NkvXXu0mjf?1``f literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6ffb3b83417bb5f2e2be3e2cd884da3d69bb0b1b GIT binary patch literal 7418 zcmX|G1ymK^*L^e+QcA}YBoyiHE|m^J>F!3lyGy!3=?+1&?pkx_ zoOAcx`3A3mRZ(T%*fPC z+Rffb*-cKxz|F#d$Bh{%e7KvmJ`k9tTjAI?EZN)%K+O1cjMA%GM+PR7fAp7LOQzhK2OP@Gy)-aPPAl-21%b z2ebckpTUyB{m(EM<{wfruwOFhSptTX4E(3{%=3>5w(B3^GgVN}zf7JLVf@d`A^p$s ze^gS>{5}6vU;tSBKe^}a62U#s89wViC;u<OaCSOKTJ@+I4r|w>HkW5 zCV}z)TZL_b8P8g)XHZx~I$4o@7r|^ z1^9frm|R-v9GWkoz#{;>b8KOCYu~>CgadlkA;88tuyp~zYB{(C1pT^Y!usz3;0XYv zLi}7Fny9WSnug=3e;1&SHgL*kVJ9e)C>Cj#w|892ob~d;6YXG=> z0ysQ67{0ekhxQ-e1CU4H>;YJV0Bh#}uXpDa06+m?@dUbe4ZyC^hwm+-fjyKqjSA62 zHvq764a=>0`x2nFZ~nV`fBpnG7R-JMA9!oqWRx-KUb+zdXMGI<{gyat`*UXf9OzlS z(n%fh599%sE>LyQA<3_&3k67hSC%12VS1dKoo_#Nz z+q!@*9zXW4U&9j6N*ePlTb$m%_pMqk7~Bu4T^ZTBSw4YwEM3eW-It9X!R`(7?5T;} zo578n)_F))_pWm6aMK*bBy(zf=XUk%aryMAe)`lRXPV2iEA02$;QA$*Wdqpy&&K(q zef~_($~lo){pi*un`_xL>>1E@RFYE$wS=LbBQRlz`QWk6DI i)AT&OgBd_qaAV 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
- {/*
*/} - {/*
*/} - {/* */} - {/* 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 ( +
+ ) +} + +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"
+
+ logo +
+ +
+ +
+