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
3
Makefile
|
@ -23,6 +23,9 @@ build: deps build/web build/app
|
||||||
build/app:
|
build/app:
|
||||||
go build -o bin/$(SERVICE) cmd/$(SERVICE)/main.go
|
go build -o bin/$(SERVICE) cmd/$(SERVICE)/main.go
|
||||||
|
|
||||||
|
build/ctl:
|
||||||
|
go build -o bin/autobrrctl cmd/autobrrctl/main.go
|
||||||
|
|
||||||
build/web:
|
build/web:
|
||||||
cd web && yarn build
|
cd web && yarn build
|
||||||
|
|
||||||
|
|
|
@ -13,8 +13,10 @@ import (
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/action"
|
"github.com/autobrr/autobrr/internal/action"
|
||||||
"github.com/autobrr/autobrr/internal/announce"
|
"github.com/autobrr/autobrr/internal/announce"
|
||||||
|
"github.com/autobrr/autobrr/internal/auth"
|
||||||
"github.com/autobrr/autobrr/internal/config"
|
"github.com/autobrr/autobrr/internal/config"
|
||||||
"github.com/autobrr/autobrr/internal/database"
|
"github.com/autobrr/autobrr/internal/database"
|
||||||
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
"github.com/autobrr/autobrr/internal/download_client"
|
"github.com/autobrr/autobrr/internal/download_client"
|
||||||
"github.com/autobrr/autobrr/internal/filter"
|
"github.com/autobrr/autobrr/internal/filter"
|
||||||
"github.com/autobrr/autobrr/internal/http"
|
"github.com/autobrr/autobrr/internal/http"
|
||||||
|
@ -23,10 +25,11 @@ import (
|
||||||
"github.com/autobrr/autobrr/internal/logger"
|
"github.com/autobrr/autobrr/internal/logger"
|
||||||
"github.com/autobrr/autobrr/internal/release"
|
"github.com/autobrr/autobrr/internal/release"
|
||||||
"github.com/autobrr/autobrr/internal/server"
|
"github.com/autobrr/autobrr/internal/server"
|
||||||
|
"github.com/autobrr/autobrr/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg config.Cfg
|
cfg domain.Config
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -62,6 +65,7 @@ func main() {
|
||||||
filterRepo = database.NewFilterRepo(db)
|
filterRepo = database.NewFilterRepo(db)
|
||||||
indexerRepo = database.NewIndexerRepo(db)
|
indexerRepo = database.NewIndexerRepo(db)
|
||||||
ircRepo = database.NewIrcRepo(db)
|
ircRepo = database.NewIrcRepo(db)
|
||||||
|
userRepo = database.NewUserRepo(db)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -72,6 +76,8 @@ func main() {
|
||||||
releaseService = release.NewService(actionService)
|
releaseService = release.NewService(actionService)
|
||||||
announceService = announce.NewService(filterService, indexerService, releaseService)
|
announceService = announce.NewService(filterService, indexerService, releaseService)
|
||||||
ircService = irc.NewService(ircRepo, announceService)
|
ircService = irc.NewService(ircRepo, announceService)
|
||||||
|
userService = user.NewService(userRepo)
|
||||||
|
authService = auth.NewService(userService)
|
||||||
)
|
)
|
||||||
|
|
||||||
addr := fmt.Sprintf("%v:%v", cfg.Host, cfg.Port)
|
addr := fmt.Sprintf("%v:%v", cfg.Host, cfg.Port)
|
||||||
|
@ -79,7 +85,7 @@ func main() {
|
||||||
errorChannel := make(chan error)
|
errorChannel := make(chan error)
|
||||||
|
|
||||||
go func() {
|
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()
|
errorChannel <- httpServer.Open()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
117
cmd/autobrrctl/main.go
Normal file
|
@ -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 <action>
|
||||||
|
|
||||||
|
create-user <username> 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
|
||||||
|
}
|
|
@ -34,3 +34,7 @@ port = 8989
|
||||||
# Options: "ERROR", "DEBUG", "INFO", "WARN"
|
# Options: "ERROR", "DEBUG", "INFO", "WARN"
|
||||||
#
|
#
|
||||||
logLevel = "DEBUG"
|
logLevel = "DEBUG"
|
||||||
|
|
||||||
|
# Session secret
|
||||||
|
#
|
||||||
|
sessionSecret = "secret-session-key"
|
||||||
|
|
2
go.mod
|
@ -6,6 +6,7 @@ require (
|
||||||
github.com/anacrolix/torrent v1.29.1
|
github.com/anacrolix/torrent v1.29.1
|
||||||
github.com/fluffle/goirc v1.0.3
|
github.com/fluffle/goirc v1.0.3
|
||||||
github.com/go-chi/chi v1.5.4
|
github.com/go-chi/chi v1.5.4
|
||||||
|
github.com/gorilla/sessions v1.2.1
|
||||||
github.com/lib/pq v1.10.2
|
github.com/lib/pq v1.10.2
|
||||||
github.com/pelletier/go-toml v1.6.0 // indirect
|
github.com/pelletier/go-toml v1.6.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
|
@ -14,6 +15,7 @@ require (
|
||||||
github.com/spf13/pflag v1.0.3
|
github.com/spf13/pflag v1.0.3
|
||||||
github.com/spf13/viper v1.7.1
|
github.com/spf13/viper v1.7.1
|
||||||
github.com/stretchr/testify v1.7.0
|
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/net v0.0.0-20210427231257-85d9c07bbe3a
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
|
||||||
gopkg.in/irc.v3 v3.1.1
|
gopkg.in/irc.v3 v3.1.1
|
||||||
|
|
8
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/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.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/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 v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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=
|
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-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-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-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-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
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-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-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-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 h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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-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/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.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
|
51
internal/auth/service.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -7,30 +7,21 @@ import (
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Cfg struct {
|
var Config domain.Config
|
||||||
Host string `toml:"host"`
|
|
||||||
Port int `toml:"port"`
|
|
||||||
LogLevel string `toml:"logLevel"`
|
|
||||||
LogPath string `toml:"logPath"`
|
|
||||||
BaseURL string `toml:"baseUrl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var Config Cfg
|
func Defaults() domain.Config {
|
||||||
|
return domain.Config{
|
||||||
func Defaults() Cfg {
|
Host: "localhost",
|
||||||
hostname, err := os.Hostname()
|
Port: 8989,
|
||||||
if err != nil {
|
LogLevel: "DEBUG",
|
||||||
hostname = "localhost"
|
LogPath: "",
|
||||||
}
|
BaseURL: "/",
|
||||||
return Cfg{
|
SessionSecret: "secret-session-key",
|
||||||
Host: hostname,
|
|
||||||
Port: 8989,
|
|
||||||
LogLevel: "DEBUG",
|
|
||||||
LogPath: "",
|
|
||||||
BaseURL: "/",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,7 +83,12 @@ port = 8989
|
||||||
#
|
#
|
||||||
# Options: "ERROR", "DEBUG", "INFO", "WARN"
|
# Options: "ERROR", "DEBUG", "INFO", "WARN"
|
||||||
#
|
#
|
||||||
logLevel = "DEBUG"`)
|
logLevel = "DEBUG"
|
||||||
|
|
||||||
|
# Session secret
|
||||||
|
#
|
||||||
|
sessionSecret = "secret-session-key"`)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error writing contents to file: %v %q", configPath, err)
|
log.Printf("error writing contents to file: %v %q", configPath, err)
|
||||||
return err
|
return err
|
||||||
|
@ -105,7 +101,7 @@ logLevel = "DEBUG"`)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Read(configPath string) Cfg {
|
func Read(configPath string) domain.Config {
|
||||||
config := Defaults()
|
config := Defaults()
|
||||||
|
|
||||||
// or use viper.SetDefault(val, def)
|
// or use viper.SetDefault(val, def)
|
||||||
|
|
|
@ -3,11 +3,18 @@ package database
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const schema = `
|
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
|
CREATE TABLE indexer
|
||||||
(
|
(
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
@ -135,8 +142,6 @@ var migrations = []string{
|
||||||
}
|
}
|
||||||
|
|
||||||
func Migrate(db *sql.DB) error {
|
func Migrate(db *sql.DB) error {
|
||||||
log.Info().Msg("Migrating database...")
|
|
||||||
|
|
||||||
var version int
|
var version int
|
||||||
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
||||||
return fmt.Errorf("failed to query schema version: %v", err)
|
return fmt.Errorf("failed to query schema version: %v", err)
|
||||||
|
|
47
internal/database/user.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
type Settings struct {
|
type Config struct {
|
||||||
Host string `toml:"host"`
|
Host string `toml:"host"`
|
||||||
Debug bool
|
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"`
|
|
||||||
//}
|
|
||||||
|
|
11
internal/domain/user.go
Normal file
|
@ -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"`
|
||||||
|
}
|
86
internal/http/auth.go
Normal file
|
@ -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)
|
||||||
|
}
|
17
internal/http/middleware.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -15,17 +15,19 @@ type Server struct {
|
||||||
address string
|
address string
|
||||||
baseUrl string
|
baseUrl string
|
||||||
actionService actionService
|
actionService actionService
|
||||||
|
authService authService
|
||||||
downloadClientService downloadClientService
|
downloadClientService downloadClientService
|
||||||
filterService filterService
|
filterService filterService
|
||||||
indexerService indexerService
|
indexerService indexerService
|
||||||
ircService ircService
|
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{
|
return Server{
|
||||||
address: address,
|
address: address,
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
actionService: actionService,
|
actionService: actionService,
|
||||||
|
authService: authService,
|
||||||
downloadClientService: downloadClientSvc,
|
downloadClientService: downloadClientSvc,
|
||||||
filterService: filterSvc,
|
filterService: filterSvc,
|
||||||
indexerService: indexerSvc,
|
indexerService: indexerSvc,
|
||||||
|
@ -62,7 +64,15 @@ func (s Server) Handler() http.Handler {
|
||||||
fileSystem.ServeHTTP(w, r)
|
fileSystem.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
authHandler := authHandler{
|
||||||
|
encoder: encoder,
|
||||||
|
authService: s.authService,
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Route("/api/auth", authHandler.Routes)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(IsAuthenticated)
|
||||||
|
|
||||||
actionHandler := actionHandler{
|
actionHandler := actionHandler{
|
||||||
encoder: encoder,
|
encoder: encoder,
|
||||||
|
|
|
@ -5,14 +5,14 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/config"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"gopkg.in/natefinch/lumberjack.v2"
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Setup(cfg config.Cfg) {
|
func Setup(cfg domain.Config) {
|
||||||
zerolog.TimeFieldFormat = time.RFC3339
|
zerolog.TimeFieldFormat = time.RFC3339
|
||||||
|
|
||||||
switch cfg.LogLevel {
|
switch cfg.LogLevel {
|
||||||
|
|
26
internal/user/service.go
Normal file
|
@ -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
|
||||||
|
}
|
172
pkg/argon2id/argon2id.go
Normal file
|
@ -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
|
||||||
|
}
|
111
pkg/argon2id/argon2id_test.go
Normal file
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@
|
||||||
"final-form": "^4.20.2",
|
"final-form": "^4.20.2",
|
||||||
"final-form-arrays": "^3.0.2",
|
"final-form-arrays": "^3.0.2",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
|
"react-cookie": "^4.1.1",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-final-form": "^6.5.3",
|
"react-final-form": "^6.5.3",
|
||||||
"react-final-form-arrays": "^3.1.3",
|
"react-final-form-arrays": "^3.1.3",
|
||||||
|
|
Before Width: | Height: | Size: 3.8 KiB |
|
@ -2,15 +2,15 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/static/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="autobrr"
|
content="autobrr"
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/static/logo192.png" />
|
||||||
<link crossorigin="use-credentials" rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<link crossorigin="use-credentials" rel="manifest" href="%PUBLIC_URL%/static/manifest.json" />
|
||||||
<title>autobrr</title>
|
<title>autobrr</title>
|
||||||
{{if eq .BaseUrl "/" }}
|
{{if eq .BaseUrl "/" }}
|
||||||
<base href="%PUBLIC_URL%/">
|
<base href="%PUBLIC_URL%/">
|
||||||
|
|
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB |
BIN
web/public/static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
web/public/static/logo192.png
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
web/public/static/logo512.png
Normal file
After Width: | Height: | Size: 18 KiB |
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"short_name": "React App",
|
"short_name": "autobrr",
|
||||||
"name": "Create React App Sample",
|
"name": "autobrr",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
36
web/src/App.tsx
Normal file
|
@ -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 (
|
||||||
|
<Layout auth={true}>
|
||||||
|
<Base />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Router basename={baseUrl()}>
|
||||||
|
<Switch>
|
||||||
|
<Route exact={true} path="/login" component={Login}/>
|
||||||
|
<Route exact={true} path="/logout" component={Logout}/>
|
||||||
|
<Route exact={true} path="/*" component={Protected}/>
|
||||||
|
</Switch>
|
||||||
|
</Router>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false}/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
|
@ -1,18 +1,8 @@
|
||||||
import {Action, DownloadClient, Filter, Indexer, Network} from "../domain/interfaces";
|
import {Action, DownloadClient, Filter, Indexer, Network} from "../domain/interfaces";
|
||||||
|
import {baseUrl} from "../utils/utils";
|
||||||
|
|
||||||
function baseClient(endpoint: string, method: string, { body, ...customConfig}: any = {}) {
|
function baseClient(endpoint: string, method: string, { body, ...customConfig}: any = {}) {
|
||||||
let baseUrl = ""
|
let baseURL = 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {'content-type': 'application/json'}
|
const headers = {'content-type': 'application/json'}
|
||||||
const config = {
|
const config = {
|
||||||
|
@ -28,13 +18,19 @@ function baseClient(endpoint: string, method: string, { body, ...customConfig}:
|
||||||
config.body = JSON.stringify(body)
|
config.body = JSON.stringify(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
return window.fetch(`${baseUrl}${endpoint}`, config)
|
return window.fetch(`${baseURL}${endpoint}`, config)
|
||||||
.then(async response => {
|
.then(async response => {
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// unauthorized
|
// unauthorized
|
||||||
// window.location.assign(window.location)
|
// 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) {
|
if (response.status === 404) {
|
||||||
|
@ -68,6 +64,11 @@ const appClient = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const APIClient = {
|
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: {
|
actions: {
|
||||||
create: (action: Action) => appClient.Post("api/actions", action),
|
create: (action: Action) => appClient.Post("api/actions", action),
|
||||||
update: (action: Action) => appClient.Put(`api/actions/${action.id}`, action),
|
update: (action: Action) => appClient.Put(`api/actions/${action.id}`, action),
|
||||||
|
|
|
@ -5,11 +5,11 @@ import {classNames} from "../styles/utils";
|
||||||
import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid";
|
import {CheckIcon, ChevronRightIcon, ExclamationIcon, SelectorIcon,} from "@heroicons/react/solid";
|
||||||
import {useToggle} from "../hooks/hooks";
|
import {useToggle} from "../hooks/hooks";
|
||||||
import {useMutation} from "react-query";
|
import {useMutation} from "react-query";
|
||||||
import {queryClient} from "..";
|
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
import {TextField} from "./inputs";
|
import {TextField} from "./inputs";
|
||||||
import DEBUG from "./debug";
|
import DEBUG from "./debug";
|
||||||
import APIClient from "../api/APIClient";
|
import APIClient from "../api/APIClient";
|
||||||
|
import {queryClient} from "../App";
|
||||||
|
|
||||||
interface radioFieldsetOption {
|
interface radioFieldsetOption {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
38
web/src/components/Layout.tsx
Normal file
|
@ -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 (
|
||||||
|
<Fragment>
|
||||||
|
{loading ? null : (
|
||||||
|
<Fragment>
|
||||||
|
{auth && !loggedIn ? <Redirect to={authFallback} /> : (
|
||||||
|
<Fragment>
|
||||||
|
{children}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
47
web/src/components/inputs/PasswordField.tsx
Normal file
|
@ -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<Props> = ({ name, label, placeholder, columns , className, autoComplete}) => (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
columns ? `col-span-${columns}` : "col-span-12"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={name} className="block text-xs font-bold text-gray-700 uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Field
|
||||||
|
name={name}
|
||||||
|
render={({input, meta}) => (
|
||||||
|
<input
|
||||||
|
{...input}
|
||||||
|
id={name}
|
||||||
|
type="password"
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Error name={name} classNames="text-red mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default PasswordField;
|
|
@ -11,9 +11,10 @@ interface Props {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
columns?: COL_WIDTHS;
|
columns?: COL_WIDTHS;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
autoComplete?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TextField: React.FC<Props> = ({ name, label, placeholder, columns , className}) => (
|
const TextField: React.FC<Props> = ({ name, label, placeholder, columns , className, autoComplete}) => (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
columns ? `col-span-${columns}` : "col-span-12"
|
columns ? `col-span-${columns}` : "col-span-12"
|
||||||
|
@ -31,6 +32,7 @@ const TextField: React.FC<Props> = ({ name, label, placeholder, columns , classN
|
||||||
{...input}
|
{...input}
|
||||||
id={name}
|
id={name}
|
||||||
type="text"
|
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"
|
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}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export { default as TextField } from "./TextField";
|
export { default as TextField } from "./TextField";
|
||||||
export { default as TextFieldWide } from "./TextFieldWide";
|
export { default as TextFieldWide } from "./TextFieldWide";
|
||||||
|
export { default as PasswordField } from "./PasswordField";
|
||||||
export { default as TextAreaWide } from "./TextAreaWide";
|
export { default as TextAreaWide } from "./TextAreaWide";
|
||||||
export { default as MultiSelectField } from "./MultiSelectField";
|
export { default as MultiSelectField } from "./MultiSelectField";
|
||||||
export { default as RadioFieldset } from "./RadioFieldset";
|
export { default as RadioFieldset } from "./RadioFieldset";
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, {Fragment, useEffect } from "react";
|
import React, {Fragment, useEffect } from "react";
|
||||||
import {useMutation} from "react-query";
|
import {useMutation} from "react-query";
|
||||||
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
|
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
import {sleep} from "../../utils/utils";
|
import {sleep} from "../../utils/utils";
|
||||||
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
|
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
|
||||||
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
|
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {Fragment, useEffect} from "react";
|
import {Fragment, useEffect} from "react";
|
||||||
import {useMutation} from "react-query";
|
import {useMutation} from "react-query";
|
||||||
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
|
import {Action, DownloadClient, Filter} from "../../domain/interfaces";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
import {sleep} from "../../utils/utils";
|
import {sleep} from "../../utils/utils";
|
||||||
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
|
import {CheckIcon, ExclamationIcon, SelectorIcon, XIcon} from "@heroicons/react/solid";
|
||||||
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
|
import {Dialog, Listbox, RadioGroup, Transition} from "@headlessui/react";
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, {Fragment, useEffect} from "react";
|
import React, {Fragment, useEffect} from "react";
|
||||||
import {useMutation} from "react-query";
|
import {useMutation} from "react-query";
|
||||||
import {Filter} from "../../domain/interfaces";
|
import {Filter} from "../../domain/interfaces";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
import {XIcon} from "@heroicons/react/solid";
|
import {XIcon} from "@heroicons/react/solid";
|
||||||
import {Dialog, Transition} from "@headlessui/react";
|
import {Dialog, Transition} from "@headlessui/react";
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {classNames} from "../../styles/utils";
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
import DEBUG from "../../components/debug";
|
import DEBUG from "../../components/debug";
|
||||||
import {SwitchGroup} from "../../components/inputs";
|
import {SwitchGroup} from "../../components/inputs";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
import APIClient from "../../api/APIClient";
|
import APIClient from "../../api/APIClient";
|
||||||
import {sleep} from "../../utils/utils";
|
import {sleep} from "../../utils/utils";
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {Fragment, useRef, useState} from "react";
|
||||||
import {useToggle} from "../../hooks/hooks";
|
import {useToggle} from "../../hooks/hooks";
|
||||||
import {useMutation} from "react-query";
|
import {useMutation} from "react-query";
|
||||||
import {DownloadClient} from "../../domain/interfaces";
|
import {DownloadClient} from "../../domain/interfaces";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
import {Dialog, RadioGroup, Transition} from "@headlessui/react";
|
import {Dialog, RadioGroup, Transition} from "@headlessui/react";
|
||||||
import {ExclamationIcon, XIcon} from "@heroicons/react/solid";
|
import {ExclamationIcon, XIcon} from "@heroicons/react/solid";
|
||||||
import {classNames} from "../../styles/utils";
|
import {classNames} from "../../styles/utils";
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, {Fragment, useEffect} from "react";
|
import React, {Fragment} from "react";
|
||||||
import {useMutation, useQuery} from "react-query";
|
import {useMutation, useQuery} from "react-query";
|
||||||
import {Indexer} from "../../domain/interfaces";
|
import {Indexer} from "../../domain/interfaces";
|
||||||
import {sleep} from "../../utils/utils";
|
import {sleep} from "../../utils/utils";
|
||||||
|
@ -7,7 +7,7 @@ import {Dialog, Transition} from "@headlessui/react";
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
import DEBUG from "../../components/debug";
|
import DEBUG from "../../components/debug";
|
||||||
import Select from "react-select";
|
import Select from "react-select";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
import { SwitchGroup } from "../../components/inputs";
|
import { SwitchGroup } from "../../components/inputs";
|
||||||
import APIClient from "../../api/APIClient";
|
import APIClient from "../../api/APIClient";
|
||||||
|
|
||||||
|
|
|
@ -7,9 +7,9 @@ import {Dialog, Transition} from "@headlessui/react";
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
import DEBUG from "../../components/debug";
|
import DEBUG from "../../components/debug";
|
||||||
import { SwitchGroup } from "../../components/inputs";
|
import { SwitchGroup } from "../../components/inputs";
|
||||||
import {queryClient} from "../../index";
|
|
||||||
import {useToggle} from "../../hooks/hooks";
|
import {useToggle} from "../../hooks/hooks";
|
||||||
import APIClient from "../../api/APIClient";
|
import APIClient from "../../api/APIClient";
|
||||||
|
import {queryClient} from "../../App";
|
||||||
|
|
||||||
interface props {
|
interface props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {XIcon} from "@heroicons/react/solid";
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
import DEBUG from "../../components/debug";
|
import DEBUG from "../../components/debug";
|
||||||
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
|
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
|
|
||||||
import arrayMutators from "final-form-arrays";
|
import arrayMutators from "final-form-arrays";
|
||||||
import { FieldArray } from "react-final-form-arrays";
|
import { FieldArray } from "react-final-form-arrays";
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {XIcon} from "@heroicons/react/solid";
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
import DEBUG from "../../components/debug";
|
import DEBUG from "../../components/debug";
|
||||||
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
|
import {SwitchGroup, TextAreaWide, TextFieldWide} from "../../components/inputs";
|
||||||
import {queryClient} from "../../index";
|
import {queryClient} from "../../App";
|
||||||
|
|
||||||
import arrayMutators from "final-form-arrays";
|
import arrayMutators from "final-form-arrays";
|
||||||
import { FieldArray } from "react-final-form-arrays";
|
import { FieldArray } from "react-final-form-arrays";
|
||||||
|
|
|
@ -1,17 +1,10 @@
|
||||||
import React, {useEffect, useState} from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import reportWebVitals from './reportWebVitals';
|
|
||||||
import Base from "./screens/Base";
|
|
||||||
|
|
||||||
import {BrowserRouter as Router,} from "react-router-dom";
|
import {RecoilRoot} from 'recoil';
|
||||||
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 {APP} from "./domain/interfaces";
|
import {APP} from "./domain/interfaces";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window { APP: APP; }
|
interface Window { APP: APP; }
|
||||||
|
@ -19,43 +12,11 @@ declare global {
|
||||||
|
|
||||||
window.APP = window.APP || {};
|
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 (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{loading ? null : (
|
|
||||||
<Router basename={config.base_url}>
|
|
||||||
<Base/>
|
|
||||||
</Router>
|
|
||||||
)}
|
|
||||||
<ReactQueryDevtools initialIsOpen={false}/>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<RecoilRoot>
|
<RecoilRoot>
|
||||||
<ConfigWrapper/>
|
<App />
|
||||||
</RecoilRoot>
|
</RecoilRoot>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById('root')
|
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();
|
|
||||||
|
|
BIN
web/src/logo.png
Normal file
After Width: | Height: | Size: 7.2 KiB |
|
@ -41,77 +41,77 @@ export default function Base() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* <div className="hidden md:block">*/}
|
<div className="hidden md:block">
|
||||||
{/* <div className="ml-4 flex items-center md:ml-6">*/}
|
<div className="ml-4 flex items-center md:ml-6">
|
||||||
{/* <button*/}
|
{/*<button*/}
|
||||||
{/* className="bg-gray-800 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">*/}
|
{/* className="bg-gray-800 p-1 text-gray-400 rounded-full hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">*/}
|
||||||
{/* <span className="sr-only">View notifications</span>*/}
|
{/* <span className="sr-only">View notifications</span>*/}
|
||||||
{/* <BellIcon className="h-6 w-6" aria-hidden="true"/>*/}
|
{/* <BellIcon className="h-6 w-6" aria-hidden="true"/>*/}
|
||||||
{/* </button>*/}
|
{/*</button>*/}
|
||||||
|
|
||||||
{/* <Menu as="div" className="ml-3 relative">*/}
|
<Menu as="div" className="ml-3 relative">
|
||||||
{/* {({open}) => (*/}
|
{({open}) => (
|
||||||
{/* <>*/}
|
<>
|
||||||
{/* <div>*/}
|
<div>
|
||||||
{/* <Menu.Button*/}
|
<Menu.Button
|
||||||
{/* className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">*/}
|
className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white">
|
||||||
{/* <span*/}
|
<span
|
||||||
{/* className="hidden text-gray-300 text-sm font-medium lg:block">*/}
|
className="hidden text-gray-300 text-sm font-medium lg:block">
|
||||||
{/* <span className="sr-only">Open user menu for </span>User*/}
|
<span className="sr-only">Open user menu for </span>User
|
||||||
{/*</span>*/}
|
</span>
|
||||||
{/* <ChevronDownIcon*/}
|
<ChevronDownIcon
|
||||||
{/* className="hidden flex-shrink-0 ml-1 h-5 w-5 text-gray-400 lg:block"*/}
|
className="hidden flex-shrink-0 ml-1 h-5 w-5 text-gray-400 lg:block"
|
||||||
{/* aria-hidden="true"*/}
|
aria-hidden="true"
|
||||||
{/* />*/}
|
/>
|
||||||
{/* </Menu.Button>*/}
|
</Menu.Button>
|
||||||
{/* </div>*/}
|
</div>
|
||||||
{/* <Transition*/}
|
<Transition
|
||||||
{/* show={open}*/}
|
show={open}
|
||||||
{/* as={Fragment}*/}
|
as={Fragment}
|
||||||
{/* enter="transition ease-out duration-100"*/}
|
enter="transition ease-out duration-100"
|
||||||
{/* enterFrom="transform opacity-0 scale-95"*/}
|
enterFrom="transform opacity-0 scale-95"
|
||||||
{/* enterTo="transform opacity-100 scale-100"*/}
|
enterTo="transform opacity-100 scale-100"
|
||||||
{/* leave="transition ease-in duration-75"*/}
|
leave="transition ease-in duration-75"
|
||||||
{/* leaveFrom="transform opacity-100 scale-100"*/}
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
{/* leaveTo="transform opacity-0 scale-95"*/}
|
leaveTo="transform opacity-0 scale-95"
|
||||||
{/* >*/}
|
>
|
||||||
{/* <Menu.Items*/}
|
<Menu.Items
|
||||||
{/* static*/}
|
static
|
||||||
{/* className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"*/}
|
className="origin-top-right absolute right-0 mt-2 w-48 z-10 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||||
{/* >*/}
|
>
|
||||||
{/* <Menu.Item>*/}
|
<Menu.Item>
|
||||||
{/* {({active}) => (*/}
|
{({active}) => (
|
||||||
{/* <Link*/}
|
<Link
|
||||||
{/* to="settings"*/}
|
to="settings"
|
||||||
{/* className={classNames(*/}
|
className={classNames(
|
||||||
{/* active ? 'bg-gray-100' : '',*/}
|
active ? 'bg-gray-100' : '',
|
||||||
{/* 'block px-4 py-2 text-sm text-gray-700'*/}
|
'block px-4 py-2 text-sm text-gray-700'
|
||||||
{/* )}*/}
|
)}
|
||||||
{/* >*/}
|
>
|
||||||
{/* Settings*/}
|
Settings
|
||||||
{/* </Link>*/}
|
</Link>
|
||||||
{/* )}*/}
|
)}
|
||||||
{/* </Menu.Item>*/}
|
</Menu.Item>
|
||||||
{/* <Menu.Item>*/}
|
<Menu.Item>
|
||||||
{/* {({active}) => (*/}
|
{({active}) => (
|
||||||
{/* <Link*/}
|
<Link
|
||||||
{/* to="logout"*/}
|
to="/logout"
|
||||||
{/* className={classNames(*/}
|
className={classNames(
|
||||||
{/* active ? 'bg-gray-100' : '',*/}
|
active ? 'bg-gray-100' : '',
|
||||||
{/* 'block px-4 py-2 text-sm text-gray-700'*/}
|
'block px-4 py-2 text-sm text-gray-700'
|
||||||
{/* )}*/}
|
)}
|
||||||
{/* >*/}
|
>
|
||||||
{/* Logout*/}
|
Logout
|
||||||
{/* </Link>*/}
|
</Link>
|
||||||
{/* )}*/}
|
)}
|
||||||
{/* </Menu.Item>*/}
|
</Menu.Item>
|
||||||
{/* </Menu.Items>*/}
|
</Menu.Items>
|
||||||
{/* </Transition>*/}
|
</Transition>
|
||||||
{/* </>*/}
|
</>
|
||||||
{/* )}*/}
|
)}
|
||||||
{/* </Menu>*/}
|
</Menu>
|
||||||
{/* </div>*/}
|
</div>
|
||||||
{/* </div>*/}
|
</div>
|
||||||
<div className="-mr-2 flex md:hidden">
|
<div className="-mr-2 flex md:hidden">
|
||||||
{/* Mobile menu button */}
|
{/* Mobile menu button */}
|
||||||
<Disclosure.Button
|
<Disclosure.Button
|
||||||
|
@ -192,7 +192,7 @@ export default function Base() {
|
||||||
<FilterDetails />
|
<FilterDetails />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/">
|
<Route exact path="/">
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {FilterActionList} from "../components/FilterActionList";
|
||||||
import {DownloadClient, Filter, Indexer} from "../domain/interfaces";
|
import {DownloadClient, Filter, Indexer} from "../domain/interfaces";
|
||||||
import {useToggle} from "../hooks/hooks";
|
import {useToggle} from "../hooks/hooks";
|
||||||
import {useMutation, useQuery} from "react-query";
|
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 {CONTAINER_OPTIONS, CODECS_OPTIONS, RESOLUTION_OPTIONS, SOURCES_OPTIONS} from "../domain/constants";
|
||||||
import {Field, Form} from "react-final-form";
|
import {Field, Form} from "react-final-form";
|
||||||
import {MultiSelectField, TextField} from "../components/inputs";
|
import {MultiSelectField, TextField} from "../components/inputs";
|
||||||
|
@ -345,7 +345,7 @@ export function FilterDetails() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (<p>Something went wrong</p>)
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,21 +1,12 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
|
import {CogIcon, DownloadIcon, KeyIcon} from '@heroicons/react/outline'
|
||||||
import {
|
import {NavLink, Route, Switch as RouteSwitch, useLocation, useRouteMatch} from "react-router-dom";
|
||||||
BrowserRouter as Router,
|
|
||||||
NavLink,
|
|
||||||
Route,
|
|
||||||
Switch as RouteSwitch,
|
|
||||||
useLocation,
|
|
||||||
useRouteMatch
|
|
||||||
} from "react-router-dom";
|
|
||||||
import IndexerSettings from "./settings/Indexer";
|
import IndexerSettings from "./settings/Indexer";
|
||||||
import IrcSettings from "./settings/Irc";
|
import IrcSettings from "./settings/Irc";
|
||||||
import ApplicationSettings from "./settings/Application";
|
import ApplicationSettings from "./settings/Application";
|
||||||
import DownloadClientSettings from "./settings/DownloadClient";
|
import DownloadClientSettings from "./settings/DownloadClient";
|
||||||
import {classNames} from "../styles/utils";
|
import {classNames} from "../styles/utils";
|
||||||
import ActionSettings from "./settings/Action";
|
import ActionSettings from "./settings/Action";
|
||||||
import {useRecoilValue} from "recoil";
|
|
||||||
import {configState} from "../state/state";
|
|
||||||
|
|
||||||
const subNavigation = [
|
const subNavigation = [
|
||||||
{name: 'Application', href: '', icon: CogIcon, current: true},
|
{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() {
|
export default function Settings() {
|
||||||
const config = useRecoilValue(configState)
|
let {url} = useRouteMatch();
|
||||||
|
|
||||||
let { url } = useRouteMatch();
|
|
||||||
let p = config.base_url ? buildPath(config.base_url, url) : url
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<main className="relative -mt-48">
|
||||||
<main className="relative -mt-48">
|
<header className="py-10">
|
||||||
<header className="py-10">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<h1 className="text-3xl font-bold text-white capitalize">Settings</h1>
|
||||||
<h1 className="text-3xl font-bold text-white capitalize">Settings</h1>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
|
<div className="max-w-screen-xl mx-auto pb-6 px-4 sm:px-6 lg:pb-16 lg:px-8">
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
|
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
|
||||||
<SidebarNav url={p} subNavigation={subNavigation}/>
|
<SidebarNav url={url} subNavigation={subNavigation}/>
|
||||||
|
|
||||||
<RouteSwitch>
|
<RouteSwitch>
|
||||||
<Route exact path={p}>
|
<Route exact path={url}>
|
||||||
<ApplicationSettings />
|
<ApplicationSettings/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path={`${p}/indexers`}>
|
<Route path={`${url}/indexers`}>
|
||||||
<IndexerSettings />
|
<IndexerSettings/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path={`${p}/irc`}>
|
<Route path={`${url}/irc`}>
|
||||||
<IrcSettings />
|
<IrcSettings/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path={`${p}/clients`}>
|
<Route path={`${url}/clients`}>
|
||||||
<DownloadClientSettings />
|
<DownloadClientSettings/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path={`${p}/actions`}>
|
<Route path={`${url}/actions`}>
|
||||||
<ActionSettings />
|
<ActionSettings/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
</RouteSwitch>
|
</RouteSwitch>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</Router>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
104
web/src/screens/auth/login.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md mb-6">
|
||||||
|
<img
|
||||||
|
className="mx-auto h-12 w-auto"
|
||||||
|
src={logo}
|
||||||
|
alt="logo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||||
|
|
||||||
|
<Form
|
||||||
|
initialValues={{
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
{({handleSubmit, values}) => {
|
||||||
|
return (
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<TextField name="username" label="Username" autoComplete="username" />
|
||||||
|
<PasswordField name="password" label="password" autoComplete="current-password"/>
|
||||||
|
|
||||||
|
{/*<div className="flex items-center justify-between">*/}
|
||||||
|
{/* <div className="flex items-center">*/}
|
||||||
|
{/* <input*/}
|
||||||
|
{/* id="remember-me"*/}
|
||||||
|
{/* name="remember-me"*/}
|
||||||
|
{/* type="checkbox"*/}
|
||||||
|
{/* className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"*/}
|
||||||
|
{/* />*/}
|
||||||
|
{/* <label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">*/}
|
||||||
|
{/* Remember me*/}
|
||||||
|
{/* </label>*/}
|
||||||
|
{/* </div>*/}
|
||||||
|
|
||||||
|
{/* <div className="text-sm">*/}
|
||||||
|
{/* <a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">*/}
|
||||||
|
{/* Forgot your password?*/}
|
||||||
|
{/* </a>*/}
|
||||||
|
{/* </div>*/}
|
||||||
|
{/*</div>*/}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
29
web/src/screens/auth/logout.tsx
Normal file
|
@ -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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||||
|
<p>Logged out</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logout;
|
|
@ -1,12 +1,25 @@
|
||||||
import React, {useState} from "react";
|
import React, {useState} from "react";
|
||||||
import {Switch} from "@headlessui/react";
|
import {Switch} from "@headlessui/react";
|
||||||
import { classNames } from "../../styles/utils";
|
import { classNames } from "../../styles/utils";
|
||||||
import {useRecoilState} from "recoil";
|
// import {useRecoilState} from "recoil";
|
||||||
import {configState} from "../../state/state";
|
// import {configState} from "../../state/state";
|
||||||
|
import {useQuery} from "react-query";
|
||||||
|
import {Config} from "../../domain/interfaces";
|
||||||
|
import APIClient from "../../api/APIClient";
|
||||||
|
|
||||||
function ApplicationSettings() {
|
function ApplicationSettings() {
|
||||||
const [isDebug, setIsDebug] = useState(true)
|
const [isDebug, setIsDebug] = useState(true)
|
||||||
const [config] = useRecoilState(configState)
|
// const [config] = useRecoilState(configState)
|
||||||
|
|
||||||
|
const {isLoading, data} = useQuery<Config, Error>(['config'], () => APIClient.config.get(),
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
onError: err => {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="divide-y divide-gray-200 lg:col-span-9" action="#" method="POST">
|
<form className="divide-y divide-gray-200 lg:col-span-9" action="#" method="POST">
|
||||||
|
@ -18,6 +31,8 @@ function ApplicationSettings() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isLoading && data && (
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
<div className="col-span-6 sm:col-span-4">
|
<div className="col-span-6 sm:col-span-4">
|
||||||
<label htmlFor="host" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="host" className="block text-sm font-medium text-gray-700">
|
||||||
|
@ -27,7 +42,7 @@ function ApplicationSettings() {
|
||||||
type="text"
|
type="text"
|
||||||
name="host"
|
name="host"
|
||||||
id="host"
|
id="host"
|
||||||
value={config.host}
|
value={data.host}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
className="mt-1 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"
|
className="mt-1 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"
|
||||||
/>
|
/>
|
||||||
|
@ -41,7 +56,7 @@ function ApplicationSettings() {
|
||||||
type="text"
|
type="text"
|
||||||
name="port"
|
name="port"
|
||||||
id="port"
|
id="port"
|
||||||
value={config.port}
|
value={data.port}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
className="mt-1 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"
|
className="mt-1 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"
|
||||||
/>
|
/>
|
||||||
|
@ -55,12 +70,13 @@ function ApplicationSettings() {
|
||||||
type="text"
|
type="text"
|
||||||
name="base_url"
|
name="base_url"
|
||||||
id="base_url"
|
id="base_url"
|
||||||
value={config.base_url}
|
value={data.base_url}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
className="mt-1 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"
|
className="mt-1 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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-6 pb-6 divide-y divide-gray-200">
|
<div className="pt-6 pb-6 divide-y divide-gray-200">
|
||||||
|
|
|
@ -9,4 +9,9 @@ export const configState = atom({
|
||||||
log_path: "",
|
log_path: "",
|
||||||
log_level: "DEBUG",
|
log_level: "DEBUG",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const isLoggedIn = atom({
|
||||||
|
key: 'isLoggedIn',
|
||||||
|
default: false,
|
||||||
|
})
|
|
@ -2,3 +2,39 @@
|
||||||
export function sleep(ms: number) {
|
export function sleep(ms: number) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -1840,6 +1840,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.3.0"
|
"@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":
|
"@types/eslint@^7.2.6":
|
||||||
version "7.28.0"
|
version "7.28.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.28.0.tgz#7e41f2481d301c68e14f483fe10b017753ce8d5a"
|
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"
|
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.9.tgz#1cfb6d60ef3822c589f18e70f8b12f9a28ce8724"
|
||||||
integrity sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==
|
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":
|
"@types/html-minifier-terser@^5.0.0":
|
||||||
version "5.1.2"
|
version "5.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz#693b316ad323ea97eed6b38ed1a3cc02b1672b57"
|
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"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
|
||||||
integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
|
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:
|
copy-concurrently@^1.0.0:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
|
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-assert "^1.0.0"
|
||||||
minimalistic-crypto-utils "^1.0.1"
|
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"
|
version "3.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||||
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||||
|
@ -9421,6 +9439,15 @@ react-app-polyfill@^2.0.0:
|
||||||
regenerator-runtime "^0.13.7"
|
regenerator-runtime "^0.13.7"
|
||||||
whatwg-fetch "^3.4.1"
|
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:
|
react-dev-utils@^11.0.3:
|
||||||
version "11.0.4"
|
version "11.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a"
|
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:
|
dependencies:
|
||||||
crypto-random-string "^1.0.0"
|
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:
|
universalify@^0.1.0, universalify@^0.1.2:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
|
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
|
||||||
|
|