Merge 53641dae88df31e3bf9b1e7381586ac39e8fc26b into 64a9260174cd7c7c27f2b82c9ebaa4657afaeea6

This commit is contained in:
Kendall Garner 2025-11-30 22:04:24 -05:00 committed by GitHub
commit 2f078f38e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 491 additions and 23 deletions

View File

@ -1,7 +1,6 @@
package cmd package cmd
import ( import (
"context"
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
"errors" "errors"
@ -10,11 +9,8 @@ import (
"strconv" "strconv"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -66,9 +62,7 @@ var (
) )
func runExporter() { func runExporter() {
sqlDB := db.Db() ds, ctx := getContext()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) { if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err) log.Fatal("Error retrieving playlist", "name", playlistID, err)
@ -105,26 +99,14 @@ func runList() {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
} }
sqlDB := db.Db() ds, ctx := getContext()
ds := persistence.New(sqlDB)
ctx := auth.WithAdminUser(context.Background(), ds)
options := model.QueryOptions{Sort: "owner_name"} options := model.QueryOptions{Sort: "owner_name"}
if userID != "" { if userID != "" {
user, err := ds.User(ctx).FindByUsername(userID) user, err := getUser(userID, ds, ctx)
if err != nil {
if err != nil && !errors.Is(err, model.ErrNotFound) { log.Fatal(ctx, "Error retrieving user", "username or id", userID)
log.Fatal("Error retrieving user by name", "name", userID, err)
} }
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(userID)
if err != nil {
log.Fatal("Error retrieving user by id", "id", userID, err)
}
}
options.Filters = squirrel.Eq{"owner_id": user.ID} options.Filters = squirrel.Eq{"owner_id": user.ID}
} }

445
cmd/user.go Normal file
View File

@ -0,0 +1,445 @@
package cmd
import (
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var (
email string
libraryIds []int
name string
password string
removeEmail bool
removeName bool
setAdmin bool
setPassword bool
setRegularUser bool
)
func init() {
rootCmd.AddCommand(userRoot)
userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
userCreateCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
userCreateCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history")
userCreateCommand.MarkFlagsMutuallyExclusive("password", "set-password")
userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userCreateCommand.Flags().IntSliceVar(&libraryIds, "library-ids", []int{}, "Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
_ = userCreateCommand.MarkFlagRequired("username")
userCreateCommand.MarkFlagsOneRequired("password", "set-password")
userRoot.AddCommand(userCreateCommand)
userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
_ = userDeleteCommand.MarkFlagRequired("user")
userRoot.AddCommand(userDeleteCommand)
userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
userEditCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history")
userEditCommand.MarkFlagsMutuallyExclusive("password", "set-password")
userEditCommand.Flags().IntSliceVar(&libraryIds, "library-ids", []int{}, "Set the user's accessible libraries by id")
_ = userEditCommand.MarkFlagRequired("user")
userRoot.AddCommand(userEditCommand)
userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
userRoot.AddCommand(userListCommand)
}
var (
userRoot = &cobra.Command{
Use: "user",
Short: "Administer users",
Long: "Create, delete, list, or update users",
}
userCreateCommand = &cobra.Command{
Use: "create",
Aliases: []string{"c"},
Short: "Create a new user",
Run: func(cmd *cobra.Command, args []string) {
runCreateUser()
},
}
userDeleteCommand = &cobra.Command{
Use: "delete",
Aliases: []string{"d"},
Short: "Deletes an existing user",
Run: func(cmd *cobra.Command, args []string) {
runDeleteUser()
},
}
userEditCommand = &cobra.Command{
Use: "edit",
Aliases: []string{"e"},
Short: "Edit a user",
Long: "Edit the password, admin status, and/or library access",
Run: func(cmd *cobra.Command, args []string) {
runUserEdit()
},
}
userListCommand = &cobra.Command{
Use: "list",
Short: "List users",
Run: func(cmd *cobra.Command, args []string) {
runUserList()
},
}
)
func promptPassword() string {
for {
fmt.Print("Enter new password (press enter with no password to cancel): ")
// This cast is necessary for some platforms
password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password", err)
}
fmt.Print("\nConfirm new password (press enter with no password to cancel): ")
confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
if err != nil {
log.Fatal("Error getting password confirmation", err)
}
// clear the line.
fmt.Println()
pass := string(password)
confirm := string(confirmation)
if pass == "" {
return ""
}
if pass == confirm {
return pass
}
fmt.Println("Password and password confirmation do not match")
}
}
func runCreateUser() {
if password == "" {
password = promptPassword()
if password == "" {
log.Fatal("Empty password provided, user creation cancelled")
}
}
user := model.User{
UserName: userID,
Email: email,
Name: name,
IsAdmin: setAdmin,
NewPassword: password,
}
ds, ctx := getContext()
err := ds.WithTx(func(tx model.DataStore) error {
existingUser, err := tx.User(ctx).FindByUsername(userID)
if existingUser != nil {
return fmt.Errorf("existing user '%s'", userID)
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return fmt.Errorf("failed to check existing username: %w", err)
}
if len(libraryIds) > 0 && !setAdmin {
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(user.Libraries) != len(libraryIds) {
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %d", libraryIds, len(user.Libraries))
}
} else {
user.Libraries, err = tx.Library(ctx).GetAll()
if err != nil {
return err
}
}
err = tx.User(ctx).Put(&user)
if err != nil {
return err
}
return nil
})
if err != nil {
log.Fatal(ctx, err)
}
log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
}
func runDeleteUser() {
ds, ctx := getContext()
var err error
var user *model.User
err = ds.WithTx(func(tx model.DataStore) error {
count, err := tx.User(ctx).CountAll()
if err != nil {
return err
}
if count == 1 {
return errors.New("refusing to delete the last user")
}
user, err = getUser(userID, tx, ctx)
if err != nil {
return err
}
return tx.User(ctx).Delete(user.ID)
})
if err != nil {
log.Fatal(ctx, "Failed to delete user", err)
}
log.Info(ctx, "Deleted user", "username", user.UserName)
}
func runUserEdit() {
ds, ctx := getContext()
var err error
var user *model.User
changes := []string{}
err = ds.WithTx(func(tx model.DataStore) error {
user, err = getUser(userID, tx, ctx)
if err != nil {
return err
}
if len(libraryIds) > 0 && !setAdmin {
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
if err != nil {
return err
}
if len(libraries) != len(libraryIds) {
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %d", libraryIds, len(libraries))
}
user.Libraries = libraries
changes = append(changes, "updated library ids")
}
if setAdmin && !user.IsAdmin {
libraries, err := tx.Library(ctx).GetAll()
if err != nil {
return err
}
user.IsAdmin = true
user.Libraries = libraries
changes = append(changes, "set admin")
}
if setRegularUser && user.IsAdmin {
user.IsAdmin = false
changes = append(changes, "set regular user")
}
if setPassword {
password = promptPassword()
}
if password != "" {
user.NewPassword = password
changes = append(changes, "updated password")
}
if email != "" && email != user.Email {
user.Email = email
changes = append(changes, "updated email")
} else if removeEmail && user.Email != "" {
user.Email = ""
changes = append(changes, "removed email")
}
if name != "" && name != user.Name {
user.Name = name
changes = append(changes, "updated name")
} else if removeName && user.Name != "" {
user.Name = ""
changes = append(changes, "removed name")
}
if len(changes) == 0 {
log.Info(ctx, "No changes for user", "user", user.UserName)
return nil
}
err = tx.User(ctx).Put(user)
return err
})
if err != nil {
log.Fatal(ctx, "Failed to update user", err)
}
log.Info(ctx, "Updated user", "user", user.Name, "changes", strings.Join(changes, ", "))
}
type displayLibrary struct {
ID int `json:"id"`
Path string `json:"path"`
}
type displayUser struct {
Id string `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
Admin bool `json:"admin"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastAccess *time.Time `json:"lastAccess"`
LastLogin *time.Time `json:"lastLogin"`
Libraries []displayLibrary `json:"libraries"`
}
func runUserList() {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
ds, ctx := getContext()
users, err := ds.User(ctx).ReadAll()
if err != nil {
log.Fatal(ctx, "Failed to retrieve users", err)
}
userList := users.(model.Users)
if outputFormat == "csv" {
w := csv.NewWriter(os.Stdout)
_ = w.Write([]string{
"user id",
"username",
"user's name",
"user email",
"admin",
"created at",
"updated at",
"last access",
"last login",
"libraries",
})
for _, user := range userList {
paths := make([]string, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
}
var lastAccess, lastLogin string
if user.LastAccessAt != nil {
lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
} else {
lastAccess = "never"
}
if user.LastLoginAt != nil {
lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
} else {
lastLogin = "never"
}
_ = w.Write([]string{
user.ID,
user.UserName,
user.Name,
user.Email,
strconv.FormatBool(user.IsAdmin),
user.CreatedAt.Format(time.RFC3339Nano),
user.UpdatedAt.Format(time.RFC3339Nano),
lastAccess,
lastLogin,
fmt.Sprintf("'%s'", strings.Join(paths, ",")),
})
}
w.Flush()
} else {
users := make([]displayUser, len(userList))
for idx, user := range userList {
paths := make([]displayLibrary, len(user.Libraries))
for idx, library := range user.Libraries {
paths[idx].ID = library.ID
paths[idx].Path = library.Path
}
users[idx].Id = user.ID
users[idx].Username = user.UserName
users[idx].Name = user.Name
users[idx].Email = user.Email
users[idx].Admin = user.IsAdmin
users[idx].CreatedAt = user.CreatedAt
users[idx].UpdatedAt = user.UpdatedAt
users[idx].LastAccess = user.LastAccessAt
users[idx].LastLogin = user.LastLoginAt
users[idx].Libraries = paths
}
j, _ := json.Marshal(users)
fmt.Printf("%s\n", j)
}
}

34
cmd/utils.go Normal file
View File

@ -0,0 +1,34 @@
package cmd
import (
"context"
"errors"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
)
func getContext() (model.DataStore, context.Context) {
sqlDB := db.Db()
ds := persistence.New(sqlDB)
return ds, auth.WithAdminUser(context.Background(), ds)
}
func getUser(id string, ds model.DataStore, ctx context.Context) (*model.User, error) {
user, err := ds.User(ctx).FindByUsername(id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}
if errors.Is(err, model.ErrNotFound) {
user, err = ds.User(ctx).Get(id)
if err != nil {
return nil, err
}
}
return user, nil
}

1
go.mod
View File

@ -66,6 +66,7 @@ require (
golang.org/x/net v0.47.0 golang.org/x/net v0.47.0
golang.org/x/sync v0.18.0 golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
golang.org/x/text v0.31.0 golang.org/x/text v0.31.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.10

2
go.sum
View File

@ -363,6 +363,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

View File

@ -42,7 +42,9 @@ func (u User) HasLibraryAccess(libraryID int) bool {
type Users []User type Users []User
type UserRepository interface { type UserRepository interface {
ResourceRepository
CountAll(...QueryOptions) (int64, error) CountAll(...QueryOptions) (int64, error)
Delete(id string) error
Get(id string) (*User, error) Get(id string) (*User, error)
Put(*User) error Put(*User) error
UpdateLastLoginAt(id string) error UpdateLastLoginAt(id string) error

View File

@ -3,6 +3,7 @@ package plugins
import ( import (
"context" "context"
"errors" "errors"
"time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
@ -23,6 +24,7 @@ var _ = Describe("Adapter Media Agent", func() {
// Ensure plugins folder is set to testdata // Ensure plugins folder is set to testdata
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir conf.Server.Plugins.Folder = testDataDir
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
mgr = createManager(nil, metrics.NewNoopInstance()) mgr = createManager(nil, metrics.NewNoopInstance())
mgr.ScanPlugins() mgr.ScanPlugins()