Merge branch 'master' into t3code/fix-windows-unc-rel-hang

This commit is contained in:
Deluan Quintão 2026-09-28 10:16:11 -04:00 • committed by GitHub
commit d90ee1819e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1930 additions and 261 deletions

View file

@ -49,6 +49,7 @@ const (
DefaultEncryptionKey = "just for obfuscation"
PasswordsEncryptedKey = "PasswordsEncryptedKey"
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
APIKeyPrefix = "nds_"
DevInitialUserName = "admin"
DevInitialName = "Dev Admin"

View file

@ -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
}

View file

@ -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 {

View 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;

View file

@ -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
}

View file

@ -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)

View file

@ -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)

View file

@ -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) {

View file

@ -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

View file

@ -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
}

View file

@ -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() {

View file

@ -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": {

View file

@ -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))

View 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)))
})
})

View file

@ -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,

View file

@ -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) {

View file

@ -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{

View file

@ -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}}),
))
})
})

View file

@ -0,0 +1,10 @@
{
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "v0.55.0",
"openSubsonic": true,
"tokenInfo": {
"username": "deluan"
}
}

View file

@ -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>

View file

@ -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 {

View file

@ -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"`
}

View file

@ -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{}

View file

@ -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
}

View 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"))
})
})

View file

@ -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
View 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 })
}

View file

@ -141,7 +141,7 @@ const Admin = (props) => {
<Resource
name="player"
{...player}
options={{ subMenu: 'settings' }}
options={{ subMenu: 'settings', label: 'resources.player.menuName' }}
/>,
permissions === 'admin' ? (
<Resource

View 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} />
)

View 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')
})
})
})

View file

@ -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'

View file

@ -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) => {

View file

@ -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: {} })

View file

@ -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": {

View file

@ -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

View file

@ -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()
})
})

View file

@ -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>

View 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

View 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()
})
})

View 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

View 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()
})
})

View file

@ -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>
)

View 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')
})
})

View file

@ -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
View 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
}

View 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)
})
})

View file

@ -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,
}

View 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)

View file

@ -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>

View file

@ -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>
)

View file

@ -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>
)

View file

@ -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>
)

View file

@ -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