mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +00:00
feat(api): add apikey support (#408)
* feat(api): add apikey support * feat(web): api settings crud
This commit is contained in:
parent
9c036033e9
commit
fa20978d58
31 changed files with 834 additions and 70 deletions
91
internal/api/service.go
Normal file
91
internal/api/service.go
Normal file
|
@ -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)
|
||||
}
|
114
internal/database/api.go
Normal file
114
internal/database/api.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
|
19
internal/domain/api.go
Normal file
19
internal/domain/api.go
Normal file
|
@ -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"`
|
||||
}
|
|
@ -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 {
|
||||
|
|
78
internal/http/apikey.go
Normal file
78
internal/http/apikey.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -3,7 +3,7 @@ package http
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type configJson struct {
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue