From fa20978d584afc68367a4c4794bd447eb25dbca2 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Mon, 15 Aug 2022 11:58:13 +0200 Subject: [PATCH] feat(api): add apikey support (#408) * feat(api): add apikey support * feat(web): api settings crud --- cmd/autobrr/main.go | 10 +- go.mod | 4 +- go.sum | 8 +- internal/api/service.go | 91 +++++++++++++ internal/database/api.go | 114 ++++++++++++++++ internal/database/postgres_migrate.go | 16 +++ internal/database/sqlite_migrate.go | 16 +++ internal/domain/api.go | 19 +++ internal/http/action.go | 2 +- internal/http/apikey.go | 78 +++++++++++ internal/http/auth.go | 2 +- internal/http/config.go | 2 +- internal/http/download_client.go | 2 +- internal/http/encoder.go | 13 +- internal/http/feed.go | 2 +- internal/http/filter.go | 2 +- internal/http/health.go | 2 +- internal/http/indexer.go | 2 +- internal/http/irc.go | 2 +- internal/http/middleware.go | 28 +++- internal/http/notification.go | 2 +- internal/http/release.go | 2 +- internal/http/server.go | 12 +- web/src/api/APIClient.ts | 5 + web/src/components/fields/text.tsx | 77 +++++++++++ web/src/domain/routes.tsx | 2 + web/src/forms/settings/APIKeyAddForm.tsx | 158 +++++++++++++++++++++++ web/src/screens/Settings.tsx | 1 + web/src/screens/settings/Api.tsx | 137 ++++++++++++++++++++ web/src/screens/settings/Application.tsx | 87 +++++++------ web/src/types/API.d.ts | 6 + 31 files changed, 834 insertions(+), 70 deletions(-) create mode 100644 internal/api/service.go create mode 100644 internal/database/api.go create mode 100644 internal/domain/api.go create mode 100644 internal/http/apikey.go create mode 100644 web/src/components/fields/text.tsx create mode 100644 web/src/forms/settings/APIKeyAddForm.tsx create mode 100644 web/src/screens/settings/Api.tsx create mode 100644 web/src/types/API.d.ts diff --git a/cmd/autobrr/main.go b/cmd/autobrr/main.go index 930735e..82aea74 100644 --- a/cmd/autobrr/main.go +++ b/cmd/autobrr/main.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/pflag" "github.com/autobrr/autobrr/internal/action" + "github.com/autobrr/autobrr/internal/api" "github.com/autobrr/autobrr/internal/auth" "github.com/autobrr/autobrr/internal/config" "github.com/autobrr/autobrr/internal/database" @@ -74,6 +75,7 @@ func main() { // setup repos var ( + apikeyRepo = database.NewAPIRepo(log, db) downloadClientRepo = database.NewDownloadClientRepo(log, db) actionRepo = database.NewActionRepo(log, db, downloadClientRepo) filterRepo = database.NewFilterRepo(log, db) @@ -88,15 +90,16 @@ func main() { // setup services var ( + apiService = api.NewService(log, apikeyRepo) notificationService = notification.NewService(log, notificationRepo) schedulingService = scheduler.NewService(log, version, notificationService) - apiService = indexer.NewAPIService(log) + indexerAPIService = indexer.NewAPIService(log) userService = user.NewService(userRepo) authService = auth.NewService(log, userService) downloadClientService = download_client.NewService(log, downloadClientRepo) actionService = action.NewService(log, actionRepo, downloadClientService, bus) - indexerService = indexer.NewService(log, cfg.Config, indexerRepo, apiService, schedulingService) - filterService = filter.NewService(log, filterRepo, actionRepo, apiService, indexerService) + indexerService = indexer.NewService(log, cfg.Config, indexerRepo, indexerAPIService, schedulingService) + filterService = filter.NewService(log, filterRepo, actionRepo, indexerAPIService, indexerService) releaseService = release.NewService(log, releaseRepo, actionService, filterService) ircService = irc.NewService(log, ircRepo, releaseService, indexerService, notificationService) feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, schedulingService) @@ -116,6 +119,7 @@ func main() { commit, date, actionService, + apiService, authService, downloadClientService, filterService, diff --git a/go.mod b/go.mod index 6a6c99f..63a6383 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,8 @@ require ( github.com/ergochat/irc-go v0.2.0 github.com/fsnotify/fsnotify v1.5.4 github.com/gdm85/go-libdeluge v0.5.6 - github.com/go-chi/chi v1.5.4 + github.com/go-chi/chi/v5 v5.0.7 + github.com/go-chi/render v1.0.2 github.com/gorilla/sessions v1.2.1 github.com/gosimple/slug v1.12.0 github.com/hashicorp/go-version v1.6.0 @@ -38,6 +39,7 @@ require ( ) require ( + github.com/ajg/form v1.5.1 // indirect github.com/anacrolix/dht/v2 v2.18.0 // indirect github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo/v2 v2.7.0 // indirect diff --git a/go.sum b/go.sum index ce9fa5a..b4ff6d8 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrX github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -134,8 +136,10 @@ github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= -github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs= -github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg= +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= +github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/internal/api/service.go b/internal/api/service.go new file mode 100644 index 0000000..84ba7d5 --- /dev/null +++ b/internal/api/service.go @@ -0,0 +1,91 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/internal/logger" + + "github.com/rs/zerolog" +) + +type Service interface { + List(ctx context.Context) ([]domain.APIKey, error) + Store(ctx context.Context, key *domain.APIKey) error + Update(ctx context.Context, key *domain.APIKey) error + Delete(ctx context.Context, key string) error + ValidateAPIKey(ctx context.Context, token string) bool +} + +type service struct { + log zerolog.Logger + repo domain.APIRepo + + keyCache []domain.APIKey +} + +func NewService(log logger.Logger, repo domain.APIRepo) Service { + return &service{ + log: log.With().Str("module", "api").Logger(), + repo: repo, + keyCache: []domain.APIKey{}, + } +} + +func (s *service) List(ctx context.Context) ([]domain.APIKey, error) { + if len(s.keyCache) > 0 { + return s.keyCache, nil + } + + return s.repo.GetKeys(ctx) +} + +func (s *service) Store(ctx context.Context, key *domain.APIKey) error { + key.Key = generateSecureToken(16) + + if err := s.repo.Store(ctx, key); err != nil { + return err + } + + if len(s.keyCache) > 0 { + // set new key + s.keyCache = append(s.keyCache, *key) + } + + return nil +} + +func (s *service) Update(ctx context.Context, key *domain.APIKey) error { + return nil +} + +func (s *service) Delete(ctx context.Context, key string) error { + // reset + s.keyCache = []domain.APIKey{} + + return s.repo.Delete(ctx, key) +} + +func (s *service) ValidateAPIKey(ctx context.Context, key string) bool { + keys, err := s.repo.GetKeys(ctx) + if err != nil { + return false + } + + for _, k := range keys { + if k.Key == key { + return true + } + } + return false +} + +func generateSecureToken(length int) string { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "" + } + return hex.EncodeToString(b) +} diff --git a/internal/database/api.go b/internal/database/api.go new file mode 100644 index 0000000..4eadc19 --- /dev/null +++ b/internal/database/api.go @@ -0,0 +1,114 @@ +package database + +import ( + "context" + "database/sql" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/internal/logger" + "github.com/autobrr/autobrr/pkg/errors" + + "github.com/lib/pq" + "github.com/rs/zerolog" +) + +func NewAPIRepo(log logger.Logger, db *DB) domain.APIRepo { + return &APIRepo{ + log: log.With().Str("repo", "api").Logger(), + db: db, + } +} + +type APIRepo struct { + log zerolog.Logger + db *DB + cache map[string]domain.APIKey +} + +func (r *APIRepo) Store(ctx context.Context, key *domain.APIKey) error { + queryBuilder := r.db.squirrel. + Insert("api_key"). + Columns( + "name", + "key", + "scopes", + ). + Values( + key.Name, + key.Key, + pq.Array(key.Scopes), + ). + Suffix("RETURNING created_at").RunWith(r.db.handler) + + var createdAt time.Time + + if err := queryBuilder.QueryRowContext(ctx).Scan(&createdAt); err != nil { + return errors.Wrap(err, "error executing query") + } + + key.CreatedAt = createdAt + + return nil +} + +func (r *APIRepo) Delete(ctx context.Context, key string) error { + queryBuilder := r.db.squirrel. + Delete("api_key"). + Where("key = ?", key) + + query, args, err := queryBuilder.ToSql() + if err != nil { + return errors.Wrap(err, "error building query") + } + + _, err = r.db.handler.ExecContext(ctx, query, args...) + if err != nil { + return errors.Wrap(err, "error executing query") + } + + r.log.Debug().Msgf("successfully deleted: %v", key) + + return nil +} + +func (r *APIRepo) GetKeys(ctx context.Context) ([]domain.APIKey, error) { + queryBuilder := r.db.squirrel. + Select( + "name", + "key", + "scopes", + "created_at", + ). + From("api_key") + + query, args, err := queryBuilder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "error building query") + } + + rows, err := r.db.handler.QueryContext(ctx, query, args...) + if err != nil { + return nil, errors.Wrap(err, "error executing query") + } + + defer rows.Close() + + keys := make([]domain.APIKey, 0) + for rows.Next() { + var a domain.APIKey + + var name sql.NullString + + if err := rows.Scan(&name, &a.Key, pq.Array(&a.Scopes), &a.CreatedAt); err != nil { + return nil, errors.Wrap(err, "error scanning row") + + } + + a.Name = name.String + + keys = append(keys, a) + } + + return keys, nil +} diff --git a/internal/database/postgres_migrate.go b/internal/database/postgres_migrate.go index 537cb33..9e6a6e5 100644 --- a/internal/database/postgres_migrate.go +++ b/internal/database/postgres_migrate.go @@ -315,6 +315,14 @@ CREATE TABLE feed_cache value TEXT, ttl TIMESTAMP ); + +CREATE TABLE api_key +( + name TEXT, + key TEXT PRIMARY KEY, + scopes TEXT [] DEFAULT '{}' NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); ` var postgresMigrations = []string{ @@ -541,4 +549,12 @@ CREATE INDEX indexer_identifier_index ALTER TABLE filter ADD COLUMN except_origins TEXT [] DEFAULT '{}'; `, + `CREATE TABLE api_key + ( + name TEXT, + key TEXT PRIMARY KEY, + scopes TEXT [] DEFAULT '{}' NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `, } diff --git a/internal/database/sqlite_migrate.go b/internal/database/sqlite_migrate.go index a639c94..1c17194 100644 --- a/internal/database/sqlite_migrate.go +++ b/internal/database/sqlite_migrate.go @@ -298,6 +298,14 @@ CREATE TABLE feed_cache value TEXT, ttl TIMESTAMP ); + +CREATE TABLE api_key +( + name TEXT, + key TEXT PRIMARY KEY, + scopes TEXT [] DEFAULT '{}' NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); ` var sqliteMigrations = []string{ @@ -861,4 +869,12 @@ CREATE INDEX indexer_identifier_index ALTER TABLE filter ADD COLUMN except_origins TEXT [] DEFAULT '{}'; `, + `CREATE TABLE api_key + ( + name TEXT, + key TEXT PRIMARY KEY, + scopes TEXT [] DEFAULT '{}' NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + `, } diff --git a/internal/domain/api.go b/internal/domain/api.go new file mode 100644 index 0000000..01c99dc --- /dev/null +++ b/internal/domain/api.go @@ -0,0 +1,19 @@ +package domain + +import ( + "context" + "time" +) + +type APIRepo interface { + Store(ctx context.Context, key *APIKey) error + Delete(ctx context.Context, key string) error + GetKeys(ctx context.Context) ([]APIKey, error) +} + +type APIKey struct { + Name string `json:"name"` + Key string `json:"key"` + Scopes []string `json:"scopes"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/http/action.go b/internal/http/action.go index b5e7048..7396c36 100644 --- a/internal/http/action.go +++ b/internal/http/action.go @@ -8,7 +8,7 @@ import ( "strconv" "github.com/autobrr/autobrr/internal/domain" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) type actionService interface { diff --git a/internal/http/apikey.go b/internal/http/apikey.go new file mode 100644 index 0000000..45f5026 --- /dev/null +++ b/internal/http/apikey.go @@ -0,0 +1,78 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/autobrr/autobrr/internal/domain" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type apikeyService interface { + List(ctx context.Context) ([]domain.APIKey, error) + Store(ctx context.Context, key *domain.APIKey) error + Update(ctx context.Context, key *domain.APIKey) error + Delete(ctx context.Context, key string) error + ValidateAPIKey(ctx context.Context, token string) bool +} + +type apikeyHandler struct { + encoder encoder + service apikeyService +} + +func newAPIKeyHandler(encoder encoder, service apikeyService) *apikeyHandler { + return &apikeyHandler{ + encoder: encoder, + service: service, + } +} + +func (h apikeyHandler) Routes(r chi.Router) { + r.Get("/", h.list) + r.Post("/", h.store) + r.Delete("/{apikey}", h.delete) +} + +func (h apikeyHandler) list(w http.ResponseWriter, r *http.Request) { + keys, err := h.service.List(r.Context()) + if err != nil { + h.encoder.Error(w, err) + return + } + + render.JSON(w, r, keys) +} + +func (h apikeyHandler) store(w http.ResponseWriter, r *http.Request) { + + var ( + ctx = r.Context() + data domain.APIKey + ) + + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + // encode error + h.encoder.StatusInternalError(w) + return + } + + if err := h.service.Store(ctx, &data); err != nil { + // encode error + h.encoder.StatusInternalError(w) + return + } + + h.encoder.StatusResponse(ctx, w, data, http.StatusCreated) +} + +func (h apikeyHandler) delete(w http.ResponseWriter, r *http.Request) { + if err := h.service.Delete(r.Context(), chi.URLParam(r, "apikey")); err != nil { + h.encoder.StatusInternalError(w) + return + } + h.encoder.NoContent(w) +} diff --git a/internal/http/auth.go b/internal/http/auth.go index 53d63cd..a4ae9c9 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -5,7 +5,7 @@ import ( "encoding/json" "net/http" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/gorilla/sessions" "github.com/autobrr/autobrr/internal/domain" diff --git a/internal/http/config.go b/internal/http/config.go index 381cf13..8fd8cde 100644 --- a/internal/http/config.go +++ b/internal/http/config.go @@ -3,7 +3,7 @@ package http import ( "net/http" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) type configJson struct { diff --git a/internal/http/download_client.go b/internal/http/download_client.go index 5f2d7f8..8e37ce3 100644 --- a/internal/http/download_client.go +++ b/internal/http/download_client.go @@ -7,7 +7,7 @@ import ( "net/http" "strconv" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/autobrr/autobrr/internal/domain" ) diff --git a/internal/http/encoder.go b/internal/http/encoder.go index 010b174..d42336d 100644 --- a/internal/http/encoder.go +++ b/internal/http/encoder.go @@ -15,7 +15,7 @@ type errorResponse struct { func (e encoder) StatusResponse(ctx context.Context, w http.ResponseWriter, response interface{}, status int) { if response != nil { - w.Header().Set("Content-Type", "application/json; charset=utf=8") + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(response); err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -30,6 +30,15 @@ func (e encoder) StatusCreated(w http.ResponseWriter) { w.WriteHeader(http.StatusCreated) } +func (e encoder) StatusCreatedData(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } +} + func (e encoder) NoContent(w http.ResponseWriter) { w.WriteHeader(http.StatusNoContent) } @@ -47,7 +56,7 @@ func (e encoder) Error(w http.ResponseWriter, err error) { Message: err.Error(), } - w.Header().Set("Content-Type", "application/json; charset=utf=8") + w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(res) } diff --git a/internal/http/feed.go b/internal/http/feed.go index c643866..1b81f4b 100644 --- a/internal/http/feed.go +++ b/internal/http/feed.go @@ -8,7 +8,7 @@ import ( "github.com/autobrr/autobrr/internal/domain" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) type feedService interface { diff --git a/internal/http/filter.go b/internal/http/filter.go index 0efb4ee..1334b4c 100644 --- a/internal/http/filter.go +++ b/internal/http/filter.go @@ -6,7 +6,7 @@ import ( "net/http" "strconv" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/autobrr/autobrr/internal/domain" ) diff --git a/internal/http/health.go b/internal/http/health.go index 1828d6c..a036dc9 100644 --- a/internal/http/health.go +++ b/internal/http/health.go @@ -5,7 +5,7 @@ import ( "github.com/autobrr/autobrr/internal/database" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) type healthHandler struct { diff --git a/internal/http/indexer.go b/internal/http/indexer.go index 8b3f0ab..0d7a7b8 100644 --- a/internal/http/indexer.go +++ b/internal/http/indexer.go @@ -8,7 +8,7 @@ import ( "github.com/autobrr/autobrr/internal/domain" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) type indexerService interface { diff --git a/internal/http/irc.go b/internal/http/irc.go index de8d1cf..0a45cae 100644 --- a/internal/http/irc.go +++ b/internal/http/irc.go @@ -6,7 +6,7 @@ import ( "net/http" "strconv" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" "github.com/autobrr/autobrr/internal/domain" ) diff --git a/internal/http/middleware.go b/internal/http/middleware.go index a1a88ab..b5b1448 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -4,14 +4,30 @@ import "net/http" func (s Server) IsAuthenticated(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // check session - session, _ := s.cookieStore.Get(r, "user_session") + if token := r.Header.Get("X-API-Token"); token != "" { + // check header + if !s.apiService.ValidateAPIKey(r.Context(), token) { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } - // Check if user is authenticated - if auth, ok := session.Values["authenticated"].(bool); !ok || !auth { - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return + } else if key := r.URL.Query().Get("apikey"); key != "" { + // check query param lke ?apikey=TOKEN + if !s.apiService.ValidateAPIKey(r.Context(), key) { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + } else { + // check session + session, _ := s.cookieStore.Get(r, "user_session") + + // Check if user is authenticated + if auth, ok := session.Values["authenticated"].(bool); !ok || !auth { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } } + next.ServeHTTP(w, r) }) } diff --git a/internal/http/notification.go b/internal/http/notification.go index ea2c619..f661999 100644 --- a/internal/http/notification.go +++ b/internal/http/notification.go @@ -8,7 +8,7 @@ import ( "github.com/autobrr/autobrr/internal/domain" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) type notificationService interface { diff --git a/internal/http/release.go b/internal/http/release.go index b742db7..285caaf 100644 --- a/internal/http/release.go +++ b/internal/http/release.go @@ -7,7 +7,7 @@ import ( "strconv" "github.com/autobrr/autobrr/internal/domain" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" ) type releaseService interface { diff --git a/internal/http/server.go b/internal/http/server.go index faa218d..944d047 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -10,7 +10,8 @@ import ( "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/web" - "github.com/go-chi/chi" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/sessions" "github.com/r3labs/sse/v2" "github.com/rs/cors" @@ -28,6 +29,7 @@ type Server struct { date string actionService actionService + apiService apikeyService authService authService downloadClientService downloadClientService filterService filterService @@ -38,7 +40,7 @@ type Server struct { releaseService releaseService } -func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService) Server { +func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, apiService apikeyService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService) Server { return Server{ config: config, sse: sse, @@ -50,6 +52,7 @@ func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version cookieStore: sessions.NewCookieStore([]byte(config.SessionSecret)), actionService: actionService, + apiService: apiService, authService: authService, downloadClientService: downloadClientSvc, filterService: filterSvc, @@ -78,6 +81,10 @@ func (s Server) Open() error { func (s Server) Handler() http.Handler { r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + c := cors.New(cors.Options{ AllowCredentials: true, AllowedMethods: []string{"HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"}, @@ -116,6 +123,7 @@ func (s Server) Handler() http.Handler { r.Route("/feeds", newFeedHandler(encoder, s.feedService).Routes) r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes) r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, s.ircService).Routes) + r.Route("/keys", newAPIKeyHandler(encoder, s.apiService).Routes) r.Route("/notification", newNotificationHandler(encoder, s.notificationService).Routes) r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes) diff --git a/web/src/api/APIClient.ts b/web/src/api/APIClient.ts index 4b8c916..80ee50b 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -74,6 +74,11 @@ export const APIClient = { delete: (id: number) => appClient.Delete(`api/actions/${id}`), toggleEnable: (id: number) => appClient.Patch(`api/actions/${id}/toggleEnabled`) }, + apikeys: { + getAll: () => appClient.Get("api/keys"), + create: (key: APIKey) => appClient.Post("api/keys", key), + delete: (key: string) => appClient.Delete(`api/keys/${key}`), + }, config: { get: () => appClient.Get("api/config") }, diff --git a/web/src/components/fields/text.tsx b/web/src/components/fields/text.tsx new file mode 100644 index 0000000..e4e4220 --- /dev/null +++ b/web/src/components/fields/text.tsx @@ -0,0 +1,77 @@ +import { useToggle } from "../../hooks/hooks"; +import { ClipboardCopyIcon, EyeIcon, EyeOffIcon, CheckIcon } from "@heroicons/react/outline"; +import { useState } from "react"; + +interface KeyFieldProps { + value: string; +} + +export const KeyField = ({ value }: KeyFieldProps) => { + const [isVisible, toggleVisibility] = useToggle(false); + const [isCopied, setIsCopied] = useState(false); + + async function copyTextToClipboard(text: string) { + if ("clipboard" in navigator) { + return await navigator.clipboard.writeText(text); + } else { + return document.execCommand("copy", true, text); + } + } + + // onClick handler function for the copy button + const handleCopyClick = () => { + // Asynchronously call copyTextToClipboard + copyTextToClipboard(value) + .then(() => { + // If successful, update the isCopied state value + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1500); + }) + .catch((err) => { + console.error(err); + }); + }; + + return ( +
+
+
+ +
+ + +
+
+ ); +}; diff --git a/web/src/domain/routes.tsx b/web/src/domain/routes.tsx index ac60b44..a7048c3 100644 --- a/web/src/domain/routes.tsx +++ b/web/src/domain/routes.tsx @@ -17,6 +17,7 @@ import { IrcSettings } from "../screens/settings/Irc"; import NotificationSettings from "../screens/settings/Notifications"; import { RegexPlayground } from "../screens/settings/RegexPlayground"; import ReleaseSettings from "../screens/settings/Releases"; +import APISettings from "../screens/settings/Api"; import { baseUrl } from "../utils"; @@ -35,6 +36,7 @@ export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => ( }> } /> + } /> } /> } /> } /> diff --git a/web/src/forms/settings/APIKeyAddForm.tsx b/web/src/forms/settings/APIKeyAddForm.tsx new file mode 100644 index 0000000..1ee9c72 --- /dev/null +++ b/web/src/forms/settings/APIKeyAddForm.tsx @@ -0,0 +1,158 @@ +import { Fragment } from "react"; +import { useMutation } from "react-query"; +import { toast } from "react-hot-toast"; +import { XIcon } from "@heroicons/react/solid"; +import { Dialog, Transition } from "@headlessui/react"; +import type { FieldProps } from "formik"; +import { Field, Form, Formik, FormikErrors, FormikValues } from "formik"; + +import { queryClient } from "../../App"; +import { APIClient } from "../../api/APIClient"; +import DEBUG from "../../components/debug"; +import Toast from "../../components/notifications/Toast"; + +interface apiKeyAddFormProps { + isOpen: boolean; + toggle: () => void; +} + +function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) { + const mutation = useMutation( + (apikey: APIKey) => APIClient.apikeys.create(apikey), + { + onSuccess: (_, key) => { + queryClient.invalidateQueries("apikeys"); + toast.custom((t) => ); + + toggle(); + } + } + ); + + const handleSubmit = (data: unknown) => mutation.mutate(data as APIKey); + const validate = (values: FormikValues) => { + const errors = {} as FormikErrors; + if (!values.name) { + errors.name = "Required"; + } + return errors; + }; + + return ( + + +
+ + +
+ +
+ + + {({ values }) => ( +
+
+
+
+
+ Create API + key +

+ Add new API key. +

+
+
+ +
+
+
+ +
+
+
+ +
+ + {({ + field, + meta + }: FieldProps) => ( +
+ + + {meta.touched && meta.error && + {meta.error}} + +
+ )} +
+
+
+
+ +
+
+ + +
+
+ + + )} +
+
+
+
+
+
+
+ ); +} + +export default APIKeyAddForm; \ No newline at end of file diff --git a/web/src/screens/Settings.tsx b/web/src/screens/Settings.tsx index b108306..38ad36f 100644 --- a/web/src/screens/Settings.tsx +++ b/web/src/screens/Settings.tsx @@ -24,6 +24,7 @@ const subNavigation: NavTabType[] = [ { name: "Feeds", href: "feeds", icon: RssIcon }, { name: "Clients", href: "clients", icon: DownloadIcon }, { name: "Notifications", href: "notifications", icon: BellIcon }, + { name: "API keys", href: "api-keys", icon: KeyIcon }, { name: "Releases", href: "releases", icon: CollectionIcon } // {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false} // {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false}, diff --git a/web/src/screens/settings/Api.tsx b/web/src/screens/settings/Api.tsx new file mode 100644 index 0000000..5cc13b0 --- /dev/null +++ b/web/src/screens/settings/Api.tsx @@ -0,0 +1,137 @@ +import { queryClient } from "../../App"; +import { useRef } from "react"; +import { useMutation, useQuery } from "react-query"; +import { KeyField } from "../../components/fields/text"; +import { DeleteModal } from "../../components/modals"; +import APIKeyAddForm from "../../forms/settings/APIKeyAddForm"; +import Toast from "../../components/notifications/Toast"; +import { APIClient } from "../../api/APIClient"; +import { useToggle } from "../../hooks/hooks"; +import { toast } from "react-hot-toast"; +import { classNames } from "../../utils"; +import { TrashIcon } from "@heroicons/react/outline"; +import { EmptySimple } from "../../components/emptystates"; + +function APISettings() { + const [addFormIsOpen, toggleAddForm] = useToggle(false); + + const { isLoading, data } = useQuery( + ["apikeys"], + () => APIClient.apikeys.getAll(), + { + retry: false, + refetchOnWindowFocus: false, + onError: err => console.log(err) + } + ); + + return ( +
+
+ + +
+
+

API keys

+

+ Manage API keys. +

+
+
+ +
+
+ + {data && data.length > 0 ? +
+
    +
  1. +
    Name +
    +
    Key +
    +
  2. + + {data && data.map((k) => ( + + ))} +
+
+ : } +
+
+ ); +} + +interface ApiKeyItemProps { + apikey: APIKey +} + +function APIListItem({ apikey }: ApiKeyItemProps) { + const cancelModalButtonRef = useRef(null); + const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); + + const deleteMutation = useMutation( + (key: string) => APIClient.apikeys.delete(key), + { + onSuccess: () => { + queryClient.invalidateQueries(["apikeys"]); + queryClient.invalidateQueries(["apikeys", apikey.key]); + + toast.custom((t) => ); + } + } + ); + + return ( +
  • + { + deleteMutation.mutate(apikey.key); + toggleDeleteModal(); + }} + title={`Remove API key: ${apikey.name}`} + text="Are you sure you want to remove this API key? This action cannot be undone." + /> + +
    +
    + {apikey.name} +
    +
    + +
    + +
    + +
    +
    +
  • + ); +} + +export default APISettings; \ No newline at end of file diff --git a/web/src/screens/settings/Application.tsx b/web/src/screens/settings/Application.tsx index f506742..1527f81 100644 --- a/web/src/screens/settings/Application.tsx +++ b/web/src/screens/settings/Application.tsx @@ -1,5 +1,4 @@ import { useQuery } from "react-query"; - import { APIClient } from "../../api/APIClient"; import { Checkbox } from "../../components/Checkbox"; import { SettingsContext } from "../../utils/Context"; @@ -18,7 +17,7 @@ function ApplicationSettings() { ); return ( -
    +

    Application

    @@ -27,54 +26,56 @@ function ApplicationSettings() {

    - {!isLoading && data && ( -
    -
    -
    - )} + )} +
    -
    +
    {data?.version ? ( @@ -124,7 +125,7 @@ function ApplicationSettings() {
    - +
    ); } diff --git a/web/src/types/API.d.ts b/web/src/types/API.d.ts new file mode 100644 index 0000000..f1f39d5 --- /dev/null +++ b/web/src/types/API.d.ts @@ -0,0 +1,6 @@ +interface APIKey { + name: string; + key: string; + scopes: string[]; + created_at: Date; +} \ No newline at end of file