mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Move api key to player model
This commit is contained in:
parent
d5b47383ae
commit
aecf959d98
@ -1,39 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddApiKeyTable, downAddApiKeyTable)
|
||||
}
|
||||
|
||||
func upAddApiKeyTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table if not exists api_key (
|
||||
id text not null primary key,
|
||||
player_id text not null,
|
||||
name text not null,
|
||||
key text not null unique,
|
||||
created_at datetime not null,
|
||||
|
||||
foreign key (player_id)
|
||||
references player(id)
|
||||
on delete cascade
|
||||
);
|
||||
|
||||
create index if not exists api_key_key on api_key(key);
|
||||
create index if not exists api_key_player_id on api_key(player_id);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddApiKeyTable(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
drop table api_key;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
34
db/migrations/20250730104020_add_api_key_to_player_table.go
Normal file
34
db/migrations/20250730104020_add_api_key_to_player_table.go
Normal file
@ -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
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
"time"
|
||||
)
|
||||
|
||||
type APIKey struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
PlayerID string `structs:"player_id" json:"playerId"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Key string `structs:"key" json:"key"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
type APIKeys []APIKey
|
||||
|
||||
type APIKeyRepository interface {
|
||||
ResourceRepository
|
||||
rest.Persistable
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Get(id string) (*APIKey, error)
|
||||
GetAll(options ...QueryOptions) (APIKeys, error)
|
||||
FindByKey(key string) (*APIKey, error)
|
||||
RefreshKey(id string) (string, error)
|
||||
}
|
||||
@ -38,7 +38,6 @@ type DataStore interface {
|
||||
User(ctx context.Context) UserRepository
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
APIKey(ctx context.Context) APIKeyRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -56,6 +56,4 @@ type UserRepository interface {
|
||||
// Library association methods
|
||||
GetUserLibraries(userID string) (Libraries, error)
|
||||
SetUserLibraries(userID string, libraryIDs []int) error
|
||||
// FindByAPIKey finds a user by the provided API key
|
||||
FindByAPIKey(key string) (*User, error)
|
||||
}
|
||||
|
||||
@ -1,203 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type apiKeyRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewAPIKeyRepository(ctx context.Context, db dbx.Builder) model.APIKeyRepository {
|
||||
r := &apiKeyRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.APIKey{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) userFilter() Sqlizer {
|
||||
user := loggedUser(r.ctx)
|
||||
if user.IsAdmin {
|
||||
return And{}
|
||||
}
|
||||
return Eq{"p.user_id": user.ID}
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
sq := r.selectAPIKey(options...).Where(r.userFilter())
|
||||
return r.count(sq, options...)
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Get(id string) (*model.APIKey, error) {
|
||||
sel := r.selectAPIKey().Where(And{Eq{"ak.id": id}})
|
||||
var res model.APIKey
|
||||
err := r.queryOne(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) selectAPIKey(options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(options...).
|
||||
From("api_key ak").
|
||||
LeftJoin("player p ON ak.player_id = p.id").
|
||||
Columns("ak.*")
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) GetAll(options ...model.QueryOptions) (model.APIKeys, error) {
|
||||
sel := r.selectAPIKey().Where(r.userFilter())
|
||||
res := model.APIKeys{}
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Read(id string) (interface{}, error) {
|
||||
apiKey, err := r.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.VerifyPlayerAccess(apiKey.PlayerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return apiKey, err
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) EntityName() string {
|
||||
return "apikey"
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) NewInstance() interface{} {
|
||||
return &model.APIKey{}
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Save(entity interface{}) (string, error) {
|
||||
ak := entity.(*model.APIKey)
|
||||
if err := r.VerifyPlayerAccess(ak.PlayerID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if ak.ID == "" {
|
||||
ak.ID = id.NewRandom()
|
||||
}
|
||||
ak.Key = generateAPIKey()
|
||||
ak.CreatedAt = time.Now()
|
||||
values, err := toSQLArgs(*ak)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
_, err = r.executeSQL(insert)
|
||||
return ak.ID, err
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Update(id string, entity interface{}, _ ...string) error {
|
||||
ak := entity.(*model.APIKey)
|
||||
current, err := r.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.VerifyPlayerAccess(current.PlayerID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
update := Update(r.tableName).
|
||||
Set("name", ak.Name).
|
||||
Where(Eq{"id": id})
|
||||
_, err = r.executeSQL(update)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Delete(id string) error {
|
||||
apiKey, err := r.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.VerifyPlayerAccess(apiKey.PlayerID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) FindByKey(key string) (*model.APIKey, error) {
|
||||
sel := r.selectAPIKey().Where(And{Eq{"ak.key": key}})
|
||||
var res model.APIKey
|
||||
err := r.queryOne(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) RefreshKey(id string) (string, error) {
|
||||
apiKey, err := r.Get(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := r.VerifyPlayerAccess(apiKey.PlayerID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
newKey := generateAPIKey()
|
||||
update := Update(r.tableName).
|
||||
Set("key", newKey).
|
||||
Where(Eq{"id": id})
|
||||
_, err = r.executeSQL(update)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return newKey, nil
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) VerifyPlayerAccess(playerID string) error {
|
||||
if playerID == "" {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
playerRepo := NewPlayerRepository(r.ctx, r.db)
|
||||
player, err := playerRepo.Get(playerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := loggedUser(r.ctx)
|
||||
if !user.IsAdmin && player.UserId != user.ID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateAPIKey() string {
|
||||
return "nav_" + id.NewRandom()
|
||||
}
|
||||
|
||||
var _ model.APIKeyRepository = (*apiKeyRepository)(nil)
|
||||
var _ rest.Repository = (*apiKeyRepository)(nil)
|
||||
var _ rest.Persistable = (*apiKeyRepository)(nil)
|
||||
@ -1,245 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("APIKeyRepository", func() {
|
||||
var repo model.APIKeyRepository
|
||||
var playerRepo model.PlayerRepository
|
||||
var database *dbx.DB
|
||||
|
||||
var (
|
||||
adminPlayer = 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}
|
||||
regularPlayer = model.Player{ID: "3", Name: "NavidromeUI [Safari/macOS]", UserAgent: "Safari/macOS", UserId: regularUser.ID, Username: regularUser.UserName, Client: "NavidromeUI", ReportRealPath: true, ScrobbleEnabled: false}
|
||||
|
||||
players = model.Players{adminPlayer, regularPlayer}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
database = GetDBXBuilder()
|
||||
|
||||
playerRepo = NewPlayerRepository(ctx, database)
|
||||
for idx := range players {
|
||||
err := playerRepo.Put(&players[idx])
|
||||
Expect(err).To(BeNil())
|
||||
}
|
||||
repo = NewAPIKeyRepository(ctx, database)
|
||||
})
|
||||
|
||||
Describe("FindByKey", func() {
|
||||
It("returns the API key with matching key", func() {
|
||||
apiKey := &model.APIKey{
|
||||
PlayerID: adminPlayer.ID,
|
||||
Name: "Unique API Key",
|
||||
}
|
||||
apiKeyId, err := repo.Save(apiKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
apiKey, err = repo.Get(apiKeyId)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
result, err := repo.FindByKey(apiKey.Key)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.ID).To(Equal(apiKey.ID))
|
||||
Expect(result.Key).To(Equal(apiKey.Key))
|
||||
})
|
||||
|
||||
It("returns error when key not found", func() {
|
||||
_, err := repo.FindByKey("non-existent-key")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
It("creates a new API key with a generated key", func() {
|
||||
apiKey := &model.APIKey{
|
||||
Name: "Test API Key Save",
|
||||
PlayerID: adminPlayer.ID,
|
||||
}
|
||||
|
||||
id, err := repo.Save(apiKey)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(apiKey.Key).To(HavePrefix("nav_"))
|
||||
Expect(apiKey.PlayerID).To(Equal(adminPlayer.ID))
|
||||
Expect(apiKey.Name).To(Equal("Test API Key Save"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("only updates the name field", func() {
|
||||
apiKey := &model.APIKey{
|
||||
PlayerID: adminPlayer.ID,
|
||||
Name: "Original Name",
|
||||
}
|
||||
|
||||
_, err := repo.Save(apiKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
updateKey := &model.APIKey{
|
||||
Name: "Updated Name",
|
||||
PlayerID: regularPlayer.ID,
|
||||
}
|
||||
|
||||
err = repo.Update(apiKey.ID, updateKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
result, err := repo.Get(apiKey.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Name).To(Equal("Updated Name"))
|
||||
Expect(result.Key).To(Equal(apiKey.Key))
|
||||
Expect(result.PlayerID).To(Equal(adminPlayer.ID))
|
||||
})
|
||||
|
||||
It("returns error when attempting to update non-existent key", func() {
|
||||
err := repo.Update("non-existent-id", &model.APIKey{Name: "Updated Name"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("deletes an existing API key", func() {
|
||||
apiKey := &model.APIKey{
|
||||
PlayerID: adminPlayer.ID,
|
||||
Name: "API Key to Delete",
|
||||
}
|
||||
|
||||
_, err := repo.Save(apiKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = repo.Delete(apiKey.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.Get(apiKey.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RefreshKey", func() {
|
||||
It("generates a new key for an existing API key", func() {
|
||||
apiKey := &model.APIKey{
|
||||
PlayerID: adminPlayer.ID,
|
||||
Name: "Test Refresh",
|
||||
}
|
||||
_, err := repo.Save(apiKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
originalKey := apiKey.Key
|
||||
|
||||
newKey, err := repo.RefreshKey(apiKey.ID)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(newKey).ToNot(BeEmpty())
|
||||
Expect(newKey).ToNot(Equal(originalKey))
|
||||
Expect(newKey).To(HavePrefix("nav_"))
|
||||
|
||||
refreshed, err := repo.Get(apiKey.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(refreshed.Key).To(Equal(newKey))
|
||||
})
|
||||
|
||||
It("returns an error for non-existent API key", func() {
|
||||
_, err := repo.RefreshKey("non-existent-id")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(MatchError(rest.ErrNotFound))
|
||||
})
|
||||
|
||||
It("enforces user permissions", func() {
|
||||
apiKey := &model.APIKey{
|
||||
PlayerID: adminPlayer.ID,
|
||||
Name: "Test Permission",
|
||||
}
|
||||
_, err := repo.Save(apiKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
nonAdminCtx := log.NewContext(context.TODO())
|
||||
nonAdminCtx = request.WithUser(nonAdminCtx, regularUser)
|
||||
nonAdminRepo := NewAPIKeyRepository(nonAdminCtx, database)
|
||||
|
||||
_, err = nonAdminRepo.RefreshKey(apiKey.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("User permissions", func() {
|
||||
var nonAdminCtx context.Context
|
||||
var nonAdminRepo model.APIKeyRepository
|
||||
var adminKey model.APIKey
|
||||
|
||||
BeforeEach(func() {
|
||||
nonAdminCtx = log.NewContext(context.TODO())
|
||||
nonAdminCtx = request.WithUser(nonAdminCtx, regularUser)
|
||||
nonAdminRepo = NewAPIKeyRepository(nonAdminCtx, database)
|
||||
|
||||
cleanupKeys := func(key string) {
|
||||
foundKey, err := repo.FindByKey(key)
|
||||
if err == nil {
|
||||
_ = repo.Delete(foundKey.ID)
|
||||
}
|
||||
}
|
||||
cleanupKeys("admin-key")
|
||||
cleanupKeys("user-key")
|
||||
|
||||
tmpAdminKey := &model.APIKey{
|
||||
PlayerID: adminPlayer.ID,
|
||||
Name: "Admin's API Key",
|
||||
}
|
||||
_, err := repo.Save(tmpAdminKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
adminKey = *tmpAdminKey
|
||||
|
||||
userKey := &model.APIKey{
|
||||
PlayerID: regularPlayer.ID,
|
||||
Name: "User's API Key",
|
||||
}
|
||||
_, err = repo.Save(userKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("non-admin users can only see their own API keys", func() {
|
||||
results, err := nonAdminRepo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
for _, key := range results {
|
||||
Expect(key.PlayerID).To(Equal(regularPlayer.ID))
|
||||
}
|
||||
})
|
||||
|
||||
It("admin users can see all API keys", func() {
|
||||
results, err := repo.GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
userIds := make(map[string]bool)
|
||||
for _, key := range results {
|
||||
userIds[key.PlayerID] = true
|
||||
}
|
||||
|
||||
Expect(userIds).To(HaveKey(adminPlayer.ID))
|
||||
Expect(userIds).To(HaveKey(regularPlayer.ID))
|
||||
})
|
||||
|
||||
It("a user cannot view/delete/update another user's key", func() {
|
||||
result, err := nonAdminRepo.Read(adminKey.ID)
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
|
||||
updatedKey := &model.APIKey{Name: "new admin key name"}
|
||||
err = nonAdminRepo.Update(adminKey.ID, updatedKey)
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
|
||||
err = nonAdminRepo.Delete(adminKey.ID)
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -89,10 +89,6 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
|
||||
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) APIKey(ctx context.Context) model.APIKeyRepository {
|
||||
return NewAPIKeyRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
@ -117,8 +113,6 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
||||
return s.Share(ctx).(model.ResourceRepository)
|
||||
case model.Tag:
|
||||
return s.Tag(ctx).(model.ResourceRepository)
|
||||
case model.APIKey:
|
||||
return s.APIKey(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -193,21 +193,6 @@ func (r *userRepository) FindByUsernameWithPassword(username string) (*model.Use
|
||||
return usr, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByAPIKey(key string) (*model.User, error) {
|
||||
// find the API key in the database
|
||||
playerRepo := NewPlayerRepository(r.ctx, r.db)
|
||||
apiKeyRepo := NewAPIKeyRepository(r.ctx, r.db)
|
||||
apiKey, err := apiKeyRepo.FindByKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
player, err := playerRepo.Get(apiKey.PlayerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Get(player.UserId)
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateLastLoginAt(id string) error {
|
||||
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
|
||||
_, err := r.executeSQL(upd)
|
||||
|
||||
@ -212,7 +212,7 @@ var _ = Describe("UserRepository", func() {
|
||||
var existingUser *model.User
|
||||
BeforeEach(func() {
|
||||
existingUser = &model.User{ID: "1", UserName: "johndoe"}
|
||||
repo = tests.CreateMockUserRepo(nil, nil)
|
||||
repo = tests.CreateMockUserRepo()
|
||||
err := repo.Put(existingUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
@ -3,8 +3,7 @@ package nativeapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"errors"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@ -53,11 +52,10 @@ 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)
|
||||
n.R(r, "/apikey", model.APIKey{}, true)
|
||||
if conf.Server.EnableSharing {
|
||||
n.RX(r, "/share", n.share.NewRepository, true)
|
||||
}
|
||||
@ -76,7 +74,6 @@ func (n *Router) routes() http.Handler {
|
||||
n.addUserLibraryRoute(r)
|
||||
n.RX(r, "/library", n.libs.NewRepository, true)
|
||||
})
|
||||
n.addRefreshApiKeyRoute(r)
|
||||
})
|
||||
|
||||
return r
|
||||
@ -106,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{})
|
||||
@ -202,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) {
|
||||
@ -250,37 +288,3 @@ func adminOnlyMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addRefreshApiKeyRoute(r chi.Router) {
|
||||
r.With(server.URLParamsMiddleware).Post("/apikey/{id}/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String(":id")
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("api key id could not be parsed: %s", id)
|
||||
log.Warn(msg)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
repo := n.ds.APIKey(r.Context())
|
||||
_, err = repo.RefreshKey(id)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "error refreshing api key", "id", id, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
updatedKey, err := repo.Get(id)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "error retrieving refreshed api key", "id", id, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = rest.RespondWithJSON(w, http.StatusOK, updatedKey)
|
||||
if err != nil {
|
||||
log.Error(r.Context(), "error marshaling refreshed api key", "id", id, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@ 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"}
|
||||
@ -154,15 +155,22 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
if apiKey != "" {
|
||||
usr, err = ds.User(ctx).FindByAPIKey(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-apikey", "remoteAddr", r.RemoteAddr, err)
|
||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "remoteAddr", r.RemoteAddr, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr)
|
||||
} else if err != nil {
|
||||
log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr, err)
|
||||
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)
|
||||
|
||||
@ -308,7 +308,6 @@ var _ = Describe("Middlewares", func() {
|
||||
})
|
||||
|
||||
When("using api key authentication", func() {
|
||||
var apiKey *model.APIKey
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
@ -333,22 +332,13 @@ var _ = Describe("Middlewares", func() {
|
||||
MaxBitRate: 320,
|
||||
ReportRealPath: false,
|
||||
ScrobbleEnabled: true,
|
||||
APIKey: "nav_12345",
|
||||
}
|
||||
_ = pr.Put(player)
|
||||
|
||||
ar := ds.APIKey(context.TODO())
|
||||
newApiKey := &model.APIKey{
|
||||
ID: "api-key-id",
|
||||
Name: "API Key",
|
||||
PlayerID: player.ID,
|
||||
}
|
||||
apiKeyId, _ := ar.Save(newApiKey)
|
||||
newApiKey, _ = ar.Get(apiKeyId)
|
||||
apiKey = newApiKey
|
||||
})
|
||||
|
||||
It("passes authentication with correct api key", func() {
|
||||
r := newGetRequest("apiKey=" + apiKey.Key)
|
||||
r := newGetRequest("apiKey=nav_12345")
|
||||
cp := authenticate(ds)(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CreateMockApiKeyRepo() *MockedAPIKeyRepo {
|
||||
return &MockedAPIKeyRepo{
|
||||
Data: map[string]*model.APIKey{},
|
||||
}
|
||||
}
|
||||
|
||||
type MockedAPIKeyRepo struct {
|
||||
model.APIKeyRepository
|
||||
Error error
|
||||
Data map[string]*model.APIKey
|
||||
}
|
||||
|
||||
func (m *MockedAPIKeyRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
|
||||
if m.Error != nil {
|
||||
return 0, m.Error
|
||||
}
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
func (m *MockedAPIKeyRepo) Save(entity interface{}) (string, error) {
|
||||
if m.Error != nil {
|
||||
return "", m.Error
|
||||
}
|
||||
apiKey := entity.(*model.APIKey)
|
||||
apiKey.Key = "nav_" + id.NewRandom()
|
||||
m.Data[strings.ToLower(apiKey.Key)] = apiKey
|
||||
return apiKey.ID, nil
|
||||
}
|
||||
|
||||
func (m *MockedAPIKeyRepo) FindByKey(key string) (*model.APIKey, error) {
|
||||
if m.Error != nil {
|
||||
return nil, m.Error
|
||||
}
|
||||
apiKey, exists := m.Data[strings.ToLower(key)]
|
||||
if !exists {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
func (m *MockedAPIKeyRepo) Get(id string) (*model.APIKey, error) {
|
||||
if m.Error != nil {
|
||||
return nil, m.Error
|
||||
}
|
||||
|
||||
for _, apiKey := range m.Data {
|
||||
if apiKey.ID == id {
|
||||
return apiKey, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
@ -28,7 +28,6 @@ type MockDataStore struct {
|
||||
MockedRadio model.RadioRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
MockedAPIKey model.APIKeyRepository
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
|
||||
@ -170,9 +169,7 @@ func (db *MockDataStore) User(ctx context.Context) model.UserRepository {
|
||||
if db.RealDS != nil {
|
||||
db.MockedUser = db.RealDS.User(ctx)
|
||||
} else {
|
||||
playerRepo := db.Player(ctx).(*MockedPlayerRepo)
|
||||
apiKeyRepo := db.APIKey(ctx).(*MockedAPIKeyRepo)
|
||||
db.MockedUser = CreateMockUserRepo(playerRepo, apiKeyRepo)
|
||||
db.MockedUser = CreateMockUserRepo()
|
||||
}
|
||||
}
|
||||
return db.MockedUser
|
||||
@ -224,17 +221,6 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
|
||||
return db.MockedRadio
|
||||
}
|
||||
|
||||
func (db *MockDataStore) APIKey(ctx context.Context) model.APIKeyRepository {
|
||||
if db.MockedAPIKey == nil {
|
||||
if db.RealDS != nil {
|
||||
db.MockedAPIKey = db.RealDS.APIKey(ctx)
|
||||
} else {
|
||||
db.MockedAPIKey = CreateMockApiKeyRepo()
|
||||
}
|
||||
}
|
||||
return db.MockedAPIKey
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
||||
return block(db)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package tests
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
@ -72,3 +73,16 @@ func (m *MockedPlayerRepo) CountByClient(_ ...model.QueryOptions) (map[string]in
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -10,17 +10,10 @@ import (
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
func CreateMockUserRepo(apiKeyRepo ...*MockedAPIKeyRepo) *MockedUserRepo {
|
||||
var repo *MockedAPIKeyRepo
|
||||
if len(apiKeyRepo) > 0 {
|
||||
repo = apiKeyRepo[0]
|
||||
} else {
|
||||
repo = CreateMockApiKeyRepo()
|
||||
}
|
||||
func CreateMockUserRepo() *MockedUserRepo {
|
||||
return &MockedUserRepo{
|
||||
Data: map[string]*model.User{},
|
||||
UserLibraries: map[string][]int{},
|
||||
APIKeyRepo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,8 +22,6 @@ type MockedUserRepo struct {
|
||||
Error error
|
||||
Data map[string]*model.User
|
||||
UserLibraries map[string][]int // userID -> libraryIDs
|
||||
APIKeyRepo *MockedAPIKeyRepo
|
||||
PlayerRepo *MockedPlayerRepo
|
||||
}
|
||||
|
||||
func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
|
||||
@ -132,27 +123,3 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error
|
||||
u.UserLibraries[userID] = libraryIDs
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *MockedUserRepo) FindByAPIKey(key string) (*model.User, error) {
|
||||
if u.Error != nil {
|
||||
return nil, u.Error
|
||||
}
|
||||
|
||||
apiKey, err := u.APIKeyRepo.FindByKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
player, err := u.PlayerRepo.Get(apiKey.PlayerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, usr := range u.Data {
|
||||
if usr.ID == player.UserId {
|
||||
return usr, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@ import SharePlayer from './share/SharePlayer'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { DndProvider } from 'react-dnd'
|
||||
import missing from './missing/index.js'
|
||||
import apikey from './apikey/index.js'
|
||||
|
||||
const history = createHashHistory()
|
||||
|
||||
@ -112,11 +111,6 @@ const Admin = (props) => {
|
||||
options={{ subMenu: 'playlist' }}
|
||||
/>,
|
||||
<Resource name="user" {...user} options={{ subMenu: 'settings' }} />,
|
||||
<Resource
|
||||
name="apikey"
|
||||
{...apikey}
|
||||
options={{ subMenu: 'settings' }}
|
||||
/>,
|
||||
<Resource
|
||||
name="player"
|
||||
{...player}
|
||||
|
||||
@ -1,22 +1,6 @@
|
||||
{
|
||||
"languageName": "English",
|
||||
"resources": {
|
||||
"apikey": {
|
||||
"name": "API Key |||| API Keys",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"key": "Key",
|
||||
"createdAt": "Created At",
|
||||
"key_help_text": "This key will be used for API authentication"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add API Key"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "API Key created",
|
||||
"updated": "API Key updated"
|
||||
}
|
||||
},
|
||||
"song": {
|
||||
"name": "Song |||| Songs",
|
||||
"fields": {
|
||||
@ -197,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": {
|
||||
@ -524,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",
|
||||
|
||||
44
ui/src/player/PlayerCreate.jsx
Normal file
44
ui/src/player/PlayerCreate.jsx
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user