diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /models/user | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
29 files changed, 4784 insertions, 0 deletions
diff --git a/models/user/avatar.go b/models/user/avatar.go new file mode 100644 index 0000000..c6937d7 --- /dev/null +++ b/models/user/avatar.go @@ -0,0 +1,115 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "crypto/md5" + "fmt" + "image/png" + "io" + "strings" + + "code.gitea.io/gitea/models/avatars" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/avatar" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" +) + +// CustomAvatarRelativePath returns user custom avatar relative path. +func (u *User) CustomAvatarRelativePath() string { + return u.Avatar +} + +// GenerateRandomAvatar generates a random avatar for user. +func GenerateRandomAvatar(ctx context.Context, u *User) error { + seed := u.Email + if len(seed) == 0 { + seed = u.Name + } + + img, err := avatar.RandomImage([]byte(seed)) + if err != nil { + return fmt.Errorf("RandomImage: %w", err) + } + + u.Avatar = avatars.HashEmail(seed) + + // Don't share the images so that we can delete them easily + if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { + if err := png.Encode(w, img); err != nil { + log.Error("Encode: %v", err) + } + return err + }); err != nil { + return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err) + } + + if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar").Update(u); err != nil { + return err + } + + log.Info("New random avatar created: %d", u.ID) + return nil +} + +// AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size +func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string { + if u.IsGhost() { + return avatars.DefaultAvatarLink() + } + + useLocalAvatar := false + autoGenerateAvatar := false + + disableGravatar := setting.Config().Picture.DisableGravatar.Value(ctx) + + switch { + case u.UseCustomAvatar: + useLocalAvatar = true + case disableGravatar, setting.OfflineMode: + useLocalAvatar = true + autoGenerateAvatar = true + } + + if useLocalAvatar { + if u.Avatar == "" && autoGenerateAvatar { + if err := GenerateRandomAvatar(ctx, u); err != nil { + log.Error("GenerateRandomAvatar: %v", err) + } + } + if u.Avatar == "" { + return avatars.DefaultAvatarLink() + } + return avatars.GenerateUserAvatarImageLink(u.Avatar, size) + } + return avatars.GenerateEmailAvatarFastLink(ctx, u.AvatarEmail, size) +} + +// AvatarLink returns the full avatar link with http host +func (u *User) AvatarLink(ctx context.Context) string { + link := u.AvatarLinkWithSize(ctx, 0) + if !strings.HasPrefix(link, "//") && !strings.Contains(link, "://") { + return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL+"/") + } + return link +} + +// IsUploadAvatarChanged returns true if the current user's avatar would be changed with the provided data +func (u *User) IsUploadAvatarChanged(data []byte) bool { + if !u.UseCustomAvatar || len(u.Avatar) == 0 { + return true + } + avatarID := fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%d-%x", u.ID, md5.Sum(data))))) + return u.Avatar != avatarID +} + +// ExistsWithAvatarAtStoragePath returns true if there is a user with this Avatar +func ExistsWithAvatarAtStoragePath(ctx context.Context, storagePath string) (bool, error) { + // See func (u *User) CustomAvatarRelativePath() + // u.Avatar is used directly as the storage path - therefore we can check for existence directly using the path + return db.GetEngine(ctx).Where("`avatar`=?", storagePath).Exist(new(User)) +} diff --git a/models/user/badge.go b/models/user/badge.go new file mode 100644 index 0000000..ee52b44 --- /dev/null +++ b/models/user/badge.go @@ -0,0 +1,41 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +// Badge represents a user badge +type Badge struct { + ID int64 `xorm:"pk autoincr"` + Description string + ImageURL string +} + +// UserBadge represents a user badge +type UserBadge struct { //nolint:revive + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 `xorm:"INDEX"` +} + +func init() { + db.RegisterModel(new(Badge)) + db.RegisterModel(new(UserBadge)) +} + +// GetUserBadges returns the user's badges. +func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) { + sess := db.GetEngine(ctx). + Select("`badge`.*"). + Join("INNER", "user_badge", "`user_badge`.badge_id=badge.id"). + Where("user_badge.user_id=?", u.ID) + + badges := make([]*Badge, 0, 8) + count, err := sess.FindAndCount(&badges) + return badges, count, err +} diff --git a/models/user/block.go b/models/user/block.go new file mode 100644 index 0000000..189cacc --- /dev/null +++ b/models/user/block.go @@ -0,0 +1,91 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "errors" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" +) + +// ErrBlockedByUser defines an error stating that the user is not allowed to perform the action because they are blocked. +var ErrBlockedByUser = errors.New("user is blocked by the poster or repository owner") + +// BlockedUser represents a blocked user entry. +type BlockedUser struct { + ID int64 `xorm:"pk autoincr"` + // UID of the one who got blocked. + BlockID int64 `xorm:"index"` + // UID of the one who did the block action. + UserID int64 `xorm:"index"` + + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// TableName provides the real table name +func (*BlockedUser) TableName() string { + return "forgejo_blocked_user" +} + +func init() { + db.RegisterModel(new(BlockedUser)) +} + +// IsBlocked returns if userID has blocked blockID. +func IsBlocked(ctx context.Context, userID, blockID int64) bool { + has, _ := db.GetEngine(ctx).Exist(&BlockedUser{UserID: userID, BlockID: blockID}) + return has +} + +// IsBlockedMultiple returns if one of the userIDs has blocked blockID. +func IsBlockedMultiple(ctx context.Context, userIDs []int64, blockID int64) bool { + has, _ := db.GetEngine(ctx).In("user_id", userIDs).Exist(&BlockedUser{BlockID: blockID}) + return has +} + +// UnblockUser removes the blocked user entry. +func UnblockUser(ctx context.Context, userID, blockID int64) error { + _, err := db.GetEngine(ctx).Delete(&BlockedUser{UserID: userID, BlockID: blockID}) + return err +} + +// CountBlockedUsers returns the number of users the user has blocked. +func CountBlockedUsers(ctx context.Context, userID int64) (int64, error) { + return db.GetEngine(ctx).Where("user_id=?", userID).Count(&BlockedUser{}) +} + +// ListBlockedUsers returns the users that the user has blocked. +// The created_unix field of the user struct is overridden by the creation_unix +// field of blockeduser. +func ListBlockedUsers(ctx context.Context, userID int64, opts db.ListOptions) ([]*User, error) { + sess := db.GetEngine(ctx). + Select("`forgejo_blocked_user`.created_unix, `user`.*"). + Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id"). + Where("`forgejo_blocked_user`.user_id=?", userID) + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, &opts) + users := make([]*User, 0, opts.PageSize) + + return users, sess.Find(&users) + } + + users := make([]*User, 0, 8) + return users, sess.Find(&users) +} + +// ListBlockedByUsersID returns the ids of the users that blocked the user. +func ListBlockedByUsersID(ctx context.Context, userID int64) ([]int64, error) { + users := make([]int64, 0, 8) + err := db.GetEngine(ctx). + Table("user"). + Select("`user`.id"). + Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.user_id"). + Where("`forgejo_blocked_user`.block_id=?", userID). + Find(&users) + + return users, err +} diff --git a/models/user/block_test.go b/models/user/block_test.go new file mode 100644 index 0000000..a795ef3 --- /dev/null +++ b/models/user/block_test.go @@ -0,0 +1,78 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsBlocked(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlocked(db.DefaultContext, 1, 1)) + assert.False(t, user_model.IsBlocked(db.DefaultContext, 3, 2)) +} + +func TestIsBlockedMultiple(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4}, 1)) + assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4, 3, 4, 5}, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{1}, 1)) + assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{3, 4, 1}, 2)) +} + +func TestUnblockUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) + + require.NoError(t, user_model.UnblockUser(db.DefaultContext, 4, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) +} + +func TestListBlockedUsers(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + blockedUsers, err := user_model.ListBlockedUsers(db.DefaultContext, 4, db.ListOptions{}) + require.NoError(t, err) + if assert.Len(t, blockedUsers, 1) { + assert.EqualValues(t, 1, blockedUsers[0].ID) + // The function returns the created Unix of the block, not that of the user. + assert.EqualValues(t, 1671607299, blockedUsers[0].CreatedUnix) + } +} + +func TestListBlockedByUsersID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + blockedByUserIDs, err := user_model.ListBlockedByUsersID(db.DefaultContext, 1) + require.NoError(t, err) + if assert.Len(t, blockedByUserIDs, 1) { + assert.EqualValues(t, 4, blockedByUserIDs[0]) + } +} + +func TestCountBlockedUsers(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + count, err := user_model.CountBlockedUsers(db.DefaultContext, 4) + require.NoError(t, err) + assert.EqualValues(t, 1, count) + + count, err = user_model.CountBlockedUsers(db.DefaultContext, 1) + require.NoError(t, err) + assert.EqualValues(t, 0, count) +} diff --git a/models/user/email_address.go b/models/user/email_address.go new file mode 100644 index 0000000..011c3ed --- /dev/null +++ b/models/user/email_address.go @@ -0,0 +1,483 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + "net/mail" + "regexp" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" + + "xorm.io/builder" +) + +// ErrEmailNotActivated e-mail address has not been activated error +var ErrEmailNotActivated = util.NewInvalidArgumentErrorf("e-mail address has not been activated") + +// ErrEmailCharIsNotSupported e-mail address contains unsupported character +type ErrEmailCharIsNotSupported struct { + Email string +} + +// IsErrEmailCharIsNotSupported checks if an error is an ErrEmailCharIsNotSupported +func IsErrEmailCharIsNotSupported(err error) bool { + _, ok := err.(ErrEmailCharIsNotSupported) + return ok +} + +func (err ErrEmailCharIsNotSupported) Error() string { + return fmt.Sprintf("e-mail address contains unsupported character [email: %s]", err.Email) +} + +func (err ErrEmailCharIsNotSupported) Unwrap() error { + return util.ErrInvalidArgument +} + +// ErrEmailInvalid represents an error where the email address does not comply with RFC 5322 +// or has a leading '-' character +type ErrEmailInvalid struct { + Email string +} + +// IsErrEmailInvalid checks if an error is an ErrEmailInvalid +func IsErrEmailInvalid(err error) bool { + _, ok := err.(ErrEmailInvalid) + return ok +} + +func (err ErrEmailInvalid) Error() string { + return fmt.Sprintf("e-mail invalid [email: %s]", err.Email) +} + +func (err ErrEmailInvalid) Unwrap() error { + return util.ErrInvalidArgument +} + +// ErrEmailAlreadyUsed represents a "EmailAlreadyUsed" kind of error. +type ErrEmailAlreadyUsed struct { + Email string +} + +// IsErrEmailAlreadyUsed checks if an error is a ErrEmailAlreadyUsed. +func IsErrEmailAlreadyUsed(err error) bool { + _, ok := err.(ErrEmailAlreadyUsed) + return ok +} + +func (err ErrEmailAlreadyUsed) Error() string { + return fmt.Sprintf("e-mail already in use [email: %s]", err.Email) +} + +func (err ErrEmailAlreadyUsed) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrEmailAddressNotExist email address not exist +type ErrEmailAddressNotExist struct { + Email string +} + +// IsErrEmailAddressNotExist checks if an error is an ErrEmailAddressNotExist +func IsErrEmailAddressNotExist(err error) bool { + _, ok := err.(ErrEmailAddressNotExist) + return ok +} + +func (err ErrEmailAddressNotExist) Error() string { + return fmt.Sprintf("Email address does not exist [email: %s]", err.Email) +} + +func (err ErrEmailAddressNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrPrimaryEmailCannotDelete primary email address cannot be deleted +type ErrPrimaryEmailCannotDelete struct { + Email string +} + +// IsErrPrimaryEmailCannotDelete checks if an error is an ErrPrimaryEmailCannotDelete +func IsErrPrimaryEmailCannotDelete(err error) bool { + _, ok := err.(ErrPrimaryEmailCannotDelete) + return ok +} + +func (err ErrPrimaryEmailCannotDelete) Error() string { + return fmt.Sprintf("Primary email address cannot be deleted [email: %s]", err.Email) +} + +func (err ErrPrimaryEmailCannotDelete) Unwrap() error { + return util.ErrInvalidArgument +} + +// EmailAddress is the list of all email addresses of a user. It also contains the +// primary email address which is saved in user table. +type EmailAddress struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX NOT NULL"` + Email string `xorm:"UNIQUE NOT NULL"` + LowerEmail string `xorm:"UNIQUE NOT NULL"` + IsActivated bool + IsPrimary bool `xorm:"DEFAULT(false) NOT NULL"` +} + +func init() { + db.RegisterModel(new(EmailAddress)) +} + +// BeforeInsert will be invoked by XORM before inserting a record +func (email *EmailAddress) BeforeInsert() { + if email.LowerEmail == "" { + email.LowerEmail = strings.ToLower(email.Email) + } +} + +func InsertEmailAddress(ctx context.Context, email *EmailAddress) (*EmailAddress, error) { + if err := db.Insert(ctx, email); err != nil { + return nil, err + } + return email, nil +} + +func UpdateEmailAddress(ctx context.Context, email *EmailAddress) error { + _, err := db.GetEngine(ctx).ID(email.ID).AllCols().Update(email) + return err +} + +var emailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + +// ValidateEmail check if email is a valid & allowed address +func ValidateEmail(email string) error { + if err := validateEmailBasic(email); err != nil { + return err + } + return validateEmailDomain(email) +} + +// ValidateEmailForAdmin check if email is a valid address when admins manually add or edit users +func ValidateEmailForAdmin(email string) error { + return validateEmailBasic(email) + // In this case we do not need to check the email domain +} + +func GetEmailAddressByEmail(ctx context.Context, email string) (*EmailAddress, error) { + ea := &EmailAddress{} + if has, err := db.GetEngine(ctx).Where("lower_email=?", strings.ToLower(email)).Get(ea); err != nil { + return nil, err + } else if !has { + return nil, ErrEmailAddressNotExist{email} + } + return ea, nil +} + +func GetEmailAddressOfUser(ctx context.Context, email string, uid int64) (*EmailAddress, error) { + ea := &EmailAddress{} + if has, err := db.GetEngine(ctx).Where("lower_email=? AND uid=?", strings.ToLower(email), uid).Get(ea); err != nil { + return nil, err + } else if !has { + return nil, ErrEmailAddressNotExist{email} + } + return ea, nil +} + +func GetPrimaryEmailAddressOfUser(ctx context.Context, uid int64) (*EmailAddress, error) { + ea := &EmailAddress{} + if has, err := db.GetEngine(ctx).Where("uid=? AND is_primary=?", uid, true).Get(ea); err != nil { + return nil, err + } else if !has { + return nil, ErrEmailAddressNotExist{} + } + return ea, nil +} + +// GetEmailAddresses returns all email addresses belongs to given user. +func GetEmailAddresses(ctx context.Context, uid int64) ([]*EmailAddress, error) { + emails := make([]*EmailAddress, 0, 5) + if err := db.GetEngine(ctx). + Where("uid=?", uid). + Asc("id"). + Find(&emails); err != nil { + return nil, err + } + return emails, nil +} + +type ActivatedEmailAddress struct { + ID int64 + Email string +} + +func GetActivatedEmailAddresses(ctx context.Context, uid int64) ([]*ActivatedEmailAddress, error) { + emails := make([]*ActivatedEmailAddress, 0, 8) + if err := db.GetEngine(ctx). + Table("email_address"). + Select("id, email"). + Where("uid=?", uid). + And("is_activated=?", true). + Asc("id"). + Find(&emails); err != nil { + return nil, err + } + return emails, nil +} + +// GetEmailAddressByID gets a user's email address by ID +func GetEmailAddressByID(ctx context.Context, uid, id int64) (*EmailAddress, error) { + // User ID is required for security reasons + email := &EmailAddress{UID: uid} + if has, err := db.GetEngine(ctx).ID(id).Get(email); err != nil { + return nil, err + } else if !has { + return nil, nil + } + return email, nil +} + +// IsEmailActive check if email is activated with a different emailID +func IsEmailActive(ctx context.Context, email string, excludeEmailID int64) (bool, error) { + if len(email) == 0 { + return true, nil + } + + // Can't filter by boolean field unless it's explicit + cond := builder.NewCond() + cond = cond.And(builder.Eq{"lower_email": strings.ToLower(email)}, builder.Neq{"id": excludeEmailID}) + if setting.Service.RegisterEmailConfirm { + // Inactive (unvalidated) addresses don't count as active if email validation is required + cond = cond.And(builder.Eq{"is_activated": true}) + } + + var em EmailAddress + if has, err := db.GetEngine(ctx).Where(cond).Get(&em); has || err != nil { + if has { + log.Info("isEmailActive(%q, %d) found duplicate in email ID %d", email, excludeEmailID, em.ID) + } + return has, err + } + + return false, nil +} + +// IsEmailUsed returns true if the email has been used. +func IsEmailUsed(ctx context.Context, email string) (bool, error) { + if len(email) == 0 { + return true, nil + } + + return db.GetEngine(ctx).Where("lower_email=?", strings.ToLower(email)).Get(&EmailAddress{}) +} + +// ActivateEmail activates the email address to given user. +func ActivateEmail(ctx context.Context, email *EmailAddress) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + if err := updateActivation(ctx, email, true); err != nil { + return err + } + return committer.Commit() +} + +func updateActivation(ctx context.Context, email *EmailAddress, activate bool) error { + user, err := GetUserByID(ctx, email.UID) + if err != nil { + return err + } + if user.Rands, err = GetUserSalt(); err != nil { + return err + } + email.IsActivated = activate + if _, err := db.GetEngine(ctx).ID(email.ID).Cols("is_activated").Update(email); err != nil { + return err + } + return UpdateUserCols(ctx, user, "rands") +} + +// SearchEmailOrderBy is used to sort the results from SearchEmails() +type SearchEmailOrderBy string + +func (s SearchEmailOrderBy) String() string { + return string(s) +} + +// Strings for sorting result +const ( + SearchEmailOrderByEmail SearchEmailOrderBy = "email_address.lower_email ASC, email_address.is_primary DESC, email_address.id ASC" + SearchEmailOrderByEmailReverse SearchEmailOrderBy = "email_address.lower_email DESC, email_address.is_primary ASC, email_address.id DESC" + SearchEmailOrderByName SearchEmailOrderBy = "`user`.lower_name ASC, email_address.is_primary DESC, email_address.id ASC" + SearchEmailOrderByNameReverse SearchEmailOrderBy = "`user`.lower_name DESC, email_address.is_primary ASC, email_address.id DESC" +) + +// SearchEmailOptions are options to search e-mail addresses for the admin panel +type SearchEmailOptions struct { + db.ListOptions + Keyword string + SortType SearchEmailOrderBy + IsPrimary optional.Option[bool] + IsActivated optional.Option[bool] +} + +// SearchEmailResult is an e-mail address found in the user or email_address table +type SearchEmailResult struct { + ID int64 + UID int64 + Email string + IsActivated bool + IsPrimary bool + // From User + Name string + FullName string +} + +// SearchEmails takes options i.e. keyword and part of email name to search, +// it returns results in given range and number of total results. +func SearchEmails(ctx context.Context, opts *SearchEmailOptions) ([]*SearchEmailResult, int64, error) { + var cond builder.Cond = builder.Eq{"`user`.`type`": UserTypeIndividual} + if len(opts.Keyword) > 0 { + likeStr := "%" + strings.ToLower(opts.Keyword) + "%" + cond = cond.And(builder.Or( + builder.Like{"lower(`user`.full_name)", likeStr}, + builder.Like{"`user`.lower_name", likeStr}, + builder.Like{"email_address.lower_email", likeStr}, + )) + } + + if opts.IsPrimary.Has() { + cond = cond.And(builder.Eq{"email_address.is_primary": opts.IsPrimary.Value()}) + } + + if opts.IsActivated.Has() { + cond = cond.And(builder.Eq{"email_address.is_activated": opts.IsActivated.Value()}) + } + + count, err := db.GetEngine(ctx).Join("INNER", "`user`", "`user`.id = email_address.uid"). + Where(cond).Count(new(EmailAddress)) + if err != nil { + return nil, 0, fmt.Errorf("Count: %w", err) + } + + orderby := opts.SortType.String() + if orderby == "" { + orderby = SearchEmailOrderByEmail.String() + } + + opts.SetDefaultValues() + + emails := make([]*SearchEmailResult, 0, opts.PageSize) + err = db.GetEngine(ctx).Table("email_address"). + Select("email_address.*, `user`.name, `user`.full_name"). + Join("INNER", "`user`", "`user`.id = email_address.uid"). + Where(cond). + OrderBy(orderby). + Limit(opts.PageSize, (opts.Page-1)*opts.PageSize). + Find(&emails) + + return emails, count, err +} + +// ActivateUserEmail will change the activated state of an email address, +// either primary or secondary (all in the email_address table) +func ActivateUserEmail(ctx context.Context, userID int64, email string, activate bool) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + // Activate/deactivate a user's secondary email address + // First check if there's another user active with the same address + addr, exist, err := db.Get[EmailAddress](ctx, builder.Eq{"uid": userID, "lower_email": strings.ToLower(email)}) + if err != nil { + return err + } else if !exist { + return fmt.Errorf("no such email: %d (%s)", userID, email) + } + + if addr.IsActivated == activate { + // Already in the desired state; no action + return nil + } + if activate { + if used, err := IsEmailActive(ctx, email, addr.ID); err != nil { + return fmt.Errorf("unable to check isEmailActive() for %s: %w", email, err) + } else if used { + return ErrEmailAlreadyUsed{Email: email} + } + } + if err = updateActivation(ctx, addr, activate); err != nil { + return fmt.Errorf("unable to updateActivation() for %d:%s: %w", addr.ID, addr.Email, err) + } + + // Activate/deactivate a user's primary email address and account + if addr.IsPrimary { + user, exist, err := db.Get[User](ctx, builder.Eq{"id": userID, "email": email}) + if err != nil { + return err + } else if !exist { + return fmt.Errorf("no user with ID: %d and Email: %s", userID, email) + } + + // The user's activation state should be synchronized with the primary email + if user.IsActive != activate { + user.IsActive = activate + if user.Rands, err = GetUserSalt(); err != nil { + return fmt.Errorf("unable to generate salt: %w", err) + } + if err = UpdateUserCols(ctx, user, "is_active", "rands"); err != nil { + return fmt.Errorf("unable to updateUserCols() for user ID: %d: %w", userID, err) + } + } + } + + return committer.Commit() +} + +// validateEmailBasic checks whether the email complies with the rules +func validateEmailBasic(email string) error { + if len(email) == 0 { + return ErrEmailInvalid{email} + } + + if !emailRegexp.MatchString(email) { + return ErrEmailCharIsNotSupported{email} + } + + if email[0] == '-' { + return ErrEmailInvalid{email} + } + + if _, err := mail.ParseAddress(email); err != nil { + return ErrEmailInvalid{email} + } + + return nil +} + +// validateEmailDomain checks whether the email domain is allowed or blocked +func validateEmailDomain(email string) error { + if !IsEmailDomainAllowed(email) { + return ErrEmailInvalid{email} + } + + return nil +} + +func IsEmailDomainAllowed(email string) bool { + if len(setting.Service.EmailDomainAllowList) == 0 { + return !validation.IsEmailDomainListed(setting.Service.EmailDomainBlockList, email) + } + + return validation.IsEmailDomainListed(setting.Service.EmailDomainAllowList, email) +} diff --git a/models/user/email_address_test.go b/models/user/email_address_test.go new file mode 100644 index 0000000..b918f21 --- /dev/null +++ b/models/user/email_address_test.go @@ -0,0 +1,222 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "fmt" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetEmailAddresses(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + emails, _ := user_model.GetEmailAddresses(db.DefaultContext, int64(1)) + if assert.Len(t, emails, 3) { + assert.True(t, emails[0].IsPrimary) + assert.True(t, emails[2].IsActivated) + assert.False(t, emails[2].IsPrimary) + } + + emails, _ = user_model.GetEmailAddresses(db.DefaultContext, int64(2)) + if assert.Len(t, emails, 2) { + assert.True(t, emails[0].IsPrimary) + assert.True(t, emails[0].IsActivated) + } +} + +func TestIsEmailUsed(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + isExist, _ := user_model.IsEmailUsed(db.DefaultContext, "") + assert.True(t, isExist) + isExist, _ = user_model.IsEmailUsed(db.DefaultContext, "user11@example.com") + assert.True(t, isExist) + isExist, _ = user_model.IsEmailUsed(db.DefaultContext, "user1234567890@example.com") + assert.False(t, isExist) +} + +func TestActivate(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + email := &user_model.EmailAddress{ + ID: int64(1), + UID: int64(1), + Email: "user11@example.com", + } + require.NoError(t, user_model.ActivateEmail(db.DefaultContext, email)) + + emails, _ := user_model.GetEmailAddresses(db.DefaultContext, int64(1)) + assert.Len(t, emails, 3) + assert.True(t, emails[0].IsActivated) + assert.True(t, emails[0].IsPrimary) + assert.False(t, emails[1].IsPrimary) + assert.True(t, emails[2].IsActivated) + assert.False(t, emails[2].IsPrimary) +} + +func TestListEmails(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // Must find all users and their emails + opts := &user_model.SearchEmailOptions{ + ListOptions: db.ListOptions{ + PageSize: 10000, + }, + } + emails, count, err := user_model.SearchEmails(db.DefaultContext, opts) + require.NoError(t, err) + assert.Greater(t, count, int64(5)) + + contains := func(match func(s *user_model.SearchEmailResult) bool) bool { + for _, v := range emails { + if match(v) { + return true + } + } + return false + } + + assert.True(t, contains(func(s *user_model.SearchEmailResult) bool { return s.UID == 18 })) + // 'org3' is an organization + assert.False(t, contains(func(s *user_model.SearchEmailResult) bool { return s.UID == 3 })) + + // Must find no records + opts = &user_model.SearchEmailOptions{Keyword: "NOTFOUND"} + emails, count, err = user_model.SearchEmails(db.DefaultContext, opts) + require.NoError(t, err) + assert.Equal(t, int64(0), count) + + // Must find users 'user2', 'user28', etc. + opts = &user_model.SearchEmailOptions{Keyword: "user2"} + emails, count, err = user_model.SearchEmails(db.DefaultContext, opts) + require.NoError(t, err) + assert.NotEqual(t, int64(0), count) + assert.True(t, contains(func(s *user_model.SearchEmailResult) bool { return s.UID == 2 })) + assert.True(t, contains(func(s *user_model.SearchEmailResult) bool { return s.UID == 27 })) + + // Must find only primary addresses (i.e. from the `user` table) + opts = &user_model.SearchEmailOptions{IsPrimary: optional.Some(true)} + emails, _, err = user_model.SearchEmails(db.DefaultContext, opts) + require.NoError(t, err) + assert.True(t, contains(func(s *user_model.SearchEmailResult) bool { return s.IsPrimary })) + assert.False(t, contains(func(s *user_model.SearchEmailResult) bool { return !s.IsPrimary })) + + // Must find only inactive addresses (i.e. not validated) + opts = &user_model.SearchEmailOptions{IsActivated: optional.Some(false)} + emails, _, err = user_model.SearchEmails(db.DefaultContext, opts) + require.NoError(t, err) + assert.True(t, contains(func(s *user_model.SearchEmailResult) bool { return !s.IsActivated })) + assert.False(t, contains(func(s *user_model.SearchEmailResult) bool { return s.IsActivated })) + + // Must find more than one page, but retrieve only one + opts = &user_model.SearchEmailOptions{ + ListOptions: db.ListOptions{ + PageSize: 5, + Page: 1, + }, + } + emails, count, err = user_model.SearchEmails(db.DefaultContext, opts) + require.NoError(t, err) + assert.Len(t, emails, 5) + assert.Greater(t, count, int64(len(emails))) +} + +func TestEmailAddressValidate(t *testing.T) { + kases := map[string]error{ + "abc@gmail.com": nil, + "132@hotmail.com": nil, + "1-3-2@test.org": nil, + "1.3.2@test.org": nil, + "a_123@test.org.cn": nil, + `first.last@iana.org`: nil, + `first!last@iana.org`: nil, + `first#last@iana.org`: nil, + `first$last@iana.org`: nil, + `first%last@iana.org`: nil, + `first&last@iana.org`: nil, + `first'last@iana.org`: nil, + `first*last@iana.org`: nil, + `first+last@iana.org`: nil, + `first/last@iana.org`: nil, + `first=last@iana.org`: nil, + `first?last@iana.org`: nil, + `first^last@iana.org`: nil, + "first`last@iana.org": nil, + `first{last@iana.org`: nil, + `first|last@iana.org`: nil, + `first}last@iana.org`: nil, + `first~last@iana.org`: nil, + `first;last@iana.org`: user_model.ErrEmailCharIsNotSupported{`first;last@iana.org`}, + ".233@qq.com": user_model.ErrEmailInvalid{".233@qq.com"}, + "!233@qq.com": nil, + "#233@qq.com": nil, + "$233@qq.com": nil, + "%233@qq.com": nil, + "&233@qq.com": nil, + "'233@qq.com": nil, + "*233@qq.com": nil, + "+233@qq.com": nil, + "-233@qq.com": user_model.ErrEmailInvalid{"-233@qq.com"}, + "/233@qq.com": nil, + "=233@qq.com": nil, + "?233@qq.com": nil, + "^233@qq.com": nil, + "_233@qq.com": nil, + "`233@qq.com": nil, + "{233@qq.com": nil, + "|233@qq.com": nil, + "}233@qq.com": nil, + "~233@qq.com": nil, + ";233@qq.com": user_model.ErrEmailCharIsNotSupported{";233@qq.com"}, + "Foo <foo@bar.com>": user_model.ErrEmailCharIsNotSupported{"Foo <foo@bar.com>"}, + string([]byte{0xE2, 0x84, 0xAA}): user_model.ErrEmailCharIsNotSupported{string([]byte{0xE2, 0x84, 0xAA})}, + } + for kase, err := range kases { + t.Run(kase, func(t *testing.T) { + assert.EqualValues(t, err, user_model.ValidateEmail(kase)) + }) + } +} + +func TestGetActivatedEmailAddresses(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testCases := []struct { + UID int64 + expected []*user_model.ActivatedEmailAddress + }{ + { + UID: 1, + expected: []*user_model.ActivatedEmailAddress{{ID: 9, Email: "user1@example.com"}, {ID: 33, Email: "user1-2@example.com"}, {ID: 34, Email: "user1-3@example.com"}}, + }, + { + UID: 2, + expected: []*user_model.ActivatedEmailAddress{{ID: 3, Email: "user2@example.com"}}, + }, + { + UID: 4, + expected: []*user_model.ActivatedEmailAddress{{ID: 11, Email: "user4@example.com"}}, + }, + { + UID: 11, + expected: []*user_model.ActivatedEmailAddress{}, + }, + } + + for _, testCase := range testCases { + t.Run(fmt.Sprintf("User %d", testCase.UID), func(t *testing.T) { + emails, err := user_model.GetActivatedEmailAddresses(db.DefaultContext, testCase.UID) + require.NoError(t, err) + assert.Equal(t, testCase.expected, emails) + }) + } +} diff --git a/models/user/error.go b/models/user/error.go new file mode 100644 index 0000000..cbf1999 --- /dev/null +++ b/models/user/error.go @@ -0,0 +1,109 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + + "code.gitea.io/gitea/modules/util" +) + +// ErrUserAlreadyExist represents a "user already exists" error. +type ErrUserAlreadyExist struct { + Name string +} + +// IsErrUserAlreadyExist checks if an error is a ErrUserAlreadyExists. +func IsErrUserAlreadyExist(err error) bool { + _, ok := err.(ErrUserAlreadyExist) + return ok +} + +func (err ErrUserAlreadyExist) Error() string { + return fmt.Sprintf("user already exists [name: %s]", err.Name) +} + +// Unwrap unwraps this error as a ErrExist error +func (err ErrUserAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrUserNotExist represents a "UserNotExist" kind of error. +type ErrUserNotExist struct { + UID int64 + Name string +} + +// IsErrUserNotExist checks if an error is a ErrUserNotExist. +func IsErrUserNotExist(err error) bool { + _, ok := err.(ErrUserNotExist) + return ok +} + +func (err ErrUserNotExist) Error() string { + return fmt.Sprintf("user does not exist [uid: %d, name: %s]", err.UID, err.Name) +} + +// Unwrap unwraps this error as a ErrNotExist error +func (err ErrUserNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrUserProhibitLogin represents a "ErrUserProhibitLogin" kind of error. +type ErrUserProhibitLogin struct { + UID int64 + Name string +} + +// IsErrUserProhibitLogin checks if an error is a ErrUserProhibitLogin +func IsErrUserProhibitLogin(err error) bool { + _, ok := err.(ErrUserProhibitLogin) + return ok +} + +func (err ErrUserProhibitLogin) Error() string { + return fmt.Sprintf("user is not allowed login [uid: %d, name: %s]", err.UID, err.Name) +} + +// Unwrap unwraps this error as a ErrPermission error +func (err ErrUserProhibitLogin) Unwrap() error { + return util.ErrPermissionDenied +} + +// ErrUserInactive represents a "ErrUserInactive" kind of error. +type ErrUserInactive struct { + UID int64 + Name string +} + +// IsErrUserInactive checks if an error is a ErrUserInactive +func IsErrUserInactive(err error) bool { + _, ok := err.(ErrUserInactive) + return ok +} + +func (err ErrUserInactive) Error() string { + return fmt.Sprintf("user is inactive [uid: %d, name: %s]", err.UID, err.Name) +} + +// Unwrap unwraps this error as a ErrPermission error +func (err ErrUserInactive) Unwrap() error { + return util.ErrPermissionDenied +} + +// ErrUserIsNotLocal represents a "ErrUserIsNotLocal" kind of error. +type ErrUserIsNotLocal struct { + UID int64 + Name string +} + +func (err ErrUserIsNotLocal) Error() string { + return fmt.Sprintf("user is not local type [uid: %d, name: %s]", err.UID, err.Name) +} + +// IsErrUserIsNotLocal +func IsErrUserIsNotLocal(err error) bool { + _, ok := err.(ErrUserIsNotLocal) + return ok +} diff --git a/models/user/external_login_user.go b/models/user/external_login_user.go new file mode 100644 index 0000000..965b7a5 --- /dev/null +++ b/models/user/external_login_user.go @@ -0,0 +1,184 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// ErrExternalLoginUserAlreadyExist represents a "ExternalLoginUserAlreadyExist" kind of error. +type ErrExternalLoginUserAlreadyExist struct { + ExternalID string + UserID int64 + LoginSourceID int64 +} + +// IsErrExternalLoginUserAlreadyExist checks if an error is a ExternalLoginUserAlreadyExist. +func IsErrExternalLoginUserAlreadyExist(err error) bool { + _, ok := err.(ErrExternalLoginUserAlreadyExist) + return ok +} + +func (err ErrExternalLoginUserAlreadyExist) Error() string { + return fmt.Sprintf("external login user already exists [externalID: %s, userID: %d, loginSourceID: %d]", err.ExternalID, err.UserID, err.LoginSourceID) +} + +func (err ErrExternalLoginUserAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrExternalLoginUserNotExist represents a "ExternalLoginUserNotExist" kind of error. +type ErrExternalLoginUserNotExist struct { + UserID int64 + LoginSourceID int64 +} + +// IsErrExternalLoginUserNotExist checks if an error is a ExternalLoginUserNotExist. +func IsErrExternalLoginUserNotExist(err error) bool { + _, ok := err.(ErrExternalLoginUserNotExist) + return ok +} + +func (err ErrExternalLoginUserNotExist) Error() string { + return fmt.Sprintf("external login user link does not exists [userID: %d, loginSourceID: %d]", err.UserID, err.LoginSourceID) +} + +func (err ErrExternalLoginUserNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ExternalLoginUser makes the connecting between some existing user and additional external login sources +type ExternalLoginUser struct { + ExternalID string `xorm:"pk NOT NULL"` + UserID int64 `xorm:"INDEX NOT NULL"` + LoginSourceID int64 `xorm:"pk NOT NULL"` + RawData map[string]any `xorm:"TEXT JSON"` + Provider string `xorm:"index VARCHAR(25)"` + Email string + Name string + FirstName string + LastName string + NickName string + Description string + AvatarURL string `xorm:"TEXT"` + Location string + AccessToken string `xorm:"TEXT"` + AccessTokenSecret string `xorm:"TEXT"` + RefreshToken string `xorm:"TEXT"` + ExpiresAt time.Time +} + +type ExternalUserMigrated interface { + GetExternalName() string + GetExternalID() int64 +} + +type ExternalUserRemappable interface { + GetUserID() int64 + RemapExternalUser(externalName string, externalID, userID int64) error + ExternalUserMigrated +} + +func init() { + db.RegisterModel(new(ExternalLoginUser)) +} + +// GetExternalLogin checks if a externalID in loginSourceID scope already exists +func GetExternalLogin(ctx context.Context, externalLoginUser *ExternalLoginUser) (bool, error) { + return db.GetEngine(ctx).Get(externalLoginUser) +} + +// LinkExternalToUser link the external user to the user +func LinkExternalToUser(ctx context.Context, user *User, externalLoginUser *ExternalLoginUser) error { + has, err := db.Exist[ExternalLoginUser](ctx, builder.Eq{ + "external_id": externalLoginUser.ExternalID, + "login_source_id": externalLoginUser.LoginSourceID, + }) + if err != nil { + return err + } else if has { + return ErrExternalLoginUserAlreadyExist{externalLoginUser.ExternalID, user.ID, externalLoginUser.LoginSourceID} + } + + _, err = db.GetEngine(ctx).Insert(externalLoginUser) + return err +} + +// RemoveAccountLink will remove all external login sources for the given user +func RemoveAccountLink(ctx context.Context, user *User, loginSourceID int64) (int64, error) { + deleted, err := db.GetEngine(ctx).Delete(&ExternalLoginUser{UserID: user.ID, LoginSourceID: loginSourceID}) + if err != nil { + return deleted, err + } + if deleted < 1 { + return deleted, ErrExternalLoginUserNotExist{user.ID, loginSourceID} + } + return deleted, err +} + +// RemoveAllAccountLinks will remove all external login sources for the given user +func RemoveAllAccountLinks(ctx context.Context, user *User) error { + _, err := db.GetEngine(ctx).Delete(&ExternalLoginUser{UserID: user.ID}) + return err +} + +// GetUserIDByExternalUserID get user id according to provider and userID +func GetUserIDByExternalUserID(ctx context.Context, provider, userID string) (int64, error) { + var id int64 + _, err := db.GetEngine(ctx).Table("external_login_user"). + Select("user_id"). + Where("provider=?", provider). + And("external_id=?", userID). + Get(&id) + if err != nil { + return 0, err + } + return id, nil +} + +// UpdateExternalUserByExternalID updates an external user's information +func UpdateExternalUserByExternalID(ctx context.Context, external *ExternalLoginUser) error { + has, err := db.Exist[ExternalLoginUser](ctx, builder.Eq{ + "external_id": external.ExternalID, + "login_source_id": external.LoginSourceID, + }) + if err != nil { + return err + } else if !has { + return ErrExternalLoginUserNotExist{external.UserID, external.LoginSourceID} + } + + _, err = db.GetEngine(ctx).Where("external_id=? AND login_source_id=?", external.ExternalID, external.LoginSourceID).AllCols().Update(external) + return err +} + +// FindExternalUserOptions represents an options to find external users +type FindExternalUserOptions struct { + db.ListOptions + Provider string + UserID int64 + OrderBy string +} + +func (opts FindExternalUserOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if len(opts.Provider) > 0 { + cond = cond.And(builder.Eq{"provider": opts.Provider}) + } + if opts.UserID > 0 { + cond = cond.And(builder.Eq{"user_id": opts.UserID}) + } + return cond +} + +func (opts FindExternalUserOptions) ToOrders() string { + return opts.OrderBy +} diff --git a/models/user/federated_user.go b/models/user/federated_user.go new file mode 100644 index 0000000..1fc42c3 --- /dev/null +++ b/models/user/federated_user.go @@ -0,0 +1,35 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/modules/validation" +) + +type FederatedUser struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` + FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` +} + +func NewFederatedUser(userID int64, externalID string, federationHostID int64) (FederatedUser, error) { + result := FederatedUser{ + UserID: userID, + ExternalID: externalID, + FederationHostID: federationHostID, + } + if valid, err := validation.IsValid(result); !valid { + return FederatedUser{}, err + } + return result, nil +} + +func (user FederatedUser) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...) + result = append(result, validation.ValidateNotEmpty(user.ExternalID, "ExternalID")...) + result = append(result, validation.ValidateNotEmpty(user.FederationHostID, "FederationHostID")...) + return result +} diff --git a/models/user/federated_user_test.go b/models/user/federated_user_test.go new file mode 100644 index 0000000..6a21126 --- /dev/null +++ b/models/user/federated_user_test.go @@ -0,0 +1,29 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/modules/validation" +) + +func Test_FederatedUserValidation(t *testing.T) { + sut := FederatedUser{ + UserID: 12, + ExternalID: "12", + FederationHostID: 1, + } + if res, err := validation.IsValid(sut); !res { + t.Errorf("sut should be valid but was %q", err) + } + + sut = FederatedUser{ + ExternalID: "12", + FederationHostID: 1, + } + if res, _ := validation.IsValid(sut); res { + t.Errorf("sut should be invalid") + } +} diff --git a/models/user/fixtures/user.yml b/models/user/fixtures/user.yml new file mode 100644 index 0000000..b1892f3 --- /dev/null +++ b/models/user/fixtures/user.yml @@ -0,0 +1,36 @@ +- + id: 1041 + lower_name: remote01 + name: remote01 + full_name: Remote01 + email: remote01@example.com + keep_email_private: false + email_notifications_preference: onmention + passwd: ZogKvWdyEx:password + passwd_hash_algo: dummy + must_change_password: false + login_source: 1001 + login_name: 123 + type: 5 + salt: ZogKvWdyEx + max_repo_creation: -1 + is_active: true + is_admin: false + is_restricted: false + allow_git_hook: false + allow_import_local: false + allow_create_organization: true + prohibit_login: true + avatar: avatarremote01 + avatar_email: avatarremote01@example.com + use_custom_avatar: false + num_followers: 0 + num_following: 0 + num_stars: 0 + num_repos: 0 + num_teams: 0 + num_members: 0 + visibility: 0 + repo_admin_change_team_access: false + theme: "" + keep_activity_private: false diff --git a/models/user/follow.go b/models/user/follow.go new file mode 100644 index 0000000..9c3283b --- /dev/null +++ b/models/user/follow.go @@ -0,0 +1,85 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" +) + +// Follow represents relations of user and their followers. +type Follow struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"UNIQUE(follow)"` + FollowID int64 `xorm:"UNIQUE(follow)"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +func init() { + db.RegisterModel(new(Follow)) +} + +// IsFollowing returns true if user is following followID. +func IsFollowing(ctx context.Context, userID, followID int64) bool { + has, _ := db.GetEngine(ctx).Get(&Follow{UserID: userID, FollowID: followID}) + return has +} + +// FollowUser marks someone be another's follower. +func FollowUser(ctx context.Context, userID, followID int64) (err error) { + if userID == followID || IsFollowing(ctx, userID, followID) { + return nil + } + + if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) { + return ErrBlockedByUser + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err = db.Insert(ctx, &Follow{UserID: userID, FollowID: followID}); err != nil { + return err + } + + if _, err = db.Exec(ctx, "UPDATE `user` SET num_followers = num_followers + 1 WHERE id = ?", followID); err != nil { + return err + } + + if _, err = db.Exec(ctx, "UPDATE `user` SET num_following = num_following + 1 WHERE id = ?", userID); err != nil { + return err + } + return committer.Commit() +} + +// UnfollowUser unmarks someone as another's follower. +func UnfollowUser(ctx context.Context, userID, followID int64) (err error) { + if userID == followID || !IsFollowing(ctx, userID, followID) { + return nil + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if _, err = db.DeleteByBean(ctx, &Follow{UserID: userID, FollowID: followID}); err != nil { + return err + } + + if _, err = db.Exec(ctx, "UPDATE `user` SET num_followers = num_followers - 1 WHERE id = ?", followID); err != nil { + return err + } + + if _, err = db.Exec(ctx, "UPDATE `user` SET num_following = num_following - 1 WHERE id = ?", userID); err != nil { + return err + } + return committer.Commit() +} diff --git a/models/user/follow_test.go b/models/user/follow_test.go new file mode 100644 index 0000000..8c56164 --- /dev/null +++ b/models/user/follow_test.go @@ -0,0 +1,24 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsFollowing(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsFollowing(db.DefaultContext, 4, 2)) + assert.False(t, user_model.IsFollowing(db.DefaultContext, 2, 4)) + assert.False(t, user_model.IsFollowing(db.DefaultContext, 5, unittest.NonexistentID)) + assert.False(t, user_model.IsFollowing(db.DefaultContext, unittest.NonexistentID, 5)) + assert.False(t, user_model.IsFollowing(db.DefaultContext, unittest.NonexistentID, unittest.NonexistentID)) +} diff --git a/models/user/list.go b/models/user/list.go new file mode 100644 index 0000000..ca589d1 --- /dev/null +++ b/models/user/list.go @@ -0,0 +1,83 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" +) + +// UserList is a list of user. +// This type provide valuable methods to retrieve information for a group of users efficiently. +type UserList []*User //revive:disable-line:exported + +// GetUserIDs returns a slice of user's id +func (users UserList) GetUserIDs() []int64 { + userIDs := make([]int64, 0, len(users)) + for _, user := range users { + userIDs = append(userIDs, user.ID) // Considering that user id are unique in the list + } + return userIDs +} + +// GetTwoFaStatus return state of 2FA enrollement +func (users UserList) GetTwoFaStatus(ctx context.Context) map[int64]bool { + results := make(map[int64]bool, len(users)) + for _, user := range users { + results[user.ID] = false // Set default to false + } + + if tokenMaps, err := users.loadTwoFactorStatus(ctx); err == nil { + for _, token := range tokenMaps { + results[token.UID] = true + } + } + + if ids, err := users.userIDsWithWebAuthn(ctx); err == nil { + for _, id := range ids { + results[id] = true + } + } + + return results +} + +func (users UserList) loadTwoFactorStatus(ctx context.Context) (map[int64]*auth.TwoFactor, error) { + if len(users) == 0 { + return nil, nil + } + + userIDs := users.GetUserIDs() + tokenMaps := make(map[int64]*auth.TwoFactor, len(userIDs)) + if err := db.GetEngine(ctx).In("uid", userIDs).Find(&tokenMaps); err != nil { + return nil, fmt.Errorf("find two factor: %w", err) + } + return tokenMaps, nil +} + +func (users UserList) userIDsWithWebAuthn(ctx context.Context) ([]int64, error) { + if len(users) == 0 { + return nil, nil + } + ids := make([]int64, 0, len(users)) + if err := db.GetEngine(ctx).Table(new(auth.WebAuthnCredential)).In("user_id", users.GetUserIDs()).Select("user_id").Distinct("user_id").Find(&ids); err != nil { + return nil, fmt.Errorf("find two factor: %w", err) + } + return ids, nil +} + +// GetUsersByIDs returns all resolved users from a list of Ids. +func GetUsersByIDs(ctx context.Context, ids []int64) (UserList, error) { + ous := make([]*User, 0, len(ids)) + if len(ids) == 0 { + return ous, nil + } + err := db.GetEngine(ctx).In("id", ids). + Asc("name"). + Find(&ous) + return ous, err +} diff --git a/models/user/main_test.go b/models/user/main_test.go new file mode 100644 index 0000000..a626d32 --- /dev/null +++ b/models/user/main_test.go @@ -0,0 +1,19 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/activities" + _ "code.gitea.io/gitea/models/user" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/models/user/must_change_password.go b/models/user/must_change_password.go new file mode 100644 index 0000000..7eab08d --- /dev/null +++ b/models/user/must_change_password.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +func SetMustChangePassword(ctx context.Context, all, mustChangePassword bool, include, exclude []string) (int64, error) { + sliceTrimSpaceDropEmpty := func(input []string) []string { + output := make([]string, 0, len(input)) + for _, in := range input { + in = strings.ToLower(strings.TrimSpace(in)) + if in == "" { + continue + } + output = append(output, in) + } + return output + } + + var cond builder.Cond + + // Only include the users where something changes to get an accurate count + cond = builder.Neq{"must_change_password": mustChangePassword} + + if !all { + include = sliceTrimSpaceDropEmpty(include) + if len(include) == 0 { + return 0, util.NewSilentWrapErrorf(util.ErrInvalidArgument, "no users to include provided") + } + + cond = cond.And(builder.In("lower_name", include)) + } + + exclude = sliceTrimSpaceDropEmpty(exclude) + if len(exclude) > 0 { + cond = cond.And(builder.NotIn("lower_name", exclude)) + } + + return db.GetEngine(ctx).Where(cond).MustCols("must_change_password").Update(&User{MustChangePassword: mustChangePassword}) +} diff --git a/models/user/openid.go b/models/user/openid.go new file mode 100644 index 0000000..ee4ecab --- /dev/null +++ b/models/user/openid.go @@ -0,0 +1,111 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" +) + +// ErrOpenIDNotExist openid is not known +var ErrOpenIDNotExist = util.NewNotExistErrorf("OpenID is unknown") + +// UserOpenID is the list of all OpenID identities of a user. +// Since this is a middle table, name it OpenID is not suitable, so we ignore the lint here +type UserOpenID struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX NOT NULL"` + URI string `xorm:"UNIQUE NOT NULL"` + Show bool `xorm:"DEFAULT false"` +} + +func init() { + db.RegisterModel(new(UserOpenID)) +} + +// GetUserOpenIDs returns all openid addresses that belongs to given user. +func GetUserOpenIDs(ctx context.Context, uid int64) ([]*UserOpenID, error) { + openids := make([]*UserOpenID, 0, 5) + if err := db.GetEngine(ctx). + Where("uid=?", uid). + Asc("id"). + Find(&openids); err != nil { + return nil, err + } + + return openids, nil +} + +// isOpenIDUsed returns true if the openid has been used. +func isOpenIDUsed(ctx context.Context, uri string) (bool, error) { + if len(uri) == 0 { + return true, nil + } + + return db.GetEngine(ctx).Get(&UserOpenID{URI: uri}) +} + +// ErrOpenIDAlreadyUsed represents a "OpenIDAlreadyUsed" kind of error. +type ErrOpenIDAlreadyUsed struct { + OpenID string +} + +// IsErrOpenIDAlreadyUsed checks if an error is a ErrOpenIDAlreadyUsed. +func IsErrOpenIDAlreadyUsed(err error) bool { + _, ok := err.(ErrOpenIDAlreadyUsed) + return ok +} + +func (err ErrOpenIDAlreadyUsed) Error() string { + return fmt.Sprintf("OpenID already in use [oid: %s]", err.OpenID) +} + +func (err ErrOpenIDAlreadyUsed) Unwrap() error { + return util.ErrAlreadyExist +} + +// AddUserOpenID adds an pre-verified/normalized OpenID URI to given user. +// NOTE: make sure openid.URI is normalized already +func AddUserOpenID(ctx context.Context, openid *UserOpenID) error { + used, err := isOpenIDUsed(ctx, openid.URI) + if err != nil { + return err + } else if used { + return ErrOpenIDAlreadyUsed{openid.URI} + } + + return db.Insert(ctx, openid) +} + +// DeleteUserOpenID deletes an openid address of given user. +func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) { + var deleted int64 + // ask to check UID + address := UserOpenID{ + UID: openid.UID, + } + if openid.ID > 0 { + deleted, err = db.GetEngine(ctx).ID(openid.ID).Delete(&address) + } else { + deleted, err = db.GetEngine(ctx). + Where("openid=?", openid.URI). + Delete(&address) + } + + if err != nil { + return err + } else if deleted != 1 { + return ErrOpenIDNotExist + } + return nil +} + +// ToggleUserOpenIDVisibility toggles visibility of an openid address of given user. +func ToggleUserOpenIDVisibility(ctx context.Context, id int64) (err error) { + _, err = db.GetEngine(ctx).Exec("update `user_open_id` set `show` = not `show` where `id` = ?", id) + return err +} diff --git a/models/user/openid_test.go b/models/user/openid_test.go new file mode 100644 index 0000000..c2857aa --- /dev/null +++ b/models/user/openid_test.go @@ -0,0 +1,68 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetUserOpenIDs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + oids, err := user_model.GetUserOpenIDs(db.DefaultContext, int64(1)) + require.NoError(t, err) + + if assert.Len(t, oids, 2) { + assert.Equal(t, "https://user1.domain1.tld/", oids[0].URI) + assert.False(t, oids[0].Show) + assert.Equal(t, "http://user1.domain2.tld/", oids[1].URI) + assert.True(t, oids[1].Show) + } + + oids, err = user_model.GetUserOpenIDs(db.DefaultContext, int64(2)) + require.NoError(t, err) + + if assert.Len(t, oids, 1) { + assert.Equal(t, "https://domain1.tld/user2/", oids[0].URI) + assert.True(t, oids[0].Show) + } +} + +func TestToggleUserOpenIDVisibility(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + oids, err := user_model.GetUserOpenIDs(db.DefaultContext, int64(2)) + require.NoError(t, err) + + if !assert.Len(t, oids, 1) { + return + } + assert.True(t, oids[0].Show) + + err = user_model.ToggleUserOpenIDVisibility(db.DefaultContext, oids[0].ID) + require.NoError(t, err) + + oids, err = user_model.GetUserOpenIDs(db.DefaultContext, int64(2)) + require.NoError(t, err) + + if !assert.Len(t, oids, 1) { + return + } + assert.False(t, oids[0].Show) + err = user_model.ToggleUserOpenIDVisibility(db.DefaultContext, oids[0].ID) + require.NoError(t, err) + + oids, err = user_model.GetUserOpenIDs(db.DefaultContext, int64(2)) + require.NoError(t, err) + + if assert.Len(t, oids, 1) { + assert.True(t, oids[0].Show) + } +} diff --git a/models/user/redirect.go b/models/user/redirect.go new file mode 100644 index 0000000..5a40d4d --- /dev/null +++ b/models/user/redirect.go @@ -0,0 +1,87 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" +) + +// ErrUserRedirectNotExist represents a "UserRedirectNotExist" kind of error. +type ErrUserRedirectNotExist struct { + Name string +} + +// IsErrUserRedirectNotExist check if an error is an ErrUserRedirectNotExist. +func IsErrUserRedirectNotExist(err error) bool { + _, ok := err.(ErrUserRedirectNotExist) + return ok +} + +func (err ErrUserRedirectNotExist) Error() string { + return fmt.Sprintf("user redirect does not exist [name: %s]", err.Name) +} + +func (err ErrUserRedirectNotExist) Unwrap() error { + return util.ErrNotExist +} + +// Redirect represents that a user name should be redirected to another +type Redirect struct { + ID int64 `xorm:"pk autoincr"` + LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"` + RedirectUserID int64 // userID to redirect to +} + +// TableName provides the real table name +func (Redirect) TableName() string { + return "user_redirect" +} + +func init() { + db.RegisterModel(new(Redirect)) +} + +// LookupUserRedirect look up userID if a user has a redirect name +func LookupUserRedirect(ctx context.Context, userName string) (int64, error) { + userName = strings.ToLower(userName) + redirect := &Redirect{LowerName: userName} + if has, err := db.GetEngine(ctx).Get(redirect); err != nil { + return 0, err + } else if !has { + return 0, ErrUserRedirectNotExist{Name: userName} + } + return redirect.RedirectUserID, nil +} + +// NewUserRedirect create a new user redirect +func NewUserRedirect(ctx context.Context, ID int64, oldUserName, newUserName string) error { + oldUserName = strings.ToLower(oldUserName) + newUserName = strings.ToLower(newUserName) + + if err := DeleteUserRedirect(ctx, oldUserName); err != nil { + return err + } + + if err := DeleteUserRedirect(ctx, newUserName); err != nil { + return err + } + + return db.Insert(ctx, &Redirect{ + LowerName: oldUserName, + RedirectUserID: ID, + }) +} + +// DeleteUserRedirect delete any redirect from the specified user name to +// anything else +func DeleteUserRedirect(ctx context.Context, userName string) error { + userName = strings.ToLower(userName) + _, err := db.GetEngine(ctx).Delete(&Redirect{LowerName: userName}) + return err +} diff --git a/models/user/redirect_test.go b/models/user/redirect_test.go new file mode 100644 index 0000000..35fd29a --- /dev/null +++ b/models/user/redirect_test.go @@ -0,0 +1,26 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupUserRedirect(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + userID, err := user_model.LookupUserRedirect(db.DefaultContext, "olduser1") + require.NoError(t, err) + assert.EqualValues(t, 1, userID) + + _, err = user_model.LookupUserRedirect(db.DefaultContext, "doesnotexist") + assert.True(t, user_model.IsErrUserRedirectNotExist(err)) +} diff --git a/models/user/search.go b/models/user/search.go new file mode 100644 index 0000000..04c434e --- /dev/null +++ b/models/user/search.go @@ -0,0 +1,178 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/structs" + + "xorm.io/builder" + "xorm.io/xorm" +) + +// SearchUserOptions contains the options for searching +type SearchUserOptions struct { + db.ListOptions + + Keyword string + Type UserType + UID int64 + LoginName string // this option should be used only for admin user + SourceID int64 // this option should be used only for admin user + OrderBy db.SearchOrderBy + Visible []structs.VisibleType + Actor *User // The user doing the search + SearchByEmail bool // Search by email as well as username/full name + + SupportedSortOrders container.Set[string] // if not nil, only allow to use the sort orders in this set + + IsActive optional.Option[bool] + IsAdmin optional.Option[bool] + IsRestricted optional.Option[bool] + IsTwoFactorEnabled optional.Option[bool] + IsProhibitLogin optional.Option[bool] + IncludeReserved bool + + ExtraParamStrings map[string]string +} + +func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { + var cond builder.Cond + if opts.Type == UserTypeIndividual { + cond = builder.In("type", UserTypeIndividual, UserTypeRemoteUser) + } else { + cond = builder.Eq{"type": opts.Type} + } + if opts.IncludeReserved { + if opts.Type == UserTypeIndividual { + cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( + builder.Eq{"type": UserTypeBot}, + ).Or( + builder.Eq{"type": UserTypeRemoteUser}, + ) + } else if opts.Type == UserTypeOrganization { + cond = cond.Or(builder.Eq{"type": UserTypeOrganizationReserved}) + } + } + + if len(opts.Keyword) > 0 { + lowerKeyword := strings.ToLower(opts.Keyword) + keywordCond := builder.Or( + builder.Like{"lower_name", lowerKeyword}, + builder.Like{"LOWER(full_name)", lowerKeyword}, + ) + if opts.SearchByEmail { + keywordCond = keywordCond.Or(builder.Like{"LOWER(email)", lowerKeyword}) + } + + cond = cond.And(keywordCond) + } + + // If visibility filtered + if len(opts.Visible) > 0 { + cond = cond.And(builder.In("visibility", opts.Visible)) + } + + cond = cond.And(BuildCanSeeUserCondition(opts.Actor)) + + if opts.UID > 0 { + cond = cond.And(builder.Eq{"id": opts.UID}) + } + + if opts.SourceID > 0 { + cond = cond.And(builder.Eq{"login_source": opts.SourceID}) + } + if opts.LoginName != "" { + cond = cond.And(builder.Eq{"login_name": opts.LoginName}) + } + + if opts.IsActive.Has() { + cond = cond.And(builder.Eq{"is_active": opts.IsActive.Value()}) + } + + if opts.IsAdmin.Has() { + cond = cond.And(builder.Eq{"is_admin": opts.IsAdmin.Value()}) + } + + if opts.IsRestricted.Has() { + cond = cond.And(builder.Eq{"is_restricted": opts.IsRestricted.Value()}) + } + + if opts.IsProhibitLogin.Has() { + cond = cond.And(builder.Eq{"prohibit_login": opts.IsProhibitLogin.Value()}) + } + + e := db.GetEngine(ctx) + if !opts.IsTwoFactorEnabled.Has() { + return e.Where(cond) + } + + // 2fa filter uses LEFT JOIN to check whether a user has a 2fa record + // While using LEFT JOIN, sometimes the performance might not be good, but it won't be a problem now, such SQL is seldom executed. + // There are some possible methods to refactor this SQL in future when we really need to optimize the performance (but not now): + // (1) add a column in user table (2) add a setting value in user_setting table (3) use search engines (bleve/elasticsearch) + if opts.IsTwoFactorEnabled.Value() { + cond = cond.And(builder.Expr("two_factor.uid IS NOT NULL")) + } else { + cond = cond.And(builder.Expr("two_factor.uid IS NULL")) + } + + return e.Join("LEFT OUTER", "two_factor", "two_factor.uid = `user`.id"). + Where(cond) +} + +// SearchUsers takes options i.e. keyword and part of user name to search, +// it returns results in given range and number of total results. +func SearchUsers(ctx context.Context, opts *SearchUserOptions) (users []*User, _ int64, _ error) { + sessCount := opts.toSearchQueryBase(ctx) + defer sessCount.Close() + count, err := sessCount.Count(new(User)) + if err != nil { + return nil, 0, fmt.Errorf("count: %w", err) + } + + if len(opts.OrderBy) == 0 { + opts.OrderBy = db.SearchOrderByAlphabetically + } + + sessQuery := opts.toSearchQueryBase(ctx).OrderBy(opts.OrderBy.String()) + defer sessQuery.Close() + if opts.PageSize > 0 { + sessQuery = db.SetSessionPagination(sessQuery, opts) + } + + // the sql may contain JOIN, so we must only select User related columns + sessQuery = sessQuery.Select("`user`.*") + users = make([]*User, 0, opts.PageSize) + return users, count, sessQuery.Find(&users) +} + +// BuildCanSeeUserCondition creates a condition which can be used to restrict results to users/orgs the actor can see +func BuildCanSeeUserCondition(actor *User) builder.Cond { + if actor != nil { + // If Admin - they see all users! + if !actor.IsAdmin { + // Users can see an organization they are a member of + cond := builder.In("`user`.id", builder.Select("org_id").From("org_user").Where(builder.Eq{"uid": actor.ID})) + if !actor.IsRestricted { + // Not-Restricted users can see public and limited users/organizations + cond = cond.Or(builder.In("`user`.visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited)) + } + // Don't forget about self + return cond.Or(builder.Eq{"`user`.id": actor.ID}) + } + + return nil + } + + // Force visibility for privacy + // Not logged in - only public users + return builder.In("`user`.visibility", structs.VisibleTypePublic) +} diff --git a/models/user/setting.go b/models/user/setting.go new file mode 100644 index 0000000..b4af0e5 --- /dev/null +++ b/models/user/setting.go @@ -0,0 +1,212 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/cache" + setting_module "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// Setting is a key value store of user settings +type Setting struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"index unique(key_userid)"` // to load all of someone's settings + SettingKey string `xorm:"varchar(255) index unique(key_userid)"` // ensure key is always lowercase + SettingValue string `xorm:"text"` +} + +// TableName sets the table name for the settings struct +func (s *Setting) TableName() string { + return "user_setting" +} + +func init() { + db.RegisterModel(new(Setting)) +} + +// ErrUserSettingIsNotExist represents an error that a setting is not exist with special key +type ErrUserSettingIsNotExist struct { + Key string +} + +// Error implements error +func (err ErrUserSettingIsNotExist) Error() string { + return fmt.Sprintf("Setting[%s] is not exist", err.Key) +} + +func (err ErrUserSettingIsNotExist) Unwrap() error { + return util.ErrNotExist +} + +// IsErrUserSettingIsNotExist return true if err is ErrSettingIsNotExist +func IsErrUserSettingIsNotExist(err error) bool { + _, ok := err.(ErrUserSettingIsNotExist) + return ok +} + +// genSettingCacheKey returns the cache key for some configuration +func genSettingCacheKey(userID int64, key string) string { + return fmt.Sprintf("user_%d.setting.%s", userID, key) +} + +// GetSetting returns the setting value via the key +func GetSetting(ctx context.Context, uid int64, key string) (string, error) { + return cache.GetString(genSettingCacheKey(uid, key), func() (string, error) { + res, err := GetSettingNoCache(ctx, uid, key) + if err != nil { + return "", err + } + return res.SettingValue, nil + }) +} + +// GetSettingNoCache returns specific setting without using the cache +func GetSettingNoCache(ctx context.Context, uid int64, key string) (*Setting, error) { + v, err := GetSettings(ctx, uid, []string{key}) + if err != nil { + return nil, err + } + if len(v) == 0 { + return nil, ErrUserSettingIsNotExist{key} + } + return v[key], nil +} + +// GetSettings returns specific settings from user +func GetSettings(ctx context.Context, uid int64, keys []string) (map[string]*Setting, error) { + settings := make([]*Setting, 0, len(keys)) + if err := db.GetEngine(ctx). + Where("user_id=?", uid). + And(builder.In("setting_key", keys)). + Find(&settings); err != nil { + return nil, err + } + settingsMap := make(map[string]*Setting) + for _, s := range settings { + settingsMap[s.SettingKey] = s + } + return settingsMap, nil +} + +// GetUserAllSettings returns all settings from user +func GetUserAllSettings(ctx context.Context, uid int64) (map[string]*Setting, error) { + settings := make([]*Setting, 0, 5) + if err := db.GetEngine(ctx). + Where("user_id=?", uid). + Find(&settings); err != nil { + return nil, err + } + settingsMap := make(map[string]*Setting) + for _, s := range settings { + settingsMap[s.SettingKey] = s + } + return settingsMap, nil +} + +func validateUserSettingKey(key string) error { + if len(key) == 0 { + return fmt.Errorf("setting key must be set") + } + if strings.ToLower(key) != key { + return fmt.Errorf("setting key should be lowercase") + } + return nil +} + +// GetUserSetting gets a specific setting for a user +func GetUserSetting(ctx context.Context, userID int64, key string, def ...string) (string, error) { + if err := validateUserSettingKey(key); err != nil { + return "", err + } + + setting := &Setting{UserID: userID, SettingKey: key} + has, err := db.GetEngine(ctx).Get(setting) + if err != nil { + return "", err + } + if !has { + if len(def) == 1 { + return def[0], nil + } + return "", nil + } + return setting.SettingValue, nil +} + +// DeleteUserSetting deletes a specific setting for a user +func DeleteUserSetting(ctx context.Context, userID int64, key string) error { + if err := validateUserSettingKey(key); err != nil { + return err + } + + cache.Remove(genSettingCacheKey(userID, key)) + _, err := db.GetEngine(ctx).Delete(&Setting{UserID: userID, SettingKey: key}) + + return err +} + +// SetUserSetting updates a users' setting for a specific key +func SetUserSetting(ctx context.Context, userID int64, key, value string) error { + if err := validateUserSettingKey(key); err != nil { + return err + } + + if err := upsertUserSettingValue(ctx, userID, key, value); err != nil { + return err + } + + cc := cache.GetCache() + if cc != nil { + return cc.Put(genSettingCacheKey(userID, key), value, setting_module.CacheService.TTLSeconds()) + } + + return nil +} + +func upsertUserSettingValue(ctx context.Context, userID int64, key, value string) error { + return db.WithTx(ctx, func(ctx context.Context) error { + e := db.GetEngine(ctx) + + // here we use a general method to do a safe upsert for different databases (and most transaction levels) + // 1. try to UPDATE the record and acquire the transaction write lock + // if UPDATE returns non-zero rows are changed, OK, the setting is saved correctly + // if UPDATE returns "0 rows changed", two possibilities: (a) record doesn't exist (b) value is not changed + // 2. do a SELECT to check if the row exists or not (we already have the transaction lock) + // 3. if the row doesn't exist, do an INSERT (we are still protected by the transaction lock, so it's safe) + // + // to optimize the SELECT in step 2, we can use an extra column like `revision=revision+1` + // to make sure the UPDATE always returns a non-zero value for existing (unchanged) records. + + res, err := e.Exec("UPDATE user_setting SET setting_value=? WHERE setting_key=? AND user_id=?", value, key, userID) + if err != nil { + return err + } + rows, _ := res.RowsAffected() + if rows > 0 { + // the existing row is updated, so we can return + return nil + } + + // in case the value isn't changed, update would return 0 rows changed, so we need this check + has, err := e.Exist(&Setting{UserID: userID, SettingKey: key}) + if err != nil { + return err + } + if has { + return nil + } + + // if no existing row, insert a new row + _, err = e.Insert(&Setting{UserID: userID, SettingKey: key, SettingValue: value}) + return err + }) +} diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go new file mode 100644 index 0000000..0e2c936 --- /dev/null +++ b/models/user/setting_keys.go @@ -0,0 +1,17 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +const ( + // SettingsKeyHiddenCommentTypes is the setting key for hidden comment types + SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types" + // SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff + SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour" + // SettingsKeyShowOutdatedComments is the setting key whether or not to show outdated comments in PRs + SettingsKeyShowOutdatedComments = "comment_code.show_outdated" + // UserActivityPubPrivPem is user's private key + UserActivityPubPrivPem = "activitypub.priv_pem" + // UserActivityPubPubPem is user's public key + UserActivityPubPubPem = "activitypub.pub_pem" +) diff --git a/models/user/setting_test.go b/models/user/setting_test.go new file mode 100644 index 0000000..0b05c54 --- /dev/null +++ b/models/user/setting_test.go @@ -0,0 +1,61 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSettings(t *testing.T) { + keyName := "test_user_setting" + require.NoError(t, unittest.PrepareTestDatabase()) + + newSetting := &user_model.Setting{UserID: 99, SettingKey: keyName, SettingValue: "Gitea User Setting Test"} + + // create setting + err := user_model.SetUserSetting(db.DefaultContext, newSetting.UserID, newSetting.SettingKey, newSetting.SettingValue) + require.NoError(t, err) + // test about saving unchanged values + err = user_model.SetUserSetting(db.DefaultContext, newSetting.UserID, newSetting.SettingKey, newSetting.SettingValue) + require.NoError(t, err) + + // get specific setting + settings, err := user_model.GetSettings(db.DefaultContext, 99, []string{keyName}) + require.NoError(t, err) + assert.Len(t, settings, 1) + assert.EqualValues(t, newSetting.SettingValue, settings[keyName].SettingValue) + + settingValue, err := user_model.GetUserSetting(db.DefaultContext, 99, keyName) + require.NoError(t, err) + assert.EqualValues(t, newSetting.SettingValue, settingValue) + + settingValue, err = user_model.GetUserSetting(db.DefaultContext, 99, "no_such") + require.NoError(t, err) + assert.EqualValues(t, "", settingValue) + + // updated setting + updatedSetting := &user_model.Setting{UserID: 99, SettingKey: keyName, SettingValue: "Updated"} + err = user_model.SetUserSetting(db.DefaultContext, updatedSetting.UserID, updatedSetting.SettingKey, updatedSetting.SettingValue) + require.NoError(t, err) + + // get all settings + settings, err = user_model.GetUserAllSettings(db.DefaultContext, 99) + require.NoError(t, err) + assert.Len(t, settings, 1) + assert.EqualValues(t, updatedSetting.SettingValue, settings[updatedSetting.SettingKey].SettingValue) + + // delete setting + err = user_model.DeleteUserSetting(db.DefaultContext, 99, keyName) + require.NoError(t, err) + settings, err = user_model.GetUserAllSettings(db.DefaultContext, 99) + require.NoError(t, err) + assert.Empty(t, settings) +} diff --git a/models/user/user.go b/models/user/user.go new file mode 100644 index 0000000..3f12f8e --- /dev/null +++ b/models/user/user.go @@ -0,0 +1,1365 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "net/mail" + "net/url" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" + + _ "image/jpeg" // Needed for jpeg support + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/auth/openid" + "code.gitea.io/gitea/modules/auth/password/hash" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" + "xorm.io/builder" +) + +// UserType defines the user type +type UserType int //revive:disable-line:exported + +const ( + // UserTypeIndividual defines an individual user + UserTypeIndividual UserType = iota // Historic reason to make it starts at 0. + + // UserTypeOrganization defines an organization + UserTypeOrganization // 1 + + // UserTypeUserReserved reserves a (non-existing) user, i.e. to prevent a spam user from re-registering after being deleted, or to reserve the name until the user is actually created later on + UserTypeUserReserved // 2 + + // UserTypeOrganizationReserved reserves a (non-existing) organization, to be used in combination with UserTypeUserReserved + UserTypeOrganizationReserved // 3 + + // UserTypeBot defines a bot user + UserTypeBot // 4 + + // UserTypeRemoteUser defines a remote user for federated users + UserTypeRemoteUser // 5 +) + +const ( + // EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own + EmailNotificationsEnabled = "enabled" + // EmailNotificationsOnMention indicates that the user would like to be notified via email when mentioned. + EmailNotificationsOnMention = "onmention" + // EmailNotificationsDisabled indicates that the user would not like to be notified via email. + EmailNotificationsDisabled = "disabled" + // EmailNotificationsAndYourOwn indicates that the user would like to receive all email notifications and your own + EmailNotificationsAndYourOwn = "andyourown" +) + +// User represents the object of individual and member of organization. +type User struct { + ID int64 `xorm:"pk autoincr"` + LowerName string `xorm:"UNIQUE NOT NULL"` + Name string `xorm:"UNIQUE NOT NULL"` + FullName string + // Email is the primary email address (to be used for communication) + Email string `xorm:"NOT NULL"` + KeepEmailPrivate bool + EmailNotificationsPreference string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'enabled'"` + Passwd string `xorm:"NOT NULL"` + PasswdHashAlgo string `xorm:"NOT NULL DEFAULT 'argon2'"` + + // MustChangePassword is an attribute that determines if a user + // is to change their password after registration. + MustChangePassword bool `xorm:"NOT NULL DEFAULT false"` + + LoginType auth.Type + LoginSource int64 `xorm:"NOT NULL DEFAULT 0"` + LoginName string + Type UserType + Location string + Website string + Pronouns string + Rands string `xorm:"VARCHAR(32)"` + Salt string `xorm:"VARCHAR(32)"` + Language string `xorm:"VARCHAR(5)"` + Description string + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + LastLoginUnix timeutil.TimeStamp `xorm:"INDEX"` + + // Remember visibility choice for convenience, true for private + LastRepoVisibility bool + // Maximum repository creation limit, -1 means use global default + MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1"` + + // IsActive true: primary email is activated, user can access Web UI and Git SSH. + // false: an inactive user can only log in Web UI for account operations (ex: activate the account by email), no other access. + IsActive bool `xorm:"INDEX"` + // the user is a Gitea admin, who can access all repositories and the admin pages. + IsAdmin bool + // true: the user is only allowed to see organizations/repositories that they has explicit rights to. + // (ex: in private Gitea instances user won't be allowed to see even organizations/repositories that are set as public) + IsRestricted bool `xorm:"NOT NULL DEFAULT false"` + + AllowGitHook bool + AllowImportLocal bool // Allow migrate repository by local path + AllowCreateOrganization bool `xorm:"DEFAULT true"` + + // true: the user is not allowed to log in Web UI. Git/SSH access could still be allowed (please refer to Git/SSH access related code/documents) + ProhibitLogin bool `xorm:"NOT NULL DEFAULT false"` + + // Avatar + Avatar string `xorm:"VARCHAR(2048) NOT NULL"` + AvatarEmail string `xorm:"NOT NULL"` + UseCustomAvatar bool + + // For federation + NormalizedFederatedURI string + + // Counters + NumFollowers int + NumFollowing int `xorm:"NOT NULL DEFAULT 0"` + NumStars int + NumRepos int + + // For organization + NumTeams int + NumMembers int + Visibility structs.VisibleType `xorm:"NOT NULL DEFAULT 0"` + RepoAdminChangeTeamAccess bool `xorm:"NOT NULL DEFAULT false"` + + // Preferences + DiffViewStyle string `xorm:"NOT NULL DEFAULT ''"` + Theme string `xorm:"NOT NULL DEFAULT ''"` + KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"` + EnableRepoUnitHints bool `xorm:"NOT NULL DEFAULT true"` +} + +func init() { + db.RegisterModel(new(User)) +} + +// SearchOrganizationsOptions options to filter organizations +type SearchOrganizationsOptions struct { + db.ListOptions + All bool +} + +func (u *User) LogString() string { + if u == nil { + return "<User nil>" + } + return fmt.Sprintf("<User %d:%s>", u.ID, u.Name) +} + +// BeforeUpdate is invoked from XORM before updating this object. +func (u *User) BeforeUpdate() { + if u.MaxRepoCreation < -1 { + u.MaxRepoCreation = -1 + } + + // Organization does not need email + u.Email = strings.ToLower(u.Email) + if !u.IsOrganization() { + if len(u.AvatarEmail) == 0 { + u.AvatarEmail = u.Email + } + } + + u.LowerName = strings.ToLower(u.Name) + u.Location = base.TruncateString(u.Location, 255) + u.Website = base.TruncateString(u.Website, 255) + u.Description = base.TruncateString(u.Description, 255) +} + +// AfterLoad is invoked from XORM after filling all the fields of this object. +func (u *User) AfterLoad() { + if u.Theme == "" { + u.Theme = setting.UI.DefaultTheme + } +} + +// SetLastLogin set time to last login +func (u *User) SetLastLogin() { + u.LastLoginUnix = timeutil.TimeStampNow() +} + +// GetPlaceholderEmail returns an noreply email +func (u *User) GetPlaceholderEmail() string { + return fmt.Sprintf("%s@%s", u.LowerName, setting.Service.NoReplyAddress) +} + +// GetEmail returns an noreply email, if the user has set to keep his +// email address private, otherwise the primary email address. +func (u *User) GetEmail() string { + if u.KeepEmailPrivate { + return u.GetPlaceholderEmail() + } + return u.Email +} + +// GetAllUsers returns a slice of all individual users found in DB. +func GetAllUsers(ctx context.Context) ([]*User, error) { + users := make([]*User, 0) + return users, db.GetEngine(ctx).OrderBy("id").In("type", UserTypeIndividual, UserTypeRemoteUser).Find(&users) +} + +// GetAllAdmins returns a slice of all adminusers found in DB. +func GetAllAdmins(ctx context.Context) ([]*User, error) { + users := make([]*User, 0) + return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).And("is_admin = ?", true).Find(&users) +} + +// IsLocal returns true if user login type is LoginPlain. +func (u *User) IsLocal() bool { + return u.LoginType <= auth.Plain +} + +// IsOAuth2 returns true if user login type is LoginOAuth2. +func (u *User) IsOAuth2() bool { + return u.LoginType == auth.OAuth2 +} + +// MaxCreationLimit returns the number of repositories a user is allowed to create +func (u *User) MaxCreationLimit() int { + if u.MaxRepoCreation <= -1 { + return setting.Repository.MaxCreationLimit + } + return u.MaxRepoCreation +} + +// CanCreateRepo returns if user login can create a repository +// NOTE: functions calling this assume a failure due to repository count limit; if new checks are added, those functions should be revised +func (u *User) CanCreateRepo() bool { + if u.IsAdmin { + return true + } + if u.MaxRepoCreation <= -1 { + if setting.Repository.MaxCreationLimit <= -1 { + return true + } + return u.NumRepos < setting.Repository.MaxCreationLimit + } + return u.NumRepos < u.MaxRepoCreation +} + +// CanCreateOrganization returns true if user can create organisation. +func (u *User) CanCreateOrganization() bool { + return u.IsAdmin || (u.AllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation) +} + +// CanEditGitHook returns true if user can edit Git hooks. +func (u *User) CanEditGitHook() bool { + return !setting.DisableGitHooks && (u.IsAdmin || u.AllowGitHook) +} + +// CanForkRepo returns if user login can fork a repository +// It checks especially that the user can create repos, and potentially more +func (u *User) CanForkRepo() bool { + if setting.Repository.AllowForkWithoutMaximumLimit { + return true + } + return u.CanCreateRepo() +} + +// CanImportLocal returns true if user can migrate repository by local path. +func (u *User) CanImportLocal() bool { + if !setting.ImportLocalPaths || u == nil { + return false + } + return u.IsAdmin || u.AllowImportLocal +} + +// DashboardLink returns the user dashboard page link. +func (u *User) DashboardLink() string { + if u.IsOrganization() { + return u.OrganisationLink() + "/dashboard" + } + return setting.AppSubURL + "/" +} + +// HomeLink returns the user or organization home page link. +func (u *User) HomeLink() string { + return setting.AppSubURL + "/" + url.PathEscape(u.Name) +} + +// HTMLURL returns the user or organization's full link. +func (u *User) HTMLURL() string { + return setting.AppURL + url.PathEscape(u.Name) +} + +// APActorID returns the IRI to the api endpoint of the user +func (u *User) APActorID() string { + return fmt.Sprintf("%vapi/v1/activitypub/user-id/%v", setting.AppURL, url.PathEscape(fmt.Sprintf("%v", u.ID))) +} + +// OrganisationLink returns the organization sub page link. +func (u *User) OrganisationLink() string { + return setting.AppSubURL + "/org/" + url.PathEscape(u.Name) +} + +// GenerateEmailAuthorizationCode generates an activation code based for the user for the specified purpose. +// The standard expiry is ActiveCodeLives minutes. +func (u *User) GenerateEmailAuthorizationCode(ctx context.Context, purpose auth.AuthorizationPurpose) (string, error) { + lookup, validator, err := auth.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(setting.Service.ActiveCodeLives)*60), purpose) + if err != nil { + return "", err + } + return lookup + ":" + validator, nil +} + +// GetUserFollowers returns range of user's followers. +func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListOptions) ([]*User, int64, error) { + sess := db.GetEngine(ctx). + Select("`user`.*"). + Join("LEFT", "follow", "`user`.id=follow.user_id"). + Where("follow.follow_id=?", u.ID). + And("`user`.type=?", UserTypeIndividual). + And(isUserVisibleToViewerCond(viewer)) + + if listOptions.Page != 0 { + sess = db.SetSessionPagination(sess, &listOptions) + + users := make([]*User, 0, listOptions.PageSize) + count, err := sess.FindAndCount(&users) + return users, count, err + } + + users := make([]*User, 0, 8) + count, err := sess.FindAndCount(&users) + return users, count, err +} + +// GetUserFollowing returns range of user's following. +func GetUserFollowing(ctx context.Context, u, viewer *User, listOptions db.ListOptions) ([]*User, int64, error) { + sess := db.GetEngine(ctx). + Select("`user`.*"). + Join("LEFT", "follow", "`user`.id=follow.follow_id"). + Where("follow.user_id=?", u.ID). + And("`user`.type IN (?, ?)", UserTypeIndividual, UserTypeOrganization). + And(isUserVisibleToViewerCond(viewer)) + + if listOptions.Page != 0 { + sess = db.SetSessionPagination(sess, &listOptions) + + users := make([]*User, 0, listOptions.PageSize) + count, err := sess.FindAndCount(&users) + return users, count, err + } + + users := make([]*User, 0, 8) + count, err := sess.FindAndCount(&users) + return users, count, err +} + +// NewGitSig generates and returns the signature of given user. +func (u *User) NewGitSig() *git.Signature { + return &git.Signature{ + Name: u.GitName(), + Email: u.GetEmail(), + When: time.Now(), + } +} + +// SetPassword hashes a password using the algorithm defined in the config value of PASSWORD_HASH_ALGO +// change passwd, salt and passwd_hash_algo fields +func (u *User) SetPassword(passwd string) (err error) { + // Invalidate all authentication tokens for this user. + if err := auth.DeleteAuthTokenByUser(db.DefaultContext, u.ID); err != nil { + return err + } + + if u.Salt, err = GetUserSalt(); err != nil { + return err + } + if u.Passwd, err = hash.Parse(setting.PasswordHashAlgo).Hash(passwd, u.Salt); err != nil { + return err + } + u.PasswdHashAlgo = setting.PasswordHashAlgo + + return nil +} + +// ValidatePassword checks if the given password matches the one belonging to the user. +func (u *User) ValidatePassword(passwd string) bool { + return hash.Parse(u.PasswdHashAlgo).VerifyPassword(passwd, u.Passwd, u.Salt) +} + +// IsPasswordSet checks if the password is set or left empty +func (u *User) IsPasswordSet() bool { + return len(u.Passwd) != 0 +} + +// IsOrganization returns true if user is actually a organization. +func (u *User) IsOrganization() bool { + return u.Type == UserTypeOrganization +} + +// IsIndividual returns true if user is actually a individual user. +func (u *User) IsIndividual() bool { + return u.Type == UserTypeIndividual +} + +func (u *User) IsUser() bool { + return u.Type == UserTypeIndividual || u.Type == UserTypeBot +} + +// IsBot returns whether or not the user is of type bot +func (u *User) IsBot() bool { + return u.Type == UserTypeBot +} + +func (u *User) IsRemote() bool { + return u.Type == UserTypeRemoteUser +} + +// DisplayName returns full name if it's not empty, +// returns username otherwise. +func (u *User) DisplayName() string { + trimmed := strings.TrimSpace(u.FullName) + if len(trimmed) > 0 { + return trimmed + } + return u.Name +} + +var emailToReplacer = strings.NewReplacer( + "\n", "", + "\r", "", + "<", "", + ">", "", + ",", "", + ":", "", + ";", "", +) + +// EmailTo returns a string suitable to be put into a e-mail `To:` header. +func (u *User) EmailTo(overrideMail ...string) string { + sanitizedDisplayName := emailToReplacer.Replace(u.DisplayName()) + + email := u.Email + if len(overrideMail) > 0 { + email = overrideMail[0] + } + + // should be an edge case but nice to have + if sanitizedDisplayName == email { + return email + } + + address, err := mail.ParseAddress(fmt.Sprintf("%s <%s>", sanitizedDisplayName, email)) + if err != nil { + return email + } + + return address.String() +} + +// GetDisplayName returns full name if it's not empty and DEFAULT_SHOW_FULL_NAME is set, +// returns username otherwise. +func (u *User) GetDisplayName() string { + if setting.UI.DefaultShowFullName { + trimmed := strings.TrimSpace(u.FullName) + if len(trimmed) > 0 { + return trimmed + } + } + return u.Name +} + +// GetCompleteName returns the full name and username in the form of +// "Full Name (username)" if full name is not empty, otherwise it returns +// "username". +func (u *User) GetCompleteName() string { + trimmedFullName := strings.TrimSpace(u.FullName) + if len(trimmedFullName) > 0 { + return fmt.Sprintf("%s (%s)", trimmedFullName, u.Name) + } + return u.Name +} + +func gitSafeName(name string) string { + return strings.TrimSpace(strings.NewReplacer("\n", "", "<", "", ">", "").Replace(name)) +} + +// GitName returns a git safe name +func (u *User) GitName() string { + gitName := gitSafeName(u.FullName) + if len(gitName) > 0 { + return gitName + } + // Although u.Name should be safe if created in our system + // LDAP users may have bad names + gitName = gitSafeName(u.Name) + if len(gitName) > 0 { + return gitName + } + // Totally pathological name so it's got to be: + return fmt.Sprintf("user-%d", u.ID) +} + +// ShortName ellipses username to length +func (u *User) ShortName(length int) string { + if setting.UI.DefaultShowFullName && len(u.FullName) > 0 { + return base.EllipsisString(u.FullName, length) + } + return base.EllipsisString(u.Name, length) +} + +// IsMailable checks if a user is eligible +// to receive emails. +func (u *User) IsMailable() bool { + return u.IsActive +} + +// IsUserExist checks if given user name exist, +// the user name should be noncased unique. +// If uid is presented, then check will rule out that one, +// it is used when update a user name in settings page. +func IsUserExist(ctx context.Context, uid int64, name string) (bool, error) { + if len(name) == 0 { + return false, nil + } + return db.GetEngine(ctx). + Where("id!=?", uid). + Get(&User{LowerName: strings.ToLower(name)}) +} + +// Note: As of the beginning of 2022, it is recommended to use at least +// 64 bits of salt, but NIST is already recommending to use to 128 bits. +// (16 bytes = 16 * 8 = 128 bits) +const SaltByteLength = 16 + +// GetUserSalt returns a random user salt token. +func GetUserSalt() (string, error) { + rBytes, err := util.CryptoRandomBytes(SaltByteLength) + if err != nil { + return "", err + } + // Returns a 32 bytes long string. + return hex.EncodeToString(rBytes), nil +} + +// Note: The set of characters here can safely expand without a breaking change, +// but characters removed from this set can cause user account linking to break +var ( + customCharsReplacement = strings.NewReplacer("Æ", "AE") + removeCharsRE = regexp.MustCompile(`['´\x60]`) + removeDiacriticsTransform = transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + replaceCharsHyphenRE = regexp.MustCompile(`[\s~+]`) +) + +// normalizeUserName returns a string with single-quotes and diacritics +// removed, and any other non-supported username characters replaced with +// a `-` character +func NormalizeUserName(s string) (string, error) { + strDiacriticsRemoved, n, err := transform.String(removeDiacriticsTransform, customCharsReplacement.Replace(s)) + if err != nil { + return "", fmt.Errorf("Failed to normalize character `%v` in provided username `%v`", s[n], s) + } + return replaceCharsHyphenRE.ReplaceAllLiteralString(removeCharsRE.ReplaceAllLiteralString(strDiacriticsRemoved, ""), "-"), nil +} + +var ( + reservedUsernames = []string{ + ".", + "..", + ".well-known", + "admin", + "api", + "assets", + "attachments", + "avatar", + "avatars", + "captcha", + "commits", + "debug", + "devtest", + "error", + "explore", + "favicon.ico", + "ghost", + "issues", + "login", + "manifest.json", + "metrics", + "milestones", + "new", + "notifications", + "org", + "pulls", + "raw", + "repo", + "repo-avatars", + "robots.txt", + "search", + "serviceworker.js", + "ssh_info", + "swagger.v1.json", + "user", + "v2", + "gitea-actions", + "forgejo-actions", + } + + // DON'T ADD ANY NEW STUFF, WE SOLVE THIS WITH `/user/{obj}` PATHS! + reservedUserPatterns = []string{"*.keys", "*.gpg", "*.rss", "*.atom", "*.png"} +) + +// IsUsableUsername returns an error when a username is reserved +func IsUsableUsername(name string) error { + // Validate username make sure it satisfies requirement. + if !validation.IsValidUsername(name) { + // Note: usually this error is normally caught up earlier in the UI + return db.ErrNameCharsNotAllowed{Name: name} + } + return db.IsUsableName(reservedUsernames, reservedUserPatterns, name) +} + +// CreateUserOverwriteOptions are an optional options who overwrite system defaults on user creation +type CreateUserOverwriteOptions struct { + KeepEmailPrivate optional.Option[bool] + Visibility *structs.VisibleType + AllowCreateOrganization optional.Option[bool] + EmailNotificationsPreference *string + MaxRepoCreation *int + Theme *string + IsRestricted optional.Option[bool] + IsActive optional.Option[bool] +} + +// CreateUser creates record of a new user. +func CreateUser(ctx context.Context, u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err error) { + return createUser(ctx, u, false, overwriteDefault...) +} + +// AdminCreateUser is used by admins to manually create users +func AdminCreateUser(ctx context.Context, u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err error) { + return createUser(ctx, u, true, overwriteDefault...) +} + +// createUser creates record of a new user. +func createUser(ctx context.Context, u *User, createdByAdmin bool, overwriteDefault ...*CreateUserOverwriteOptions) (err error) { + if err = IsUsableUsername(u.Name); err != nil { + return err + } + + // set system defaults + u.KeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate + u.Visibility = setting.Service.DefaultUserVisibilityMode + u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation + u.EmailNotificationsPreference = setting.Admin.DefaultEmailNotification + u.MaxRepoCreation = -1 + u.Theme = setting.UI.DefaultTheme + u.IsRestricted = setting.Service.DefaultUserIsRestricted + u.IsActive = !(setting.Service.RegisterEmailConfirm || setting.Service.RegisterManualConfirm) + + // Ensure consistency of the dates. + if u.UpdatedUnix < u.CreatedUnix { + u.UpdatedUnix = u.CreatedUnix + } + + // overwrite defaults if set + if len(overwriteDefault) != 0 && overwriteDefault[0] != nil { + overwrite := overwriteDefault[0] + if overwrite.KeepEmailPrivate.Has() { + u.KeepEmailPrivate = overwrite.KeepEmailPrivate.Value() + } + if overwrite.Visibility != nil { + u.Visibility = *overwrite.Visibility + } + if overwrite.AllowCreateOrganization.Has() { + u.AllowCreateOrganization = overwrite.AllowCreateOrganization.Value() + } + if overwrite.EmailNotificationsPreference != nil { + u.EmailNotificationsPreference = *overwrite.EmailNotificationsPreference + } + if overwrite.MaxRepoCreation != nil { + u.MaxRepoCreation = *overwrite.MaxRepoCreation + } + if overwrite.Theme != nil { + u.Theme = *overwrite.Theme + } + if overwrite.IsRestricted.Has() { + u.IsRestricted = overwrite.IsRestricted.Value() + } + if overwrite.IsActive.Has() { + u.IsActive = overwrite.IsActive.Value() + } + } + + // validate data + if err := ValidateUser(u); err != nil { + return err + } + + if createdByAdmin { + if err := ValidateEmailForAdmin(u.Email); err != nil { + return err + } + } else { + if err := ValidateEmail(u.Email); err != nil { + return err + } + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + isExist, err := IsUserExist(ctx, 0, u.Name) + if err != nil { + return err + } else if isExist { + return ErrUserAlreadyExist{u.Name} + } + + isExist, err = IsEmailUsed(ctx, u.Email) + if err != nil { + return err + } else if isExist { + return ErrEmailAlreadyUsed{ + Email: u.Email, + } + } + + // prepare for database + + u.LowerName = strings.ToLower(u.Name) + u.AvatarEmail = u.Email + if u.Rands, err = GetUserSalt(); err != nil { + return err + } + if u.Passwd != "" { + if err = u.SetPassword(u.Passwd); err != nil { + return err + } + } else { + u.Salt = "" + u.PasswdHashAlgo = "" + } + + // save changes to database + + if err = DeleteUserRedirect(ctx, u.Name); err != nil { + return err + } + + if u.CreatedUnix == 0 { + // Caller expects auto-time for creation & update timestamps. + err = db.Insert(ctx, u) + } else { + // Caller sets the timestamps themselves. They are responsible for ensuring + // both `CreatedUnix` and `UpdatedUnix` are set appropriately. + _, err = db.GetEngine(ctx).NoAutoTime().Insert(u) + } + if err != nil { + return err + } + + // insert email address + if err := db.Insert(ctx, &EmailAddress{ + UID: u.ID, + Email: u.Email, + LowerEmail: strings.ToLower(u.Email), + IsActivated: u.IsActive, + IsPrimary: true, + }); err != nil { + return err + } + + return committer.Commit() +} + +// IsLastAdminUser check whether user is the last admin +func IsLastAdminUser(ctx context.Context, user *User) bool { + if user.IsAdmin && CountUsers(ctx, &CountUserFilter{IsAdmin: optional.Some(true)}) <= 1 { + return true + } + return false +} + +// CountUserFilter represent optional filters for CountUsers +type CountUserFilter struct { + LastLoginSince *int64 + IsAdmin optional.Option[bool] +} + +// CountUsers returns number of users. +func CountUsers(ctx context.Context, opts *CountUserFilter) int64 { + return countUsers(ctx, opts) +} + +func countUsers(ctx context.Context, opts *CountUserFilter) int64 { + sess := db.GetEngine(ctx) + cond := builder.NewCond() + cond = cond.And(builder.Eq{"type": UserTypeIndividual}) + + if opts != nil { + if opts.LastLoginSince != nil { + cond = cond.And(builder.Gte{"last_login_unix": *opts.LastLoginSince}) + } + + if opts.IsAdmin.Has() { + cond = cond.And(builder.Eq{"is_admin": opts.IsAdmin.Value()}) + } + } + + count, err := sess.Where(cond).Count(new(User)) + if err != nil { + log.Error("user.countUsers: %v", err) + } + + return count +} + +// VerifyUserActiveCode verifies that the code is valid for the given purpose for this user. +// If delete is specified, the token will be deleted. +func VerifyUserAuthorizationToken(ctx context.Context, code string, purpose auth.AuthorizationPurpose, delete bool) (*User, error) { + lookupKey, validator, found := strings.Cut(code, ":") + if !found { + return nil, nil + } + + authToken, err := auth.FindAuthToken(ctx, lookupKey, purpose) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return nil, nil + } + return nil, err + } + + if authToken.IsExpired() { + return nil, auth.DeleteAuthToken(ctx, authToken) + } + + rawValidator, err := hex.DecodeString(validator) + if err != nil { + return nil, err + } + + if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 { + return nil, errors.New("validator doesn't match") + } + + u, err := GetUserByID(ctx, authToken.UID) + if err != nil { + if IsErrUserNotExist(err) { + return nil, nil + } + return nil, err + } + + if delete { + if err := auth.DeleteAuthToken(ctx, authToken); err != nil { + return nil, err + } + } + + return u, nil +} + +// ValidateUser check if user is valid to insert / update into database +func ValidateUser(u *User, cols ...string) error { + if len(cols) == 0 || util.SliceContainsString(cols, "visibility", true) { + if !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(u.Visibility) && !u.IsOrganization() { + return fmt.Errorf("visibility Mode not allowed: %s", u.Visibility.String()) + } + } + + return nil +} + +func (u User) Validate() []string { + var result []string + if err := ValidateUser(&u); err != nil { + result = append(result, err.Error()) + } + if err := ValidateEmail(u.Email); err != nil { + result = append(result, err.Error()) + } + return result +} + +// UpdateUserCols update user according special columns +func UpdateUserCols(ctx context.Context, u *User, cols ...string) error { + if err := ValidateUser(u, cols...); err != nil { + return err + } + + _, err := db.GetEngine(ctx).ID(u.ID).Cols(cols...).Update(u) + return err +} + +// GetInactiveUsers gets all inactive users +func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, error) { + cond := builder.And( + builder.Eq{"is_active": false}, + builder.Or( // only plain user + builder.Eq{"`type`": UserTypeIndividual}, + builder.Eq{"`type`": UserTypeUserReserved}, + ), + ) + + if olderThan > 0 { + cond = cond.And(builder.Lt{"created_unix": time.Now().Add(-olderThan).Unix()}) + } + + users := make([]*User, 0, 10) + return users, db.GetEngine(ctx). + Where(cond). + Find(&users) +} + +// UserPath returns the path absolute path of user repositories. +func UserPath(userName string) string { //revive:disable-line:exported + return filepath.Join(setting.RepoRootPath, strings.ToLower(userName)) +} + +// GetUserByID returns the user object by given ID if exists. +func GetUserByID(ctx context.Context, id int64) (*User, error) { + u := new(User) + has, err := db.GetEngine(ctx).ID(id).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUserNotExist{UID: id} + } + return u, nil +} + +// GetUserByIDs returns the user objects by given IDs if exists. +func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) { + if len(ids) == 0 { + return nil, nil + } + + users := make([]*User, 0, len(ids)) + err := db.GetEngine(ctx).In("id", ids). + Table("user"). + Find(&users) + return users, err +} + +func IsValidUserID(id int64) bool { + return id > 0 || id == GhostUserID || id == ActionsUserID +} + +func GetUserFromMap(id int64, idMap map[int64]*User) (int64, *User) { + if user, ok := idMap[id]; ok { + return id, user + } + if id == ActionsUserID { + return ActionsUserID, NewActionsUser() + } + return GhostUserID, NewGhostUser() +} + +// GetPossibleUserByID returns the user if id > 0 or return system usrs if id < 0 +func GetPossibleUserByID(ctx context.Context, id int64) (*User, error) { + switch id { + case GhostUserID: + return NewGhostUser(), nil + case ActionsUserID: + return NewActionsUser(), nil + case 0: + return nil, ErrUserNotExist{} + default: + return GetUserByID(ctx, id) + } +} + +// GetPossibleUserByIDs returns the users if id > 0 or return system users if id < 0 +func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) { + uniqueIDs := container.SetOf(ids...) + users := make([]*User, 0, len(ids)) + _ = uniqueIDs.Remove(0) + if uniqueIDs.Remove(GhostUserID) { + users = append(users, NewGhostUser()) + } + if uniqueIDs.Remove(ActionsUserID) { + users = append(users, NewActionsUser()) + } + res, err := GetUserByIDs(ctx, uniqueIDs.Values()) + if err != nil { + return nil, err + } + users = append(users, res...) + return users, nil +} + +// GetUserByNameCtx returns user by given name. +func GetUserByName(ctx context.Context, name string) (*User, error) { + if len(name) == 0 { + return nil, ErrUserNotExist{Name: name} + } + // adding Type: UserTypeIndividual is a noop because it is zero and discarded + u := &User{LowerName: strings.ToLower(name)} + has, err := db.GetEngine(ctx).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUserNotExist{Name: name} + } + return u, nil +} + +// GetUserEmailsByNames returns a list of e-mails corresponds to names of users +// that have their email notifications set to enabled or onmention. +func GetUserEmailsByNames(ctx context.Context, names []string) []string { + mails := make([]string, 0, len(names)) + for _, name := range names { + u, err := GetUserByName(ctx, name) + if err != nil { + continue + } + if u.IsMailable() && u.EmailNotificationsPreference != EmailNotificationsDisabled { + mails = append(mails, u.Email) + } + } + return mails +} + +// GetMaileableUsersByIDs gets users from ids, but only if they can receive mails +func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([]*User, error) { + if len(ids) == 0 { + return nil, nil + } + ous := make([]*User, 0, len(ids)) + + if isMention { + return ous, db.GetEngine(ctx). + In("id", ids). + Where("`type` = ?", UserTypeIndividual). + And("`prohibit_login` = ?", false). + And("`is_active` = ?", true). + In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsOnMention, EmailNotificationsAndYourOwn). + Find(&ous) + } + + return ous, db.GetEngine(ctx). + In("id", ids). + Where("`type` = ?", UserTypeIndividual). + And("`prohibit_login` = ?", false). + And("`is_active` = ?", true). + In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsAndYourOwn). + Find(&ous) +} + +// GetUserNamesByIDs returns usernames for all resolved users from a list of Ids. +func GetUserNamesByIDs(ctx context.Context, ids []int64) ([]string, error) { + unames := make([]string, 0, len(ids)) + err := db.GetEngine(ctx).In("id", ids). + Table("user"). + Asc("name"). + Cols("name"). + Find(&unames) + return unames, err +} + +// GetUserNameByID returns username for the id +func GetUserNameByID(ctx context.Context, id int64) (string, error) { + var name string + has, err := db.GetEngine(ctx).Table("user").Where("id = ?", id).Cols("name").Get(&name) + if err != nil { + return "", err + } + if has { + return name, nil + } + return "", nil +} + +// GetUserIDsByNames returns a slice of ids corresponds to names. +func GetUserIDsByNames(ctx context.Context, names []string, ignoreNonExistent bool) ([]int64, error) { + ids := make([]int64, 0, len(names)) + for _, name := range names { + u, err := GetUserByName(ctx, name) + if err != nil { + if ignoreNonExistent { + continue + } + return nil, err + } + ids = append(ids, u.ID) + } + return ids, nil +} + +// GetUsersBySource returns a list of Users for a login source +func GetUsersBySource(ctx context.Context, s *auth.Source) ([]*User, error) { + var users []*User + err := db.GetEngine(ctx).Where("login_type = ? AND login_source = ?", s.Type, s.ID).Find(&users) + return users, err +} + +// UserCommit represents a commit with validation of user. +type UserCommit struct { //revive:disable-line:exported + User *User + *git.Commit +} + +// ValidateCommitWithEmail check if author's e-mail of commit is corresponding to a user. +func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User { + if c.Author == nil { + return nil + } + u, err := GetUserByEmail(ctx, c.Author.Email) + if err != nil { + return nil + } + return u +} + +// ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users. +func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) []*UserCommit { + var ( + emails = make(map[string]*User) + newCommits = make([]*UserCommit, 0, len(oldCommits)) + ) + for _, c := range oldCommits { + var u *User + if c.Author != nil { + if v, ok := emails[c.Author.Email]; !ok { + u, _ = GetUserByEmail(ctx, c.Author.Email) + emails[c.Author.Email] = u + } else { + u = v + } + } + + newCommits = append(newCommits, &UserCommit{ + User: u, + Commit: c, + }) + } + return newCommits +} + +// GetUserByEmail returns the user object by given e-mail if exists. +func GetUserByEmail(ctx context.Context, email string) (*User, error) { + if len(email) == 0 { + return nil, ErrUserNotExist{Name: email} + } + + email = strings.ToLower(email) + // Otherwise, check in alternative list for activated email addresses + emailAddress := &EmailAddress{LowerEmail: email, IsActivated: true} + has, err := db.GetEngine(ctx).Get(emailAddress) + if err != nil { + return nil, err + } + if has { + return GetUserByID(ctx, emailAddress.UID) + } + + // Finally, if email address is the protected email address: + if strings.HasSuffix(email, fmt.Sprintf("@%s", setting.Service.NoReplyAddress)) { + username := strings.TrimSuffix(email, fmt.Sprintf("@%s", setting.Service.NoReplyAddress)) + user := &User{} + has, err := db.GetEngine(ctx).Where("lower_name=?", username).Get(user) + if err != nil { + return nil, err + } + if has { + return user, nil + } + } + + return nil, ErrUserNotExist{Name: email} +} + +// GetUser checks if a user already exists +func GetUser(ctx context.Context, user *User) (bool, error) { + return db.GetEngine(ctx).Get(user) +} + +// GetUserByOpenID returns the user object by given OpenID if exists. +func GetUserByOpenID(ctx context.Context, uri string) (*User, error) { + if len(uri) == 0 { + return nil, ErrUserNotExist{Name: uri} + } + + uri, err := openid.Normalize(uri) + if err != nil { + return nil, err + } + + log.Trace("Normalized OpenID URI: " + uri) + + // Otherwise, check in openid table + oid := &UserOpenID{} + has, err := db.GetEngine(ctx).Where("uri=?", uri).Get(oid) + if err != nil { + return nil, err + } + if has { + return GetUserByID(ctx, oid.UID) + } + + return nil, ErrUserNotExist{Name: uri} +} + +// GetAdminUser returns the first administrator +func GetAdminUser(ctx context.Context) (*User, error) { + var admin User + has, err := db.GetEngine(ctx). + Where("is_admin=?", true). + Asc("id"). // Reliably get the admin with the lowest ID. + Get(&admin) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUserNotExist{} + } + + return &admin, nil +} + +func isUserVisibleToViewerCond(viewer *User) builder.Cond { + if viewer != nil && viewer.IsAdmin { + return builder.NewCond() + } + + if viewer == nil || viewer.IsRestricted { + return builder.Eq{ + "`user`.visibility": structs.VisibleTypePublic, + } + } + + return builder.Neq{ + "`user`.visibility": structs.VisibleTypePrivate, + }.Or( + // viewer self + builder.Eq{"`user`.id": viewer.ID}, + // viewer's following + builder.In("`user`.id", + builder. + Select("`follow`.user_id"). + From("follow"). + Where(builder.Eq{"`follow`.follow_id": viewer.ID})), + // viewer's org user + builder.In("`user`.id", + builder. + Select("`team_user`.uid"). + From("team_user"). + Join("INNER", "`team_user` AS t2", "`team_user`.org_id = `t2`.org_id"). + Where(builder.Eq{"`t2`.uid": viewer.ID})), + // viewer's org + builder.In("`user`.id", + builder. + Select("`team_user`.org_id"). + From("team_user"). + Where(builder.Eq{"`team_user`.uid": viewer.ID}))) +} + +// IsUserVisibleToViewer check if viewer is able to see user profile +func IsUserVisibleToViewer(ctx context.Context, u, viewer *User) bool { + if viewer != nil && (viewer.IsAdmin || viewer.ID == u.ID) { + return true + } + + switch u.Visibility { + case structs.VisibleTypePublic: + return true + case structs.VisibleTypeLimited: + if viewer == nil || viewer.IsRestricted { + return false + } + return true + case structs.VisibleTypePrivate: + if viewer == nil || viewer.IsRestricted { + return false + } + + // If they follow - they see each over + follower := IsFollowing(ctx, u.ID, viewer.ID) + if follower { + return true + } + + // Now we need to check if they in some organization together + count, err := db.GetEngine(ctx).Table("team_user"). + Where( + builder.And( + builder.Eq{"uid": viewer.ID}, + builder.Or( + builder.Eq{"org_id": u.ID}, + builder.In("org_id", + builder.Select("org_id"). + From("team_user", "t2"). + Where(builder.Eq{"uid": u.ID}))))). + Count() + if err != nil { + return false + } + + if count == 0 { + // No common organization + return false + } + + // they are in an organization together + return true + } + return false +} + +// CountWrongUserType count OrgUser who have wrong type +func CountWrongUserType(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Where(builder.Eq{"type": 0}.And(builder.Neq{"num_teams": 0})).Count(new(User)) +} + +// FixWrongUserType fix OrgUser who have wrong type +func FixWrongUserType(ctx context.Context) (int64, error) { + return db.GetEngine(ctx).Where(builder.Eq{"type": 0}.And(builder.Neq{"num_teams": 0})).Cols("type").NoAutoTime().Update(&User{Type: 1}) +} + +func GetOrderByName() string { + if setting.UI.DefaultShowFullName { + return "full_name, name" + } + return "name" +} + +// IsFeatureDisabledWithLoginType checks if a user feature is disabled, taking into account the login type of the +// user if applicable +func IsFeatureDisabledWithLoginType(user *User, feature string) bool { + // NOTE: in the long run it may be better to check the ExternalLoginUser table rather than user.LoginType + return (user != nil && user.LoginType > auth.Plain && setting.Admin.ExternalUserDisableFeatures.Contains(feature)) || + setting.Admin.UserDisabledFeatures.Contains(feature) +} + +// DisabledFeaturesWithLoginType returns the set of user features disabled, taking into account the login type +// of the user if applicable +func DisabledFeaturesWithLoginType(user *User) *container.Set[string] { + // NOTE: in the long run it may be better to check the ExternalLoginUser table rather than user.LoginType + if user != nil && user.LoginType > auth.Plain { + return &setting.Admin.ExternalUserDisableFeatures + } + return &setting.Admin.UserDisabledFeatures +} diff --git a/models/user/user_repository.go b/models/user/user_repository.go new file mode 100644 index 0000000..c06441b --- /dev/null +++ b/models/user/user_repository.go @@ -0,0 +1,83 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/validation" +) + +func init() { + db.RegisterModel(new(FederatedUser)) +} + +func CreateFederatedUser(ctx context.Context, user *User, federatedUser *FederatedUser) error { + if res, err := validation.IsValid(user); !res { + return err + } + overwrite := CreateUserOverwriteOptions{ + IsActive: optional.Some(false), + IsRestricted: optional.Some(false), + } + + // Begin transaction + ctx, committer, err := db.TxContext((ctx)) + if err != nil { + return err + } + defer committer.Close() + + if err := CreateUser(ctx, user, &overwrite); err != nil { + return err + } + + federatedUser.UserID = user.ID + if res, err := validation.IsValid(federatedUser); !res { + return err + } + + _, err = db.GetEngine(ctx).Insert(federatedUser) + if err != nil { + return err + } + + // Commit transaction + return committer.Commit() +} + +func FindFederatedUser(ctx context.Context, externalID string, + federationHostID int64, +) (*User, *FederatedUser, error) { + federatedUser := new(FederatedUser) + user := new(User) + has, err := db.GetEngine(ctx).Where("external_id=? and federation_host_id=?", externalID, federationHostID).Get(federatedUser) + if err != nil { + return nil, nil, err + } else if !has { + return nil, nil, nil + } + has, err = db.GetEngine(ctx).ID(federatedUser.UserID).Get(user) + if err != nil { + return nil, nil, err + } else if !has { + return nil, nil, fmt.Errorf("User %v for federated user is missing", federatedUser.UserID) + } + + if res, err := validation.IsValid(*user); !res { + return nil, nil, err + } + if res, err := validation.IsValid(*federatedUser); !res { + return nil, nil, err + } + return user, federatedUser, nil +} + +func DeleteFederatedUser(ctx context.Context, userID int64) error { + _, err := db.GetEngine(ctx).Delete(&FederatedUser{UserID: userID}) + return err +} diff --git a/models/user/user_system.go b/models/user/user_system.go new file mode 100644 index 0000000..ba9a213 --- /dev/null +++ b/models/user/user_system.go @@ -0,0 +1,97 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/url" + "strings" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" +) + +const ( + GhostUserID = -1 + GhostUserName = "Ghost" + GhostUserLowerName = "ghost" +) + +// NewGhostUser creates and returns a fake user for someone has deleted their account. +func NewGhostUser() *User { + return &User{ + ID: GhostUserID, + Name: GhostUserName, + LowerName: GhostUserLowerName, + } +} + +// IsGhost check if user is fake user for a deleted account +func (u *User) IsGhost() bool { + if u == nil { + return false + } + return u.ID == GhostUserID && u.Name == GhostUserName +} + +// NewReplaceUser creates and returns a fake user for external user +func NewReplaceUser(name string) *User { + return &User{ + ID: 0, + Name: name, + LowerName: strings.ToLower(name), + } +} + +const ( + ActionsUserID = -2 + ActionsUserName = "forgejo-actions" + ActionsFullName = "Forgejo Actions" + ActionsEmail = "noreply@forgejo.org" +) + +// NewActionsUser creates and returns a fake user for running the actions. +func NewActionsUser() *User { + return &User{ + ID: ActionsUserID, + Name: ActionsUserName, + LowerName: ActionsUserName, + IsActive: true, + FullName: ActionsFullName, + Email: ActionsEmail, + KeepEmailPrivate: true, + LoginName: ActionsUserName, + Type: UserTypeIndividual, + AllowCreateOrganization: true, + Visibility: structs.VisibleTypePublic, + } +} + +func (u *User) IsActions() bool { + return u != nil && u.ID == ActionsUserID +} + +const ( + APActorUserID = -3 + APActorUserName = "actor" + APActorEmail = "noreply@forgejo.org" +) + +func NewAPActorUser() *User { + return &User{ + ID: APActorUserID, + Name: APActorUserName, + LowerName: APActorUserName, + IsActive: true, + Email: APActorEmail, + KeepEmailPrivate: true, + LoginName: APActorUserName, + Type: UserTypeIndividual, + Visibility: structs.VisibleTypePublic, + } +} + +func APActorUserAPActorID() string { + path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor") + return path +} diff --git a/models/user/user_test.go b/models/user/user_test.go new file mode 100644 index 0000000..f0b7e16 --- /dev/null +++ b/models/user/user_test.go @@ -0,0 +1,781 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password/hash" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOAuth2Application_LoadUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + app := unittest.AssertExistsAndLoadBean(t, &auth.OAuth2Application{ID: 1}) + user, err := user_model.GetUserByID(db.DefaultContext, app.UID) + require.NoError(t, err) + assert.NotNil(t, user) +} + +func TestIsValidUserID(t *testing.T) { + assert.False(t, user_model.IsValidUserID(-30)) + assert.False(t, user_model.IsValidUserID(0)) + assert.True(t, user_model.IsValidUserID(user_model.GhostUserID)) + assert.True(t, user_model.IsValidUserID(user_model.ActionsUserID)) + assert.True(t, user_model.IsValidUserID(200)) +} + +func TestGetUserFromMap(t *testing.T) { + id := int64(200) + idMap := map[int64]*user_model.User{ + id: {ID: id}, + } + + ghostID := int64(user_model.GhostUserID) + actionsID := int64(user_model.ActionsUserID) + actualID, actualUser := user_model.GetUserFromMap(-20, idMap) + assert.Equal(t, ghostID, actualID) + assert.Equal(t, ghostID, actualUser.ID) + + actualID, actualUser = user_model.GetUserFromMap(0, idMap) + assert.Equal(t, ghostID, actualID) + assert.Equal(t, ghostID, actualUser.ID) + + actualID, actualUser = user_model.GetUserFromMap(ghostID, idMap) + assert.Equal(t, ghostID, actualID) + assert.Equal(t, ghostID, actualUser.ID) + + actualID, actualUser = user_model.GetUserFromMap(actionsID, idMap) + assert.Equal(t, actionsID, actualID) + assert.Equal(t, actionsID, actualUser.ID) +} + +func TestGetUserByName(t *testing.T) { + defer tests.AddFixtures("models/user/fixtures/")() + require.NoError(t, unittest.PrepareTestDatabase()) + + { + _, err := user_model.GetUserByName(db.DefaultContext, "") + assert.True(t, user_model.IsErrUserNotExist(err), err) + } + { + _, err := user_model.GetUserByName(db.DefaultContext, "UNKNOWN") + assert.True(t, user_model.IsErrUserNotExist(err), err) + } + { + user, err := user_model.GetUserByName(db.DefaultContext, "USER2") + require.NoError(t, err) + assert.Equal(t, "user2", user.Name) + } + { + user, err := user_model.GetUserByName(db.DefaultContext, "org3") + require.NoError(t, err) + assert.Equal(t, "org3", user.Name) + } + { + user, err := user_model.GetUserByName(db.DefaultContext, "remote01") + require.NoError(t, err) + assert.Equal(t, "remote01", user.Name) + } +} + +func TestGetUserEmailsByNames(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // ignore none active user email + assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(db.DefaultContext, []string{"user8", "user9"})) + assert.ElementsMatch(t, []string{"user8@example.com", "user5@example.com"}, user_model.GetUserEmailsByNames(db.DefaultContext, []string{"user8", "user5"})) + + assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(db.DefaultContext, []string{"user8", "org7"})) +} + +func TestCanCreateOrganization(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + assert.True(t, admin.CanCreateOrganization()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.True(t, user.CanCreateOrganization()) + // Disable user create organization permission. + user.AllowCreateOrganization = false + assert.False(t, user.CanCreateOrganization()) + + setting.Admin.DisableRegularOrgCreation = true + user.AllowCreateOrganization = true + assert.True(t, admin.CanCreateOrganization()) + assert.False(t, user.CanCreateOrganization()) +} + +func TestGetAllUsers(t *testing.T) { + defer tests.AddFixtures("models/user/fixtures/")() + require.NoError(t, unittest.PrepareTestDatabase()) + + users, err := user_model.GetAllUsers(db.DefaultContext) + require.NoError(t, err) + + found := make(map[user_model.UserType]bool, 0) + for _, user := range users { + found[user.Type] = true + } + assert.True(t, found[user_model.UserTypeIndividual], users) + assert.True(t, found[user_model.UserTypeRemoteUser], users) + assert.False(t, found[user_model.UserTypeOrganization], users) +} + +func TestAPActorID(t *testing.T) { + user := user_model.User{ID: 1} + url := user.APActorID() + expected := "https://try.gitea.io/api/v1/activitypub/user-id/1" + if url != expected { + t.Errorf("unexpected APActorID, expected: %q, actual: %q", expected, url) + } +} + +func TestSearchUsers(t *testing.T) { + defer tests.AddFixtures("models/user/fixtures/")() + require.NoError(t, unittest.PrepareTestDatabase()) + testSuccess := func(opts *user_model.SearchUserOptions, expectedUserOrOrgIDs []int64) { + users, _, err := user_model.SearchUsers(db.DefaultContext, opts) + require.NoError(t, err) + cassText := fmt.Sprintf("ids: %v, opts: %v", expectedUserOrOrgIDs, opts) + if assert.Len(t, users, len(expectedUserOrOrgIDs), "case: %s", cassText) { + for i, expectedID := range expectedUserOrOrgIDs { + assert.EqualValues(t, expectedID, users[i].ID, "case: %s", cassText) + } + } + } + + // test orgs + testOrgSuccess := func(opts *user_model.SearchUserOptions, expectedOrgIDs []int64) { + opts.Type = user_model.UserTypeOrganization + testSuccess(opts, expectedOrgIDs) + } + + testOrgSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1, PageSize: 2}}, + []int64{3, 6}) + + testOrgSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 2, PageSize: 2}}, + []int64{7, 17}) + + testOrgSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 3, PageSize: 2}}, + []int64{19, 25}) + + testOrgSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 4, PageSize: 2}}, + []int64{26, 41}) + + testOrgSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 5, PageSize: 2}}, + []int64{}) + + // test users + testUserSuccess := func(opts *user_model.SearchUserOptions, expectedUserIDs []int64) { + opts.Type = user_model.UserTypeIndividual + testSuccess(opts, expectedUserIDs) + } + + testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}}, + []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40, 1041}) + + testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(false)}, + []int64{9}) + + testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)}, + []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40, 1041}) + + testUserSuccess(&user_model.SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)}, + []int64{1, 10, 11, 12, 13, 14, 15, 16, 18}) + + // order by name asc default + testUserSuccess(&user_model.SearchUserOptions{Keyword: "user1", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)}, + []int64{1, 10, 11, 12, 13, 14, 15, 16, 18}) + + testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsAdmin: optional.Some(true)}, + []int64{1}) + + testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsRestricted: optional.Some(true)}, + []int64{29}) + + testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsProhibitLogin: optional.Some(true)}, + []int64{1041, 37}) + + testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: optional.Some(true)}, + []int64{24}) +} + +func TestEmailNotificationPreferences(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + for _, test := range []struct { + expected string + userID int64 + }{ + {user_model.EmailNotificationsEnabled, 1}, + {user_model.EmailNotificationsEnabled, 2}, + {user_model.EmailNotificationsOnMention, 3}, + {user_model.EmailNotificationsOnMention, 4}, + {user_model.EmailNotificationsEnabled, 5}, + {user_model.EmailNotificationsEnabled, 6}, + {user_model.EmailNotificationsDisabled, 7}, + {user_model.EmailNotificationsEnabled, 8}, + {user_model.EmailNotificationsOnMention, 9}, + } { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: test.userID}) + assert.Equal(t, test.expected, user.EmailNotificationsPreference) + } +} + +func TestHashPasswordDeterministic(t *testing.T) { + b := make([]byte, 16) + u := &user_model.User{} + algos := hash.RecommendedHashAlgorithms + for j := 0; j < len(algos); j++ { + u.PasswdHashAlgo = algos[j] + for i := 0; i < 50; i++ { + // generate a random password + rand.Read(b) + pass := string(b) + + // save the current password in the user - hash it and store the result + u.SetPassword(pass) + r1 := u.Passwd + + // run again + u.SetPassword(pass) + r2 := u.Passwd + + assert.NotEqual(t, r1, r2) + assert.True(t, u.ValidatePassword(pass)) + } + } +} + +func BenchmarkHashPassword(b *testing.B) { + // BenchmarkHashPassword ensures that it takes a reasonable amount of time + // to hash a password - in order to protect from brute-force attacks. + pass := "password1337" + u := &user_model.User{Passwd: pass} + b.ResetTimer() + for i := 0; i < b.N; i++ { + u.SetPassword(pass) + } +} + +func TestNewGitSig(t *testing.T) { + users := make([]*user_model.User, 0, 20) + err := db.GetEngine(db.DefaultContext).Find(&users) + require.NoError(t, err) + + for _, user := range users { + sig := user.NewGitSig() + assert.NotContains(t, sig.Name, "<") + assert.NotContains(t, sig.Name, ">") + assert.NotContains(t, sig.Name, "\n") + assert.NotEmpty(t, strings.TrimSpace(sig.Name)) + } +} + +func TestDisplayName(t *testing.T) { + users := make([]*user_model.User, 0, 20) + err := db.GetEngine(db.DefaultContext).Find(&users) + require.NoError(t, err) + + for _, user := range users { + displayName := user.DisplayName() + assert.Equal(t, strings.TrimSpace(displayName), displayName) + if len(strings.TrimSpace(user.FullName)) == 0 { + assert.Equal(t, user.Name, displayName) + } + assert.NotEmpty(t, strings.TrimSpace(displayName)) + } +} + +func TestCreateUserInvalidEmail(t *testing.T) { + user := &user_model.User{ + Name: "GiteaBot", + Email: "GiteaBot@gitea.io\r\n", + Passwd: ";p['////..-++']", + IsAdmin: false, + Theme: setting.UI.DefaultTheme, + MustChangePassword: false, + } + + err := user_model.CreateUser(db.DefaultContext, user) + require.Error(t, err) + assert.True(t, user_model.IsErrEmailCharIsNotSupported(err)) +} + +func TestCreateUserEmailAlreadyUsed(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // add new user with user2's email + user.Name = "testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + err := user_model.CreateUser(db.DefaultContext, user) + require.Error(t, err) + assert.True(t, user_model.IsErrEmailAlreadyUsed(err)) +} + +func TestCreateUserCustomTimestamps(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // Add new user with a custom creation timestamp. + var creationTimestamp timeutil.TimeStamp = 12345 + user.Name = "testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + user.Email = "unique@example.com" + user.CreatedUnix = creationTimestamp + err := user_model.CreateUser(db.DefaultContext, user) + require.NoError(t, err) + + fetched, err := user_model.GetUserByID(context.Background(), user.ID) + require.NoError(t, err) + assert.Equal(t, creationTimestamp, fetched.CreatedUnix) + assert.Equal(t, creationTimestamp, fetched.UpdatedUnix) +} + +func TestCreateUserWithoutCustomTimestamps(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // There is no way to use a mocked time for the XORM auto-time functionality, + // so use the real clock to approximate the expected timestamp. + timestampStart := time.Now().Unix() + + // Add new user without a custom creation timestamp. + user.Name = "Testuser" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + user.Email = "unique@example.com" + user.CreatedUnix = 0 + user.UpdatedUnix = 0 + err := user_model.CreateUser(db.DefaultContext, user) + require.NoError(t, err) + + timestampEnd := time.Now().Unix() + + fetched, err := user_model.GetUserByID(context.Background(), user.ID) + require.NoError(t, err) + + assert.LessOrEqual(t, timestampStart, fetched.CreatedUnix) + assert.LessOrEqual(t, fetched.CreatedUnix, timestampEnd) + + assert.LessOrEqual(t, timestampStart, fetched.UpdatedUnix) + assert.LessOrEqual(t, fetched.UpdatedUnix, timestampEnd) +} + +func TestGetUserIDsByNames(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // ignore non existing + IDs, err := user_model.GetUserIDsByNames(db.DefaultContext, []string{"user1", "user2", "none_existing_user"}, true) + require.NoError(t, err) + assert.Equal(t, []int64{1, 2}, IDs) + + // ignore non existing + IDs, err = user_model.GetUserIDsByNames(db.DefaultContext, []string{"user1", "do_not_exist"}, false) + require.Error(t, err) + assert.Equal(t, []int64(nil), IDs) +} + +func TestGetMaileableUsersByIDs(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + results, err := user_model.GetMaileableUsersByIDs(db.DefaultContext, []int64{1, 4}, false) + require.NoError(t, err) + assert.Len(t, results, 1) + if len(results) > 1 { + assert.Equal(t, 1, results[0].ID) + } + + results, err = user_model.GetMaileableUsersByIDs(db.DefaultContext, []int64{1, 4}, true) + require.NoError(t, err) + assert.Len(t, results, 2) + if len(results) > 2 { + assert.Equal(t, 1, results[0].ID) + assert.Equal(t, 4, results[1].ID) + } +} + +func TestNewUserRedirect(t *testing.T) { + // redirect to a completely new name + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + require.NoError(t, user_model.NewUserRedirect(db.DefaultContext, user.ID, user.Name, "newusername")) + + unittest.AssertExistsAndLoadBean(t, &user_model.Redirect{ + LowerName: user.LowerName, + RedirectUserID: user.ID, + }) + unittest.AssertExistsAndLoadBean(t, &user_model.Redirect{ + LowerName: "olduser1", + RedirectUserID: user.ID, + }) +} + +func TestNewUserRedirect2(t *testing.T) { + // redirect to previously used name + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + require.NoError(t, user_model.NewUserRedirect(db.DefaultContext, user.ID, user.Name, "olduser1")) + + unittest.AssertExistsAndLoadBean(t, &user_model.Redirect{ + LowerName: user.LowerName, + RedirectUserID: user.ID, + }) + unittest.AssertNotExistsBean(t, &user_model.Redirect{ + LowerName: "olduser1", + RedirectUserID: user.ID, + }) +} + +func TestNewUserRedirect3(t *testing.T) { + // redirect for a previously-unredirected user + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.NoError(t, user_model.NewUserRedirect(db.DefaultContext, user.ID, user.Name, "newusername")) + + unittest.AssertExistsAndLoadBean(t, &user_model.Redirect{ + LowerName: user.LowerName, + RedirectUserID: user.ID, + }) +} + +func TestGetUserByOpenID(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, err := user_model.GetUserByOpenID(db.DefaultContext, "https://unknown") + if assert.Error(t, err) { + assert.True(t, user_model.IsErrUserNotExist(err)) + } + + user, err := user_model.GetUserByOpenID(db.DefaultContext, "https://user1.domain1.tld") + require.NoError(t, err) + + assert.Equal(t, int64(1), user.ID) + + user, err = user_model.GetUserByOpenID(db.DefaultContext, "https://domain1.tld/user2/") + require.NoError(t, err) + + assert.Equal(t, int64(2), user.ID) +} + +func TestFollowUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(followerID, followedID int64) { + require.NoError(t, user_model.FollowUser(db.DefaultContext, followerID, followedID)) + unittest.AssertExistsAndLoadBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID}) + } + testSuccess(4, 2) + testSuccess(5, 2) + + require.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2)) + + // Blocked user. + require.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 1, 4)) + require.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 4, 1)) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 1, FollowID: 4}) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 4, FollowID: 1}) + + unittest.CheckConsistencyFor(t, &user_model.User{}) +} + +func TestUnfollowUser(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(followerID, followedID int64) { + require.NoError(t, user_model.UnfollowUser(db.DefaultContext, followerID, followedID)) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID}) + } + testSuccess(4, 2) + testSuccess(5, 2) + testSuccess(2, 2) + + unittest.CheckConsistencyFor(t, &user_model.User{}) +} + +func TestIsUserVisibleToViewer(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) // admin, public + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // normal, public + user20 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20}) // public, same team as user31 + user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) // public, is restricted + user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31}) // private, same team as user20 + user33 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 33}) // limited, follows 31 + + test := func(u, viewer *user_model.User, expected bool) { + name := func(u *user_model.User) string { + if u == nil { + return "<nil>" + } + return u.Name + } + assert.Equal(t, expected, user_model.IsUserVisibleToViewer(db.DefaultContext, u, viewer), "user %v should be visible to viewer %v: %v", name(u), name(viewer), expected) + } + + // admin viewer + test(user1, user1, true) + test(user20, user1, true) + test(user31, user1, true) + test(user33, user1, true) + + // non admin viewer + test(user4, user4, true) + test(user20, user4, true) + test(user31, user4, false) + test(user33, user4, true) + test(user4, nil, true) + + // public user + test(user4, user20, true) + test(user4, user31, true) + test(user4, user33, true) + + // limited user + test(user33, user33, true) + test(user33, user4, true) + test(user33, user29, false) + test(user33, nil, false) + + // private user + test(user31, user31, true) + test(user31, user4, false) + test(user31, user20, true) + test(user31, user29, false) + test(user31, user33, true) + test(user31, nil, false) +} + +func TestGetAllAdmins(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + admins, err := user_model.GetAllAdmins(db.DefaultContext) + require.NoError(t, err) + + assert.Len(t, admins, 1) + assert.Equal(t, int64(1), admins[0].ID) +} + +func Test_ValidateUser(t *testing.T) { + oldSetting := setting.Service.AllowedUserVisibilityModesSlice + defer func() { + setting.Service.AllowedUserVisibilityModesSlice = oldSetting + }() + setting.Service.AllowedUserVisibilityModesSlice = []bool{true, false, true} + kases := map[*user_model.User]bool{ + {ID: 1, Visibility: structs.VisibleTypePublic}: true, + {ID: 2, Visibility: structs.VisibleTypeLimited}: false, + {ID: 2, Visibility: structs.VisibleTypePrivate}: true, + } + for kase, expected := range kases { + assert.EqualValues(t, expected, nil == user_model.ValidateUser(kase)) + } +} + +func Test_NormalizeUserFromEmail(t *testing.T) { + oldSetting := setting.Service.AllowDotsInUsernames + defer func() { + setting.Service.AllowDotsInUsernames = oldSetting + }() + setting.Service.AllowDotsInUsernames = true + testCases := []struct { + Input string + Expected string + IsNormalizedValid bool + }{ + {"test", "test", true}, + {"Sinéad.O'Connor", "Sinead.OConnor", true}, + {"Æsir", "AEsir", true}, + // \u00e9\u0065\u0301 + {"éé", "ee", true}, + {"Awareness Hub", "Awareness-Hub", true}, + {"double__underscore", "double__underscore", false}, // We should consider squashing double non-alpha characters + {".bad.", ".bad.", false}, + {"new😀user", "new😀user", false}, // No plans to support + } + for _, testCase := range testCases { + normalizedName, err := user_model.NormalizeUserName(testCase.Input) + require.NoError(t, err) + assert.EqualValues(t, testCase.Expected, normalizedName) + if testCase.IsNormalizedValid { + require.NoError(t, user_model.IsUsableUsername(normalizedName)) + } else { + require.Error(t, user_model.IsUsableUsername(normalizedName)) + } + } +} + +func TestEmailTo(t *testing.T) { + testCases := []struct { + fullName string + mail string + result string + }{ + {"Awareness Hub", "awareness@hub.net", `"Awareness Hub" <awareness@hub.net>`}, + {"name@example.com", "name@example.com", "name@example.com"}, + {"Hi Its <Mee>", "ee@mail.box", `"Hi Its Mee" <ee@mail.box>`}, + {"Sinéad.O'Connor", "sinead.oconnor@gmail.com", "=?utf-8?b?U2luw6lhZC5PJ0Nvbm5vcg==?= <sinead.oconnor@gmail.com>"}, + {"Æsir", "aesir@gmx.de", "=?utf-8?q?=C3=86sir?= <aesir@gmx.de>"}, + {"new😀user", "new.user@alo.com", "=?utf-8?q?new=F0=9F=98=80user?= <new.user@alo.com>"}, // codespell-ignore + {`"quoted"`, "quoted@test.com", `"quoted" <quoted@test.com>`}, + {`gusted`, "gusted@test.com", `"gusted" <gusted@test.com>`}, + {`Joe Q. Public`, "john.q.public@example.com", `"Joe Q. Public" <john.q.public@example.com>`}, + {`Who?`, "one@y.test", `"Who?" <one@y.test>`}, + } + + for _, testCase := range testCases { + t.Run(testCase.result, func(t *testing.T) { + testUser := &user_model.User{FullName: testCase.fullName, Email: testCase.mail} + assert.EqualValues(t, testCase.result, testUser.EmailTo()) + }) + } + + t.Run("Override user's email", func(t *testing.T) { + testUser := &user_model.User{FullName: "Christine Jorgensen", Email: "christine@test.com"} + assert.EqualValues(t, `"Christine Jorgensen" <christine@example.org>`, testUser.EmailTo("christine@example.org")) + }) +} + +func TestDisabledUserFeatures(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + testValues := container.SetOf(setting.UserFeatureDeletion, + setting.UserFeatureManageSSHKeys, + setting.UserFeatureManageGPGKeys) + + oldSetting := setting.Admin.ExternalUserDisableFeatures + defer func() { + setting.Admin.ExternalUserDisableFeatures = oldSetting + }() + setting.Admin.ExternalUserDisableFeatures = testValues + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + assert.Empty(t, setting.Admin.UserDisabledFeatures.Values()) + + // no features should be disabled with a plain login type + assert.LessOrEqual(t, user.LoginType, auth.Plain) + assert.Empty(t, user_model.DisabledFeaturesWithLoginType(user).Values()) + for _, f := range testValues.Values() { + assert.False(t, user_model.IsFeatureDisabledWithLoginType(user, f)) + } + + // check disabled features with external login type + user.LoginType = auth.OAuth2 + + // all features should be disabled + assert.NotEmpty(t, user_model.DisabledFeaturesWithLoginType(user).Values()) + for _, f := range testValues.Values() { + assert.True(t, user_model.IsFeatureDisabledWithLoginType(user, f)) + } +} + +func TestGenerateEmailAuthorizationCode(t *testing.T) { + defer test.MockVariableValue(&setting.Service.ActiveCodeLives, 2)() + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation) + require.NoError(t, err) + + lookupKey, validator, ok := strings.Cut(code, ":") + assert.True(t, ok) + + rawValidator, err := hex.DecodeString(validator) + require.NoError(t, err) + + authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation) + require.NoError(t, err) + assert.False(t, authToken.IsExpired()) + assert.EqualValues(t, authToken.HashedValidator, auth.HashValidator(rawValidator)) + + authToken.Expiry = authToken.Expiry.Add(-int64(setting.Service.ActiveCodeLives) * 60) + assert.True(t, authToken.IsExpired()) +} + +func TestVerifyUserAuthorizationToken(t *testing.T) { + defer test.MockVariableValue(&setting.Service.ActiveCodeLives, 2)() + require.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + code, err := user.GenerateEmailAuthorizationCode(db.DefaultContext, auth.UserActivation) + require.NoError(t, err) + + lookupKey, _, ok := strings.Cut(code, ":") + assert.True(t, ok) + + t.Run("Wrong purpose", func(t *testing.T) { + u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.PasswordReset, false) + require.NoError(t, err) + assert.Nil(t, u) + }) + + t.Run("No delete", func(t *testing.T) { + u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation, false) + require.NoError(t, err) + assert.EqualValues(t, user.ID, u.ID) + + authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation) + require.NoError(t, err) + assert.NotNil(t, authToken) + }) + + t.Run("Delete", func(t *testing.T) { + u, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation, true) + require.NoError(t, err) + assert.EqualValues(t, user.ID, u.ID) + + authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation) + require.ErrorIs(t, err, util.ErrNotExist) + assert.Nil(t, authToken) + }) +} + +func TestGetInactiveUsers(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + // all inactive users + // user1's createdunix is 1730468968 + users, err := user_model.GetInactiveUsers(db.DefaultContext, 0) + require.NoError(t, err) + assert.Len(t, users, 1) + interval := time.Now().Unix() - 1730468968 + 3600*24 + users, err = user_model.GetInactiveUsers(db.DefaultContext, time.Duration(interval*int64(time.Second))) + require.NoError(t, err) + require.Empty(t, users) +} diff --git a/models/user/user_update.go b/models/user/user_update.go new file mode 100644 index 0000000..66702e2 --- /dev/null +++ b/models/user/user_update.go @@ -0,0 +1,15 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" +) + +func IncrUserRepoNum(ctx context.Context, userID int64) error { + _, err := db.GetEngine(ctx).Incr("num_repos").ID(userID).Update(new(User)) + return err +} |