diff --git a/internal/commands/passwd.go b/internal/commands/passwd.go index d113a94ab..4861fcbd6 100644 --- a/internal/commands/passwd.go +++ b/internal/commands/passwd.go @@ -39,7 +39,7 @@ func passwdAction(ctx *cli.Context) error { user := entity.Admin - log.Infof("please enter a new password for %s (at least 6 characters)\n", txt.Quote(user.UserName)) + log.Infof("please enter a new password for %s (at least 6 characters)\n", txt.Quote(user.Username())) newPassword := getPassword("New Password: ") @@ -57,7 +57,7 @@ func passwdAction(ctx *cli.Context) error { return err } - log.Infof("changed password for %s\n", txt.Quote(user.UserName)) + log.Infof("changed password for %s\n", txt.Quote(user.Username())) conf.Shutdown() diff --git a/internal/commands/users.go b/internal/commands/users.go index 29b9ee5c2..638b0caf7 100644 --- a/internal/commands/users.go +++ b/internal/commands/users.go @@ -211,7 +211,7 @@ func usersListAction(ctx *cli.Context) error { fmt.Printf("%-4s %-16s %-16s %-16s\n", "ID", "LOGIN", "NAME", "EMAIL") for _, user := range users { - fmt.Printf("%-4d %-16s %-16s %-16s", user.ID, user.UserName, user.FullName, user.PrimaryEmail) + fmt.Printf("%-4d %-16s %-16s %-16s", user.ID, user.Username(), user.FullName, user.PrimaryEmail) fmt.Printf("\n") } @@ -242,7 +242,7 @@ func usersUpdateAction(ctx *cli.Context) error { if err != nil { return err } - fmt.Printf("password successfully changed: %v\n", u.UserName) + fmt.Printf("password successfully changed: %v\n", u.Username()) } if ctx.IsSet("fullname") { @@ -261,7 +261,7 @@ func usersUpdateAction(ctx *cli.Context) error { return err } - fmt.Printf("user successfully updated: %v\n", u.UserName) + fmt.Printf("user successfully updated: %v\n", u.Username()) return nil }) diff --git a/internal/entity/cell.go b/internal/entity/cell.go index 30fca69a1..318749e25 100644 --- a/internal/entity/cell.go +++ b/internal/entity/cell.go @@ -79,34 +79,41 @@ func (m *Cell) Refresh(api string) (err error) { return nil } - place := &Place{} + cellTable := Cell{}.TableName() + placeTable := Place{}.TableName() + + place := Place{} // Find existing place by label. if err := UnscopedDb().Where("place_label = ?", l.Label()).First(&place).Error; err != nil { log.Tracef("places: %s for cell %s", err, m.ID) - place = &Place{ID: m.ID} + place = Place{ID: m.ID} } else { log.Tracef("places: found matching place %s for cell %s", place.ID, m.ID) } // Update place. - if res := UnscopedDb().Model(place).Updates(Values{ - "PlaceLabel": l.Label(), - "PlaceCity": l.City(), - "PlaceDistrict": l.District(), - "PlaceState": l.State(), - "PlaceCountry": l.CountryCode(), - "PlaceKeywords": l.KeywordString(), - }); res.Error == nil && res.RowsAffected == 1 { + if place.ID == "" { + // Do nothing. + } else if res := UnscopedDb().Table(placeTable).Where("id = ?", place.ID).UpdateColumns(Values{ + "place_label": l.Label(), + "place_city": l.City(), + "place_district": l.District(), + "place_state": l.State(), + "place_country": l.CountryCode(), + "place_keywords": l.KeywordString(), + }); res.Error != nil { + log.Tracef("places: %s for cell %s", err, m.ID) + } else if res.RowsAffected > 0 { // Update cell place id, name, and category. log.Tracef("places: updating place, name, and category for cell %s", m.ID) - m.PlaceID = place.ID - err = UnscopedDb().Model(m).Updates(Values{"PlaceID": m.PlaceID, "CellName": l.Name(), "CellCategory": l.Category()}).Error - + err = UnscopedDb().Table(cellTable).Where("id = ?", m.ID). + UpdateColumns(Values{"cell_name": l.Name(), "cell_category": l.Category(), "place_id": place.ID}).Error } else { // Update cell name and category. log.Tracef("places: updating name and category for cell %s", m.ID) - err = UnscopedDb().Model(m).Updates(Values{"CellName": l.Name(), "CellCategory": l.Category()}).Error + err = UnscopedDb().Table(cellTable).Where("id = ?", m.ID). + UpdateColumns(Values{"cell_name": l.Name(), "cell_category": l.Category()}).Error } log.Debugf("places: refreshed cell %s [%s]", txt.Quote(m.ID), time.Since(start)) diff --git a/internal/entity/user.go b/internal/entity/user.go index e7068bab6..9a7ddb76e 100644 --- a/internal/entity/user.go +++ b/internal/entity/user.go @@ -165,6 +165,8 @@ func FirstOrCreateUser(m *User) *User { // FindUserByName returns an existing user or nil if not found. func FindUserByName(userName string) *User { + userName = txt.NormalizeUsername(userName) + if userName == "" { return nil } @@ -211,8 +213,8 @@ func (m *User) Deleted() bool { // String returns an identifier that can be used in logs. func (m *User) String() string { - if m.UserName != "" { - return m.UserName + if n := m.Username(); n != "" { + return n } if m.FullName != "" { @@ -222,9 +224,14 @@ func (m *User) String() string { return m.UserUID } +// Username returns the normalized username. +func (m *User) Username() string { + return txt.NormalizeUsername(m.UserName) +} + // Registered tests if the user is registered e.g. has a username. func (m *User) Registered() bool { - return m.UserName != "" && rnd.IsPPID(m.UserUID, 'u') + return m.Username() != "" && rnd.IsPPID(m.UserUID, 'u') } // Admin returns true if the user is an admin with user name. @@ -249,7 +256,7 @@ func (m *User) SetPassword(password string) error { } if len(password) < 4 { - return fmt.Errorf("new password for %s must be at least 4 characters", txt.Quote(m.UserName)) + return fmt.Errorf("new password for %s must be at least 4 characters", txt.Quote(m.Username())) } pw := NewPassword(m.UserUID, password) @@ -342,30 +349,37 @@ func (m *User) Role() acl.Role { // Validate Makes sure username and email are unique and meet requirements. Returns error if any property is invalid func (m *User) Validate() error { - if m.UserName == "" { + if m.Username() == "" { return errors.New("username must not be empty") } - if len(m.UserName) < 4 { + + if len(m.Username()) < 4 { return errors.New("username must be at least 4 characters") } + var err error var resultName = User{} - if err = Db().Where("user_name = ? AND id <> ?", m.UserName, m.ID).First(&resultName).Error; err == nil { + + if err = Db().Where("user_name = ? AND id <> ?", m.Username(), m.ID).First(&resultName).Error; err == nil { return errors.New("username already exists") } else if err != gorm.ErrRecordNotFound { return err } + // stop here if no email is provided if m.PrimaryEmail == "" { return nil } + // validate email address if a, err := mail.ParseAddress(m.PrimaryEmail); err != nil { return err } else { m.PrimaryEmail = a.Address // make sure email address will be used without name } + var resultMail = User{} + if err = Db().Where("primary_email = ? AND id <> ?", m.PrimaryEmail, m.ID).First(&resultMail).Error; err == nil { return errors.New("email already exists") } else if err != gorm.ErrRecordNotFound { @@ -384,7 +398,7 @@ func CreateWithPassword(uc form.UserCreate) error { RoleAdmin: true, } if len(uc.Password) < 4 { - return fmt.Errorf("new password for %s must be at least 4 characters", txt.Quote(u.UserName)) + return fmt.Errorf("new password for %s must be at least 4 characters", txt.Quote(u.Username())) } err := u.Validate() if err != nil { @@ -398,7 +412,7 @@ func CreateWithPassword(uc form.UserCreate) error { if err := tx.Create(&pw).Error; err != nil { return err } - log.Infof("created user %v with uid %v", txt.Quote(u.UserName), txt.Quote(u.UserUID)) + log.Infof("created user %v with uid %v", txt.Quote(u.Username()), txt.Quote(u.UserUID)) return nil }) } diff --git a/internal/entity/user_fixtures_test.go b/internal/entity/user_fixtures_test.go index 954d8089c..5ff5e680e 100644 --- a/internal/entity/user_fixtures_test.go +++ b/internal/entity/user_fixtures_test.go @@ -10,11 +10,13 @@ func TestUserMap_Get(t *testing.T) { t.Run("get existing user", func(t *testing.T) { r := UserFixtures.Get("alice") assert.Equal(t, "alice", r.UserName) + assert.Equal(t, "alice", r.Username()) assert.IsType(t, User{}, r) }) t.Run("get not existing user", func(t *testing.T) { r := UserFixtures.Get("monstera") assert.Equal(t, "", r.UserName) + assert.Equal(t, "", r.Username()) assert.IsType(t, User{}, r) }) } diff --git a/internal/entity/user_test.go b/internal/entity/user_test.go index e08410b3e..1c0742cec 100644 --- a/internal/entity/user_test.go +++ b/internal/entity/user_test.go @@ -20,6 +20,10 @@ func TestFindUserByName(t *testing.T) { assert.Equal(t, 1, m.ID) assert.NotEmpty(t, m.UserUID) assert.Equal(t, "admin", m.UserName) + assert.Equal(t, "admin", m.Username()) + m.UserName = "Admin " + assert.Equal(t, "admin", m.Username()) + assert.Equal(t, "Admin ", m.UserName) assert.Equal(t, "Admin", m.FullName) assert.True(t, m.RoleAdmin) assert.False(t, m.RoleGuest) @@ -203,7 +207,7 @@ func TestFindUserByUID(t *testing.T) { assert.Equal(t, 5, m.ID) assert.Equal(t, "uqxetse3cy5eo9z2", m.UserUID) - assert.Equal(t, "alice", m.UserName) + assert.Equal(t, "alice", m.Username()) assert.Equal(t, "Alice", m.FullName) assert.Equal(t, "alice@example.com", m.PrimaryEmail) assert.True(t, m.RoleAdmin) @@ -251,17 +255,17 @@ func TestFindUserByUID(t *testing.T) { } func TestUser_String(t *testing.T) { - t.Run("return UID", func(t *testing.T) { + t.Run("UID", func(t *testing.T) { p := User{UserUID: "abc123", UserName: "", FullName: ""} assert.Equal(t, "abc123", p.String()) }) - t.Run("return display name", func(t *testing.T) { + t.Run("DisplayName", func(t *testing.T) { p := User{UserUID: "abc123", UserName: "", FullName: "Test"} assert.Equal(t, "Test", p.String()) }) - t.Run("return user name", func(t *testing.T) { - p := User{UserUID: "abc123", UserName: "Super-User", FullName: "Test"} - assert.Equal(t, "Super-User", p.String()) + t.Run("UserName", func(t *testing.T) { + p := User{UserUID: "abc123", UserName: "Super-User ", FullName: "Test"} + assert.Equal(t, "super-user", p.String()) }) } diff --git a/internal/form/user.go b/internal/form/user.go index b0ce3b869..ddcf5825b 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -1,5 +1,7 @@ package form +import "github.com/photoprism/photoprism/pkg/txt" + // UserCreate represents a User with a new password. type UserCreate struct { UserName string `json:"username"` @@ -7,3 +9,8 @@ type UserCreate struct { Email string `json:"email"` Password string `json:"password"` } + +// Username returns the normalized username in lowercase and without whitespace padding. +func (f UserCreate) Username() string { + return txt.NormalizeUsername(f.UserName) +} diff --git a/internal/photoprism/places.go b/internal/photoprism/places.go index 5d020977a..fde61db8a 100644 --- a/internal/photoprism/places.go +++ b/internal/photoprism/places.go @@ -81,7 +81,7 @@ func (w *Places) Start() (updated []string, err error) { } // Short break. - time.Sleep(100 * time.Millisecond) + time.Sleep(25 * time.Millisecond) } return updated, err diff --git a/internal/query/users_test.go b/internal/query/users_test.go index 8f4c71ff7..9e5a5ab30 100644 --- a/internal/query/users_test.go +++ b/internal/query/users_test.go @@ -11,7 +11,7 @@ func TestRegisteredUsers(t *testing.T) { users := RegisteredUsers() for _, user := range users { - t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.UserName, user.FullName) + t.Logf("user: %v, %s, %s, %s", user.ID, user.UserUID, user.Username(), user.FullName) } t.Logf("user count: %v", len(users)) diff --git a/pkg/txt/clip.go b/pkg/txt/clip.go index d172fa502..a838fb931 100644 --- a/pkg/txt/clip.go +++ b/pkg/txt/clip.go @@ -8,6 +8,7 @@ const ( Ellipsis = "…" ClipCountryCode = 2 ClipKeyword = 40 + ClipUsername = 64 ClipSlug = 80 ClipCategory = 100 ClipPlace = 128 diff --git a/pkg/txt/normalize.go b/pkg/txt/normalize.go index 8e95d078e..83a90492f 100644 --- a/pkg/txt/normalize.go +++ b/pkg/txt/normalize.go @@ -67,3 +67,8 @@ func NormalizeQuery(s string) string { s = strings.ReplaceAll(s, "%", "*") return strings.Trim(s, "+&|_-=!@$%^(){}\\<>,.;: ") } + +// NormalizeUsername returns the normalized username (lowercase, whitespace trimmed). +func NormalizeUsername(s string) string { + return strings.ToLower(Clip(s, ClipUsername)) +}