mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
feat(cli): add user administration (#4754)
* 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>
This commit is contained in:
35
cmd/pls.go
35
cmd/pls.go
@@ -10,11 +10,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +49,7 @@ var (
|
|||||||
Short: "Export playlists",
|
Short: "Export playlists",
|
||||||
Long: "Export Navidrome playlists to M3U files",
|
Long: "Export Navidrome playlists to M3U files",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runExporter()
|
runExporter(cmd.Context())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +57,13 @@ var (
|
|||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List playlists",
|
Short: "List playlists",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runList()
|
runList(cmd.Context())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func runExporter() {
|
func runExporter(ctx context.Context) {
|
||||||
sqlDB := db.Db()
|
ds, ctx := getAdminContext(ctx)
|
||||||
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)
|
||||||
@@ -100,31 +95,19 @@ func runExporter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runList() {
|
func runList(ctx context.Context) {
|
||||||
if outputFormat != "csv" && outputFormat != "json" {
|
if outputFormat != "csv" && outputFormat != "json" {
|
||||||
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 := getAdminContext(ctx)
|
||||||
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(ctx, userID, ds)
|
||||||
|
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}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
477
cmd/user.go
Normal file
477
cmd/user.go
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
cmd/utils.go
Normal file
42
cmd/utils.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"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/model/request"
|
||||||
|
"github.com/navidrome/navidrome/persistence"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||||
|
sqlDB := db.Db()
|
||||||
|
ds := persistence.New(sqlDB)
|
||||||
|
ctx = auth.WithAdminUser(ctx, ds)
|
||||||
|
u, _ := request.UserFrom(ctx)
|
||||||
|
if !u.IsAdmin {
|
||||||
|
log.Fatal(ctx, "There must be at least one admin user to run this command.")
|
||||||
|
}
|
||||||
|
return ds, ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||||
|
user, err := ds.User(ctx).FindByUsername(id)
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
|
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
user, err = ds.User(ctx).Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@@ -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
2
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.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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user