mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge branch 'master' into t3code/fix-windows-unc-rel-hang
This commit is contained in:
commit
d90ee1819e
53 changed files with 1930 additions and 261 deletions
|
|
@ -49,6 +49,7 @@ const (
|
|||
DefaultEncryptionKey = "just for obfuscation"
|
||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||
APIKeyPrefix = "nds_"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
type Players interface {
|
||||
Get(ctx context.Context, playerId string) (*model.Player, error)
|
||||
Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
|
||||
Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error)
|
||||
}
|
||||
|
||||
func NewPlayers(ds model.DataStore) Players {
|
||||
|
|
@ -33,7 +34,6 @@ type players struct {
|
|||
|
||||
func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
var plr *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
user, _ := request.UserFrom(ctx)
|
||||
if playerID != "" {
|
||||
|
|
@ -58,7 +58,21 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
|
|||
log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent)
|
||||
}
|
||||
}
|
||||
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
|
||||
if !plr.HasAPIKey {
|
||||
plr.Name = fmt.Sprintf("%s [%s]", client, userAgent)
|
||||
}
|
||||
return p.refresh(ctx, plr, userAgent, ip)
|
||||
}
|
||||
|
||||
// Touch refreshes a player that the request already identified (by API key), without guessing or renaming it.
|
||||
func (p *players) Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
if plr.Client == "" {
|
||||
plr.Client = client
|
||||
}
|
||||
return p.refresh(ctx, &plr, userAgent, ip)
|
||||
}
|
||||
|
||||
func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip string) (*model.Player, *model.Transcoding, error) {
|
||||
plr.UserAgent = userAgent
|
||||
plr.IP = ip
|
||||
plr.LastSeen = time.Now()
|
||||
|
|
@ -66,14 +80,14 @@ func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip
|
|||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = p.ds.Player().Put(ctx, plr)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err)
|
||||
if err := p.ds.Player().Put(ctx, plr); err != nil {
|
||||
log.Warn(ctx, "Could not save player", "id", plr.ID, "client", plr.Client, "username", userName(ctx), "type", plr.UserAgent, err)
|
||||
}
|
||||
})
|
||||
if plr.TranscodingId != "" {
|
||||
trc, err = p.ds.Transcoding().Get(ctx, plr.TranscodingId)
|
||||
if plr.TranscodingId == "" {
|
||||
return plr, nil, nil
|
||||
}
|
||||
trc, err := p.ds.Transcoding().Get(ctx, plr.TranscodingId)
|
||||
return plr, trc, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,15 @@ var _ = Describe("Players", func() {
|
|||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
|
||||
It("does not rename a player that has an API key", func() {
|
||||
plr := &model.Player{ID: "123", Name: "My Phone", Client: "client", UserId: "userid", HasAPIKey: true}
|
||||
repo.add(plr)
|
||||
p, _, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(p.Name).To(Equal("My Phone"))
|
||||
})
|
||||
|
||||
Context("bad username casing", func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"})
|
||||
|
|
@ -130,6 +139,34 @@ var _ = Describe("Players", func() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Touch", func() {
|
||||
It("records usage but keeps the name and client", func() {
|
||||
plr := model.Player{ID: "123", Name: "My Phone", Client: "Symfonium", UserId: "userid", HasAPIKey: true}
|
||||
p, trc, err := players.Touch(ctx, plr, "OtherClient", "android", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Name).To(Equal("My Phone"))
|
||||
Expect(p.Client).To(Equal("Symfonium"))
|
||||
Expect(p.UserAgent).To(Equal("android"))
|
||||
Expect(p.IP).To(Equal("1.2.3.4"))
|
||||
Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister))
|
||||
Expect(repo.lastSaved).To(Equal(p))
|
||||
Expect(trc).To(BeNil())
|
||||
})
|
||||
|
||||
It("fills in the client on first use", func() {
|
||||
p, _, err := players.Touch(ctx, model.Player{ID: "123", Name: "Manual", UserId: "userid"}, "Symfonium", "android", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.Client).To(Equal("Symfonium"))
|
||||
})
|
||||
|
||||
It("returns the player's transcoding", func() {
|
||||
p, trc, err := players.Touch(ctx, model.Player{ID: "123", UserId: "userid", TranscodingId: "1"}, "c", "ua", "1.2.3.4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(p.ID).To(Equal("123"))
|
||||
Expect(trc.ID).To(Equal("1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type mockPlayerRepository struct {
|
||||
|
|
|
|||
8
db/migrations/20260924010054_add_player_api_key_hash.sql
Normal file
8
db/migrations/20260924010054_add_player_api_key_hash.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
alter table player add column api_key_hash varchar default null;
|
||||
create unique index if not exists player_api_key_hash on player(api_key_hash);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
SELECT 1;
|
||||
|
|
@ -21,6 +21,8 @@ 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"`
|
||||
HasAPIKey bool `structs:"-" db:"has_api_key" json:"hasApiKey"`
|
||||
APIKey *string `structs:"-" json:"apiKey,omitempty"`
|
||||
}
|
||||
|
||||
type Players []Player
|
||||
|
|
@ -33,4 +35,6 @@ type PlayerRepository interface {
|
|||
Put(ctx context.Context, p *Player) error
|
||||
CountAll(ctx context.Context, options ...QueryOptions) (int64, error)
|
||||
CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error)
|
||||
FindByAPIKey(ctx context.Context, key string) (*Player, error)
|
||||
SetAPIKey(ctx context.Context, playerID, key string) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ package persistence
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
|
|
@ -17,7 +23,8 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository {
|
|||
r := &playerRepository{}
|
||||
r.db = db
|
||||
r.registerModel(&model.Player{}, map[string]filterFunc{
|
||||
"name": containsFilter("player.name"),
|
||||
"name": containsFilter("player.name"),
|
||||
"hasapikey": hasAPIKeyFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"user_name": "username", //TODO rename all user_name and userName to username
|
||||
|
|
@ -25,6 +32,13 @@ func NewPlayerRepository(db dbx.Builder) model.PlayerRepository {
|
|||
return r
|
||||
}
|
||||
|
||||
func hasAPIKeyFilter(_ string, value any) Sqlizer {
|
||||
if v, _ := value.(string); strings.EqualFold(v, "true") {
|
||||
return NotEq{"player.api_key_hash": nil}
|
||||
}
|
||||
return Eq{"player.api_key_hash": nil}
|
||||
}
|
||||
|
||||
func (r *playerRepository) Put(ctx context.Context, p *model.Player) error {
|
||||
_, err := r.put(ctx, p.ID, p)
|
||||
return err
|
||||
|
|
@ -32,7 +46,7 @@ func (r *playerRepository) Put(ctx context.Context, p *model.Player) error {
|
|||
|
||||
func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
|
||||
return r.newSelect(ctx, options...).
|
||||
Columns("player.*").
|
||||
Columns("player.*", "player.api_key_hash is not null as has_api_key").
|
||||
Join("user ON player.user_id = user.id").
|
||||
Columns("user.user_name username")
|
||||
}
|
||||
|
|
@ -103,32 +117,115 @@ func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOpt
|
|||
return res, err
|
||||
}
|
||||
|
||||
// isPermitted authorizes creating a new record, based on the owner declared in the request body.
|
||||
// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a
|
||||
// player they own. Updates must not use this (the body owner is attacker-controlled); they go
|
||||
// through updateOwned, which authorizes against the persisted user_id in the WHERE clause.
|
||||
func (r *playerRepository) isPermitted(ctx context.Context, p *model.Player) bool {
|
||||
u := loggedUser(ctx)
|
||||
return u.IsAdmin || p.UserId == u.ID
|
||||
var apiKeyFormat = regexp.MustCompile(`^` + consts.APIKeyPrefix + `[0-9A-Za-z]{22}$`)
|
||||
|
||||
func apiKeyValidationError(msg string) error {
|
||||
return &rest.ValidationError{Errors: map[string]string{"apiKey": msg}}
|
||||
}
|
||||
|
||||
func validateAPIKey(key string) error {
|
||||
if !apiKeyFormat.MatchString(key) {
|
||||
return apiKeyValidationError("resources.player.validation.apiKeyFormat")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) {
|
||||
if !r.isPermitted(ctx, t) {
|
||||
u := loggedUser(ctx)
|
||||
if t.UserId == "" && u.ID != invalidUserId {
|
||||
t.UserId = u.ID
|
||||
}
|
||||
if t.UserId != u.ID {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
return r.put(ctx, "", t) // Save only creates; edits go through the owner-scoped Update
|
||||
// Hand-made players are only reachable through a key, so one is required
|
||||
if t.APIKey == nil || *t.APIKey == "" {
|
||||
return "", apiKeyValidationError("ra.validation.required")
|
||||
}
|
||||
if err := validateAPIKey(*t.APIKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
values, err := toSQLArgs(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Save only creates, so the key hash goes in the same INSERT and the unique index settles races
|
||||
values["id"] = id.NewRandom()
|
||||
values["api_key_hash"] = hashAPIKey(*t.APIKey)
|
||||
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
|
||||
if isUniqueViolation(err) {
|
||||
return "", apiKeyValidationError("ra.validation.unique")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return values["id"].(string), nil
|
||||
}
|
||||
|
||||
func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error {
|
||||
t := &entity
|
||||
t.ID = id
|
||||
return r.updateOwned(ctx, id, t, cols...)
|
||||
if t.APIKey == nil {
|
||||
return r.updateOwned(ctx, id, t, cols...)
|
||||
}
|
||||
// The key and the other columns are two writes; commit both or neither
|
||||
return r.inTx(func(tx *playerRepository) error {
|
||||
if err := tx.SetAPIKey(ctx, id, *t.APIKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.updateOwned(ctx, id, t, cols...)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *playerRepository) inTx(block func(tx *playerRepository) error) error {
|
||||
conn, ok := r.db.(*dbx.DB)
|
||||
if !ok {
|
||||
return block(r) // already inside a transaction
|
||||
}
|
||||
return conn.Transactional(func(tx *dbx.Tx) error {
|
||||
return block(NewPlayerRepository(tx).(*playerRepository))
|
||||
})
|
||||
}
|
||||
|
||||
func (r *playerRepository) Delete(ctx context.Context, ids ...string) error {
|
||||
return r.deleteOwnedAll(ctx, ids...)
|
||||
}
|
||||
|
||||
// Keys are long random strings, not user-chosen passwords, so a fast unsalted hash is enough and keeps lookups indexed.
|
||||
func hashAPIKey(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (r *playerRepository) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) {
|
||||
sel := r.selectPlayer(ctx).Where(Eq{"player.api_key_hash": hashAPIKey(key)})
|
||||
var res model.Player
|
||||
if err := r.queryOne(ctx, sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// SetAPIKey stores the key's hash, or revokes it when key is empty. Setting is owner-only, even for
|
||||
// admins, so nobody can mint a login for someone else.
|
||||
func (r *playerRepository) SetAPIKey(ctx context.Context, playerID, key string) error {
|
||||
if key == "" {
|
||||
return r.updateOwnedRow(ctx, playerID, ownerOrAdmin, map[string]any{"api_key_hash": nil})
|
||||
}
|
||||
if err := validateAPIKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
err := r.updateOwnedRow(ctx, playerID, ownerOnly, map[string]any{"api_key_hash": hashAPIKey(key)})
|
||||
if isUniqueViolation(err) {
|
||||
return apiKeyValidationError("ra.validation.unique")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
var _ model.PlayerRepository = (*playerRepository)(nil)
|
||||
var _ rest.Repository[model.Player] = (*playerRepository)(nil)
|
||||
var _ rest.Persistable[model.Player] = (*playerRepository)(nil)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package persistence
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
|
@ -12,6 +13,14 @@ import (
|
|||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
const testAPIKey = "nds_0123456789abcdefghijkl"
|
||||
|
||||
func expectAPIKeyError(err error, msg string) {
|
||||
var verr *rest.ValidationError
|
||||
ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue())
|
||||
ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg))
|
||||
}
|
||||
|
||||
var _ = Describe("PlayerRepository", func() {
|
||||
var adminRepo *playerRepository
|
||||
var database *dbx.DB
|
||||
|
|
@ -178,11 +187,12 @@ var _ = Describe("PlayerRepository", func() {
|
|||
clone := player
|
||||
clone.ID = ""
|
||||
clone.IP = "192.168.1.1"
|
||||
clone.APIKey = new(testAPIKey)
|
||||
id, err := repo.Save(repoCtx, &clone)
|
||||
|
||||
if clone.UserId == "" {
|
||||
Expect(err).To(HaveOccurred())
|
||||
} else if !admin && player.Username == adminPlayer1.Username {
|
||||
} else if player.UserId != userPlayer.UserId {
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
clone.UserId = ""
|
||||
} else {
|
||||
|
|
@ -202,12 +212,13 @@ var _ = Describe("PlayerRepository", func() {
|
|||
} else {
|
||||
Expect(count).To(Equal(baseCount + 1))
|
||||
Expect(err).To(BeNil())
|
||||
clone.APIKey = nil
|
||||
clone.HasAPIKey = true
|
||||
Expect(*newItem).To(Equal(clone))
|
||||
}
|
||||
},
|
||||
Entry("same user", userPlayer),
|
||||
Entry("other item", otherPlayer),
|
||||
Entry("fake item", model.Player{}),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
@ -251,6 +262,259 @@ var _ = Describe("PlayerRepository", func() {
|
|||
Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1),
|
||||
)
|
||||
|
||||
Describe("API keys", func() {
|
||||
const key = testAPIKey
|
||||
const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV"
|
||||
var ownerCtx, otherCtx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser)
|
||||
otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser)
|
||||
})
|
||||
|
||||
storedHash := func(id string) string {
|
||||
var row struct {
|
||||
Hash string `db:"api_key_hash"`
|
||||
}
|
||||
Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}").
|
||||
Bind(dbx.Params{"id": id}).One(&row)).To(Succeed())
|
||||
return row.Hash
|
||||
}
|
||||
|
||||
Describe("SetAPIKey", func() {
|
||||
It("stores only the hash and finds the player by the key", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
|
||||
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
||||
plr, err := adminRepo.FindByAPIKey(ctx, key)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(plr.ID).To(Equal(regularPlayer.ID))
|
||||
Expect(plr.HasAPIKey).To(BeTrue())
|
||||
Expect(plr.APIKey).To(BeNil())
|
||||
})
|
||||
|
||||
It("replaces the previous key", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed())
|
||||
|
||||
_, err := adminRepo.FindByAPIKey(ctx, key)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = adminRepo.FindByAPIKey(ctx, otherKey)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
DescribeTable("rejects malformed keys",
|
||||
func(bad string) {
|
||||
err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad)
|
||||
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
},
|
||||
Entry("no prefix", "0123456789abcdefghijklmn"),
|
||||
Entry("too short", "nds_short"),
|
||||
Entry("too long", key+"x"),
|
||||
Entry("bad chars", "nds_0123456789abcdefghij-!"),
|
||||
)
|
||||
|
||||
It("revokes with an empty key, by the owner or an admin", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed())
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("accepts revoking a player that has no key", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed())
|
||||
})
|
||||
|
||||
It("does not let an admin set a key on another user's player", func() {
|
||||
Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied))
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not let another user set or revoke", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied))
|
||||
Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied))
|
||||
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
||||
})
|
||||
|
||||
It("returns not found for a missing player", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound))
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound))
|
||||
})
|
||||
|
||||
It("does not find unknown or empty keys", func() {
|
||||
_, err := adminRepo.FindByAPIKey(ctx, otherKey)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = adminRepo.FindByAPIKey(ctx, "")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("drops the key with the player", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed())
|
||||
_, err := adminRepo.FindByAPIKey(ctx, key)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("hasApiKey filter", func() {
|
||||
BeforeEach(func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
})
|
||||
|
||||
filtered := func(value string) []string {
|
||||
res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var ids []string
|
||||
for _, p := range res {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
It("lists only players with a key", func() {
|
||||
Expect(filtered("true")).To(ConsistOf(regularPlayer.ID))
|
||||
count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("lists only players without a key", func() {
|
||||
Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Save (create)", func() {
|
||||
It("creates the player with the key, owned by the logged-in user", func() {
|
||||
id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
plr, err := adminRepo.FindByAPIKey(ctx, key)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(plr.ID).To(Equal(id))
|
||||
Expect(plr.UserId).To(Equal(regularUser.ID))
|
||||
})
|
||||
|
||||
It("requires a key", func() {
|
||||
count, _ := adminRepo.CountAll(ctx)
|
||||
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"})
|
||||
expectAPIKeyError(err, "ra.validation.required")
|
||||
|
||||
_, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")})
|
||||
expectAPIKeyError(err, "ra.validation.required")
|
||||
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
||||
})
|
||||
|
||||
It("rejects a malformed key without creating the player", func() {
|
||||
count, _ := adminRepo.CountAll(ctx)
|
||||
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")})
|
||||
expectAPIKeyError(err, "resources.player.validation.apiKeyFormat")
|
||||
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
||||
})
|
||||
|
||||
It("does not let an admin create a keyed player for another user", func() {
|
||||
count, _ := adminRepo.CountAll(ctx)
|
||||
_, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)})
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
_, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID})
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
||||
})
|
||||
|
||||
It("rejects a key already used by another player without creating the player", func() {
|
||||
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
|
||||
count, _ := adminRepo.CountAll(ctx)
|
||||
_, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)})
|
||||
expectAPIKeyError(err, "ra.validation.unique")
|
||||
Expect(adminRepo.CountAll(ctx)).To(Equal(count))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update (edit)", func() {
|
||||
It("rolls back the key change when the rest of the edit fails", func() {
|
||||
_, err := database.NewQuery(`create trigger fail_player_rename before update of name on player
|
||||
when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
_, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute()
|
||||
})
|
||||
|
||||
plr := regularPlayer
|
||||
plr.Name = "boom"
|
||||
plr.APIKey = new(key)
|
||||
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed())
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("keeps the key when apiKey is absent (a normal edit)", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
|
||||
plr := regularPlayer
|
||||
plr.Name = "Renamed"
|
||||
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed())
|
||||
Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed())
|
||||
|
||||
found, err := adminRepo.FindByAPIKey(ctx, key)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(found.Name).To(Equal("Renamed"))
|
||||
})
|
||||
|
||||
It("sets a new key when apiKey has a value", func() {
|
||||
plr := regularPlayer
|
||||
plr.APIKey = new(key)
|
||||
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed())
|
||||
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
||||
})
|
||||
|
||||
It("revokes the key when apiKey is empty", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
plr := regularPlayer
|
||||
plr.APIKey = new("")
|
||||
Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed())
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("lets an admin edit another user's keyed player without touching the key", func() {
|
||||
Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed())
|
||||
plr := regularPlayer
|
||||
plr.MaxBitRate = 192
|
||||
Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed())
|
||||
Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key)))
|
||||
})
|
||||
|
||||
It("refuses an admin setting a key on another user's player and leaves other columns alone", func() {
|
||||
plr := regularPlayer
|
||||
plr.Name = "Hijacked"
|
||||
plr.APIKey = new(key)
|
||||
Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied))
|
||||
|
||||
got, err := adminRepo.Get(ctx, regularPlayer.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.Name).To(Equal(regularPlayer.Name))
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("refuses a key already used by another player and leaves other columns alone", func() {
|
||||
Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed())
|
||||
plr := regularPlayer
|
||||
plr.Name = "Renamed"
|
||||
plr.APIKey = new(key)
|
||||
err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")
|
||||
expectAPIKeyError(err, "ra.validation.unique")
|
||||
|
||||
got, err := adminRepo.Get(ctx, regularPlayer.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.Name).To(Equal(regularPlayer.Name))
|
||||
Expect(storedHash(regularPlayer.ID)).To(BeEmpty())
|
||||
Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Ownership enforcement (cross-tenant write protection)", func() {
|
||||
var regularRepo *playerRepository
|
||||
var regularCtx context.Context
|
||||
|
|
@ -287,6 +551,7 @@ var _ = Describe("PlayerRepository", func() {
|
|||
Name: "HIJACKED",
|
||||
UserId: regularUser.ID,
|
||||
ReportRealPath: true,
|
||||
APIKey: new(testAPIKey),
|
||||
}
|
||||
|
||||
id, err := regularRepo.Save(regularCtx, &spoofed)
|
||||
|
|
|
|||
|
|
@ -234,7 +234,9 @@ func (r *playlistTrackRepository) AddAlbums(ctx context.Context, albumIds []stri
|
|||
}
|
||||
|
||||
func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) {
|
||||
return r.addMediaFileIds(ctx, Eq{"album_artist_id": artistIds})
|
||||
// Match by album-artist participation, not the deprecated album_artist_id
|
||||
// column, which only holds the first album artist.
|
||||
return r.addMediaFileIds(ctx, ParticipantIDFilter("media_file", artistIds, model.RoleAlbumArtist))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.DiscID) (int, error) {
|
||||
|
|
|
|||
|
|
@ -219,6 +219,45 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("AddArtists", func() {
|
||||
var tracks model.PlaylistTrackRepository
|
||||
var joint model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
mfRepo := NewMediaFileRepository(GetDBXBuilder())
|
||||
joint = mf(model.MediaFile{ID: "pls-coartist-track", Title: "Joint Track", ArtistID: artistPunctuation.ID,
|
||||
Artist: artistPunctuation.Name, AlbumID: "pls-coartist-album", Album: "Joint Album",
|
||||
AlbumArtistID: artistKraftwerk.ID, AlbumArtist: artistKraftwerk.Name, Path: p("joint/track.mp3")})
|
||||
joint.Participants[model.RoleAlbumArtist] = model.ParticipantList{
|
||||
{Artist: artistKraftwerk},
|
||||
{Artist: artistBeatles},
|
||||
}
|
||||
Expect(mfRepo.Put(ctx, &joint)).To(Succeed())
|
||||
DeferCleanup(func() { _ = mfRepo.Delete(ctx, joint.ID) })
|
||||
|
||||
plsRepo := NewPlaylistRepository(GetDBXBuilder())
|
||||
pls := model.Playlist{Name: "Co-album-artist", OwnerID: adminUser.ID, OwnerName: adminUser.UserName}
|
||||
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = plsRepo.Delete(ctx, pls.ID) })
|
||||
tracks = plsRepo.Tracks(ctx, pls.ID, false)
|
||||
})
|
||||
|
||||
It("adds tracks where the artist is the first album artist", func() {
|
||||
Expect(tracks.AddArtists(ctx, []string{artistKraftwerk.ID})).To(Equal(1))
|
||||
Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID))
|
||||
})
|
||||
|
||||
It("adds tracks where the artist is not the first album artist", func() {
|
||||
Expect(tracks.AddArtists(ctx, []string{artistBeatles.ID})).To(Equal(1))
|
||||
Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID))
|
||||
})
|
||||
|
||||
It("does not add tracks where the artist is only the track artist", func() {
|
||||
Expect(tracks.AddArtists(ctx, []string{artistPunctuation.ID})).To(Equal(0))
|
||||
Expect(tracks.GetMediaFileIDs(ctx)).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("library access", func() {
|
||||
var otherLib model.Library
|
||||
var restrictedUser model.User
|
||||
|
|
|
|||
|
|
@ -85,6 +85,22 @@ func (r sqlRepository) addRestriction(ctx context.Context, sql ...Sqlizer) Sqliz
|
|||
return s
|
||||
}
|
||||
|
||||
// writeAccess says who may change a row in a table with a user_id column.
|
||||
type writeAccess int
|
||||
|
||||
const (
|
||||
ownerOrAdmin writeAccess = iota // admins may write any row
|
||||
ownerOnly // even admins may only write their own rows
|
||||
)
|
||||
|
||||
// ownedRow matches the row rowID only if the logged-in user may write it under access.
|
||||
func (r sqlRepository) ownedRow(ctx context.Context, rowID string, access writeAccess) Sqlizer {
|
||||
if access == ownerOnly {
|
||||
return And{Eq{"id": rowID}, Eq{"user_id": loggedUser(ctx).ID}}
|
||||
}
|
||||
return r.addRestriction(ctx, Eq{"id": rowID})
|
||||
}
|
||||
|
||||
func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) {
|
||||
if r.tableName == "" {
|
||||
r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.")
|
||||
|
|
@ -494,15 +510,12 @@ func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsTo
|
|||
}
|
||||
updateValues := filterUpdateValues(values, id, colsToUpdate...)
|
||||
delete(updateValues, "user_id") // ownership is immutable on update
|
||||
update := Update(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})).SetMap(updateValues)
|
||||
count, err := r.executeSQL(ctx, update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return r.classifyOwnedWriteMiss(ctx, id)
|
||||
}
|
||||
return nil
|
||||
return r.updateOwnedRow(ctx, id, ownerOrAdmin, updateValues)
|
||||
}
|
||||
|
||||
// updateOwnedRow sets values on the row rowID if the logged-in user may write it under access.
|
||||
func (r sqlRepository) updateOwnedRow(ctx context.Context, rowID string, access writeAccess, values map[string]any) error {
|
||||
return r.runRowWrite(ctx, rowID, Update(r.tableName).SetMap(values).Where(r.ownedRow(ctx, rowID, access)))
|
||||
}
|
||||
|
||||
// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for
|
||||
|
|
@ -511,12 +524,17 @@ func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsTo
|
|||
// does not match and is left untouched. The failure path mirrors updateOwned (see
|
||||
// classifyOwnedWriteMiss), so there is no TOCTOU on the delete.
|
||||
func (r sqlRepository) deleteOwned(ctx context.Context, id string) error {
|
||||
count, err := r.executeSQL(ctx, Delete(r.tableName).Where(r.addRestriction(ctx, Eq{"id": id})))
|
||||
return r.runRowWrite(ctx, id, Delete(r.tableName).Where(r.ownedRow(ctx, id, ownerOrAdmin)))
|
||||
}
|
||||
|
||||
// runRowWrite executes q, a write already filtered by ownedRow(rowID, …), and classifies a miss.
|
||||
func (r sqlRepository) runRowWrite(ctx context.Context, rowID string, q Sqlizer) error {
|
||||
count, err := r.executeSQL(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return r.classifyOwnedWriteMiss(ctx, id)
|
||||
return r.classifyOwnedWriteMiss(ctx, rowID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,26 @@ var _ = Describe("sqlRepository", func() {
|
|||
r.tableName = "table"
|
||||
})
|
||||
|
||||
Describe("ownedRow", func() {
|
||||
DescribeTable("matches the row, limited to what the logged-in user may write",
|
||||
func(user model.User, access writeAccess, expectedSQL string, expectedArgs ...any) {
|
||||
userCtx := request.WithUser(GinkgoT().Context(), user)
|
||||
sql, args, err := r.ownedRow(userCtx, "row-1", access).ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(Equal(expectedArgs))
|
||||
},
|
||||
Entry("admin, ownerOrAdmin: any row", model.User{ID: "admin", IsAdmin: true}, ownerOrAdmin,
|
||||
"(id = ?)", "row-1"),
|
||||
Entry("regular, ownerOrAdmin: own rows", model.User{ID: "user"}, ownerOrAdmin,
|
||||
"(id = ? AND user_id = ?)", "row-1", "user"),
|
||||
Entry("admin, ownerOnly: own rows", model.User{ID: "admin", IsAdmin: true}, ownerOnly,
|
||||
"(id = ? AND user_id = ?)", "row-1", "admin"),
|
||||
Entry("regular, ownerOnly: own rows", model.User{ID: "user"}, ownerOnly,
|
||||
"(id = ? AND user_id = ?)", "row-1", "user"),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("applyOptions", func() {
|
||||
var sq squirrel.SelectBuilder
|
||||
BeforeEach(func() {
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@
|
|||
},
|
||||
"player": {
|
||||
"name": "Tocador |||| Tocadores",
|
||||
"menuName": "Tocadores e chaves de API",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"transcodingId": "Conversão",
|
||||
|
|
@ -190,7 +191,29 @@
|
|||
"userName": "Usuário",
|
||||
"lastSeen": "Últ. acesso",
|
||||
"reportRealPath": "Use paths reais",
|
||||
"scrobbleEnabled": "Enviar scrobbles para serviços externos"
|
||||
"scrobbleEnabled": "Enviar scrobbles para serviços externos",
|
||||
"hasApiKey": "Chave de API"
|
||||
},
|
||||
"actions": {
|
||||
"generateApiKey": "Gerar chave de API",
|
||||
"regenerateApiKey": "Gerar nova",
|
||||
"revokeApiKey": "Revogar",
|
||||
"copyApiKey": "Copiar"
|
||||
},
|
||||
"message": {
|
||||
"apiKeyActive": "Este tocador tem uma chave de API. Use-a no seu app como chave de API, ou como senha se o app não usar autenticação por token.",
|
||||
"apiKeyNone": "Sem chave de API. Gere uma para conectar um app a este tocador.",
|
||||
"apiKeyNoneOther": "Sem chave de API.",
|
||||
"apiKeyPending": "Copie esta chave agora. Ela será salva quando você clicar em Salvar e não será exibida novamente.",
|
||||
"apiKeyRevokePending": "A chave de API será removida quando você salvar.",
|
||||
"deleteWithKeyTitle": "Excluir tocador",
|
||||
"deleteWithKeyContent": "Este tocador tem uma chave de API. Os apps que a usam vão parar de funcionar."
|
||||
},
|
||||
"notifications": {
|
||||
"apiKeyCopied": "Chave de API copiada para o clipboard"
|
||||
},
|
||||
"validation": {
|
||||
"apiKeyFormat": "Formato de chave de API inválido"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ func (api *Router) routes() http.Handler {
|
|||
r.Use(getPlayer(api.players))
|
||||
h(r, "ping", api.Ping)
|
||||
h(r, "getLicense", api.GetLicense)
|
||||
h(r, "tokenInfo", api.TokenInfo)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
|
|
|
|||
54
server/subsonic/e2e/subsonic_apikey_test.go
Normal file
54
server/subsonic/e2e/subsonic_apikey_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package e2e
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("API key authentication", func() {
|
||||
var key string
|
||||
|
||||
BeforeEach(func() {
|
||||
setupTestDB()
|
||||
userCtx := request.WithUser(ctx, regularUser)
|
||||
player := &model.Player{ID: "apikey-player", Name: "Phone", UserId: regularUser.ID, Client: "test-client"}
|
||||
Expect(ds.Player().Put(userCtx, player)).To(Succeed())
|
||||
key = "nds_0123456789abcdefghijkl"
|
||||
Expect(ds.Player().SetAPIKey(userCtx, player.ID, key)).To(Succeed())
|
||||
})
|
||||
|
||||
doKeyReq := func(endpoint, apiKey string) *responses.Subsonic {
|
||||
q := url.Values{"apiKey": {apiKey}, "v": {"1.16.1"}, "c": {"test-client"}, "f": {"json"}}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil))
|
||||
return parseJSONResponse(w)
|
||||
}
|
||||
|
||||
It("authenticates ping with only the key", func() {
|
||||
resp := doKeyReq("ping", key)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
})
|
||||
|
||||
It("reports the key owner in tokenInfo", func() {
|
||||
resp := doKeyReq("tokenInfo", key)
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.TokenInfo).ToNot(BeNil())
|
||||
Expect(resp.TokenInfo.Username).To(Equal(regularUser.UserName))
|
||||
})
|
||||
|
||||
It("rejects an unknown key with error 44", func() {
|
||||
resp := doKeyReq("ping", "nds_unknown")
|
||||
|
||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Code).To(Equal(int32(44)))
|
||||
})
|
||||
})
|
||||
|
|
@ -65,14 +65,15 @@ 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 != "" {
|
||||
apiKey, _ := p.String("apiKey")
|
||||
if username != "" || 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)
|
||||
|
|
@ -104,10 +105,14 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||
ctx := r.Context()
|
||||
|
||||
var usr *model.User
|
||||
var keyPlayer *model.Player
|
||||
var err error
|
||||
|
||||
p := req.Params(r)
|
||||
apiKey, _ := p.String("apiKey")
|
||||
username, isInternalAuth := fromInternalOrProxyAuth(r)
|
||||
if username != "" {
|
||||
switch {
|
||||
case username != "":
|
||||
authType := If(isInternalAuth, "internal", "reverse-proxy")
|
||||
usr, err = ds.User().FindByUsername(ctx, username)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
|
|
@ -119,8 +124,16 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||
} else if err != nil {
|
||||
log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||
}
|
||||
} else {
|
||||
p := req.Params(r)
|
||||
case apiKey != "":
|
||||
usr, keyPlayer, err = authenticateAPIKey(ctx, ds, limiter, r, apiKey)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
sendError(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx = request.WithUsername(ctx, usr.UserName)
|
||||
default:
|
||||
username, _ := p.String("u")
|
||||
pass, _ := p.String("p")
|
||||
token, _ := p.String("t")
|
||||
|
|
@ -142,6 +155,9 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||
usr, err = ds.User().FindByUsernameWithPassword(ctx, username)
|
||||
if err == nil {
|
||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
||||
if errors.Is(err, model.ErrInvalidAuth) && pass != "" && jwt == "" {
|
||||
keyPlayer, err = playerFromPasswordKey(ctx, ds, usr, pass)
|
||||
}
|
||||
}
|
||||
invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth)
|
||||
slot.release(invalidLogin)
|
||||
|
|
@ -162,11 +178,77 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
ctx = request.WithUser(ctx, *usr)
|
||||
if keyPlayer != nil {
|
||||
ctx = request.WithPlayer(ctx, *keyPlayer)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var apiKeyConflicts = []string{"u", "p", "t", "s", "jwt"}
|
||||
|
||||
func authenticateAPIKey(ctx context.Context, ds model.DataStore, limiter *authLimiter, r *http.Request, key string) (*model.User, *model.Player, error) {
|
||||
query := r.URL.Query()
|
||||
for _, param := range apiKeyConflicts {
|
||||
if query.Has(param) {
|
||||
log.Warn(ctx, "API: apiKey sent with other credentials", "auth", "apikey", "param", param, "remoteAddr", r.RemoteAddr)
|
||||
return nil, nil, newError(responses.ErrorMultipleAuthMechanismsProvided)
|
||||
}
|
||||
}
|
||||
|
||||
// Per key, so a stale key on one device cannot lock out valid keys sharing the IP
|
||||
slot, allowed := limiter.acquire(ctx, "apikey\x00"+server.ClientIP(r)+"\x00"+key)
|
||||
if !allowed {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
log.Warn(ctx, "API: Too many failed API key attempts", "auth", "apikey", "remoteAddr", r.RemoteAddr)
|
||||
return nil, nil, newError(responses.ErrorInvalidAPIKey)
|
||||
}
|
||||
|
||||
player, err := ds.Player().FindByAPIKey(ctx, key)
|
||||
var usr *model.User
|
||||
if err == nil {
|
||||
usr, err = ds.User().Get(ctx, player.UserId)
|
||||
}
|
||||
slot.release(errors.Is(err, model.ErrNotFound))
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
return nil, nil, err
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
log.Warn(ctx, "API: Invalid API key", "auth", "apikey", "remoteAddr", r.RemoteAddr)
|
||||
return nil, nil, newError(responses.ErrorInvalidAPIKey)
|
||||
case err != nil:
|
||||
log.Error(ctx, "API: Error authenticating API key", "auth", "apikey", "remoteAddr", r.RemoteAddr, err)
|
||||
return nil, nil, newError(responses.ErrorAuthenticationFail)
|
||||
}
|
||||
return usr, player, nil
|
||||
}
|
||||
|
||||
// playerFromPasswordKey lets clients that only have a password field log in with an API key.
|
||||
// It returns ErrInvalidAuth when pass is not a key of usr, so only real failures skip the limiter count.
|
||||
func playerFromPasswordKey(ctx context.Context, ds model.DataStore, usr *model.User, pass string) (*model.Player, error) {
|
||||
key := decodePassword(pass)
|
||||
if !strings.HasPrefix(key, consts.APIKeyPrefix) {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
plr, err := ds.Player().FindByAPIKey(ctx, key)
|
||||
if errors.Is(err, model.ErrNotFound) || (err == nil && plr.UserId != usr.ID) {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
return plr, err
|
||||
}
|
||||
|
||||
func decodePassword(pass string) string {
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
return string(dec)
|
||||
}
|
||||
}
|
||||
return pass
|
||||
}
|
||||
|
||||
func adminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
loggedUser, ok := request.UserFrom(r.Context())
|
||||
|
|
@ -194,12 +276,7 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error
|
|||
claims.Subject == user.UserName &&
|
||||
auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
pass = string(dec)
|
||||
}
|
||||
}
|
||||
valid = pass == user.Password
|
||||
valid = decodePassword(pass) == user.Password
|
||||
case token != "":
|
||||
t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt)))
|
||||
valid = t == token
|
||||
|
|
@ -217,12 +294,20 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler {
|
|||
ctx := r.Context()
|
||||
userName, _ := request.UsernameFrom(ctx)
|
||||
client, _ := request.ClientFrom(ctx)
|
||||
playerId := playerIDFromCookie(r, userName)
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
userAgent := canonicalUserAgent(r)
|
||||
player, trc, err := players.Register(ctx, playerId, client, userAgent, ip)
|
||||
|
||||
var player *model.Player
|
||||
var trc *model.Transcoding
|
||||
var err error
|
||||
keyPlayer, boundByKey := request.PlayerFrom(ctx)
|
||||
if boundByKey {
|
||||
player, trc, err = players.Touch(ctx, keyPlayer, client, userAgent, ip)
|
||||
} else {
|
||||
player, trc, err = players.Register(ctx, playerIDFromCookie(r, userName), client, userAgent, ip)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not register player", "username", userName, "client", client, err)
|
||||
log.Error(ctx, "Could not resolve player", "username", userName, "client", client, err)
|
||||
} else {
|
||||
ctx = request.WithPlayer(ctx, *player)
|
||||
if trc != nil {
|
||||
|
|
@ -230,6 +315,11 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler {
|
|||
}
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// A key already identifies the player, so the cookie would only add a second, weaker signal
|
||||
if boundByKey {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP
|
||||
Name: playerIDCookieName(userName),
|
||||
Value: player.ID,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package subsonic
|
|||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
|
@ -119,6 +120,14 @@ var _ = Describe("Middlewares", func() {
|
|||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not require u when apiKey is present", func() {
|
||||
r := newGetRequest("apiKey=nds_abc", "v=1.15", "c=test")
|
||||
cp := checkRequiredParameters(next)
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("fails when user is missing", func() {
|
||||
r := newGetRequest("v=1.15", "c=test")
|
||||
cp := checkRequiredParameters(next)
|
||||
|
|
@ -311,6 +320,101 @@ var _ = Describe("Middlewares", func() {
|
|||
})
|
||||
})
|
||||
|
||||
When("using API key authentication", func() {
|
||||
var key string
|
||||
serve := func(params ...string) {
|
||||
authenticate(ds)(next).ServeHTTP(w, newGetRequest(params...))
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
usr, err := ds.User().FindByUsername(ctx, "admin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ds.Player().Put(ctx, &model.Player{ID: "player-1", Name: "My Phone", UserId: usr.ID, Client: "Symfonium"})).To(Succeed())
|
||||
key = "nds_0123456789abcdefghijkl"
|
||||
Expect(ds.Player().SetAPIKey(ctx, "player-1", key)).To(Succeed())
|
||||
})
|
||||
|
||||
It("authenticates the owner and binds the key's player", func() {
|
||||
serve("apiKey=" + key)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
user, _ := request.UserFrom(next.req.Context())
|
||||
Expect(user.UserName).To(Equal("admin"))
|
||||
username, _ := request.UsernameFrom(next.req.Context())
|
||||
Expect(username).To(Equal("admin"))
|
||||
player, ok := request.PlayerFrom(next.req.Context())
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(player.ID).To(Equal("player-1"))
|
||||
})
|
||||
|
||||
It("accepts the key in a POST form body", func() {
|
||||
r := newPostRequest("", "apiKey="+key)
|
||||
cp := postFormToQueryParams(authenticate(ds)(next))
|
||||
cp.ServeHTTP(w, r)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
player, _ := request.PlayerFrom(next.req.Context())
|
||||
Expect(player.ID).To(Equal("player-1"))
|
||||
})
|
||||
|
||||
It("rejects an unknown key with error 44", func() {
|
||||
serve("apiKey=nds_unknown")
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="44"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
|
||||
DescribeTable("rejects apiKey mixed with other credentials with error 43",
|
||||
func(extra string) {
|
||||
serve("apiKey="+key, extra)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
},
|
||||
Entry("u", "u=admin"),
|
||||
Entry("p", "p=wordpass"),
|
||||
Entry("t", "t=abc"),
|
||||
Entry("s", "s=abc"),
|
||||
Entry("jwt", "jwt=abc"),
|
||||
Entry("empty u", "u="),
|
||||
Entry("empty p", "p="),
|
||||
)
|
||||
|
||||
Context("key sent as the password", func() {
|
||||
It("authenticates and binds the key's player", func() {
|
||||
serve("u=admin", "p="+key)
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
player, ok := request.PlayerFrom(next.req.Context())
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(player.ID).To(Equal("player-1"))
|
||||
})
|
||||
|
||||
It("accepts the hex-encoded form", func() {
|
||||
serve("u=admin", "p=enc:"+hex.EncodeToString([]byte(key)))
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("still accepts a real password that starts with the key prefix", func() {
|
||||
Expect(ds.User().Put(ctx, &model.User{UserName: "prefixed", NewPassword: "nds_secret"})).To(Succeed())
|
||||
serve("u=prefixed", "p=nds_secret")
|
||||
|
||||
Expect(next.called).To(BeTrue())
|
||||
_, ok := request.PlayerFrom(next.req.Context())
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("rejects another user's key with error 40", func() {
|
||||
Expect(ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "pw"})).To(Succeed())
|
||||
serve("u=other", "p="+key)
|
||||
|
||||
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||
Expect(next.called).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
When("failed attempts reach AuthRequestLimit", func() {
|
||||
var cp http.Handler
|
||||
|
||||
|
|
@ -376,6 +480,21 @@ var _ = Describe("Middlewares", func() {
|
|||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not count server errors when a key is sent as the password", func() {
|
||||
usr, _ := ds.User().FindByUsername(ctx, "admin")
|
||||
playerRepo := ds.Player().(*tests.MockPlayerRepo)
|
||||
Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed())
|
||||
key := "nds_0123456789abcdefghijkl"
|
||||
Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed())
|
||||
|
||||
playerRepo.Error = errors.New("db down")
|
||||
failTimes(5, "u=admin", "p="+key)
|
||||
playerRepo.Error = nil
|
||||
|
||||
serve(newGetRequest("u=admin", "p="+key))
|
||||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not block other usernames from the same IP", func() {
|
||||
_ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"})
|
||||
failTimes(3, "u=admin", "p=WRONG")
|
||||
|
|
@ -405,6 +524,25 @@ var _ = Describe("Middlewares", func() {
|
|||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("throttles a repeated bad key without locking out valid keys from the same IP", func() {
|
||||
usr, _ := ds.User().FindByUsername(ctx, "admin")
|
||||
playerRepo := ds.Player().(*tests.MockPlayerRepo)
|
||||
Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed())
|
||||
key := "nds_0123456789abcdefghijkl"
|
||||
Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed())
|
||||
|
||||
for range 3 {
|
||||
Expect(serve(newGetRequest("apiKey=nds_bad")).Body.String()).To(ContainSubstring(`code="44"`))
|
||||
}
|
||||
playerRepo.APIKeys["nds_bad"] = "player-1"
|
||||
rec := serve(newGetRequest("apiKey=nds_bad"))
|
||||
Expect(next.called).To(BeFalse())
|
||||
Expect(rec.Body.String()).To(ContainSubstring(`code="44"`))
|
||||
|
||||
serve(newGetRequest("apiKey=" + key))
|
||||
Expect(next.called).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is disabled when AuthRequestLimit is 0", func() {
|
||||
conf.Server.AuthRequestLimit = 0
|
||||
cp = authenticate(ds)(next)
|
||||
|
|
@ -532,6 +670,24 @@ var _ = Describe("Middlewares", func() {
|
|||
Expect(cookieStr).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("player bound by an API key", func() {
|
||||
BeforeEach(func() {
|
||||
r = r.WithContext(request.WithPlayer(r.Context(), model.Player{ID: "keyed"}))
|
||||
gp := getPlayer(mockedPlayers)(next)
|
||||
gp.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
It("uses the key's player", func() {
|
||||
Expect(mockedPlayers.touched).To(BeTrue())
|
||||
player, _ := request.PlayerFrom(next.req.Context())
|
||||
Expect(player.ID).To(Equal("keyed"))
|
||||
})
|
||||
|
||||
It("does not set the player cookie", func() {
|
||||
Expect(w.Header().Get("Set-Cookie")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("PlayerId specified in Cookies", func() {
|
||||
BeforeEach(func() {
|
||||
cookie := &http.Cookie{
|
||||
|
|
@ -712,6 +868,12 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
type mockPlayers struct {
|
||||
core.Players
|
||||
transcoding *model.Transcoding
|
||||
touched bool
|
||||
}
|
||||
|
||||
func (mp *mockPlayers) Touch(_ context.Context, plr model.Player, _, _, _ string) (*model.Player, *model.Transcoding, error) {
|
||||
mp.touched = true
|
||||
return &plr, mp.transcoding, nil
|
||||
}
|
||||
|
||||
func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
|||
{Name: "transcoding", Versions: []int32{1}},
|
||||
{Name: "playbackReport", Versions: []int32{1}},
|
||||
{Name: "topSongsByArtistId", Versions: []int32{1}},
|
||||
{Name: "apiKeyAuthentication", Versions: []int32{1}},
|
||||
}
|
||||
if api.sonic != nil && api.sonic.HasProvider() {
|
||||
extensions = append(extensions, responses.OpenSubsonicExtension{
|
||||
|
|
|
|||
|
|
@ -44,44 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() {
|
||||
It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
// Make sure the endpoint is public, by not passing any authentication
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(7),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
|
||||
))
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Context("with sonic similarity plugin", func() {
|
||||
BeforeEach(func() {
|
||||
sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
|
||||
})
|
||||
|
||||
It("should return 7 extensions including sonicSimilarity", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
|
@ -93,8 +62,41 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}),
|
||||
))
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Context("with sonic similarity plugin", func() {
|
||||
BeforeEach(func() {
|
||||
sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
|
||||
})
|
||||
|
||||
It("should return 9 extensions including sonicSimilarity", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
|
||||
var response responses.JsonWrapper
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(9),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"tokenInfo": {
|
||||
"username": "deluan"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<tokenInfo username="deluan"></tokenInfo>
|
||||
</subsonic-response>
|
||||
|
|
@ -1,25 +1,29 @@
|
|||
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
|
||||
ErrorMultipleAuthMechanismsProvided int32 = 43
|
||||
ErrorInvalidAPIKey int32 = 44
|
||||
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",
|
||||
var errors = map[int32]string{ //nolint:gosec // G101 false positive: error messages, not credentials
|
||||
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",
|
||||
ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided",
|
||||
ErrorInvalidAPIKey: "Invalid API key",
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ type Subsonic struct {
|
|||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
|
||||
SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"`
|
||||
TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
@ -596,6 +597,10 @@ type OpenSubsonicExtension struct {
|
|||
|
||||
type OpenSubsonicExtensions []OpenSubsonicExtension
|
||||
|
||||
type TokenInfo struct {
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
}
|
||||
|
||||
type ItemGenre struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1015,6 +1015,20 @@ var _ = Describe("Responses", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("TokenInfo", func() {
|
||||
BeforeEach(func() {
|
||||
response.OpenSubsonic = true
|
||||
response.TokenInfo = &TokenInfo{Username: "deluan"}
|
||||
})
|
||||
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("InternetRadioStations", func() {
|
||||
BeforeEach(func() {
|
||||
response.InternetRadioStations = &InternetRadioStations{}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package subsonic
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
)
|
||||
|
||||
|
|
@ -15,3 +16,10 @@ func (api *Router) GetLicense(_ *http.Request) (*responses.Subsonic, error) {
|
|||
response.License = &responses.License{Valid: true}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) TokenInfo(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
response := newResponse()
|
||||
response.TokenInfo = &responses.TokenInfo{Username: user.UserName}
|
||||
return response, nil
|
||||
}
|
||||
|
|
|
|||
23
server/subsonic/system_test.go
Normal file
23
server/subsonic/system_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("TokenInfo", func() {
|
||||
It("returns the authenticated username", func() {
|
||||
api := &Router{}
|
||||
r := httptest.NewRequest("GET", "/tokenInfo", nil)
|
||||
r = r.WithContext(request.WithUser(r.Context(), model.User{UserName: "deluan"}))
|
||||
|
||||
resp, err := api.TokenInfo(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.TokenInfo.Username).To(Equal("deluan"))
|
||||
})
|
||||
})
|
||||
|
|
@ -228,7 +228,7 @@ func (db *MockDataStore) Player() model.PlayerRepository {
|
|||
if db.RealDS != nil {
|
||||
return db.RealDS.Player()
|
||||
}
|
||||
db.MockedPlayer = struct{ model.PlayerRepository }{}
|
||||
db.MockedPlayer = CreateMockPlayerRepo()
|
||||
return db.MockedPlayer
|
||||
}
|
||||
|
||||
|
|
|
|||
75
tests/mock_player_repo.go
Normal file
75
tests/mock_player_repo.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
func CreateMockPlayerRepo() *MockPlayerRepo {
|
||||
return &MockPlayerRepo{Data: map[string]*model.Player{}, APIKeys: map[string]string{}}
|
||||
}
|
||||
|
||||
// MockPlayerRepo keeps API keys in plaintext (key -> player ID); hashing belongs to the real repository.
|
||||
type MockPlayerRepo struct {
|
||||
model.PlayerRepository
|
||||
Error error
|
||||
Data map[string]*model.Player
|
||||
APIKeys map[string]string
|
||||
}
|
||||
|
||||
func (m *MockPlayerRepo) Get(_ context.Context, id string) (*model.Player, error) {
|
||||
if m.Error != nil {
|
||||
return nil, m.Error
|
||||
}
|
||||
p, ok := m.Data[id]
|
||||
if !ok {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
cp := *p
|
||||
cp.HasAPIKey = slices.Contains(slices.Collect(maps.Values(m.APIKeys)), id)
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *MockPlayerRepo) Put(_ context.Context, p *model.Player) error {
|
||||
if m.Error != nil {
|
||||
return m.Error
|
||||
}
|
||||
if p.ID == "" {
|
||||
p.ID = id.NewRandom()
|
||||
}
|
||||
cp := *p
|
||||
m.Data[p.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPlayerRepo) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) {
|
||||
if m.Error != nil {
|
||||
return nil, m.Error
|
||||
}
|
||||
if playerID, ok := m.APIKeys[key]; ok {
|
||||
return m.Get(ctx, playerID)
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockPlayerRepo) SetAPIKey(_ context.Context, playerID, key string) error {
|
||||
if m.Error != nil {
|
||||
return m.Error
|
||||
}
|
||||
if _, ok := m.Data[playerID]; !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
m.removeKeys(playerID)
|
||||
if key != "" {
|
||||
m.APIKeys[key] = playerID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPlayerRepo) removeKeys(playerID string) {
|
||||
maps.DeleteFunc(m.APIKeys, func(_ string, v string) bool { return v == playerID })
|
||||
}
|
||||
|
|
@ -141,7 +141,7 @@ const Admin = (props) => {
|
|||
<Resource
|
||||
name="player"
|
||||
{...player}
|
||||
options={{ subMenu: 'settings' }}
|
||||
options={{ subMenu: 'settings', label: 'resources.player.menuName' }}
|
||||
/>,
|
||||
permissions === 'admin' ? (
|
||||
<Resource
|
||||
|
|
|
|||
99
ui/src/common/ReadOnlyFields.jsx
Normal file
99
ui/src/common/ReadOnlyFields.jsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import get from 'lodash/get'
|
||||
import { FieldTitle, useRecordContext } from 'react-admin'
|
||||
import { TextField } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useDateLocale } from '../i18n/useDateLocale'
|
||||
import {
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
formatDuration2,
|
||||
formatNumber,
|
||||
} from '../utils/formatters'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
inputRoot: {
|
||||
'&:hover $notchedOutline': {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
},
|
||||
notchedOutline: {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
}),
|
||||
{ name: 'NDReadOnlyField' },
|
||||
)
|
||||
|
||||
const identity = (v) => v
|
||||
|
||||
// Renders a record value as a dimmed, non-editable input, so it lines up with the inputs in a form
|
||||
export const ReadOnlyTextField = ({
|
||||
source,
|
||||
label,
|
||||
resource,
|
||||
className,
|
||||
fullWidth,
|
||||
format = identity,
|
||||
...props
|
||||
}) => {
|
||||
const classes = useStyles(props)
|
||||
const record = useRecordContext(props)
|
||||
const value = get(record, source)
|
||||
|
||||
return (
|
||||
<TextField
|
||||
id={source}
|
||||
className={className}
|
||||
label={<FieldTitle label={label} source={source} resource={resource} />}
|
||||
value={value == null ? '' : format(value)}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth={fullWidth}
|
||||
focused={false}
|
||||
helperText=" "
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
classes: {
|
||||
root: classes.inputRoot,
|
||||
notchedOutline: classes.notchedOutline,
|
||||
},
|
||||
}}
|
||||
inputProps={{ tabIndex: -1 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
ReadOnlyTextField.propTypes = {
|
||||
source: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
record: PropTypes.object,
|
||||
resource: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
classes: PropTypes.object,
|
||||
fullWidth: PropTypes.bool,
|
||||
format: PropTypes.func,
|
||||
}
|
||||
|
||||
export const ReadOnlyDateField = (props) => {
|
||||
const locale = useDateLocale()
|
||||
const format = (v) => (isDateSet(v) ? formatDateTime(v, locale) : '')
|
||||
return <ReadOnlyTextField format={format} {...props} />
|
||||
}
|
||||
|
||||
export const ReadOnlyNumberField = (props) => {
|
||||
const locale = useDateLocale()
|
||||
return (
|
||||
<ReadOnlyTextField format={(v) => formatNumber(v, locale)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export const ReadOnlySizeField = (props) => (
|
||||
<ReadOnlyTextField format={formatBytes} {...props} />
|
||||
)
|
||||
|
||||
export const ReadOnlyDurationField = (props) => (
|
||||
<ReadOnlyTextField format={formatDuration2} {...props} />
|
||||
)
|
||||
121
ui/src/common/ReadOnlyFields.test.jsx
Normal file
121
ui/src/common/ReadOnlyFields.test.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
ReadOnlyDateField,
|
||||
ReadOnlyDurationField,
|
||||
ReadOnlyNumberField,
|
||||
ReadOnlySizeField,
|
||||
ReadOnlyTextField,
|
||||
} from './ReadOnlyFields'
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useLocale: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ReadOnlyFields', () => {
|
||||
const record = {
|
||||
id: '1',
|
||||
client: 'NavidromeUI',
|
||||
createdAt: '2026-09-17T14:30:00Z',
|
||||
lastVisitedAt: '0001-01-01T00:00:00Z',
|
||||
count: 1234567,
|
||||
zero: 0,
|
||||
size: 1536000,
|
||||
duration: 3725,
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(navigator, 'languages', 'get').mockReturnValue([])
|
||||
const { useLocale } = await import('react-admin')
|
||||
vi.mocked(useLocale).mockReturnValue('de')
|
||||
})
|
||||
|
||||
const renderField = (Field, props) =>
|
||||
render(<Field record={record} resource="player" {...props} />)
|
||||
|
||||
describe('<ReadOnlyTextField>', () => {
|
||||
it('shows the record value with the translated field label', () => {
|
||||
renderField(ReadOnlyTextField, { source: 'client' })
|
||||
const input = screen.getByLabelText('resources.player.fields.client')
|
||||
expect(input).toHaveValue('NavidromeUI')
|
||||
})
|
||||
|
||||
it('cannot be edited or reached with the Tab key', () => {
|
||||
renderField(ReadOnlyTextField, { source: 'client' })
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
expect(input).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
|
||||
it('shows an empty value when the record has no value', () => {
|
||||
renderField(ReadOnlyTextField, { source: 'userName' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('uses an explicit label when given', () => {
|
||||
renderField(ReadOnlyTextField, { source: 'client', label: 'Custom' })
|
||||
expect(screen.getByLabelText('Custom')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies a custom format', () => {
|
||||
renderField(ReadOnlyTextField, {
|
||||
source: 'client',
|
||||
format: (v) => v.toUpperCase(),
|
||||
})
|
||||
expect(screen.getByRole('textbox')).toHaveValue('NAVIDROMEUI')
|
||||
})
|
||||
|
||||
it('exposes theme-overridable class names', () => {
|
||||
const { container } = renderField(ReadOnlyTextField, {
|
||||
source: 'client',
|
||||
})
|
||||
expect(
|
||||
container.querySelector('[class*="NDReadOnlyField-inputRoot"]'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
container.querySelector('[class*="NDReadOnlyField-notchedOutline"]'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('<ReadOnlyDateField>', () => {
|
||||
it('formats the date using the selected language', () => {
|
||||
renderField(ReadOnlyDateField, { source: 'createdAt' })
|
||||
expect(screen.getByRole('textbox').value).toMatch(/^17\.9\.2026, /)
|
||||
})
|
||||
|
||||
it('shows an empty value when the date is not set', () => {
|
||||
renderField(ReadOnlyDateField, { source: 'lastVisitedAt' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('<ReadOnlyNumberField>', () => {
|
||||
it('formats the number using the selected language', () => {
|
||||
renderField(ReadOnlyNumberField, { source: 'count' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('1.234.567')
|
||||
})
|
||||
|
||||
it('shows zero', () => {
|
||||
renderField(ReadOnlyNumberField, { source: 'zero' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('<ReadOnlySizeField>', () => {
|
||||
it('formats bytes as a human-readable size', () => {
|
||||
renderField(ReadOnlySizeField, { source: 'size' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('1.46 MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('<ReadOnlyDurationField>', () => {
|
||||
it('formats seconds as a human-readable duration', () => {
|
||||
renderField(ReadOnlyDurationField, { source: 'duration' })
|
||||
expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -15,6 +15,7 @@ export * from './perPageStore'
|
|||
export * from './PlayButton'
|
||||
export * from './QuickFilter'
|
||||
export * from './RangeField'
|
||||
export * from './ReadOnlyFields'
|
||||
export * from './ShuffleAllButton'
|
||||
export * from './SimpleList'
|
||||
export * from './SizeField'
|
||||
|
|
|
|||
|
|
@ -148,6 +148,12 @@ const updateUser = async (params) => {
|
|||
return userResponse
|
||||
}
|
||||
|
||||
// ra-data-json-server merges the request body into the result; re-read so the plaintext key is never cached
|
||||
const createPlayer = async (resource, params) => {
|
||||
const { data } = await dataProvider.create(resource, params)
|
||||
return dataProvider.getOne(resource, { id: data.id })
|
||||
}
|
||||
|
||||
const wrapperDataProvider = {
|
||||
...dataProvider,
|
||||
getList: (resource, params) => {
|
||||
|
|
@ -194,6 +200,9 @@ const wrapperDataProvider = {
|
|||
return createUser(params)
|
||||
}
|
||||
const [r, p] = mapResource(resource, params)
|
||||
if (resource === 'player') {
|
||||
return createPlayer(r, p)
|
||||
}
|
||||
return dataProvider.create(r, p)
|
||||
},
|
||||
delete: (resource, params) => {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,21 @@ describe('wrapperDataProvider', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('create player', () => {
|
||||
it('returns the server record, never the plaintext API key', async () => {
|
||||
const data = { name: 'Phone', apiKey: 'nds_0123456789abcdefghijkl' }
|
||||
const saved = { id: 'p1', name: 'Phone', hasApiKey: true, userId: 'u1' }
|
||||
mockProvider.create.mockResolvedValue({ data: { ...data, id: 'p1' } })
|
||||
mockProvider.getOne.mockResolvedValue({ data: saved })
|
||||
|
||||
const result = await wrapperDataProvider.create('player', { data })
|
||||
|
||||
expect(mockProvider.create).toHaveBeenCalledWith('player', { data })
|
||||
expect(mockProvider.getOne).toHaveBeenCalledWith('player', { id: 'p1' })
|
||||
expect(result.data).toEqual(saved)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshMetadata', () => {
|
||||
it('posts to the album metadata refresh endpoint', () => {
|
||||
mockHttpClient.mockResolvedValue({ json: {} })
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@
|
|||
},
|
||||
"player": {
|
||||
"name": "Player |||| Players",
|
||||
"menuName": "Players & API keys",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"transcodingId": "Transcoding",
|
||||
|
|
@ -190,7 +191,29 @@
|
|||
"userName": "Username",
|
||||
"lastSeen": "Last Seen At",
|
||||
"reportRealPath": "Report Real Path",
|
||||
"scrobbleEnabled": "Send Scrobbles to external services"
|
||||
"scrobbleEnabled": "Send Scrobbles to external services",
|
||||
"hasApiKey": "API Key"
|
||||
},
|
||||
"actions": {
|
||||
"generateApiKey": "Generate API key",
|
||||
"regenerateApiKey": "Regenerate",
|
||||
"revokeApiKey": "Revoke",
|
||||
"copyApiKey": "Copy"
|
||||
},
|
||||
"message": {
|
||||
"apiKeyActive": "This player has an API key. Use it in your app as the API key, or as the password if the app does not use token authentication.",
|
||||
"apiKeyNone": "No API key. Generate one to connect an app to this player.",
|
||||
"apiKeyNoneOther": "No API key.",
|
||||
"apiKeyPending": "Copy this key now. It is saved when you click Save and will not be shown again.",
|
||||
"apiKeyRevokePending": "The API key will be removed when you save.",
|
||||
"deleteWithKeyTitle": "Delete player",
|
||||
"deleteWithKeyContent": "This player has an API key. Apps using it will stop working."
|
||||
},
|
||||
"notifications": {
|
||||
"apiKeyCopied": "API key copied to clipboard"
|
||||
},
|
||||
"validation": {
|
||||
"apiKeyFormat": "Invalid API key format"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
|
|
|
|||
|
|
@ -102,9 +102,11 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
|||
}
|
||||
|
||||
const renderSettingsMenuItemLink = (resource, id) => {
|
||||
const label = translate(`resources.${resource.name}.name`, {
|
||||
smart_count: id ? 1 : 2,
|
||||
})
|
||||
const label = resource.options.label
|
||||
? translate(resource.options.label)
|
||||
: translate(`resources.${resource.name}.name`, {
|
||||
smart_count: id ? 1 : 2,
|
||||
})
|
||||
const link = id ? `/${resource.name}/${id}` : `/${resource.name}`
|
||||
return (
|
||||
<MenuItemLink
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import config from '../config'
|
|||
|
||||
let store
|
||||
|
||||
const mocks = vi.hoisted(() => ({ resources: [] }))
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>,
|
||||
MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>,
|
||||
useTranslate: () => (x) => x,
|
||||
usePermissions: () => ({ permissions: 'admin' }),
|
||||
getResources: () => [],
|
||||
getResources: () => mocks.resources,
|
||||
}))
|
||||
|
||||
vi.mock('./NowPlayingPanel', () => ({
|
||||
|
|
@ -41,6 +44,7 @@ describe('<AppBar />', () => {
|
|||
config.devActivityPanel = true
|
||||
config.enableNowPlaying = true
|
||||
config.enableQuickConnect = false
|
||||
mocks.resources = []
|
||||
store = createStore(combineReducers({ activity: activityReducer }), {
|
||||
activity: { nowPlayingCount: 0 },
|
||||
})
|
||||
|
|
@ -84,4 +88,22 @@ describe('<AppBar />', () => {
|
|||
expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0)
|
||||
expect(screen.queryAllByText('menu.about')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the resource label for settings items when set', () => {
|
||||
mocks.resources = [
|
||||
{
|
||||
name: 'player',
|
||||
hasList: true,
|
||||
options: { subMenu: 'settings', label: 'resources.player.menuName' },
|
||||
},
|
||||
{ name: 'transcoding', hasList: true, options: { subMenu: 'settings' } },
|
||||
]
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<AppBar />
|
||||
</Provider>,
|
||||
)
|
||||
expect(screen.getByText('resources.player.menuName')).toBeInTheDocument()
|
||||
expect(screen.getByText('resources.transcoding.name')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
BooleanInput,
|
||||
required,
|
||||
SaveButton,
|
||||
DateField,
|
||||
useTranslate,
|
||||
useMutation,
|
||||
useNotify,
|
||||
|
|
@ -16,8 +15,13 @@ import {
|
|||
import { Typography, Box } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import DeleteLibraryButton from './DeleteLibraryButton'
|
||||
import { Title } from '../common'
|
||||
import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js'
|
||||
import {
|
||||
ReadOnlyDateField,
|
||||
ReadOnlyDurationField,
|
||||
ReadOnlyNumberField,
|
||||
ReadOnlySizeField,
|
||||
Title,
|
||||
} from '../common'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
toolbar: {
|
||||
|
|
@ -26,6 +30,8 @@ const useStyles = makeStyles({
|
|||
},
|
||||
})
|
||||
|
||||
const readOnlyProps = { resource: 'library', fullWidth: true }
|
||||
|
||||
const LibraryTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
const resourceName = translate('resources.library.name', { smart_count: 1 })
|
||||
|
|
@ -125,132 +131,40 @@ const LibraryEdit = (props) => {
|
|||
{translate('resources.library.sections.statistics')}
|
||||
</Typography>
|
||||
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="0.5em">
|
||||
<TextInput
|
||||
InputProps={{ readOnly: true }}
|
||||
resource={'library'}
|
||||
source={'totalSongs'}
|
||||
label={translate('resources.library.fields.totalSongs')}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} ml="0.5em">
|
||||
<TextInput
|
||||
InputProps={{ readOnly: true }}
|
||||
resource={'library'}
|
||||
source={'totalAlbums'}
|
||||
label={translate(
|
||||
'resources.library.fields.totalAlbums',
|
||||
)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="0.5em">
|
||||
<TextInput
|
||||
InputProps={{ readOnly: true }}
|
||||
resource={'library'}
|
||||
source={'totalArtists'}
|
||||
label={translate(
|
||||
'resources.library.fields.totalArtists',
|
||||
)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} ml="0.5em">
|
||||
<TextInput
|
||||
InputProps={{ readOnly: true }}
|
||||
resource={'library'}
|
||||
source={'totalSize'}
|
||||
label={translate('resources.library.fields.totalSize')}
|
||||
format={(v) => formatBytes(v, 2)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex">
|
||||
<Box flex={1} mr="0.5em">
|
||||
<TextInput
|
||||
InputProps={{ readOnly: true }}
|
||||
resource={'library'}
|
||||
source={'totalDuration'}
|
||||
label={translate(
|
||||
'resources.library.fields.totalDuration',
|
||||
)}
|
||||
format={formatDuration2}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} ml="0.5em">
|
||||
<TextInput
|
||||
InputProps={{ readOnly: true }}
|
||||
resource={'library'}
|
||||
source={'totalMissingFiles'}
|
||||
label={translate(
|
||||
'resources.library.fields.totalMissingFiles',
|
||||
)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Timestamps Section */}
|
||||
<Box mb="1em">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
{translate('resources.library.fields.lastScanAt')}
|
||||
</Typography>
|
||||
<DateField
|
||||
variant="body1"
|
||||
source="lastScanAt"
|
||||
showTime
|
||||
record={formProps.record}
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="1fr 1fr"
|
||||
gridColumnGap="1em"
|
||||
>
|
||||
<ReadOnlyNumberField
|
||||
source="totalSongs"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mb="1em">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
{translate('resources.library.fields.updatedAt')}
|
||||
</Typography>
|
||||
<DateField
|
||||
variant="body1"
|
||||
source="updatedAt"
|
||||
showTime
|
||||
record={formProps.record}
|
||||
<ReadOnlyNumberField
|
||||
source="totalAlbums"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mb="2em">
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
gutterBottom
|
||||
>
|
||||
{translate('resources.library.fields.createdAt')}
|
||||
</Typography>
|
||||
<DateField
|
||||
variant="body1"
|
||||
source="createdAt"
|
||||
showTime
|
||||
record={formProps.record}
|
||||
<ReadOnlyNumberField
|
||||
source="totalArtists"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
<ReadOnlySizeField source="totalSize" {...readOnlyProps} />
|
||||
<ReadOnlyDurationField
|
||||
source="totalDuration"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
<ReadOnlyNumberField
|
||||
source="totalMissingFiles"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
<Box gridColumn="1 / -1">
|
||||
<ReadOnlyDateField
|
||||
source="lastScanAt"
|
||||
{...readOnlyProps}
|
||||
/>
|
||||
</Box>
|
||||
<ReadOnlyDateField source="updatedAt" {...readOnlyProps} />
|
||||
<ReadOnlyDateField source="createdAt" {...readOnlyProps} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
107
ui/src/player/ApiKeyInput.jsx
Normal file
107
ui/src/player/ApiKeyInput.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useInput, useNotify, useTranslate } from 'react-admin'
|
||||
import { Button, TextField } from '@material-ui/core'
|
||||
import { FaKey } from 'react-icons/fa'
|
||||
import { MdContentCopy, MdDelete, MdRefresh } from 'react-icons/md'
|
||||
import { isWritable } from '../common/playlistUtils'
|
||||
import { generateApiKey } from './apiKey'
|
||||
|
||||
const identity = (v) => v
|
||||
const MASK = '•'.repeat(26)
|
||||
|
||||
const ApiKeyInput = ({ record, isCreate, fullWidth, className, ...props }) => {
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
// Identity format/parse keep "" (revoke) distinct from undefined (untouched)
|
||||
const {
|
||||
input: { value, onChange },
|
||||
meta: { error, touched },
|
||||
} = useInput({ ...props, format: identity, parse: identity })
|
||||
|
||||
const isOwner = isCreate || record?.userId === localStorage.getItem('userId')
|
||||
const pending = !!value
|
||||
const revoking = value === '' && !!record?.hasApiKey
|
||||
const saved = value == null && !!record?.hasApiKey
|
||||
const hasKey = pending || saved
|
||||
|
||||
const copy = () => {
|
||||
const fallback = () =>
|
||||
prompt(translate('message.shareCopyToClipboard'), value)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(
|
||||
() => notify('resources.player.notifications.apiKeyCopied'),
|
||||
fallback,
|
||||
)
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
|
||||
const helperText = pending
|
||||
? 'resources.player.message.apiKeyPending'
|
||||
: revoking
|
||||
? 'resources.player.message.apiKeyRevokePending'
|
||||
: saved
|
||||
? 'resources.player.message.apiKeyActive'
|
||||
: isOwner
|
||||
? 'resources.player.message.apiKeyNone'
|
||||
: 'resources.player.message.apiKeyNoneOther'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TextField
|
||||
label={translate('resources.player.fields.hasApiKey')}
|
||||
value={pending ? value : saved ? MASK : ''}
|
||||
variant="outlined"
|
||||
margin="dense"
|
||||
fullWidth={fullWidth}
|
||||
className={className}
|
||||
// Monospace keeps the whole key visible in a standard-width input
|
||||
inputProps={{
|
||||
style: { fontFamily: 'monospace', fontSize: '0.875rem' },
|
||||
}}
|
||||
InputProps={{ readOnly: true }}
|
||||
error={!!(touched && error)}
|
||||
helperText={translate(touched && error ? error : helperText)}
|
||||
/>
|
||||
<div>
|
||||
{pending && (
|
||||
<Button startIcon={<MdContentCopy />} onClick={copy}>
|
||||
{translate('resources.player.actions.copyApiKey')}
|
||||
</Button>
|
||||
)}
|
||||
{isOwner && (
|
||||
<Button
|
||||
startIcon={hasKey ? <MdRefresh /> : <FaKey />}
|
||||
onClick={() => onChange(generateApiKey())}
|
||||
>
|
||||
{translate(
|
||||
hasKey
|
||||
? 'resources.player.actions.regenerateApiKey'
|
||||
: 'resources.player.actions.generateApiKey',
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{saved && isWritable(record?.userId) && (
|
||||
<Button startIcon={<MdDelete />} onClick={() => onChange('')}>
|
||||
{translate('resources.player.actions.revokeApiKey')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ApiKeyInput.propTypes = {
|
||||
source: PropTypes.string.isRequired,
|
||||
record: PropTypes.object,
|
||||
isCreate: PropTypes.bool,
|
||||
fullWidth: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
validate: PropTypes.oneOfType([PropTypes.func, PropTypes.array]),
|
||||
}
|
||||
|
||||
export default ApiKeyInput
|
||||
172
ui/src/player/ApiKeyInput.test.jsx
Normal file
172
ui/src/player/ApiKeyInput.test.jsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import * as React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Form } from 'react-final-form'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
|
||||
const hooks = vi.hoisted(() => ({ notify: vi.fn() }))
|
||||
const KEY = 'nds_0123456789abcdefghijkl'
|
||||
const KEY_FORMAT = /^nds_[0-9A-Za-z]{22}$/
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
useNotify: () => hooks.notify,
|
||||
useTranslate: () => (key) => key,
|
||||
}
|
||||
})
|
||||
|
||||
const renderInput = ({
|
||||
record,
|
||||
initialValues = {},
|
||||
isCreate = false,
|
||||
fullWidth,
|
||||
}) => {
|
||||
let values
|
||||
const utils = render(
|
||||
<Form
|
||||
onSubmit={() => {}}
|
||||
initialValues={initialValues}
|
||||
render={({ values: v }) => {
|
||||
values = v
|
||||
return (
|
||||
<ApiKeyInput
|
||||
source="apiKey"
|
||||
record={record}
|
||||
isCreate={isCreate}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
return { ...utils, values: () => values }
|
||||
}
|
||||
|
||||
const text = (key) => screen.queryByText(key)
|
||||
|
||||
describe('ApiKeyInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.setItem('userId', 'owner')
|
||||
localStorage.setItem('role', 'regular')
|
||||
})
|
||||
|
||||
it('shows a pending key with copy and regenerate on create', () => {
|
||||
const { values } = renderInput({
|
||||
record: {},
|
||||
isCreate: true,
|
||||
initialValues: { apiKey: KEY },
|
||||
})
|
||||
expect(screen.getByDisplayValue(KEY)).toBeInTheDocument()
|
||||
expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'resources.player.actions.copyApiKey',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
text('resources.player.actions.revokeApiKey'),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByText('resources.player.actions.regenerateApiKey'),
|
||||
)
|
||||
expect(values().apiKey).toMatch(KEY_FORMAT)
|
||||
expect(values().apiKey).not.toBe(KEY)
|
||||
})
|
||||
|
||||
it('masks a saved key and lets the owner regenerate or revoke', () => {
|
||||
const { values } = renderInput({
|
||||
record: { id: 'p1', userId: 'owner', hasApiKey: true },
|
||||
})
|
||||
expect(screen.queryByDisplayValue(/^nds_/)).not.toBeInTheDocument()
|
||||
expect(text('resources.player.message.apiKeyActive')).toBeInTheDocument()
|
||||
expect(
|
||||
text('resources.player.actions.regenerateApiKey'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('resources.player.actions.revokeApiKey'))
|
||||
expect(values().apiKey).toBe('')
|
||||
expect(
|
||||
text('resources.player.message.apiKeyRevokePending'),
|
||||
).toBeInTheDocument()
|
||||
expect(text('resources.player.actions.generateApiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the owner generate a key when there is none', () => {
|
||||
const { values } = renderInput({
|
||||
record: { id: 'p1', userId: 'owner', hasApiKey: false },
|
||||
})
|
||||
expect(values().apiKey).toBeUndefined()
|
||||
expect(text('resources.player.message.apiKeyNone')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('resources.player.actions.generateApiKey'))
|
||||
expect(values().apiKey).toMatch(KEY_FORMAT)
|
||||
expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets an admin revoke but not set a key on another user player', () => {
|
||||
localStorage.setItem('role', 'admin')
|
||||
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } })
|
||||
expect(
|
||||
text('resources.player.actions.regenerateApiKey'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(text('resources.player.actions.revokeApiKey')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to a prompt when the clipboard write fails', async () => {
|
||||
vi.stubGlobal('isSecureContext', true)
|
||||
vi.stubGlobal('prompt', vi.fn())
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
try {
|
||||
renderInput({
|
||||
record: {},
|
||||
isCreate: true,
|
||||
initialValues: { apiKey: KEY },
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'resources.player.actions.copyApiKey',
|
||||
}),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(window.prompt).toHaveBeenCalledWith(
|
||||
'message.shareCopyToClipboard',
|
||||
KEY,
|
||||
),
|
||||
)
|
||||
expect(hooks.notify).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
delete navigator.clipboard
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a neutral message to an admin viewing another user player with no key', () => {
|
||||
localStorage.setItem('role', 'admin')
|
||||
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: false } })
|
||||
expect(text('resources.player.message.apiKeyNoneOther')).toBeInTheDocument()
|
||||
expect(text('resources.player.message.apiKeyNone')).not.toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows no actions to another regular user', () => {
|
||||
renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } })
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is not full width unless asked', () => {
|
||||
const record = { id: 'p1', userId: 'owner', hasApiKey: true }
|
||||
const { container, unmount } = renderInput({ record })
|
||||
expect(container.querySelector('.MuiFormControl-fullWidth')).toBeNull()
|
||||
unmount()
|
||||
|
||||
const { container: wide } = renderInput({ record, fullWidth: true })
|
||||
expect(wide.querySelector('.MuiFormControl-fullWidth')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
33
ui/src/player/PlayerCreate.jsx
Normal file
33
ui/src/player/PlayerCreate.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import { Create, SimpleForm, required, useTranslate } from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import { playerInputs } from './playerInputs'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
import { generateApiKey } from './apiKey'
|
||||
|
||||
const PlayerCreateTitle = () => {
|
||||
const translate = useTranslate()
|
||||
const resourceName = translate('resources.player.name', { smart_count: 1 })
|
||||
return (
|
||||
<Title subTitle={translate('ra.page.create', { name: resourceName })} />
|
||||
)
|
||||
}
|
||||
|
||||
const PlayerCreate = (props) => {
|
||||
// Memoized so re-renders don't swap the key the user may have already copied
|
||||
const initialValues = useMemo(() => ({ apiKey: generateApiKey() }), [])
|
||||
return (
|
||||
<Create title={<PlayerCreateTitle />} {...props}>
|
||||
<SimpleForm
|
||||
variant="outlined"
|
||||
redirect="list"
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{playerInputs()}
|
||||
<ApiKeyInput source="apiKey" isCreate validate={required()} />
|
||||
</SimpleForm>
|
||||
</Create>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayerCreate
|
||||
47
ui/src/player/PlayerCreate.test.jsx
Normal file
47
ui/src/player/PlayerCreate.test.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import * as React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import PlayerCreate from './PlayerCreate'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
|
||||
const hooks = vi.hoisted(() => ({ forms: [] }))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
Create: ({ children }) => children,
|
||||
SimpleForm: (props) => {
|
||||
hooks.forms.push(props)
|
||||
return null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlayerCreate', () => {
|
||||
beforeEach(() => {
|
||||
hooks.forms = []
|
||||
})
|
||||
|
||||
it('pre-fills one generated API key that survives re-renders', () => {
|
||||
const { rerender } = render(<PlayerCreate resource="player" />)
|
||||
rerender(<PlayerCreate resource="player" />)
|
||||
|
||||
const [first, second] = hooks.forms.map((f) => f.initialValues)
|
||||
expect(hooks.forms).toHaveLength(2)
|
||||
expect(first.apiKey).toMatch(/^nds_[0-9A-Za-z]{22}$/)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('requires the API key', () => {
|
||||
render(<PlayerCreate resource="player" />)
|
||||
|
||||
const input = React.Children.toArray(hooks.forms[0].children).find(
|
||||
(child) => child.type === ApiKeyInput,
|
||||
)
|
||||
expect(input.props.source).toBe('apiKey')
|
||||
expect(input.props.isCreate).toBe(true)
|
||||
expect(input.props.validate('')).toBeTruthy()
|
||||
expect(input.props.validate('nds_0123456789abcdefghijkl')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import {
|
||||
TextInput,
|
||||
BooleanInput,
|
||||
TextField,
|
||||
Edit,
|
||||
required,
|
||||
SimpleForm,
|
||||
SelectInput,
|
||||
ReferenceInput,
|
||||
useTranslate,
|
||||
DeleteButton,
|
||||
DeleteWithConfirmButton,
|
||||
SaveButton,
|
||||
Toolbar,
|
||||
} from 'react-admin'
|
||||
import { Title } from '../common'
|
||||
import config from '../config'
|
||||
import { BITRATE_CHOICES } from '../consts'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { ReadOnlyTextField, Title } from '../common'
|
||||
import ApiKeyInput from './ApiKeyInput'
|
||||
import { playerInputs } from './playerInputs'
|
||||
|
||||
const PlayerTitle = ({ record }) => {
|
||||
const translate = useTranslate()
|
||||
|
|
@ -19,24 +18,35 @@ const PlayerTitle = ({ record }) => {
|
|||
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
|
||||
}
|
||||
|
||||
const useToolbarStyles = makeStyles({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
})
|
||||
|
||||
const PlayerEditToolbar = (props) => (
|
||||
<Toolbar {...props} classes={useToolbarStyles()}>
|
||||
<SaveButton />
|
||||
{props.record?.hasApiKey ? (
|
||||
<DeleteWithConfirmButton
|
||||
mutationMode="pessimistic"
|
||||
confirmTitle="resources.player.message.deleteWithKeyTitle"
|
||||
confirmContent="resources.player.message.deleteWithKeyContent"
|
||||
/>
|
||||
) : (
|
||||
<DeleteButton />
|
||||
)}
|
||||
</Toolbar>
|
||||
)
|
||||
|
||||
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 />
|
||||
)}
|
||||
<TextField source="client" />
|
||||
<TextField source="userName" />
|
||||
<Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}>
|
||||
<SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}>
|
||||
{playerInputs()}
|
||||
<ReadOnlyTextField source="client" />
|
||||
<ReadOnlyTextField source="userName" />
|
||||
<ApiKeyInput source="apiKey" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
)
|
||||
|
|
|
|||
25
ui/src/player/PlayerEdit.test.jsx
Normal file
25
ui/src/player/PlayerEdit.test.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import PlayerEdit from './PlayerEdit'
|
||||
|
||||
const hooks = vi.hoisted(() => ({ editProps: null }))
|
||||
|
||||
vi.mock('react-admin', async () => {
|
||||
const actual = await vi.importActual('react-admin')
|
||||
return {
|
||||
...actual,
|
||||
Edit: (props) => {
|
||||
hooks.editProps = props
|
||||
return null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PlayerEdit', () => {
|
||||
// An optimistic or undoable save would put the new key in react-admin's cache
|
||||
it('saves pessimistically', () => {
|
||||
render(<PlayerEdit resource="player" id="p1" />)
|
||||
expect(hooks.editProps.mutationMode).toBe('pessimistic')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,18 +2,20 @@ import React from 'react'
|
|||
import {
|
||||
Datagrid,
|
||||
TextField,
|
||||
DateField,
|
||||
FunctionField,
|
||||
ReferenceField,
|
||||
Filter,
|
||||
SearchInput,
|
||||
NullableBooleanInput,
|
||||
} from 'react-admin'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { SimpleList, List } from '../common'
|
||||
import { FaKey } from 'react-icons/fa'
|
||||
import { SimpleList, List, DateField } from '../common'
|
||||
|
||||
const PlayerFilter = (props) => (
|
||||
<Filter {...props} variant={'outlined'}>
|
||||
<SearchInput id="search" source="name" alwaysOn />
|
||||
<NullableBooleanInput source="hasApiKey" alwaysOn />
|
||||
</Filter>
|
||||
)
|
||||
|
||||
|
|
@ -30,7 +32,11 @@ const PlayerList = ({ permissions, ...props }) => {
|
|||
<SimpleList
|
||||
primaryText={(r) => r.name}
|
||||
secondaryText={(r) => r.userName}
|
||||
tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : '-')}
|
||||
tertiaryText={(r) => (
|
||||
<>
|
||||
{r.hasApiKey && <FaKey />} {r.maxBitRate ? r.maxBitRate : '-'}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Datagrid rowClick="edit">
|
||||
|
|
@ -43,6 +49,11 @@ const PlayerList = ({ permissions, ...props }) => {
|
|||
source="maxBitRate"
|
||||
render={(r) => (r.maxBitRate ? r.maxBitRate : '-')}
|
||||
/>
|
||||
<FunctionField
|
||||
source="hasApiKey"
|
||||
sortable={false}
|
||||
render={(r) => (r.hasApiKey ? <FaKey /> : null)}
|
||||
/>
|
||||
<DateField source="lastSeen" showTime sortByOrder={'DESC'} />
|
||||
</Datagrid>
|
||||
)}
|
||||
|
|
|
|||
15
ui/src/player/apiKey.js
Normal file
15
ui/src/player/apiKey.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
const API_KEY_PREFIX = 'nds_'
|
||||
const ALPHABET =
|
||||
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
const KEY_LENGTH = 22
|
||||
|
||||
// Bytes >= 248 are dropped so every character is equally likely (248 = 4 * 62).
|
||||
export const generateApiKey = () => {
|
||||
let key = ''
|
||||
while (key.length < KEY_LENGTH) {
|
||||
for (const b of window.crypto.getRandomValues(new Uint8Array(32))) {
|
||||
if (b < 248 && key.length < KEY_LENGTH) key += ALPHABET[b % 62]
|
||||
}
|
||||
}
|
||||
return API_KEY_PREFIX + key
|
||||
}
|
||||
15
ui/src/player/apiKey.test.js
Normal file
15
ui/src/player/apiKey.test.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { generateApiKey } from './apiKey'
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
it('returns the prefix plus 22 base62 characters', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(generateApiKey()).toMatch(/^nds_[0-9A-Za-z]{22}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns a different key each time', () => {
|
||||
const keys = new Set(Array.from({ length: 100 }, generateApiKey))
|
||||
expect(keys.size).toBe(100)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { BsFillMusicPlayerFill } from 'react-icons/bs'
|
||||
import PlayerList from './PlayerList'
|
||||
import PlayerEdit from './PlayerEdit'
|
||||
import PlayerCreate from './PlayerCreate'
|
||||
|
||||
export default {
|
||||
list: PlayerList,
|
||||
edit: PlayerEdit,
|
||||
create: PlayerCreate,
|
||||
icon: BsFillMusicPlayerFill,
|
||||
}
|
||||
|
|
|
|||
34
ui/src/player/playerInputs.jsx
Normal file
34
ui/src/player/playerInputs.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
BooleanInput,
|
||||
ReferenceInput,
|
||||
SelectInput,
|
||||
TextInput,
|
||||
required,
|
||||
} from 'react-admin'
|
||||
import config from '../config'
|
||||
import { BITRATE_CHOICES } from '../consts'
|
||||
|
||||
// Returned as an array, not a component, so SimpleForm still injects its props into each input.
|
||||
export const playerInputs = () =>
|
||||
[
|
||||
<TextInput key="name" source="name" validate={[required()]} />,
|
||||
<ReferenceInput
|
||||
key="transcodingId"
|
||||
source="transcodingId"
|
||||
reference="transcoding"
|
||||
sort={{ field: 'name', order: 'ASC' }}
|
||||
>
|
||||
<SelectInput source="name" resettable />
|
||||
</ReferenceInput>,
|
||||
<SelectInput
|
||||
key="maxBitRate"
|
||||
source="maxBitRate"
|
||||
resettable
|
||||
choices={BITRATE_CHOICES}
|
||||
/>,
|
||||
<BooleanInput key="reportRealPath" source="reportRealPath" fullWidth />,
|
||||
(config.lastFMEnabled || config.listenBrainzEnabled) && (
|
||||
<BooleanInput key="scrobbleEnabled" source="scrobbleEnabled" fullWidth />
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
|
@ -3,7 +3,6 @@ import {
|
|||
FormDataConsumer,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
TextField,
|
||||
BooleanInput,
|
||||
required,
|
||||
useTranslate,
|
||||
|
|
@ -11,13 +10,14 @@ import {
|
|||
ReferenceInput,
|
||||
SelectInput,
|
||||
} from 'react-admin'
|
||||
import { isWritable, Title } from '../common'
|
||||
import { isWritable, ReadOnlyTextField, Title } from '../common'
|
||||
|
||||
const SyncFragment = ({ formData, variant, ...rest }) => {
|
||||
if (!formData.path) return null
|
||||
return (
|
||||
<>
|
||||
{formData.path && <BooleanInput source="sync" {...rest} />}
|
||||
{formData.path && <TextField source="path" {...rest} />}
|
||||
<BooleanInput source="sync" {...rest} />
|
||||
<ReadOnlyTextField source="path" {...rest} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -56,10 +56,10 @@ const PlaylistEditForm = (props) => {
|
|||
/>
|
||||
</ReferenceInput>
|
||||
) : (
|
||||
<TextField source="ownerName" />
|
||||
<ReadOnlyTextField source="ownerName" />
|
||||
)}
|
||||
<BooleanInput source="public" disabled={!isWritable(record.ownerId)} />
|
||||
<FormDataConsumer>
|
||||
<FormDataConsumer fullWidth>
|
||||
{(formDataProps) => <SyncFragment {...formDataProps} />}
|
||||
</FormDataConsumer>
|
||||
</SimpleForm>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
DateField,
|
||||
Edit,
|
||||
required,
|
||||
SimpleForm,
|
||||
|
|
@ -9,7 +8,12 @@ import {
|
|||
import { CardMedia } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { urlValidate } from '../utils/validations'
|
||||
import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
|
||||
import {
|
||||
Title,
|
||||
ImageUploadOverlay,
|
||||
ReadOnlyDateField,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import config from '../config'
|
||||
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
|
||||
|
|
@ -65,8 +69,8 @@ const RadioEdit = (props) => {
|
|||
fullWidth
|
||||
validate={[urlValidate]}
|
||||
/>
|
||||
<DateField variant="body1" source="updatedAt" showTime />
|
||||
<DateField variant="body1" source="createdAt" showTime />
|
||||
<ReadOnlyDateField source="updatedAt" />
|
||||
<ReadOnlyDateField source="createdAt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import {
|
|||
DateTimeInput,
|
||||
BooleanInput,
|
||||
Edit,
|
||||
NumberField,
|
||||
SimpleForm,
|
||||
TextInput,
|
||||
} from 'react-admin'
|
||||
import { sharePlayerUrl } from '../utils'
|
||||
import { Link } from '@material-ui/core'
|
||||
import { DateField } from '../common'
|
||||
import {
|
||||
ReadOnlyDateField,
|
||||
ReadOnlyNumberField,
|
||||
ReadOnlyTextField,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
|
||||
export const ShareEdit = (props) => {
|
||||
|
|
@ -16,20 +19,26 @@ export const ShareEdit = (props) => {
|
|||
const url = sharePlayerUrl(id)
|
||||
return (
|
||||
<Edit {...props}>
|
||||
<SimpleForm {...rest}>
|
||||
<Link source="URL" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<SimpleForm variant={'outlined'} {...rest}>
|
||||
<Link
|
||||
source="URL"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="inherit"
|
||||
>
|
||||
{url}
|
||||
</Link>
|
||||
<TextInput source="description" />
|
||||
{config.enableDownloads && <BooleanInput source="downloadable" />}
|
||||
<DateTimeInput source="expiresAt" />
|
||||
<TextInput source="contents" disabled />
|
||||
<TextInput source="format" disabled />
|
||||
<TextInput source="maxBitRate" disabled />
|
||||
<TextInput source="username" disabled />
|
||||
<NumberField source="visitCount" disabled />
|
||||
<DateField source="lastVisitedAt" disabled showTime />
|
||||
<DateField source="createdAt" disabled showTime />
|
||||
<ReadOnlyTextField source="contents" />
|
||||
<ReadOnlyTextField source="format" />
|
||||
<ReadOnlyTextField source="maxBitRate" />
|
||||
<ReadOnlyTextField source="username" />
|
||||
<ReadOnlyNumberField source="visitCount" />
|
||||
<ReadOnlyDateField source="lastVisitedAt" />
|
||||
<ReadOnlyDateField source="createdAt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { makeStyles } from '@material-ui/core/styles'
|
|||
import {
|
||||
TextInput,
|
||||
BooleanInput,
|
||||
DateField,
|
||||
PasswordInput,
|
||||
Edit,
|
||||
required,
|
||||
|
|
@ -21,7 +20,7 @@ import {
|
|||
useRecordContext,
|
||||
} from 'react-admin'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { Title } from '../common'
|
||||
import { ReadOnlyDateField, Title } from '../common'
|
||||
import DeleteUserButton from './DeleteUserButton'
|
||||
import { LibrarySelectionField } from './LibrarySelectionField.jsx'
|
||||
import { validateUserForm } from './userValidation'
|
||||
|
|
@ -183,10 +182,10 @@ const UserEdit = (props) => {
|
|||
helperText={translate('resources.user.helperTexts.scrobbleFilter')}
|
||||
/>
|
||||
|
||||
<DateField variant="body1" source="lastLoginAt" showTime />
|
||||
<DateField variant="body1" source="lastAccessAt" showTime />
|
||||
<DateField variant="body1" source="updatedAt" showTime />
|
||||
<DateField variant="body1" source="createdAt" showTime />
|
||||
<ReadOnlyDateField source="lastLoginAt" />
|
||||
<ReadOnlyDateField source="lastAccessAt" />
|
||||
<ReadOnlyDateField source="updatedAt" />
|
||||
<ReadOnlyDateField source="createdAt" />
|
||||
</SimpleForm>
|
||||
</Edit>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -51,9 +51,6 @@ vi.mock('react-admin', () => ({
|
|||
BooleanInput: ({ source }) => (
|
||||
<input type="checkbox" data-testid={`boolean-input-${source}`} />
|
||||
),
|
||||
DateField: ({ source }) => (
|
||||
<div data-testid={`date-field-${source}`}>Date</div>
|
||||
),
|
||||
PasswordInput: ({ source }) => (
|
||||
<input type="password" data-testid={`password-input-${source}`} />
|
||||
),
|
||||
|
|
@ -82,6 +79,9 @@ vi.mock('./DeleteUserButton', () => ({
|
|||
|
||||
vi.mock('../common', () => ({
|
||||
Title: ({ subTitle }) => <div data-testid="title">{subTitle}</div>,
|
||||
ReadOnlyDateField: ({ source }) => (
|
||||
<div data-testid={`date-field-${source}`}>Date</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Material-UI
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue