Merge aecf959d98e968ad5c3c036b72c8a6d0241b4bd3 into 4f7dc105b0414cd202fc7e560d51b8abc27ad7ef

This commit is contained in:
Kartik Ohri 2025-11-06 18:19:15 -05:00 committed by GitHub
commit 63c125db99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 974 additions and 98 deletions

View File

@ -0,0 +1,34 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddAPIKeyToPlayer, downDropAPIKeyFromPlayer)
}
func upAddAPIKeyToPlayer(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
-- Add nullable api_key column to player table
ALTER TABLE player ADD COLUMN api_key VARCHAR(255);
-- Add index on api_key for faster lookups
CREATE INDEX IF NOT EXISTS player_api_key ON player(api_key);
`)
return err
}
func downDropAPIKeyFromPlayer(_ context.Context, tx *sql.Tx) error {
_, err := tx.Exec(`
-- Drop the index first
DROP INDEX IF EXISTS player_api_key;
-- Then drop the column
ALTER TABLE player DROP COLUMN api_key;
`)
return err
}

View File

@ -18,6 +18,7 @@ type Player struct {
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"`
ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"`
ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"`
APIKey string `structs:"api_key" json:"apiKey" db:"api_key"`
}
type Players []Player
@ -28,4 +29,6 @@ type PlayerRepository interface {
Put(p *Player) error
CountAll(...QueryOptions) (int64, error)
CountByClient(...QueryOptions) (map[string]int64, error)
FindByAPIKey(key string) (*Player, error)
GenerateAPIKey(playerId string) (string, error)
}

View File

@ -7,6 +7,7 @@ import (
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
@ -57,6 +58,19 @@ func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.P
return &res, err
}
func (r *playerRepository) FindByAPIKey(key string) (*model.Player, error) {
if key == "" {
return nil, model.ErrNotFound
}
sel := r.selectPlayer().Where(Eq{"api_key": key})
var res model.Player
err := r.queryOne(sel, &res)
if err != nil {
return nil, err
}
return &res, nil
}
func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder {
s := r.selectPlayer(options...)
return s.Where(r.addRestriction())
@ -130,25 +144,37 @@ func (r *playerRepository) isPermitted(p *model.Player) bool {
return u.IsAdmin || p.UserId == u.ID
}
func (r *playerRepository) preparePlayerForSave(p *model.Player) *model.Player {
playerCopy := *p
if playerCopy.UserId == "" {
user := loggedUser(r.ctx)
playerCopy.UserId = user.ID
}
playerCopy.APIKey = ""
return &playerCopy
}
func (r *playerRepository) Save(entity interface{}) (string, error) {
t := entity.(*model.Player)
if !r.isPermitted(t) {
playerToSave := r.preparePlayerForSave(t)
if !r.isPermitted(playerToSave) {
return "", rest.ErrPermissionDenied
}
id, err := r.put(t.ID, t)
playerId, err := r.put(playerToSave.ID, playerToSave)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return id, err
return playerId, err
}
func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
t := entity.(*model.Player)
t.ID = id
if !r.isPermitted(t) {
playerToUpdate := r.preparePlayerForSave(t)
if !r.isPermitted(playerToUpdate) {
return rest.ErrPermissionDenied
}
_, err := r.put(id, t, cols...)
_, err := r.put(id, playerToUpdate, cols...)
if errors.Is(err, model.ErrNotFound) {
return rest.ErrNotFound
}
@ -164,6 +190,33 @@ func (r *playerRepository) Delete(id string) error {
return err
}
// GenerateAPIKey generates a new API key for the specified player
func (r *playerRepository) GenerateAPIKey(playerID string) (string, error) {
player, err := r.Get(playerID)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
if err != nil {
return "", err
}
if !r.isPermitted(player) {
return "", rest.ErrPermissionDenied
}
player.APIKey = generateAPIKey()
err = r.Put(player)
if err != nil {
return "", err
}
return player.APIKey, nil
}
// generateAPIKey creates a new random API key
func generateAPIKey() string {
return "nav_" + id.NewRandom()
}
var _ model.PlayerRepository = (*playerRepository)(nil)
var _ rest.Repository = (*playerRepository)(nil)
var _ rest.Persistable = (*playerRepository)(nil)

View File

@ -13,8 +13,12 @@ import (
)
var _ = Describe("PlayerRepository", func() {
var adminRepo *playerRepository
var database *dbx.DB
var (
adminRepo *playerRepository
database *dbx.DB
adminCtx context.Context
regularCtx context.Context
)
var (
adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true}
@ -25,11 +29,14 @@ var _ = Describe("PlayerRepository", func() {
)
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
adminCtx = log.NewContext(context.TODO())
adminCtx = request.WithUser(adminCtx, adminUser)
regularCtx = log.NewContext(context.TODO())
regularCtx = request.WithUser(regularCtx, regularUser)
database = GetDBXBuilder()
adminRepo = NewPlayerRepository(ctx, database).(*playerRepository)
adminRepo = NewPlayerRepository(adminCtx, database).(*playerRepository)
for idx := range players {
err := adminRepo.Put(&players[idx])
@ -86,6 +93,28 @@ var _ = Describe("PlayerRepository", func() {
})
})
Describe("Save", func() {
var repo *playerRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, regularUser)
repo = NewPlayerRepository(ctx, database).(*playerRepository)
})
It("fails if different user id is set", func() {
newPlayer := model.Player{
Name: "Test Player",
Client: "TestClient",
UserAgent: "TestAgent",
UserId: adminUser.ID,
}
id, err := repo.Save(&newPlayer)
Expect(err).To(Equal(rest.ErrPermissionDenied))
_, err = repo.Get(id)
Expect(err).To(Equal(rest.ErrNotFound))
})
})
DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) {
var repo *playerRepository
@ -171,41 +200,37 @@ var _ = Describe("PlayerRepository", func() {
})
Describe("Save", func() {
DescribeTable("item type", func(player model.Player) {
clone := player
clone.ID = ""
clone.IP = "192.168.1.1"
id, err := repo.Save(&clone)
if clone.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.UserId = ""
} else {
Expect(err).To(BeNil())
Expect(id).ToNot(BeEmpty())
It("excludes API key from being saved", func() {
newPlayer := model.Player{
Name: "Test Player",
Client: "TestClient",
UserAgent: "TestAgent",
APIKey: "should-not-be-saved",
}
count, err := repo.Count()
id, err := repo.Save(&newPlayer)
Expect(err).To(BeNil())
clone.ID = id
newItem, err := repo.Get(id)
savedPlayer, err := repo.Get(id)
Expect(err).To(BeNil())
Expect(savedPlayer.APIKey).To(BeEmpty())
})
if clone.UserId == "" {
Expect(count).To(Equal(baseCount))
Expect(err).To(Equal(model.ErrNotFound))
} else {
Expect(count).To(Equal(baseCount + 1))
Expect(err).To(BeNil())
Expect(*newItem).To(Equal(clone))
It("sets user ID from context", func() {
repo := NewPlayerRepository(regularCtx, database).(*playerRepository)
newPlayer := model.Player{
Name: "Test Player",
Client: "TestClient",
UserAgent: "TestAgent",
}
},
Entry("same user", userPlayer),
Entry("other item", otherPlayer),
Entry("fake item", model.Player{}),
)
id, err := repo.Save(&newPlayer)
Expect(err).To(BeNil())
savedPlayer, err := repo.Get(id)
Expect(err).To(BeNil())
Expect(savedPlayer.UserId).To(Equal(regularUser.ID))
})
})
Describe("Update", func() {
@ -215,9 +240,7 @@ var _ = Describe("PlayerRepository", func() {
clone.MaxBitRate = 10000
err := repo.Update(clone.ID, &clone, "ip")
if clone.UserId == "" {
Expect(err).To(HaveOccurred())
} else if !admin && player.Username == adminPlayer1.Username {
if !admin && player.Username == adminPlayer1.Username {
Expect(err).To(Equal(rest.ErrPermissionDenied))
clone.IP = player.IP
} else {

View File

@ -3,6 +3,7 @@ package nativeapi
import (
"context"
"encoding/json"
"errors"
"html"
"net/http"
"strconv"
@ -51,7 +52,7 @@ func (n *Router) routes() http.Handler {
n.R(r, "/album", model.Album{}, false)
n.R(r, "/artist", model.Artist{}, false)
n.R(r, "/genre", model.Genre{}, false)
n.R(r, "/player", model.Player{}, true)
n.addPlayerRoute(r)
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
n.R(r, "/radio", model.Radio{}, true)
n.R(r, "/tag", model.Tag{}, true)
@ -102,6 +103,25 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
})
}
func (n *Router) addPlayerRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return n.ds.Resource(ctx, model.Player{})
}
r.Route("/player", func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Post("/", rest.Post(constructor))
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor))
r.Post("/apiKey", generatePlayerApiKey(n.ds))
})
})
}
func (n *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return n.ds.Resource(ctx, model.Playlist{})
@ -198,6 +218,28 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
}
}
func generatePlayerApiKey(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
playerId := chi.URLParam(r, "id")
err := ds.WithTxImmediate(func(tx model.DataStore) error {
repo := tx.Player(ctx)
apiKey, err := repo.GenerateAPIKey(playerId)
if err == nil {
resp := []byte(`{"apiKey":"` + html.EscapeString(apiKey) + `"}`)
_, err = w.Write(resp)
}
return err
})
if errors.Is(err, model.ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
} else if err != nil {
log.Error(ctx, "Error retrieving player from DB", "id", playerId, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (n *Router) addInspectRoute(r chi.Router) {
if conf.Server.Inspect.Enabled {
r.Group(func(r chi.Router) {

View File

@ -63,14 +63,16 @@ func checkRequiredParameters(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var requiredParameters []string
p := req.Params(r)
username, _ := fromInternalOrProxyAuth(r)
if username != "" {
requiredParameters = []string{"v", "c"}
} else if apiKey, _ := p.String("apiKey"); apiKey != "" {
requiredParameters = []string{"v", "c"}
} else {
requiredParameters = []string{"u", "v", "c"}
}
p := req.Params(r)
for _, param := range requiredParameters {
if _, err := p.String(param); err != nil {
log.Warn(r, err)
@ -123,21 +125,69 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
token, _ := p.String("t")
salt, _ := p.String("s")
jwt, _ := p.String("jwt")
apiKey, _ := p.String("apiKey")
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
// When an API key is provided, username should not be provided
if apiKey != "" && username != "" {
log.Warn(ctx, "API: Invalid login - username provided with API key", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil {
// Check for conflicting authentication mechanisms
authMechanismsCount := 0
if apiKey != "" {
authMechanismsCount++
}
if pass != "" {
authMechanismsCount++
}
if token != "" && salt != "" {
authMechanismsCount++
}
if jwt != "" {
authMechanismsCount++
}
if authMechanismsCount > 1 {
log.Warn(ctx, "API: Invalid login - multiple authentication mechanisms", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
return
}
if apiKey != "" {
var player *model.Player
player, err = ds.Player(ctx).FindByAPIKey(apiKey)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
default:
usr, err = ds.User(ctx).Get(player.UserId)
if err != nil {
log.Error(ctx, "API: Error retrieving user from API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err)
}
}
} else {
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
if errors.Is(err, context.Canceled) {
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
return
}
switch {
case errors.Is(err, model.ErrNotFound):
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
case err != nil:
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
default:
err = validateCredentials(usr, pass, token, salt, jwt)
if err != nil {
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
}
}
}
}

View File

@ -306,6 +306,74 @@ var _ = Describe("Middlewares", func() {
Expect(next.called).To(BeFalse())
})
})
When("using api key authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ur := ds.User(context.TODO())
user := &model.User{
UserName: "user-api",
NewPassword: "wordpass-api",
}
_ = ur.Put(user)
pr := ds.Player(context.TODO())
player := &model.Player{
ID: "player1",
Name: "Test Player",
UserAgent: "Test/1.0",
UserId: user.ID,
Client: "test-client",
IP: "127.0.0.1",
LastSeen: time.Now(),
TranscodingId: "",
MaxBitRate: 320,
ReportRealPath: false,
ScrobbleEnabled: true,
APIKey: "nav_12345",
}
_ = pr.Put(player)
})
It("passes authentication with correct api key", func() {
r := newGetRequest("apiKey=nav_12345")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(next.called).To(BeTrue())
user, _ := request.UserFrom(next.req.Context())
Expect(user.UserName).To(Equal("user-api"))
})
It("fails authentication with invalid api key", func() {
r := newGetRequest("apiKey=invalid-api-key")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
It("fails authentication with empty api key", func() {
r := newGetRequest("apiKey=")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
Expect(next.called).To(BeFalse())
})
It("fails authentication if both api key and username are provided", func() {
r := newGetRequest("apiKey=api-key", "u=user-api")
cp := authenticate(ds)(next)
cp.ServeHTTP(w, r)
Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
Expect(next.called).To(BeFalse())
})
})
})
Describe("GetPlayer", func() {

View File

@ -1,25 +1,31 @@
package responses
const (
ErrorGeneric int32 = 0
ErrorMissingParameter int32 = 10
ErrorClientTooOld int32 = 20
ErrorServerTooOld int32 = 30
ErrorAuthenticationFail int32 = 40
ErrorAuthorizationFail int32 = 50
ErrorTrialExpired int32 = 60
ErrorDataNotFound int32 = 70
ErrorGeneric int32 = 0
ErrorMissingParameter int32 = 10
ErrorClientTooOld int32 = 20
ErrorServerTooOld int32 = 30
ErrorAuthenticationFail int32 = 40
ErrorTokenAuthNotSupported int32 = 41
ErrorAuthMechanismNotSupported int32 = 42
ErrorMultipleAuthMechanismsProvided int32 = 43
ErrorAuthorizationFail int32 = 50
ErrorTrialExpired int32 = 60
ErrorDataNotFound int32 = 70
)
var errors = map[int32]string{
ErrorGeneric: "A generic error",
ErrorMissingParameter: "Required parameter is missing",
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
ErrorAuthenticationFail: "Wrong username or password",
ErrorAuthorizationFail: "User is not authorized for the given operation",
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
ErrorDataNotFound: "The requested data was not found",
ErrorGeneric: "A generic error",
ErrorMissingParameter: "Required parameter is missing",
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
ErrorAuthenticationFail: "Invalid username, password, or API key",
ErrorTokenAuthNotSupported: "Token authentication not supported",
ErrorAuthMechanismNotSupported: "Provided authentication mechanism not supported",
ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided",
ErrorAuthorizationFail: "User is not authorized for the given operation",
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
ErrorDataNotFound: "The requested data was not found",
}
func ErrorMsg(code int32) string {

View File

@ -191,7 +191,7 @@ func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository {
if db.RealDS != nil {
db.MockedPlayer = db.RealDS.Player(ctx)
} else {
db.MockedPlayer = struct{ model.PlayerRepository }{}
db.MockedPlayer = CreateMockPlayerRepo()
}
}
return db.MockedPlayer

88
tests/mock_player_repo.go Normal file
View File

@ -0,0 +1,88 @@
package tests
import (
"encoding/base64"
"github.com/navidrome/navidrome/model"
)
func CreateMockPlayerRepo() *MockedPlayerRepo {
return &MockedPlayerRepo{
Data: make(map[string]*model.Player),
}
}
type MockedPlayerRepo struct {
model.PlayerRepository
Error error
Data map[string]*model.Player
}
func (m *MockedPlayerRepo) Get(id string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
player, exists := m.Data[id]
if !exists {
return nil, model.ErrNotFound
}
return player, nil
}
func (m *MockedPlayerRepo) FindMatch(userId, client, userAgent string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
for _, player := range m.Data {
if player.UserId == userId && player.Client == client && player.UserAgent == userAgent {
return player, nil
}
}
return nil, model.ErrNotFound
}
func (m *MockedPlayerRepo) Put(p *model.Player) error {
if m.Error != nil {
return m.Error
}
if p.ID == "" {
p.ID = base64.StdEncoding.EncodeToString([]byte(p.Name + "_" + p.UserId + "_" + p.Client))
}
m.Data[p.ID] = p
return nil
}
func (m *MockedPlayerRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
if m.Error != nil {
return 0, m.Error
}
return int64(len(m.Data)), nil
}
func (m *MockedPlayerRepo) CountByClient(_ ...model.QueryOptions) (map[string]int64, error) {
if m.Error != nil {
return nil, m.Error
}
result := make(map[string]int64)
for _, player := range m.Data {
result[player.Client]++
}
return result, nil
}
func (m *MockedPlayerRepo) FindByAPIKey(key string) (*model.Player, error) {
if m.Error != nil {
return nil, m.Error
}
for _, player := range m.Data {
if player.APIKey == key {
return player, nil
}
}
return nil, model.ErrNotFound
}

View File

@ -24,7 +24,7 @@ type MockedUserRepo struct {
UserLibraries map[string][]int // userID -> libraryIDs
}
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
if u.Error != nil {
return 0, u.Error
}

View File

@ -0,0 +1,57 @@
import React, { useCallback } from 'react'
import {
Create,
SimpleForm,
TextInput,
required,
useTranslate,
useMutation,
useNotify,
useRedirect,
} from 'react-admin'
import { Title } from '../common'
const ApiKeyCreate = (props) => {
const translate = useTranslate()
const [mutate] = useMutation()
const notify = useNotify()
const redirect = useRedirect()
const resourceName = translate('resources.apikey.name', { smart_count: 1 })
const title = translate('ra.page.create', {
name: `${resourceName}`,
})
const save = useCallback(
async (values) => {
try {
await mutate(
{
type: 'create',
resource: 'apikey',
payload: { data: values },
},
{ returnPromise: true },
)
notify('resources.apikey.notifications.created', 'info', {
smart_count: 1,
})
redirect('/apikey')
} catch (error) {
if (error.body.errors) {
return error.body.errors
}
}
},
[mutate, notify, redirect],
)
return (
<Create title={<Title subTitle={title} />} {...props}>
<SimpleForm save={save} variant={'outlined'}>
<TextInput source="name" validate={[required()]} autoFocus />
</SimpleForm>
</Create>
)
}
export default ApiKeyCreate

View File

@ -0,0 +1,75 @@
import React, { useCallback } from 'react'
import {
Edit,
SimpleForm,
TextInput,
required,
Toolbar,
SaveButton,
DeleteButton,
useTranslate,
useMutation,
useNotify,
useRefresh,
DateField,
} from 'react-admin'
import { Title } from '../common'
const ApiKeyTitle = ({ record }) => {
const translate = useTranslate()
const resourceName = translate('resources.apikey.name', { smart_count: 1 })
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
}
const ApiKeyEditToolbar = (props) => (
<Toolbar {...props}>
<SaveButton />
<DeleteButton />
</Toolbar>
)
const ApiKeyEdit = (props) => {
const [mutate] = useMutation()
const notify = useNotify()
const refresh = useRefresh()
const save = useCallback(
async (values) => {
try {
await mutate(
{
type: 'update',
resource: 'apikey',
payload: { id: values.id, data: values },
},
{ returnPromise: true },
)
notify('resources.apikey.notifications.updated', 'info', {
smart_count: 1,
})
refresh()
} catch (error) {
if (error.body.errors) {
return error.body.errors
}
}
},
[mutate, notify, refresh],
)
return (
<Edit title={<ApiKeyTitle />} {...props}>
<SimpleForm
toolbar={<ApiKeyEditToolbar />}
save={save}
variant={'outlined'}
>
<TextInput source="name" validate={[required()]} />
<TextInput source="key" disabled fullWidth />
<DateField variant="body1" source="createdAt" showTime />
</SimpleForm>
</Edit>
)
}
export default ApiKeyEdit

View File

@ -0,0 +1,167 @@
import React, { useState } from 'react'
import {
CreateButton,
Datagrid,
DateField,
Filter,
FunctionField,
List,
SearchInput,
TextField,
useNotify,
useTranslate,
} from 'react-admin'
import {
IconButton,
makeStyles,
Tooltip,
useMediaQuery,
} from '@material-ui/core'
import { SimpleList } from '../common'
import AddIcon from '@material-ui/icons/Add'
import VisibilityIcon from '@material-ui/icons/Visibility'
import VisibilityOffIcon from '@material-ui/icons/VisibilityOff'
import FileCopyIcon from '@material-ui/icons/FileCopy'
const useStyles = makeStyles({
actionContainer: {
display: 'flex',
alignItems: 'center',
},
keyContainer: {
display: 'flex',
alignItems: 'center',
flex: 1,
},
visibilityButton: {
padding: 4,
},
copyButton: {
padding: 4,
},
})
const ApiKeyFilter = (props) => (
<Filter {...props} variant={'outlined'}>
<SearchInput id="search" source="name" alwaysOn />
</Filter>
)
const MaskedKeyField = ({ record, isSimpleMode }) => {
const [visible, setVisible] = useState(false)
const classes = useStyles()
const notify = useNotify()
if (!record || !record.key) return null
const maskKey = (key) => {
return '*'.repeat(key.length)
}
const handleCopy = (e) => {
if (isSimpleMode) {
e.preventDefault()
} else {
e.stopPropagation()
}
navigator.clipboard.writeText(record.key)
notify('API key copied to clipboard', 'info')
}
const toggleVisibility = (e) => {
if (isSimpleMode) {
e.preventDefault()
} else {
e.stopPropagation()
}
setVisible(!visible)
}
const keyVisibilityButton = (
<IconButton
className={classes.visibilityButton}
onClick={toggleVisibility}
size="small"
>
{visible ? (
<VisibilityOffIcon fontSize="small" />
) : (
<VisibilityIcon fontSize="small" />
)}
</IconButton>
)
const copyButton = (
<IconButton
className={classes.copyButton}
onClick={handleCopy}
size="small"
>
<FileCopyIcon fontSize="small" />
</IconButton>
)
return (
<div className={classes.actionContainer}>
<div className={classes.keyContainer}>
{visible ? record.key : maskKey(record.key)}
</div>
{isSimpleMode ? (
<>{keyVisibilityButton}</>
) : (
<Tooltip title={visible ? 'Hide key' : 'Show key'}>
{keyVisibilityButton}
</Tooltip>
)}
{isSimpleMode ? (
<>{copyButton}</>
) : (
<Tooltip title="Copy to clipboard">{copyButton}</Tooltip>
)}
</div>
)
}
const ApiKeyList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate()
return (
<List
{...props}
actions={
<CreateButton
basePath="/apikey"
icon={<AddIcon />}
label={translate('resources.apikey.actions.add')}
/>
}
sort={{ field: 'createdAt', order: 'DESC' }}
exporter={false}
bulkActionButtons={false}
filters={<ApiKeyFilter />}
>
{isXsmall ? (
<SimpleList
primaryText={(r) => r.name}
secondaryText={(r) => (
<MaskedKeyField record={r} isSimpleMode={true} />
)}
tertiaryText={(r) => <DateField record={r} source="createdAt" />}
linkType={'edit'}
/>
) : (
<Datagrid rowClick="edit">
<TextField source="name" />
<FunctionField
label="Key"
render={(record) => (
<MaskedKeyField isSimpleMode={false} record={record} />
)}
/>
<DateField source="createdAt" showTime />
</Datagrid>
)}
</List>
)
}
export default ApiKeyList

11
ui/src/apikey/index.js Normal file
View File

@ -0,0 +1,11 @@
import VpnKeyIcon from '@material-ui/icons/VpnKey'
import ApiKeyList from './ApiKeyList'
import ApiKeyCreate from './ApiKeyCreate'
import ApiKeyEdit from './ApiKeyEdit'
export default {
list: ApiKeyList,
create: ApiKeyCreate,
edit: ApiKeyEdit,
icon: VpnKeyIcon,
}

View File

@ -181,7 +181,8 @@
"userName": "Username",
"lastSeen": "Last Seen At",
"reportRealPath": "Report Real Path",
"scrobbleEnabled": "Send Scrobbles to external services"
"scrobbleEnabled": "Send Scrobbles to external services",
"apiKey": "API Key"
}
},
"transcoding": {
@ -508,7 +509,8 @@
"shareSuccess": "URL copied to clipboard: %{url}",
"shareFailure": "Error copying URL %{url} to clipboard",
"downloadDialogTitle": "Download %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Download in original format"
"downloadOriginalFormat": "Download in original format",
"apiKeyGenerated": "API key generated successfully"
},
"menu": {
"library": "Library",

View File

@ -63,6 +63,7 @@ AboutMenuItem.displayName = 'AboutMenuItem'
const settingsResources = (resource) =>
resource.name !== 'user' &&
resource.name !== 'apikey' &&
resource.hasList &&
resource.options &&
resource.options.subMenu === 'settings'
@ -95,6 +96,14 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
)
}
const renderApiKeyMenuItemLink = () => {
const apiKeyResource = resourceDefinition('apikey')
if (!apiKeyResource) {
return null
}
return renderSettingsMenuItemLink(apiKeyResource)
}
const renderSettingsMenuItemLink = (resource, id) => {
const label = translate(`resources.${resource.name}.name`, {
smart_count: id ? 1 : 2,
@ -128,6 +137,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
<Divider />
{renderUserMenuItemLink()}
{renderApiKeyMenuItemLink()}
{resources
.filter(settingsResources)
.map((r) => renderSettingsMenuItemLink(r))}

View File

@ -103,7 +103,10 @@ const Menu = ({ dense = false }) => {
}
const subItems = (subMenu) => (resource) =>
resource.hasList && resource.options && resource.options.subMenu === subMenu
resource.hasList &&
resource.options &&
resource.options.subMenu === subMenu &&
resource.name !== 'apikey'
return (
<div

View File

@ -0,0 +1,44 @@
import React from 'react'
import {
TextInput,
BooleanInput,
Create,
required,
SimpleForm,
SelectInput,
ReferenceInput,
} from 'react-admin'
import { BITRATE_CHOICES } from '../consts'
import config from '../config.js'
import { Title } from '../common'
import { useTranslate } from 'react-admin'
const PlayerCreateTitle = () => {
const translate = useTranslate()
const resourceName = translate('resources.player.name', { smart_count: 1 })
return <Title subTitle={`${translate('ra.action.create')} ${resourceName}`} />
}
const PlayerCreate = (props) => {
return (
<Create title={<PlayerCreateTitle />} {...props}>
<SimpleForm variant="outlined">
<TextInput source="name" validate={[required()]} />
<ReferenceInput
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
<BooleanInput source="reportRealPath" fullWidth />
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
)}
</SimpleForm>
</Create>
)
}
export default PlayerCreate

View File

@ -1,3 +1,4 @@
import React, { useState, useCallback } from 'react'
import {
TextInput,
BooleanInput,
@ -8,10 +9,25 @@ import {
SelectInput,
ReferenceInput,
useTranslate,
useNotify,
Button,
useRecordContext,
} from 'react-admin'
import FileCopyIcon from '@material-ui/icons/FileCopy'
import VpnKeyIcon from '@material-ui/icons/VpnKey'
import VisibilityIcon from '@material-ui/icons/Visibility'
import VisibilityOffIcon from '@material-ui/icons/VisibilityOff'
import { Title } from '../common'
import config from '../config'
import { BITRATE_CHOICES } from '../consts'
import { BITRATE_CHOICES, REST_URL } from '../consts'
import { makeStyles } from '@material-ui/core/styles'
import {
IconButton,
InputAdornment,
Tooltip,
TextField as MuiTextField,
} from '@material-ui/core'
import httpClient from '../dataProvider/httpClient.js'
import config from '../config.js'
const PlayerTitle = ({ record }) => {
const translate = useTranslate()
@ -19,26 +35,148 @@ const PlayerTitle = ({ record }) => {
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
}
const PlayerEdit = (props) => (
<Edit title={<PlayerTitle />} {...props}>
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<ReferenceInput
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
<BooleanInput source="reportRealPath" fullWidth />
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
const useStyles = makeStyles({
apiKeyField: {
marginTop: '16px',
marginBottom: '8px',
},
generateButton: {
marginTop: '16px',
marginBottom: '8px',
},
copyButton: {
padding: 4,
},
})
const ApiKeySection = () => {
const record = useRecordContext()
const recordId = record ? record.id : null
const initialApiKey = record ? record.apiKey : null
const classes = useStyles()
const notify = useNotify()
const [showApiKey, setShowApiKey] = useState(false)
const [loading, setLoading] = useState(false)
const [apiKey, setApiKey] = useState(initialApiKey)
const generateApiKey = useCallback(async () => {
if (!recordId) {
notify('Player ID not available', 'error')
return
}
try {
setLoading(true)
const { json } = await httpClient(
`${REST_URL}/player/${recordId}/apiKey`,
{
method: 'POST',
},
)
setApiKey(json.apiKey)
setShowApiKey(true)
notify('message.apiKeyGenerated', 'info')
} catch (error) {
notify(error.message || 'Error generating API key', 'error')
} finally {
setLoading(false)
}
}, [recordId, notify])
const copyToClipboard = () => {
if (apiKey) {
navigator.clipboard.writeText(apiKey)
notify('API key copied to clipboard', 'info')
}
}
if (!recordId) {
return <></>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', marginTop: 16 }}>
{apiKey ? (
<MuiTextField
label="API Key"
value={showApiKey ? apiKey : '*'.repeat(apiKey.length)}
InputProps={{
readOnly: true,
disableUnderline: true,
endAdornment: (
<InputAdornment position="end">
<Tooltip title={showApiKey ? 'Hide' : 'Show'}>
<IconButton
aria-label="toggle api key visibility"
onClick={() => setShowApiKey(!showApiKey)}
size="small"
>
{showApiKey ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Copy to clipboard">
<IconButton
aria-label="copy api key"
onClick={copyToClipboard}
className={classes.copyButton}
size="small"
>
<FileCopyIcon />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
style={{ width: 320, color: 'black' }}
/>
) : (
!loading && (
<MuiTextField
label="API Key"
value="No API Key Found"
disabled
style={{ width: 320 }}
/>
)
)}
<TextField source="client" />
<TextField source="userName" />
</SimpleForm>
</Edit>
)
<Button
style={{ marginTop: 12, alignSelf: 'flex-start' }}
onClick={generateApiKey}
label="Generate New API Key"
startIcon={<VpnKeyIcon />}
variant="outlined"
disabled={loading}
/>
</div>
)
}
const PlayerEdit = (props) => {
return (
<Edit title={<PlayerTitle />} {...props}>
<SimpleForm variant={'outlined'}>
<TextInput source="name" validate={[required()]} />
<ReferenceInput
source="transcodingId"
reference="transcoding"
sort={{ field: 'name', order: 'ASC' }}
>
<SelectInput source="name" resettable />
</ReferenceInput>
<SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} />
<BooleanInput source="reportRealPath" fullWidth />
{(config.lastFMEnabled || config.listenBrainzEnabled) && (
<BooleanInput source="scrobbleEnabled" fullWidth />
)}
<TextField source="client" />
<TextField source="userName" />
<ApiKeySection />
</SimpleForm>
</Edit>
)
}
export default PlayerEdit

View File

@ -1,8 +1,10 @@
import { BsFillMusicPlayerFill } from 'react-icons/bs'
import PlayerList from './PlayerList'
import PlayerEdit from './PlayerEdit'
import PlayerCreate from './PlayerCreate.jsx'
export default {
create: PlayerCreate,
list: PlayerList,
edit: PlayerEdit,
icon: BsFillMusicPlayerFill,