diff --git a/db/migrations/20250730104020_add_api_key_to_player_table.go b/db/migrations/20250730104020_add_api_key_to_player_table.go new file mode 100644 index 000000000..34b887d54 --- /dev/null +++ b/db/migrations/20250730104020_add_api_key_to_player_table.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAPIKeyToPlayer, downDropAPIKeyFromPlayer) +} + +func upAddAPIKeyToPlayer(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +-- Add nullable api_key column to player table +ALTER TABLE player ADD COLUMN api_key VARCHAR(255); + +-- Add index on api_key for faster lookups +CREATE INDEX IF NOT EXISTS player_api_key ON player(api_key); +`) + return err +} + +func downDropAPIKeyFromPlayer(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +-- Drop the index first +DROP INDEX IF EXISTS player_api_key; + +-- Then drop the column +ALTER TABLE player DROP COLUMN api_key; +`) + return err +} diff --git a/model/player.go b/model/player.go index 39ea99d1a..f896ebd25 100644 --- a/model/player.go +++ b/model/player.go @@ -18,6 +18,7 @@ type Player struct { MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"` ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"` ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"` + APIKey string `structs:"api_key" json:"apiKey" db:"api_key"` } type Players []Player @@ -28,4 +29,6 @@ type PlayerRepository interface { Put(p *Player) error CountAll(...QueryOptions) (int64, error) CountByClient(...QueryOptions) (map[string]int64, error) + FindByAPIKey(key string) (*Player, error) + GenerateAPIKey(playerId string) (string, error) } diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 73c820753..00c5d1fee 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -7,6 +7,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" ) @@ -57,6 +58,19 @@ func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.P return &res, err } +func (r *playerRepository) FindByAPIKey(key string) (*model.Player, error) { + if key == "" { + return nil, model.ErrNotFound + } + sel := r.selectPlayer().Where(Eq{"api_key": key}) + var res model.Player + err := r.queryOne(sel, &res) + if err != nil { + return nil, err + } + return &res, nil +} + func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder { s := r.selectPlayer(options...) return s.Where(r.addRestriction()) @@ -130,25 +144,37 @@ func (r *playerRepository) isPermitted(p *model.Player) bool { return u.IsAdmin || p.UserId == u.ID } +func (r *playerRepository) preparePlayerForSave(p *model.Player) *model.Player { + playerCopy := *p + if playerCopy.UserId == "" { + user := loggedUser(r.ctx) + playerCopy.UserId = user.ID + } + playerCopy.APIKey = "" + return &playerCopy +} + func (r *playerRepository) Save(entity interface{}) (string, error) { t := entity.(*model.Player) - if !r.isPermitted(t) { + playerToSave := r.preparePlayerForSave(t) + if !r.isPermitted(playerToSave) { return "", rest.ErrPermissionDenied } - id, err := r.put(t.ID, t) + playerId, err := r.put(playerToSave.ID, playerToSave) if errors.Is(err, model.ErrNotFound) { return "", rest.ErrNotFound } - return id, err + return playerId, err } func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error { t := entity.(*model.Player) t.ID = id - if !r.isPermitted(t) { + playerToUpdate := r.preparePlayerForSave(t) + if !r.isPermitted(playerToUpdate) { return rest.ErrPermissionDenied } - _, err := r.put(id, t, cols...) + _, err := r.put(id, playerToUpdate, cols...) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound } @@ -164,6 +190,33 @@ func (r *playerRepository) Delete(id string) error { return err } +// GenerateAPIKey generates a new API key for the specified player +func (r *playerRepository) GenerateAPIKey(playerID string) (string, error) { + player, err := r.Get(playerID) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + if err != nil { + return "", err + } + + if !r.isPermitted(player) { + return "", rest.ErrPermissionDenied + } + + player.APIKey = generateAPIKey() + err = r.Put(player) + if err != nil { + return "", err + } + return player.APIKey, nil +} + +// generateAPIKey creates a new random API key +func generateAPIKey() string { + return "nav_" + id.NewRandom() +} + var _ model.PlayerRepository = (*playerRepository)(nil) var _ rest.Repository = (*playerRepository)(nil) var _ rest.Persistable = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f6c669493..f7e71f6b4 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -13,8 +13,12 @@ import ( ) var _ = Describe("PlayerRepository", func() { - var adminRepo *playerRepository - var database *dbx.DB + var ( + adminRepo *playerRepository + database *dbx.DB + adminCtx context.Context + regularCtx context.Context + ) var ( adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true} @@ -25,11 +29,14 @@ var _ = Describe("PlayerRepository", func() { ) BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) + adminCtx = log.NewContext(context.TODO()) + adminCtx = request.WithUser(adminCtx, adminUser) + + regularCtx = log.NewContext(context.TODO()) + regularCtx = request.WithUser(regularCtx, regularUser) database = GetDBXBuilder() - adminRepo = NewPlayerRepository(ctx, database).(*playerRepository) + adminRepo = NewPlayerRepository(adminCtx, database).(*playerRepository) for idx := range players { err := adminRepo.Put(&players[idx]) @@ -86,6 +93,28 @@ var _ = Describe("PlayerRepository", func() { }) }) + Describe("Save", func() { + var repo *playerRepository + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + repo = NewPlayerRepository(ctx, database).(*playerRepository) + }) + It("fails if different user id is set", func() { + newPlayer := model.Player{ + Name: "Test Player", + Client: "TestClient", + UserAgent: "TestAgent", + UserId: adminUser.ID, + } + id, err := repo.Save(&newPlayer) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + _, err = repo.Get(id) + Expect(err).To(Equal(rest.ErrNotFound)) + }) + }) + DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) { var repo *playerRepository @@ -171,41 +200,37 @@ var _ = Describe("PlayerRepository", func() { }) Describe("Save", func() { - DescribeTable("item type", func(player model.Player) { - clone := player - clone.ID = "" - clone.IP = "192.168.1.1" - id, err := repo.Save(&clone) - - if clone.UserId == "" { - Expect(err).To(HaveOccurred()) - } else if !admin && player.Username == adminPlayer1.Username { - Expect(err).To(Equal(rest.ErrPermissionDenied)) - clone.UserId = "" - } else { - Expect(err).To(BeNil()) - Expect(id).ToNot(BeEmpty()) + It("excludes API key from being saved", func() { + newPlayer := model.Player{ + Name: "Test Player", + Client: "TestClient", + UserAgent: "TestAgent", + APIKey: "should-not-be-saved", } - count, err := repo.Count() + id, err := repo.Save(&newPlayer) Expect(err).To(BeNil()) - clone.ID = id - newItem, err := repo.Get(id) + savedPlayer, err := repo.Get(id) + Expect(err).To(BeNil()) + Expect(savedPlayer.APIKey).To(BeEmpty()) + }) - if clone.UserId == "" { - Expect(count).To(Equal(baseCount)) - Expect(err).To(Equal(model.ErrNotFound)) - } else { - Expect(count).To(Equal(baseCount + 1)) - Expect(err).To(BeNil()) - Expect(*newItem).To(Equal(clone)) + It("sets user ID from context", func() { + repo := NewPlayerRepository(regularCtx, database).(*playerRepository) + newPlayer := model.Player{ + Name: "Test Player", + Client: "TestClient", + UserAgent: "TestAgent", } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), - ) + + id, err := repo.Save(&newPlayer) + Expect(err).To(BeNil()) + + savedPlayer, err := repo.Get(id) + Expect(err).To(BeNil()) + Expect(savedPlayer.UserId).To(Equal(regularUser.ID)) + }) }) Describe("Update", func() { @@ -215,9 +240,7 @@ var _ = Describe("PlayerRepository", func() { clone.MaxBitRate = 10000 err := repo.Update(clone.ID, &clone, "ip") - if clone.UserId == "" { - Expect(err).To(HaveOccurred()) - } else if !admin && player.Username == adminPlayer1.Username { + if !admin && player.Username == adminPlayer1.Username { Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.IP = player.IP } else { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 370bdbd1e..97509657e 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -3,6 +3,7 @@ package nativeapi import ( "context" "encoding/json" + "errors" "html" "net/http" "strconv" @@ -51,7 +52,7 @@ func (n *Router) routes() http.Handler { n.R(r, "/album", model.Album{}, false) n.R(r, "/artist", model.Artist{}, false) n.R(r, "/genre", model.Genre{}, false) - n.R(r, "/player", model.Player{}, true) + n.addPlayerRoute(r) n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) n.R(r, "/radio", model.Radio{}, true) n.R(r, "/tag", model.Tag{}, true) @@ -102,6 +103,25 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository }) } +func (n *Router) addPlayerRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return n.ds.Resource(ctx, model.Player{}) + } + + r.Route("/player", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", rest.Post(constructor)) + + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + r.Post("/apiKey", generatePlayerApiKey(n.ds)) + }) + }) +} + func (n *Router) addPlaylistRoute(r chi.Router) { constructor := func(ctx context.Context) rest.Repository { return n.ds.Resource(ctx, model.Playlist{}) @@ -198,6 +218,28 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin } } +func generatePlayerApiKey(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + playerId := chi.URLParam(r, "id") + err := ds.WithTxImmediate(func(tx model.DataStore) error { + repo := tx.Player(ctx) + apiKey, err := repo.GenerateAPIKey(playerId) + if err == nil { + resp := []byte(`{"apiKey":"` + html.EscapeString(apiKey) + `"}`) + _, err = w.Write(resp) + } + return err + }) + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + } else if err != nil { + log.Error(ctx, "Error retrieving player from DB", "id", playerId, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + func (n *Router) addInspectRoute(r chi.Router) { if conf.Server.Inspect.Enabled { r.Group(func(r chi.Router) { diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index af1ba448f..3ea68cd9c 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -63,14 +63,16 @@ func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requiredParameters []string + p := req.Params(r) username, _ := fromInternalOrProxyAuth(r) if username != "" { requiredParameters = []string{"v", "c"} + } else if apiKey, _ := p.String("apiKey"); apiKey != "" { + requiredParameters = []string{"v", "c"} } else { requiredParameters = []string{"u", "v", "c"} } - p := req.Params(r) for _, param := range requiredParameters { if _, err := p.String(param); err != nil { log.Warn(r, err) @@ -123,21 +125,69 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { token, _ := p.String("t") salt, _ := p.String("s") jwt, _ := p.String("jwt") + apiKey, _ := p.String("apiKey") - usr, err = ds.User(ctx).FindByUsernameWithPassword(username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + // When an API key is provided, username should not be provided + if apiKey != "" && username != "" { + log.Warn(ctx, "API: Invalid login - username provided with API key", "auth", "subsonic", "remoteAddr", r.RemoteAddr) + sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided)) return } - switch { - case errors.Is(err, model.ErrNotFound): - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - case err != nil: - log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - default: - err = validateCredentials(usr, pass, token, salt, jwt) - if err != nil { + + // Check for conflicting authentication mechanisms + authMechanismsCount := 0 + if apiKey != "" { + authMechanismsCount++ + } + if pass != "" { + authMechanismsCount++ + } + if token != "" && salt != "" { + authMechanismsCount++ + } + if jwt != "" { + authMechanismsCount++ + } + if authMechanismsCount > 1 { + log.Warn(ctx, "API: Invalid login - multiple authentication mechanisms", "auth", "subsonic", "remoteAddr", r.RemoteAddr) + sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided)) + return + } + + if apiKey != "" { + var player *model.Player + player, err = ds.Player(ctx).FindByAPIKey(apiKey) + if errors.Is(err, context.Canceled) { + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "remoteAddr", r.RemoteAddr, err) + return + } + switch { + case errors.Is(err, model.ErrNotFound): + log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err) + case err != nil: + log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err) + default: + usr, err = ds.User(ctx).Get(player.UserId) + if err != nil { + log.Error(ctx, "API: Error retrieving user from API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err) + } + } + } else { + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if errors.Is(err, context.Canceled) { + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + return + } + switch { + case errors.Is(err, model.ErrNotFound): log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + case err != nil: + log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + default: + err = validateCredentials(usr, pass, token, salt, jwt) + if err != nil { + log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + } } } } diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index a30d5b3af..33c38404d 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -306,6 +306,74 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeFalse()) }) }) + + When("using api key authentication", func() { + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + ur := ds.User(context.TODO()) + user := &model.User{ + UserName: "user-api", + NewPassword: "wordpass-api", + } + _ = ur.Put(user) + + pr := ds.Player(context.TODO()) + player := &model.Player{ + ID: "player1", + Name: "Test Player", + UserAgent: "Test/1.0", + UserId: user.ID, + Client: "test-client", + IP: "127.0.0.1", + LastSeen: time.Now(), + TranscodingId: "", + MaxBitRate: 320, + ReportRealPath: false, + ScrobbleEnabled: true, + APIKey: "nav_12345", + } + _ = pr.Put(player) + }) + + It("passes authentication with correct api key", func() { + r := newGetRequest("apiKey=nav_12345") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + user, _ := request.UserFrom(next.req.Context()) + Expect(user.UserName).To(Equal("user-api")) + }) + + It("fails authentication with invalid api key", func() { + r := newGetRequest("apiKey=invalid-api-key") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(next.called).To(BeFalse()) + }) + + It("fails authentication with empty api key", func() { + r := newGetRequest("apiKey=") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(next.called).To(BeFalse()) + }) + + It("fails authentication if both api key and username are provided", func() { + r := newGetRequest("apiKey=api-key", "u=user-api") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="43"`)) + Expect(next.called).To(BeFalse()) + }) + }) }) Describe("GetPlayer", func() { diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go index 42e5427b3..b90f9260b 100644 --- a/server/subsonic/responses/errors.go +++ b/server/subsonic/responses/errors.go @@ -1,25 +1,31 @@ package responses const ( - ErrorGeneric int32 = 0 - ErrorMissingParameter int32 = 10 - ErrorClientTooOld int32 = 20 - ErrorServerTooOld int32 = 30 - ErrorAuthenticationFail int32 = 40 - ErrorAuthorizationFail int32 = 50 - ErrorTrialExpired int32 = 60 - ErrorDataNotFound int32 = 70 + ErrorGeneric int32 = 0 + ErrorMissingParameter int32 = 10 + ErrorClientTooOld int32 = 20 + ErrorServerTooOld int32 = 30 + ErrorAuthenticationFail int32 = 40 + ErrorTokenAuthNotSupported int32 = 41 + ErrorAuthMechanismNotSupported int32 = 42 + ErrorMultipleAuthMechanismsProvided int32 = 43 + ErrorAuthorizationFail int32 = 50 + ErrorTrialExpired int32 = 60 + ErrorDataNotFound int32 = 70 ) var errors = map[int32]string{ - ErrorGeneric: "A generic error", - ErrorMissingParameter: "Required parameter is missing", - ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", - ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", - ErrorAuthenticationFail: "Wrong username or password", - ErrorAuthorizationFail: "User is not authorized for the given operation", - ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", - ErrorDataNotFound: "The requested data was not found", + ErrorGeneric: "A generic error", + ErrorMissingParameter: "Required parameter is missing", + ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", + ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", + ErrorAuthenticationFail: "Invalid username, password, or API key", + ErrorTokenAuthNotSupported: "Token authentication not supported", + ErrorAuthMechanismNotSupported: "Provided authentication mechanism not supported", + ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided", + ErrorAuthorizationFail: "User is not authorized for the given operation", + ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", + ErrorDataNotFound: "The requested data was not found", } func ErrorMsg(code int32) string { diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 56f68a74b..d05795d70 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -191,7 +191,7 @@ func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { if db.RealDS != nil { db.MockedPlayer = db.RealDS.Player(ctx) } else { - db.MockedPlayer = struct{ model.PlayerRepository }{} + db.MockedPlayer = CreateMockPlayerRepo() } } return db.MockedPlayer diff --git a/tests/mock_player_repo.go b/tests/mock_player_repo.go new file mode 100644 index 000000000..7d5c6dd1e --- /dev/null +++ b/tests/mock_player_repo.go @@ -0,0 +1,88 @@ +package tests + +import ( + "encoding/base64" + + "github.com/navidrome/navidrome/model" +) + +func CreateMockPlayerRepo() *MockedPlayerRepo { + return &MockedPlayerRepo{ + Data: make(map[string]*model.Player), + } +} + +type MockedPlayerRepo struct { + model.PlayerRepository + Error error + Data map[string]*model.Player +} + +func (m *MockedPlayerRepo) Get(id string) (*model.Player, error) { + if m.Error != nil { + return nil, m.Error + } + + player, exists := m.Data[id] + if !exists { + return nil, model.ErrNotFound + } + return player, nil +} + +func (m *MockedPlayerRepo) FindMatch(userId, client, userAgent string) (*model.Player, error) { + if m.Error != nil { + return nil, m.Error + } + + for _, player := range m.Data { + if player.UserId == userId && player.Client == client && player.UserAgent == userAgent { + return player, nil + } + } + return nil, model.ErrNotFound +} + +func (m *MockedPlayerRepo) Put(p *model.Player) error { + if m.Error != nil { + return m.Error + } + + if p.ID == "" { + p.ID = base64.StdEncoding.EncodeToString([]byte(p.Name + "_" + p.UserId + "_" + p.Client)) + } + m.Data[p.ID] = p + return nil +} + +func (m *MockedPlayerRepo) CountAll(_ ...model.QueryOptions) (int64, error) { + if m.Error != nil { + return 0, m.Error + } + return int64(len(m.Data)), nil +} + +func (m *MockedPlayerRepo) CountByClient(_ ...model.QueryOptions) (map[string]int64, error) { + if m.Error != nil { + return nil, m.Error + } + + result := make(map[string]int64) + for _, player := range m.Data { + result[player.Client]++ + } + return result, nil +} + +func (m *MockedPlayerRepo) FindByAPIKey(key string) (*model.Player, error) { + if m.Error != nil { + return nil, m.Error + } + + for _, player := range m.Data { + if player.APIKey == key { + return player, nil + } + } + return nil, model.ErrNotFound +} diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 9f3dd672e..ef4ad8508 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -24,7 +24,7 @@ type MockedUserRepo struct { UserLibraries map[string][]int // userID -> libraryIDs } -func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { +func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) { if u.Error != nil { return 0, u.Error } diff --git a/ui/src/apikey/ApiKeyCreate.jsx b/ui/src/apikey/ApiKeyCreate.jsx new file mode 100644 index 000000000..575246082 --- /dev/null +++ b/ui/src/apikey/ApiKeyCreate.jsx @@ -0,0 +1,57 @@ +import React, { useCallback } from 'react' +import { + Create, + SimpleForm, + TextInput, + required, + useTranslate, + useMutation, + useNotify, + useRedirect, +} from 'react-admin' +import { Title } from '../common' + +const ApiKeyCreate = (props) => { + const translate = useTranslate() + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + const resourceName = translate('resources.apikey.name', { smart_count: 1 }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'create', + resource: 'apikey', + payload: { data: values }, + }, + { returnPromise: true }, + ) + notify('resources.apikey.notifications.created', 'info', { + smart_count: 1, + }) + redirect('/apikey') + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [mutate, notify, redirect], + ) + + return ( + } {...props}> + + + + + ) +} + +export default ApiKeyCreate diff --git a/ui/src/apikey/ApiKeyEdit.jsx b/ui/src/apikey/ApiKeyEdit.jsx new file mode 100644 index 000000000..cbcfa7396 --- /dev/null +++ b/ui/src/apikey/ApiKeyEdit.jsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react' +import { + Edit, + SimpleForm, + TextInput, + required, + Toolbar, + SaveButton, + DeleteButton, + useTranslate, + useMutation, + useNotify, + useRefresh, + DateField, +} from 'react-admin' +import { Title } from '../common' + +const ApiKeyTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.apikey.name', { smart_count: 1 }) + return +} + +const ApiKeyEditToolbar = (props) => ( + <Toolbar {...props}> + <SaveButton /> + <DeleteButton /> + </Toolbar> +) + +const ApiKeyEdit = (props) => { + const [mutate] = useMutation() + const notify = useNotify() + const refresh = useRefresh() + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'update', + resource: 'apikey', + payload: { id: values.id, data: values }, + }, + { returnPromise: true }, + ) + notify('resources.apikey.notifications.updated', 'info', { + smart_count: 1, + }) + refresh() + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [mutate, notify, refresh], + ) + + return ( + <Edit title={<ApiKeyTitle />} {...props}> + <SimpleForm + toolbar={<ApiKeyEditToolbar />} + save={save} + variant={'outlined'} + > + <TextInput source="name" validate={[required()]} /> + <TextInput source="key" disabled fullWidth /> + <DateField variant="body1" source="createdAt" showTime /> + </SimpleForm> + </Edit> + ) +} + +export default ApiKeyEdit diff --git a/ui/src/apikey/ApiKeyList.jsx b/ui/src/apikey/ApiKeyList.jsx new file mode 100644 index 000000000..2f656111f --- /dev/null +++ b/ui/src/apikey/ApiKeyList.jsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react' +import { + CreateButton, + Datagrid, + DateField, + Filter, + FunctionField, + List, + SearchInput, + TextField, + useNotify, + useTranslate, +} from 'react-admin' +import { + IconButton, + makeStyles, + Tooltip, + useMediaQuery, +} from '@material-ui/core' +import { SimpleList } from '../common' +import AddIcon from '@material-ui/icons/Add' +import VisibilityIcon from '@material-ui/icons/Visibility' +import VisibilityOffIcon from '@material-ui/icons/VisibilityOff' +import FileCopyIcon from '@material-ui/icons/FileCopy' + +const useStyles = makeStyles({ + actionContainer: { + display: 'flex', + alignItems: 'center', + }, + keyContainer: { + display: 'flex', + alignItems: 'center', + flex: 1, + }, + visibilityButton: { + padding: 4, + }, + copyButton: { + padding: 4, + }, +}) + +const ApiKeyFilter = (props) => ( + <Filter {...props} variant={'outlined'}> + <SearchInput id="search" source="name" alwaysOn /> + </Filter> +) + +const MaskedKeyField = ({ record, isSimpleMode }) => { + const [visible, setVisible] = useState(false) + const classes = useStyles() + const notify = useNotify() + + if (!record || !record.key) return null + + const maskKey = (key) => { + return '*'.repeat(key.length) + } + + const handleCopy = (e) => { + if (isSimpleMode) { + e.preventDefault() + } else { + e.stopPropagation() + } + navigator.clipboard.writeText(record.key) + notify('API key copied to clipboard', 'info') + } + + const toggleVisibility = (e) => { + if (isSimpleMode) { + e.preventDefault() + } else { + e.stopPropagation() + } + setVisible(!visible) + } + + const keyVisibilityButton = ( + <IconButton + className={classes.visibilityButton} + onClick={toggleVisibility} + size="small" + > + {visible ? ( + <VisibilityOffIcon fontSize="small" /> + ) : ( + <VisibilityIcon fontSize="small" /> + )} + </IconButton> + ) + const copyButton = ( + <IconButton + className={classes.copyButton} + onClick={handleCopy} + size="small" + > + <FileCopyIcon fontSize="small" /> + </IconButton> + ) + + return ( + <div className={classes.actionContainer}> + <div className={classes.keyContainer}> + {visible ? record.key : maskKey(record.key)} + </div> + {isSimpleMode ? ( + <>{keyVisibilityButton}</> + ) : ( + <Tooltip title={visible ? 'Hide key' : 'Show key'}> + {keyVisibilityButton} + </Tooltip> + )} + {isSimpleMode ? ( + <>{copyButton}</> + ) : ( + <Tooltip title="Copy to clipboard">{copyButton}</Tooltip> + )} + </div> + ) +} + +const ApiKeyList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const translate = useTranslate() + return ( + <List + {...props} + actions={ + <CreateButton + basePath="/apikey" + icon={<AddIcon />} + label={translate('resources.apikey.actions.add')} + /> + } + sort={{ field: 'createdAt', order: 'DESC' }} + exporter={false} + bulkActionButtons={false} + filters={<ApiKeyFilter />} + > + {isXsmall ? ( + <SimpleList + primaryText={(r) => r.name} + secondaryText={(r) => ( + <MaskedKeyField record={r} isSimpleMode={true} /> + )} + tertiaryText={(r) => <DateField record={r} source="createdAt" />} + linkType={'edit'} + /> + ) : ( + <Datagrid rowClick="edit"> + <TextField source="name" /> + <FunctionField + label="Key" + render={(record) => ( + <MaskedKeyField isSimpleMode={false} record={record} /> + )} + /> + <DateField source="createdAt" showTime /> + </Datagrid> + )} + </List> + ) +} + +export default ApiKeyList diff --git a/ui/src/apikey/index.js b/ui/src/apikey/index.js new file mode 100644 index 000000000..9c5c411c1 --- /dev/null +++ b/ui/src/apikey/index.js @@ -0,0 +1,11 @@ +import VpnKeyIcon from '@material-ui/icons/VpnKey' +import ApiKeyList from './ApiKeyList' +import ApiKeyCreate from './ApiKeyCreate' +import ApiKeyEdit from './ApiKeyEdit' + +export default { + list: ApiKeyList, + create: ApiKeyCreate, + edit: ApiKeyEdit, + icon: VpnKeyIcon, +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 4a9039a67..a0847f7c1 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -181,7 +181,8 @@ "userName": "Username", "lastSeen": "Last Seen At", "reportRealPath": "Report Real Path", - "scrobbleEnabled": "Send Scrobbles to external services" + "scrobbleEnabled": "Send Scrobbles to external services", + "apiKey": "API Key" } }, "transcoding": { @@ -508,7 +509,8 @@ "shareSuccess": "URL copied to clipboard: %{url}", "shareFailure": "Error copying URL %{url} to clipboard", "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Download in original format" + "downloadOriginalFormat": "Download in original format", + "apiKeyGenerated": "API key generated successfully" }, "menu": { "library": "Library", diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index 561701dce..9a8e7017b 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -63,6 +63,7 @@ AboutMenuItem.displayName = 'AboutMenuItem' const settingsResources = (resource) => resource.name !== 'user' && + resource.name !== 'apikey' && resource.hasList && resource.options && resource.options.subMenu === 'settings' @@ -95,6 +96,14 @@ const CustomUserMenu = ({ onClick, ...rest }) => { ) } + const renderApiKeyMenuItemLink = () => { + const apiKeyResource = resourceDefinition('apikey') + if (!apiKeyResource) { + return null + } + return renderSettingsMenuItemLink(apiKeyResource) + } + const renderSettingsMenuItemLink = (resource, id) => { const label = translate(`resources.${resource.name}.name`, { smart_count: id ? 1 : 2, @@ -128,6 +137,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => { <PersonalMenu sidebarIsOpen={true} onClick={onClick} /> <Divider /> {renderUserMenuItemLink()} + {renderApiKeyMenuItemLink()} {resources .filter(settingsResources) .map((r) => renderSettingsMenuItemLink(r))} diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx index 45f40b26d..cffbb3acf 100644 --- a/ui/src/layout/Menu.jsx +++ b/ui/src/layout/Menu.jsx @@ -103,7 +103,10 @@ const Menu = ({ dense = false }) => { } const subItems = (subMenu) => (resource) => - resource.hasList && resource.options && resource.options.subMenu === subMenu + resource.hasList && + resource.options && + resource.options.subMenu === subMenu && + resource.name !== 'apikey' return ( <div diff --git a/ui/src/player/PlayerCreate.jsx b/ui/src/player/PlayerCreate.jsx new file mode 100644 index 000000000..86b5dc7bd --- /dev/null +++ b/ui/src/player/PlayerCreate.jsx @@ -0,0 +1,44 @@ +import React from 'react' +import { + TextInput, + BooleanInput, + Create, + required, + SimpleForm, + SelectInput, + ReferenceInput, +} from 'react-admin' +import { BITRATE_CHOICES } from '../consts' +import config from '../config.js' +import { Title } from '../common' +import { useTranslate } from 'react-admin' + +const PlayerCreateTitle = () => { + const translate = useTranslate() + const resourceName = translate('resources.player.name', { smart_count: 1 }) + return <Title subTitle={`${translate('ra.action.create')} ${resourceName}`} /> +} + +const PlayerCreate = (props) => { + return ( + <Create title={<PlayerCreateTitle />} {...props}> + <SimpleForm variant="outlined"> + <TextInput source="name" validate={[required()]} /> + <ReferenceInput + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput> + <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> + <BooleanInput source="reportRealPath" fullWidth /> + {(config.lastFMEnabled || config.listenBrainzEnabled) && ( + <BooleanInput source="scrobbleEnabled" fullWidth /> + )} + </SimpleForm> + </Create> + ) +} + +export default PlayerCreate diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index 1826500bd..6154ff5bf 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -1,3 +1,4 @@ +import React, { useState, useCallback } from 'react' import { TextInput, BooleanInput, @@ -8,10 +9,25 @@ import { SelectInput, ReferenceInput, useTranslate, + useNotify, + Button, + useRecordContext, } from 'react-admin' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import VpnKeyIcon from '@material-ui/icons/VpnKey' +import VisibilityIcon from '@material-ui/icons/Visibility' +import VisibilityOffIcon from '@material-ui/icons/VisibilityOff' import { Title } from '../common' -import config from '../config' -import { BITRATE_CHOICES } from '../consts' +import { BITRATE_CHOICES, REST_URL } from '../consts' +import { makeStyles } from '@material-ui/core/styles' +import { + IconButton, + InputAdornment, + Tooltip, + TextField as MuiTextField, +} from '@material-ui/core' +import httpClient from '../dataProvider/httpClient.js' +import config from '../config.js' const PlayerTitle = ({ record }) => { const translate = useTranslate() @@ -19,26 +35,148 @@ const PlayerTitle = ({ record }) => { return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> } -const PlayerEdit = (props) => ( - <Edit title={<PlayerTitle />} {...props}> - <SimpleForm variant={'outlined'}> - <TextInput source="name" validate={[required()]} /> - <ReferenceInput - source="transcodingId" - reference="transcoding" - sort={{ field: 'name', order: 'ASC' }} - > - <SelectInput source="name" resettable /> - </ReferenceInput> - <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> - <BooleanInput source="reportRealPath" fullWidth /> - {(config.lastFMEnabled || config.listenBrainzEnabled) && ( - <BooleanInput source="scrobbleEnabled" fullWidth /> +const useStyles = makeStyles({ + apiKeyField: { + marginTop: '16px', + marginBottom: '8px', + }, + generateButton: { + marginTop: '16px', + marginBottom: '8px', + }, + copyButton: { + padding: 4, + }, +}) + +const ApiKeySection = () => { + const record = useRecordContext() + const recordId = record ? record.id : null + const initialApiKey = record ? record.apiKey : null + + const classes = useStyles() + const notify = useNotify() + const [showApiKey, setShowApiKey] = useState(false) + const [loading, setLoading] = useState(false) + const [apiKey, setApiKey] = useState(initialApiKey) + + const generateApiKey = useCallback(async () => { + if (!recordId) { + notify('Player ID not available', 'error') + return + } + + try { + setLoading(true) + const { json } = await httpClient( + `${REST_URL}/player/${recordId}/apiKey`, + { + method: 'POST', + }, + ) + setApiKey(json.apiKey) + setShowApiKey(true) + notify('message.apiKeyGenerated', 'info') + } catch (error) { + notify(error.message || 'Error generating API key', 'error') + } finally { + setLoading(false) + } + }, [recordId, notify]) + + const copyToClipboard = () => { + if (apiKey) { + navigator.clipboard.writeText(apiKey) + notify('API key copied to clipboard', 'info') + } + } + + if (!recordId) { + return <></> + } + + return ( + <div style={{ display: 'flex', flexDirection: 'column', marginTop: 16 }}> + {apiKey ? ( + <MuiTextField + label="API Key" + value={showApiKey ? apiKey : '*'.repeat(apiKey.length)} + InputProps={{ + readOnly: true, + disableUnderline: true, + endAdornment: ( + <InputAdornment position="end"> + <Tooltip title={showApiKey ? 'Hide' : 'Show'}> + <IconButton + aria-label="toggle api key visibility" + onClick={() => setShowApiKey(!showApiKey)} + size="small" + > + {showApiKey ? <VisibilityOffIcon /> : <VisibilityIcon />} + </IconButton> + </Tooltip> + <Tooltip title="Copy to clipboard"> + <IconButton + aria-label="copy api key" + onClick={copyToClipboard} + className={classes.copyButton} + size="small" + > + <FileCopyIcon /> + </IconButton> + </Tooltip> + </InputAdornment> + ), + }} + style={{ width: 320, color: 'black' }} + /> + ) : ( + !loading && ( + <MuiTextField + label="API Key" + value="No API Key Found" + disabled + style={{ width: 320 }} + /> + ) )} - <TextField source="client" /> - <TextField source="userName" /> - </SimpleForm> - </Edit> -) + + <Button + style={{ marginTop: 12, alignSelf: 'flex-start' }} + onClick={generateApiKey} + label="Generate New API Key" + startIcon={<VpnKeyIcon />} + variant="outlined" + disabled={loading} + /> + </div> + ) +} + +const PlayerEdit = (props) => { + return ( + <Edit title={<PlayerTitle />} {...props}> + <SimpleForm variant={'outlined'}> + <TextInput source="name" validate={[required()]} /> + <ReferenceInput + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput> + <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> + <BooleanInput source="reportRealPath" fullWidth /> + {(config.lastFMEnabled || config.listenBrainzEnabled) && ( + <BooleanInput source="scrobbleEnabled" fullWidth /> + )} + <TextField source="client" /> + <TextField source="userName" /> + + <ApiKeySection /> + </SimpleForm> + </Edit> + ) +} export default PlayerEdit diff --git a/ui/src/player/index.js b/ui/src/player/index.js index aaa3d58d7..6e356266e 100644 --- a/ui/src/player/index.js +++ b/ui/src/player/index.js @@ -1,8 +1,10 @@ import { BsFillMusicPlayerFill } from 'react-icons/bs' import PlayerList from './PlayerList' import PlayerEdit from './PlayerEdit' +import PlayerCreate from './PlayerCreate.jsx' export default { + create: PlayerCreate, list: PlayerList, edit: PlayerEdit, icon: BsFillMusicPlayerFill,