aboutsummaryrefslogtreecommitdiff
path: root/internal/db/users.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/db/users.go')
-rw-r--r--internal/db/users.go211
1 files changed, 209 insertions, 2 deletions
diff --git a/internal/db/users.go b/internal/db/users.go
index 631a7ff8..c39f9f39 100644
--- a/internal/db/users.go
+++ b/internal/db/users.go
@@ -49,7 +49,7 @@ type UsersStore interface {
// Create creates a new user and persists to database. It returns
// ErrNameNotAllowed if the given name or pattern of the name is not allowed as
// a username, or ErrUserAlreadyExist when a user with same name already exists,
- // or ErrEmailAlreadyUsed if the email has been used by another user.
+ // or ErrEmailAlreadyUsed if the email has been verified by another user.
Create(ctx context.Context, username, email string, opts CreateUserOptions) (*User, error)
// GetByEmail returns the user (not organization) with given email. It ignores
@@ -101,6 +101,27 @@ type UsersStore interface {
// DeleteInactivated deletes all inactivated users.
DeleteInactivated() error
+ // AddEmail adds a new email address to given user. It returns
+ // ErrEmailAlreadyUsed if the email has been verified by another user.
+ AddEmail(ctx context.Context, userID int64, email string, isActivated bool) error
+ // GetEmail returns the email address of the given user. If `needsActivated` is
+ // true, only activated email will be returned, otherwise, it may return
+ // inactivated email addresses. It returns ErrEmailNotExist when no qualified
+ // email is not found.
+ GetEmail(ctx context.Context, userID int64, email string, needsActivated bool) (*EmailAddress, error)
+ // ListEmails returns all email addresses of the given user. It always includes
+ // a primary email address.
+ ListEmails(ctx context.Context, userID int64) ([]*EmailAddress, error)
+ // MarkEmailActivated marks the email address of the given user as activated,
+ // and new rands are generated for the user.
+ MarkEmailActivated(ctx context.Context, userID int64, email string) error
+ // MarkEmailPrimary marks the email address of the given user as primary. It
+ // returns ErrEmailNotExist when the email is not found for the user, and
+ // ErrEmailNotActivated when the email is not activated.
+ MarkEmailPrimary(ctx context.Context, userID int64, email string) error
+ // DeleteEmail deletes the email address of the given user.
+ DeleteEmail(ctx context.Context, userID int64, email string) error
+
// Follow marks the user to follow the other user.
Follow(ctx context.Context, userID, followID int64) error
// Unfollow removes the mark the user to follow the other user.
@@ -386,7 +407,7 @@ func (db *users) Create(ctx context.Context, username, email string, opts Create
}
}
- email = strings.ToLower(email)
+ email = strings.ToLower(strings.TrimSpace(email))
_, err = db.GetByEmail(ctx, email)
if err == nil {
return nil, ErrEmailAlreadyUsed{
@@ -1061,6 +1082,183 @@ func (db *users) UseCustomAvatar(ctx context.Context, userID int64, avatar []byt
Error
}
+func (db *users) AddEmail(ctx context.Context, userID int64, email string, isActivated bool) error {
+ email = strings.ToLower(strings.TrimSpace(email))
+ _, err := db.GetByEmail(ctx, email)
+ if err == nil {
+ return ErrEmailAlreadyUsed{
+ args: errutil.Args{
+ "email": email,
+ },
+ }
+ } else if !IsErrUserNotExist(err) {
+ return errors.Wrap(err, "check user by email")
+ }
+
+ return db.WithContext(ctx).Create(
+ &EmailAddress{
+ UserID: userID,
+ Email: email,
+ IsActivated: isActivated,
+ },
+ ).Error
+}
+
+var _ errutil.NotFound = (*ErrEmailNotExist)(nil)
+
+type ErrEmailNotExist struct {
+ args errutil.Args
+}
+
+// IsErrEmailAddressNotExist returns true if the underlying error has the type
+// ErrEmailNotExist.
+func IsErrEmailAddressNotExist(err error) bool {
+ _, ok := errors.Cause(err).(ErrEmailNotExist)
+ return ok
+}
+
+func (err ErrEmailNotExist) Error() string {
+ return fmt.Sprintf("email address does not exist: %v", err.args)
+}
+
+func (ErrEmailNotExist) NotFound() bool {
+ return true
+}
+
+func (db *users) GetEmail(ctx context.Context, userID int64, email string, needsActivated bool) (*EmailAddress, error) {
+ tx := db.WithContext(ctx).Where("uid = ? AND email = ?", userID, email)
+ if needsActivated {
+ tx = tx.Where("is_activated = ?", true)
+ }
+
+ emailAddress := new(EmailAddress)
+ err := tx.First(emailAddress).Error
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return nil, ErrEmailNotExist{
+ args: errutil.Args{
+ "email": email,
+ },
+ }
+ }
+ return nil, err
+ }
+ return emailAddress, nil
+}
+
+func (db *users) ListEmails(ctx context.Context, userID int64) ([]*EmailAddress, error) {
+ user, err := db.GetByID(ctx, userID)
+ if err != nil {
+ return nil, errors.Wrap(err, "get user")
+ }
+
+ var emails []*EmailAddress
+ err = db.WithContext(ctx).Where("uid = ?", userID).Order("id ASC").Find(&emails).Error
+ if err != nil {
+ return nil, errors.Wrap(err, "list emails")
+ }
+
+ isPrimaryFound := false
+ for _, email := range emails {
+ if email.Email == user.Email {
+ isPrimaryFound = true
+ email.IsPrimary = true
+ break
+ }
+ }
+
+ // We always want the primary email address displayed, even if it's not in the
+ // email_address table yet.
+ if !isPrimaryFound {
+ emails = append(emails, &EmailAddress{
+ Email: user.Email,
+ IsActivated: user.IsActive,
+ IsPrimary: true,
+ })
+ }
+ return emails, nil
+}
+
+func (db *users) MarkEmailActivated(ctx context.Context, userID int64, email string) error {
+ return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ err := db.WithContext(ctx).
+ Model(&EmailAddress{}).
+ Where("uid = ? AND email = ?", userID, email).
+ Update("is_activated", true).
+ Error
+ if err != nil {
+ return errors.Wrap(err, "mark email activated")
+ }
+
+ return NewUsersStore(tx).Update(ctx, userID, UpdateUserOptions{GenerateNewRands: true})
+ })
+}
+
+type ErrEmailNotVerified struct {
+ args errutil.Args
+}
+
+// IsErrEmailNotVerified returns true if the underlying error has the type
+// ErrEmailNotVerified.
+func IsErrEmailNotVerified(err error) bool {
+ _, ok := errors.Cause(err).(ErrEmailNotVerified)
+ return ok
+}
+
+func (err ErrEmailNotVerified) Error() string {
+ return fmt.Sprintf("email has not been verified: %v", err.args)
+}
+
+func (db *users) MarkEmailPrimary(ctx context.Context, userID int64, email string) error {
+ var emailAddress EmailAddress
+ err := db.WithContext(ctx).Where("uid = ? AND email = ?", userID, email).First(&emailAddress).Error
+ if err != nil {
+ if err == gorm.ErrRecordNotFound {
+ return ErrEmailNotExist{args: errutil.Args{"email": email}}
+ }
+ return errors.Wrap(err, "get email address")
+ }
+
+ if !emailAddress.IsActivated {
+ return ErrEmailNotVerified{args: errutil.Args{"email": email}}
+ }
+
+ user, err := db.GetByID(ctx, userID)
+ if err != nil {
+ return errors.Wrap(err, "get user")
+ }
+
+ return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // Make sure the former primary email doesn't disappear.
+ err = tx.FirstOrCreate(
+ &EmailAddress{
+ UserID: user.ID,
+ Email: user.Email,
+ IsActivated: user.IsActive,
+ },
+ &EmailAddress{
+ UserID: user.ID,
+ Email: user.Email,
+ },
+ ).Error
+ if err != nil {
+ return errors.Wrap(err, "upsert former primary email address")
+ }
+
+ return tx.Model(&User{}).
+ Where("id = ?", user.ID).
+ Updates(map[string]any{
+ "email": email,
+ "updated_unix": tx.NowFunc().Unix(),
+ },
+ ).Error
+ })
+}
+
+func (db *users) DeleteEmail(ctx context.Context, userID int64, email string) error {
+ return db.WithContext(ctx).Where("uid = ? AND email = ?", userID, email).Delete(&EmailAddress{}).Error
+}
+
// UserType indicates the type of the user account.
type UserType int
@@ -1422,6 +1620,15 @@ func isUsernameAllowed(name string) error {
return isNameAllowed(reservedUsernames, reservedUsernamePatterns, name)
}
+// EmailAddress is an email address of a user.
+type EmailAddress struct {
+ ID int64 `gorm:"primaryKey"`
+ UserID int64 `xorm:"uid INDEX NOT NULL" gorm:"column:uid;index;uniqueIndex:email_address_user_email_unique;not null"`
+ Email string `xorm:"UNIQUE NOT NULL" gorm:"uniqueIndex:email_address_user_email_unique;not null;size:254"`
+ IsActivated bool `gorm:"not null;default:FALSE"`
+ IsPrimary bool `xorm:"-" gorm:"-" json:"-"`
+}
+
// Follow represents relations of users and their followers.
type Follow struct {
ID int64 `gorm:"primaryKey"`