diff --git a/internal/commands/passwd.go b/internal/commands/passwd.go index d99da262e..bebce5f90 100644 --- a/internal/commands/passwd.go +++ b/internal/commands/passwd.go @@ -57,6 +57,8 @@ func passwdAction(ctx *cli.Context) error { if m == nil { return fmt.Errorf("user %s not found", clean.LogQuote(id)) + } else if m.Deleted() { + return fmt.Errorf("user %s has been deleted", clean.LogQuote(id)) } log.Infof("please enter a new password for %s (minimum %d characters)\n", clean.Log(m.Name()), entity.PasswordLength) diff --git a/internal/commands/users_add.go b/internal/commands/users_add.go index 8fbb110ad..8bbe3943b 100644 --- a/internal/commands/users_add.go +++ b/internal/commands/users_add.go @@ -47,7 +47,33 @@ func usersAddAction(ctx *cli.Context) error { return err } - frm.UserName = clean.Username(res) + frm.UserName = clean.DN(res) + } + + // Check if account exists but is deleted. + if frm.UserName == "" { + return fmt.Errorf("username is required") + } else if m := entity.FindUserByName(frm.UserName); m != nil { + if !m.Deleted() { + return fmt.Errorf("user already exists") + } + + prompt := promptui.Prompt{ + Label: fmt.Sprintf("Restore user %s?", m.String()), + IsConfirm: true, + } + + if _, err := prompt.Run(); err != nil { + return fmt.Errorf("user already exists") + } + + if err := m.RestoreFromCli(ctx, frm.Password); err != nil { + return err + } + + log.Infof("user %s has been restored", m.String()) + + return nil } if interactive && frm.UserEmail == "" { diff --git a/internal/commands/users_mod.go b/internal/commands/users_mod.go index 3aa8cd86c..2c09cd9ca 100644 --- a/internal/commands/users_mod.go +++ b/internal/commands/users_mod.go @@ -3,13 +3,13 @@ package commands import ( "fmt" - "github.com/photoprism/photoprism/pkg/rnd" - + "github.com/manifoldco/promptui" "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/rnd" ) // UsersModCommand configures the command name, flags, and action. @@ -46,6 +46,21 @@ func usersModAction(ctx *cli.Context) error { return fmt.Errorf("user %s not found", clean.LogQuote(id)) } + // Check if account exists but is deleted. + if m.Deleted() { + prompt := promptui.Prompt{ + Label: fmt.Sprintf("Restore user %s?", m.String()), + IsConfirm: true, + } + + if _, err := prompt.Run(); err != nil { + return fmt.Errorf("user already exists") + } + + m.DeletedAt = nil + log.Infof("user %s will be restored", m.String()) + } + // Set values. if err := m.SetValuesFromCli(ctx); err != nil { return err diff --git a/internal/commands/users_remove.go b/internal/commands/users_remove.go index 32e4ac4a3..8a0db1fea 100644 --- a/internal/commands/users_remove.go +++ b/internal/commands/users_remove.go @@ -3,14 +3,13 @@ package commands import ( "fmt" - "github.com/photoprism/photoprism/pkg/rnd" - "github.com/manifoldco/promptui" "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/rnd" ) // UsersRemoveCommand configures the command name, flags, and action. @@ -18,7 +17,13 @@ var UsersRemoveCommand = cli.Command{ Name: "rm", Usage: "Removes a user account", ArgsUsage: "[username]", - Action: usersRemoveAction, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "force, f", + Usage: "don't ask for confirmation", + }, + }, + Action: usersRemoveAction, } // usersRemoveAction deletes a user account. @@ -44,23 +49,28 @@ func usersRemoveAction(ctx *cli.Context) error { if m == nil { return fmt.Errorf("user %s not found", clean.LogQuote(id)) + } else if m.Deleted() { + return fmt.Errorf("user %s has already been deleted", clean.LogQuote(id)) } - actionPrompt := promptui.Prompt{ - Label: fmt.Sprintf("Remove user %s?", m.String()), - IsConfirm: true, - } - - if _, err := actionPrompt.Run(); err == nil { - if err = m.Delete(); err != nil { - return err - } else { - log.Infof("user %s has been removed", m.String()) + if !ctx.Bool("force") { + actionPrompt := promptui.Prompt{ + Label: fmt.Sprintf("Delete user %s?", m.String()), + IsConfirm: true, + } + + if _, err := actionPrompt.Run(); err != nil { + log.Infof("user %s was not deleted", m.String()) + return nil } - } else { - log.Infof("user %s was not removed", m.String()) } + if err := m.Delete(); err != nil { + return err + } + + log.Infof("user %s has been deleted", m.String()) + return nil }) } diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go index 7ed1790af..20ffe1160 100644 --- a/internal/entity/auth_user.go +++ b/internal/entity/auth_user.go @@ -232,7 +232,7 @@ func (m *User) Create() (err error) { func (m *User) Save() (err error) { m.GenerateTokens(false) - err = Db().Save(m).Error + err = UnscopedDb().Save(m).Error if err == nil { m.SaveRelated() @@ -242,12 +242,22 @@ func (m *User) Save() (err error) { } // Delete marks the entity as deleted. -func (m *User) Delete() error { +func (m *User) Delete() (err error) { if m.ID <= 1 { return fmt.Errorf("cannot delete system user") + } else if m.UserUID == "" { + return fmt.Errorf("uid is required to delete user") } - return Db().Delete(m).Error + if err = UnscopedDb().Delete(Session{}, "user_uid = ?", m.UserUID).Error; err != nil { + event.AuditErr([]string{"user %s", "delete", "failed to remove sessions", "%s"}, m.RefID, err) + } + + err = Db().Delete(m).Error + + FlushSessionCache() + + return err } // Deleted checks if the user account has been deleted. @@ -325,7 +335,11 @@ func (m *User) Disabled() bool { // CanLogIn checks if the user is allowed to log in and use the web UI. func (m *User) CanLogIn() bool { - if !m.CanLogin && !m.SuperAdmin || m.ID <= 0 || m.UserName == "" { + if m == nil { + return false + } else if m.Deleted() { + return false + } else if !m.CanLogin && !m.SuperAdmin || m.ID <= 0 || m.UserName == "" { return false } else if role := m.AclRole(); m.Disabled() || role == acl.RoleUnknown { return false @@ -404,9 +418,9 @@ func (m *User) SetUploadPath(dir string) *User { // String returns an identifier that can be used in logs. func (m *User) String() string { if n := m.Name(); n != "" { - return clean.Log(n) + return clean.LogQuote(n) } else if n = m.FullName(); n != "" { - return clean.Log(n) + return clean.LogQuote(n) } return clean.Log(m.UserUID) diff --git a/internal/entity/auth_user_cli.go b/internal/entity/auth_user_cli.go index f0e42cb85..14c51327d 100644 --- a/internal/entity/auth_user_cli.go +++ b/internal/entity/auth_user_cli.go @@ -58,3 +58,24 @@ func (m *User) SetValuesFromCli(ctx *cli.Context) error { return m.Validate() } + +// RestoreFromCli restored the account from a CLI context. +func (m *User) RestoreFromCli(ctx *cli.Context, newPassword string) (err error) { + m.DeletedAt = nil + + // Set values. + if err = m.SetValuesFromCli(ctx); err != nil { + return err + } + + // Save values. + if err = m.Save(); err != nil { + return err + } else if newPassword == "" { + return nil + } else if err = m.SetPassword(newPassword); err != nil { + return err + } + + return nil +} diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go index d37767272..90d46a3e9 100644 --- a/internal/entity/auth_user_test.go +++ b/internal/entity/auth_user_test.go @@ -384,11 +384,11 @@ func TestUser_String(t *testing.T) { }) t.Run("FullName", func(t *testing.T) { p := User{UserUID: "abc123", UserName: "", DisplayName: "Test"} - assert.Equal(t, "Test", p.String()) + assert.Equal(t, "'Test'", p.String()) }) t.Run("UserName", func(t *testing.T) { p := User{UserUID: "abc123", UserName: "Super-User ", DisplayName: "Test"} - assert.Equal(t, "super-user", p.String()) + assert.Equal(t, "'super-user'", p.String()) }) } diff --git a/internal/migrate/migrations.go b/internal/migrate/migrations.go index 54dde0cd8..ee6913761 100644 --- a/internal/migrate/migrations.go +++ b/internal/migrate/migrations.go @@ -76,7 +76,7 @@ func (m *Migrations) Start(db *gorm.DB, opt Options) { // Log information about existing migrations. if prev := len(executed); prev > 0 { stage := fmt.Sprintf("previously executed %s stage", opt.StageName()) - log.Debugf("migrate: found %s", english.Plural(len(executed), stage+" migration", stage+" migrations")) + log.Tracef("migrate: found %s", english.Plural(len(executed), stage+" migration", stage+" migrations")) } // Run migrations.