mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
* feat(cli): add user administration * clean go.mod, address comments * fix lint, I hope * bump compilation timeoit in adapter_media_agent_test * address initial comments * feedback 2 * update user commands to use context to allow proper cancellation Signed-off-by: Deluan <deluan@navidrome.org> * enforce admin user requirement in context for command execution Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> Co-authored-by: Deluan <deluan@navidrome.org>
478 lines
12 KiB
Go
478 lines
12 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
|
|
removeEmail bool
|
|
removeName bool
|
|
setAdmin bool
|
|
setPassword bool
|
|
setRegularUser bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(userRoot)
|
|
|
|
userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
|
|
|
|
userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
|
|
userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. 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")
|
|
|
|
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().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. 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(cmd.Context())
|
|
},
|
|
}
|
|
|
|
userDeleteCommand = &cobra.Command{
|
|
Use: "delete",
|
|
Aliases: []string{"d"},
|
|
Short: "Deletes an existing user",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runDeleteUser(cmd.Context())
|
|
},
|
|
}
|
|
|
|
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(cmd.Context())
|
|
},
|
|
}
|
|
|
|
userListCommand = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List users",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
runUserList(cmd.Context())
|
|
},
|
|
}
|
|
)
|
|
|
|
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 libraryError(libraries model.Libraries) error {
|
|
ids := make([]int, len(libraries))
|
|
for idx, library := range libraries {
|
|
ids[idx] = library.ID
|
|
}
|
|
return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
|
|
}
|
|
|
|
func runCreateUser(ctx context.Context) {
|
|
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,
|
|
}
|
|
|
|
if user.Name == "" {
|
|
user.Name = userID
|
|
}
|
|
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
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 libraryError(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
|
|
}
|
|
|
|
updatedIds := make([]int, len(user.Libraries))
|
|
for idx, lib := range user.Libraries {
|
|
updatedIds[idx] = lib.ID
|
|
}
|
|
|
|
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
|
return err
|
|
})
|
|
|
|
if err != nil {
|
|
log.Fatal(ctx, err)
|
|
}
|
|
|
|
log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
|
|
}
|
|
|
|
func runDeleteUser(ctx context.Context) {
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
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(ctx, userID, tx)
|
|
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(ctx context.Context) {
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
var err error
|
|
var user *model.User
|
|
changes := []string{}
|
|
|
|
err = ds.WithTx(func(tx model.DataStore) error {
|
|
var newLibraries model.Libraries
|
|
|
|
user, err = getUser(ctx, userID, tx)
|
|
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 libraryError(libraries)
|
|
}
|
|
|
|
newLibraries = 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")
|
|
|
|
newLibraries = libraries
|
|
}
|
|
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
err := tx.User(ctx).Put(user)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(newLibraries) > 0 {
|
|
updatedIds := make([]int, len(newLibraries))
|
|
for idx, lib := range newLibraries {
|
|
updatedIds[idx] = lib.ID
|
|
}
|
|
|
|
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
log.Fatal(ctx, "Failed to update user", err)
|
|
}
|
|
|
|
if len(changes) == 0 {
|
|
log.Info(ctx, "No changes for user", "user", user.UserName)
|
|
} else {
|
|
log.Info(ctx, "Updated user", "user", user.UserName, "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(ctx context.Context) {
|
|
if outputFormat != "csv" && outputFormat != "json" {
|
|
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
|
|
}
|
|
|
|
ds, ctx := getAdminContext(ctx)
|
|
|
|
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)
|
|
}
|
|
}
|