summaryrefslogtreecommitdiffstats
path: root/models/user
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--models/user/avatar.go115
-rw-r--r--models/user/badge.go41
-rw-r--r--models/user/block.go91
-rw-r--r--models/user/block_test.go78
-rw-r--r--models/user/email_address.go555
-rw-r--r--models/user/email_address_test.go256
-rw-r--r--models/user/error.go109
-rw-r--r--models/user/external_login_user.go184
-rw-r--r--models/user/federated_user.go35
-rw-r--r--models/user/federated_user_test.go29
-rw-r--r--models/user/fixtures/user.yml36
-rw-r--r--models/user/follow.go85
-rw-r--r--models/user/follow_test.go24
-rw-r--r--models/user/list.go83
-rw-r--r--models/user/main_test.go19
-rw-r--r--models/user/must_change_password.go49
-rw-r--r--models/user/openid.go111
-rw-r--r--models/user/openid_test.go68
-rw-r--r--models/user/redirect.go87
-rw-r--r--models/user/redirect_test.go26
-rw-r--r--models/user/search.go178
-rw-r--r--models/user/setting.go212
-rw-r--r--models/user/setting_keys.go17
-rw-r--r--models/user/setting_test.go61
-rw-r--r--models/user/user.go1306
-rw-r--r--models/user/user_repository.go83
-rw-r--r--models/user/user_system.go70
-rw-r--r--models/user/user_test.go670
-rw-r--r--models/user/user_update.go15
29 files changed, 4693 insertions, 0 deletions
diff --git a/models/user/avatar.go b/models/user/avatar.go
new file mode 100644
index 00000000..c6937d7b
--- /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 00000000..ee52b44c
--- /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 00000000..189cacc2
--- /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 00000000..a795ef34
--- /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 00000000..18bf6d0b
--- /dev/null
+++ b/models/user/email_address.go
@@ -0,0 +1,555 @@
+// 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"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/base"
+ "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")
+}
+
+func MakeEmailPrimaryWithUser(ctx context.Context, user *User, email *EmailAddress) error {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ // 1. Update user table
+ user.Email = email.Email
+ if _, err = sess.ID(user.ID).Cols("email").Update(user); err != nil {
+ return err
+ }
+
+ // 2. Update old primary email
+ if _, err = sess.Where("uid=? AND is_primary=?", email.UID, true).Cols("is_primary").Update(&EmailAddress{
+ IsPrimary: false,
+ }); err != nil {
+ return err
+ }
+
+ // 3. update new primary email
+ email.IsPrimary = true
+ if _, err = sess.ID(email.ID).Cols("is_primary").Update(email); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// MakeEmailPrimary sets primary email address of given user.
+func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
+ has, err := db.GetEngine(ctx).Get(email)
+ if err != nil {
+ return err
+ } else if !has {
+ return ErrEmailAddressNotExist{Email: email.Email}
+ }
+
+ if !email.IsActivated {
+ return ErrEmailNotActivated
+ }
+
+ user := &User{}
+ has, err = db.GetEngine(ctx).ID(email.UID).Get(user)
+ if err != nil {
+ return err
+ } else if !has {
+ return ErrUserNotExist{UID: email.UID}
+ }
+
+ return MakeEmailPrimaryWithUser(ctx, user, email)
+}
+
+// VerifyActiveEmailCode verifies active email code when active account
+func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress {
+ if user := GetVerifyUser(ctx, code); user != nil {
+ // time limit code
+ prefix := code[:base.TimeLimitCodeLength]
+ data := fmt.Sprintf("%d%s%s%s%s", user.ID, email, user.LowerName, user.Passwd, user.Rands)
+
+ if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) {
+ emailAddress := &EmailAddress{UID: user.ID, Email: email}
+ if has, _ := db.GetEngine(ctx).Get(emailAddress); has {
+ return emailAddress
+ }
+ }
+ }
+ return nil
+}
+
+// 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 {
+ 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 00000000..58cf8674
--- /dev/null
+++ b/models/user/email_address_test.go
@@ -0,0 +1,256 @@
+// 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 TestMakeEmailPrimary(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ email := &user_model.EmailAddress{
+ Email: "user567890@example.com",
+ }
+ err := user_model.MakeEmailPrimary(db.DefaultContext, email)
+ require.Error(t, err)
+ require.EqualError(t, err, user_model.ErrEmailAddressNotExist{Email: email.Email}.Error())
+
+ email = &user_model.EmailAddress{
+ Email: "user11@example.com",
+ }
+ err = user_model.MakeEmailPrimary(db.DefaultContext, email)
+ require.Error(t, err)
+ require.EqualError(t, err, user_model.ErrEmailNotActivated.Error())
+
+ email = &user_model.EmailAddress{
+ Email: "user9999999@example.com",
+ }
+ err = user_model.MakeEmailPrimary(db.DefaultContext, email)
+ require.Error(t, err)
+ assert.True(t, user_model.IsErrUserNotExist(err))
+
+ email = &user_model.EmailAddress{
+ Email: "user101@example.com",
+ }
+ err = user_model.MakeEmailPrimary(db.DefaultContext, email)
+ require.NoError(t, err)
+
+ user, _ := user_model.GetUserByID(db.DefaultContext, int64(10))
+ assert.Equal(t, "user101@example.com", user.Email)
+}
+
+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 00000000..cbf19998
--- /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 00000000..965b7a5e
--- /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 00000000..1fc42c3c
--- /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 00000000..6a211266
--- /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 00000000..b1892f33
--- /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 00000000..9c3283b8
--- /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 00000000..8c56164e
--- /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 00000000..ca589d1e
--- /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 00000000..a626d323
--- /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 00000000..7eab08de
--- /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 00000000..ee4ecaba
--- /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 00000000..c2857aac
--- /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 00000000..5a40d4df
--- /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 00000000..35fd29aa
--- /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 00000000..04c434e4
--- /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 00000000..b4af0e5c
--- /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 00000000..0e2c9369
--- /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 00000000..0b05c54e
--- /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 00000000..56a2bc38
--- /dev/null
+++ b/models/user/user.go
@@ -0,0 +1,1306 @@
+// 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"
+ "encoding/hex"
+ "fmt"
+ "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
+
+ // 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
+
+ // UserTypeOrganizationReserved reserves a (non-existing) organization, to be used in combination with UserTypeUserReserved
+ UserTypeOrganizationReserved
+
+ // UserTypeBot defines a bot user
+ UserTypeBot
+
+ // UserTypeRemoteUser defines a remote user for federated users
+ UserTypeRemoteUser
+)
+
+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)
+}
+
+// GenerateEmailActivateCode generates an activate code based on user information and given e-mail.
+func (u *User) GenerateEmailActivateCode(email string) string {
+ code := base.CreateTimeLimitCode(
+ fmt.Sprintf("%d%s%s%s%s", u.ID, email, u.LowerName, u.Passwd, u.Rands),
+ setting.Service.ActiveCodeLives, time.Now(), nil)
+
+ // Add tail hex username
+ code += hex.EncodeToString([]byte(u.LowerName))
+ return code
+}
+
+// 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
+}
+
+// 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
+}
+
+// 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
+}
+
+// GetVerifyUser get user by verify code
+func GetVerifyUser(ctx context.Context, code string) (user *User) {
+ if len(code) <= base.TimeLimitCodeLength {
+ return nil
+ }
+
+ // use tail hex username query user
+ hexStr := code[base.TimeLimitCodeLength:]
+ if b, err := hex.DecodeString(hexStr); err == nil {
+ if user, err = GetUserByName(ctx, string(b)); user != nil {
+ return user
+ }
+ log.Error("user.getVerifyUser: %v", err)
+ }
+
+ return nil
+}
+
+// VerifyUserActiveCode verifies active code when active account
+func VerifyUserActiveCode(ctx context.Context, code string) (user *User) {
+ if user = GetVerifyUser(ctx, code); user != nil {
+ // time limit code
+ prefix := code[:base.TimeLimitCodeLength]
+ data := fmt.Sprintf("%d%s%s%s%s", user.ID, user.Email, user.LowerName, user.Passwd, user.Rands)
+ if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) {
+ return user
+ }
+ }
+ return 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) {
+ var cond builder.Cond = builder.Eq{"is_active": false}
+
+ 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 00000000..c06441b5
--- /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 00000000..ac2505dd
--- /dev/null
+++ b/models/user/user_system.go
@@ -0,0 +1,70 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "strings"
+
+ "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
+}
diff --git a/models/user/user_test.go b/models/user/user_test.go
new file mode 100644
index 00000000..ca94e147
--- /dev/null
+++ b/models/user/user_test.go
@@ -0,0 +1,670 @@
+// 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"
+ "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/timeutil"
+ "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), fmt.Sprintf("case: %+v", 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 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))
+ }
+}
diff --git a/models/user/user_update.go b/models/user/user_update.go
new file mode 100644
index 00000000..66702e2a
--- /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
+}