From b28a87f358cb50b386f134b16b8ccd3028190a95 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:19:00 -0800 Subject: [PATCH] feat(cli): add user administration --- cmd/pls.go | 28 +--- cmd/user.go | 418 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/utils.go | 34 ++++ go.mod | 1 + go.sum | 2 + model/user.go | 2 + 6 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 cmd/user.go create mode 100644 cmd/utils.go diff --git a/cmd/pls.go b/cmd/pls.go index fc0f22fba..9fe1f96c5 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -1,7 +1,6 @@ package cmd import ( - "context" "encoding/csv" "encoding/json" "errors" @@ -10,11 +9,8 @@ import ( "strconv" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" "github.com/spf13/cobra" ) @@ -66,9 +62,7 @@ var ( ) func runExporter() { - sqlDB := db.Db() - ds := persistence.New(sqlDB) - ctx := auth.WithAdminUser(context.Background(), ds) + ds, ctx := getContext() playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { 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) } - sqlDB := db.Db() - ds := persistence.New(sqlDB) - ctx := auth.WithAdminUser(context.Background(), ds) - + ds, ctx := getContext() options := model.QueryOptions{Sort: "owner_name"} if userID != "" { - user, err := ds.User(ctx).FindByUsername(userID) - - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal("Error retrieving user by name", "name", userID, err) + user, err := getUser(userID, ds, ctx) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) } - - 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} } diff --git a/cmd/user.go b/cmd/user.go new file mode 100644 index 000000000..f2a5fe754 --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,418 @@ +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): ") + password, err := term.ReadPassword(syscall.Stdin) + + 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(syscall.Stdin) + + if err != nil { + log.Fatal("Error getting password confirmation", err) + } + + fmt.Println() + + pass := string(password) + confirm := string(confirmation) + + if pass == "" { + return "" + } + + if pass == confirm { + return pass + } + + fmt.Print("Username and password 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", "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) + } + + _ = 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), + user.LastAccessAt.Format(time.RFC3339Nano), + user.LastLoginAt.Format(time.RFC3339Nano), + 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) + } +} diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 000000000..0b4a33913 --- /dev/null +++ b/cmd/utils.go @@ -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 +} diff --git a/go.mod b/go.mod index f680bda51..f3934575c 100644 --- a/go.mod +++ b/go.mod @@ -131,6 +131,7 @@ require ( golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect + golang.org/x/term v0.37.0 // indirect golang.org/x/tools v0.39.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index 77c0cbb40..04e986f94 100644 --- a/go.sum +++ b/go.sum @@ -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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/model/user.go b/model/user.go index aabedc096..c590ba260 100644 --- a/model/user.go +++ b/model/user.go @@ -42,7 +42,9 @@ func (u User) HasLibraryAccess(libraryID int) bool { type Users []User type UserRepository interface { + ResourceRepository CountAll(...QueryOptions) (int64, error) + Delete(id string) error Get(id string) (*User, error) Put(*User) error UpdateLastLoginAt(id string) error