summaryrefslogtreecommitdiffstats
path: root/services/user
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /services/user
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--services/user/TestPurgeUser/public_key.yml11
-rw-r--r--services/user/avatar.go73
-rw-r--r--services/user/avatar_test.go81
-rw-r--r--services/user/block.go95
-rw-r--r--services/user/block_test.go92
-rw-r--r--services/user/delete.go224
-rw-r--r--services/user/email.go232
-rw-r--r--services/user/email_test.go178
-rw-r--r--services/user/update.go233
-rw-r--r--services/user/update_test.go121
-rw-r--r--services/user/user.go332
-rw-r--r--services/user/user_test.go264
12 files changed, 1936 insertions, 0 deletions
diff --git a/services/user/TestPurgeUser/public_key.yml b/services/user/TestPurgeUser/public_key.yml
new file mode 100644
index 0000000..75e409a
--- /dev/null
+++ b/services/user/TestPurgeUser/public_key.yml
@@ -0,0 +1,11 @@
+-
+ id: 1001
+ owner_id: 2
+ name: user2@localhost
+ fingerprint: "SHA256:7s+isLFauDv7QSbhAd0Z4OGIYJlQQ4YMtOH9LdjCZL8"
+ content: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHAv3EOUcaK918Fk9d7mWuVS7oQamif/PNwqnAf/Z34G user2@localhost"
+ mode: 2
+ type: 3
+ created_unix: 1733363453
+ updated_unix: 1733363453
+ login_source_id: 0
diff --git a/services/user/avatar.go b/services/user/avatar.go
new file mode 100644
index 0000000..3f87466
--- /dev/null
+++ b/services/user/avatar.go
@@ -0,0 +1,73 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/avatar"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/storage"
+)
+
+// UploadAvatar saves custom avatar for user.
+func UploadAvatar(ctx context.Context, u *user_model.User, data []byte) error {
+ avatarData, err := avatar.ProcessAvatarImage(data)
+ if err != nil {
+ return err
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ u.UseCustomAvatar = true
+ u.Avatar = avatar.HashAvatar(u.ID, data)
+ if err = user_model.UpdateUserCols(ctx, u, "use_custom_avatar", "avatar"); err != nil {
+ return fmt.Errorf("updateUser: %w", err)
+ }
+
+ if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error {
+ _, err := w.Write(avatarData)
+ return err
+ }); err != nil {
+ return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err)
+ }
+
+ return committer.Commit()
+}
+
+// DeleteAvatar deletes the user's custom avatar.
+func DeleteAvatar(ctx context.Context, u *user_model.User) error {
+ aPath := u.CustomAvatarRelativePath()
+ log.Trace("DeleteAvatar[%d]: %s", u.ID, aPath)
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ hasAvatar := len(u.Avatar) > 0
+ u.UseCustomAvatar = false
+ u.Avatar = ""
+ if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar, use_custom_avatar").Update(u); err != nil {
+ return fmt.Errorf("DeleteAvatar: %w", err)
+ }
+
+ if hasAvatar {
+ if err := storage.Avatars.Delete(aPath); err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("failed to remove %s: %w", aPath, err)
+ }
+ log.Warn("Deleting avatar %s but it doesn't exist", aPath)
+ }
+ }
+
+ return nil
+ })
+}
diff --git a/services/user/avatar_test.go b/services/user/avatar_test.go
new file mode 100644
index 0000000..21fca8d
--- /dev/null
+++ b/services/user/avatar_test.go
@@ -0,0 +1,81 @@
+// Copyright The Forgejo Authors.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "bytes"
+ "image"
+ "image/png"
+ "os"
+ "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/storage"
+ "code.gitea.io/gitea/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type alreadyDeletedStorage struct {
+ storage.DiscardStorage
+}
+
+func (s alreadyDeletedStorage) Delete(_ string) error {
+ return os.ErrNotExist
+}
+
+func TestUserDeleteAvatar(t *testing.T) {
+ myImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
+ var buff bytes.Buffer
+ png.Encode(&buff, myImage)
+
+ t.Run("AtomicStorageFailure", func(t *testing.T) {
+ defer test.MockProtect[storage.ObjectStorage](&storage.Avatars)()
+
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ err := UploadAvatar(db.DefaultContext, user, buff.Bytes())
+ require.NoError(t, err)
+ verification := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ assert.NotEqual(t, "", verification.Avatar)
+
+ // fail to delete ...
+ storage.Avatars = storage.UninitializedStorage
+ err = DeleteAvatar(db.DefaultContext, user)
+ require.Error(t, err)
+
+ // ... the avatar is not removed from the database
+ verification = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ assert.True(t, verification.UseCustomAvatar)
+
+ // already deleted ...
+ storage.Avatars = alreadyDeletedStorage{}
+ err = DeleteAvatar(db.DefaultContext, user)
+ require.NoError(t, err)
+
+ // ... the avatar is removed from the database
+ verification = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ assert.Equal(t, "", verification.Avatar)
+ })
+
+ t.Run("Success", func(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ err := UploadAvatar(db.DefaultContext, user, buff.Bytes())
+ require.NoError(t, err)
+ verification := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ assert.NotEqual(t, "", verification.Avatar)
+
+ err = DeleteAvatar(db.DefaultContext, user)
+ require.NoError(t, err)
+
+ verification = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ assert.Equal(t, "", verification.Avatar)
+ })
+}
diff --git a/services/user/block.go b/services/user/block.go
new file mode 100644
index 0000000..0b31119
--- /dev/null
+++ b/services/user/block.go
@@ -0,0 +1,95 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package user
+
+import (
+ "context"
+
+ model "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "xorm.io/builder"
+)
+
+// BlockUser adds a blocked user entry for userID to block blockID.
+// TODO: Figure out if instance admins should be immune to blocking.
+// TODO: Add more mechanism like removing blocked user as collaborator on
+// repositories where the user is an owner.
+func BlockUser(ctx context.Context, userID, blockID int64) error {
+ if userID == blockID || user_model.IsBlocked(ctx, userID, blockID) {
+ return nil
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ // Add the blocked user entry.
+ _, err = db.GetEngine(ctx).Insert(&user_model.BlockedUser{UserID: userID, BlockID: blockID})
+ if err != nil {
+ return err
+ }
+
+ // Unfollow the user from the block's perspective.
+ err = user_model.UnfollowUser(ctx, blockID, userID)
+ if err != nil {
+ return err
+ }
+
+ // Unfollow the user from the doer's perspective.
+ err = user_model.UnfollowUser(ctx, userID, blockID)
+ if err != nil {
+ return err
+ }
+
+ // Blocked user unwatch all repository owned by the doer.
+ repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID)
+ if err != nil {
+ return err
+ }
+
+ err = repo_model.UnwatchRepos(ctx, blockID, repoIDs)
+ if err != nil {
+ return err
+ }
+
+ // Remove blocked user as collaborator from repositories the user owns as an
+ // individual.
+ collabsID, err := repo_model.GetCollaboratorWithUser(ctx, userID, blockID)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.GetEngine(ctx).In("id", collabsID).Delete(&repo_model.Collaboration{})
+ if err != nil {
+ return err
+ }
+
+ // Remove pending repository transfers, and set the status on those repository
+ // back to ready.
+ pendingTransfersIDs, err := model.GetPendingTransferIDs(ctx, userID, blockID)
+ if err != nil {
+ return err
+ }
+
+ // Use a subquery instead of a JOIN, because not every database supports JOIN
+ // on a UPDATE query.
+ _, err = db.GetEngine(ctx).Table("repository").
+ In("id", builder.Select("repo_id").From("repo_transfer").Where(builder.In("id", pendingTransfersIDs))).
+ Cols("status").
+ Update(&repo_model.Repository{Status: repo_model.RepositoryReady})
+ if err != nil {
+ return err
+ }
+
+ _, err = db.GetEngine(ctx).In("id", pendingTransfersIDs).Delete(&model.RepoTransfer{})
+ if err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
diff --git a/services/user/block_test.go b/services/user/block_test.go
new file mode 100644
index 0000000..f9e95ed
--- /dev/null
+++ b/services/user/block_test.go
@@ -0,0 +1,92 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "testing"
+
+ model "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestBlockUser will ensure that when you block a user, certain actions have
+// been taken, like unfollowing each other etc.
+func TestBlockUser(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ t.Run("Follow", func(t *testing.T) {
+ defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
+
+ // Follow each other.
+ require.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID))
+ require.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID))
+
+ require.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+ // Ensure they aren't following each other anymore.
+ assert.False(t, user_model.IsFollowing(db.DefaultContext, doer.ID, blockedUser.ID))
+ assert.False(t, user_model.IsFollowing(db.DefaultContext, blockedUser.ID, doer.ID))
+ })
+
+ t.Run("Watch", func(t *testing.T) {
+ defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
+
+ // Blocked user watch repository of doer.
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID})
+ require.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true))
+
+ require.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+ // Ensure blocked user isn't following doer's repository.
+ assert.False(t, repo_model.IsWatching(db.DefaultContext, blockedUser.ID, repo.ID))
+ })
+
+ t.Run("Collaboration", func(t *testing.T) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
+ blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18})
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22, OwnerID: doer.ID})
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21, OwnerID: doer.ID})
+ defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
+
+ isBlockedUserCollab := func(repo *repo_model.Repository) bool {
+ isCollaborator, err := repo_model.IsCollaborator(db.DefaultContext, repo.ID, blockedUser.ID)
+ require.NoError(t, err)
+ return isCollaborator
+ }
+
+ assert.True(t, isBlockedUserCollab(repo1))
+ assert.True(t, isBlockedUserCollab(repo2))
+
+ require.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+ assert.False(t, isBlockedUserCollab(repo1))
+ assert.False(t, isBlockedUserCollab(repo2))
+ })
+
+ t.Run("Pending transfers", func(t *testing.T) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
+ defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
+
+ unittest.AssertExistsIf(t, true, &repo_model.Repository{ID: 3, OwnerID: blockedUser.ID, Status: repo_model.RepositoryPendingTransfer})
+ unittest.AssertExistsIf(t, true, &model.RepoTransfer{ID: 1, RecipientID: doer.ID, DoerID: blockedUser.ID})
+
+ require.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
+
+ unittest.AssertExistsIf(t, false, &model.RepoTransfer{ID: 1, RecipientID: doer.ID, DoerID: blockedUser.ID})
+
+ // Don't use AssertExistsIf, as it doesn't include the zero values in the condition such as `repo_model.RepositoryReady`.
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3, OwnerID: blockedUser.ID})
+ assert.Equal(t, repo_model.RepositoryReady, repo.Status)
+ })
+}
diff --git a/services/user/delete.go b/services/user/delete.go
new file mode 100644
index 0000000..587e3c2
--- /dev/null
+++ b/services/user/delete.go
@@ -0,0 +1,224 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ _ "image/jpeg" // Needed for jpeg support
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ activities_model "code.gitea.io/gitea/models/activities"
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/organization"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ pull_model "code.gitea.io/gitea/models/pull"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ issue_service "code.gitea.io/gitea/services/issue"
+
+ "xorm.io/builder"
+)
+
+// deleteUser deletes models associated to an user.
+func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) {
+ e := db.GetEngine(ctx)
+
+ // ***** START: Watch *****
+ watchedRepoIDs, err := db.FindIDs(ctx, "watch", "watch.repo_id",
+ builder.Eq{"watch.user_id": u.ID}.
+ And(builder.Neq{"watch.mode": repo_model.WatchModeDont}))
+ if err != nil {
+ return fmt.Errorf("get all watches: %w", err)
+ }
+ if err = db.DecrByIDs(ctx, watchedRepoIDs, "num_watches", new(repo_model.Repository)); err != nil {
+ return fmt.Errorf("decrease repository num_watches: %w", err)
+ }
+ // ***** END: Watch *****
+
+ // ***** START: Star *****
+ starredRepoIDs, err := db.FindIDs(ctx, "star", "star.repo_id",
+ builder.Eq{"star.uid": u.ID})
+ if err != nil {
+ return fmt.Errorf("get all stars: %w", err)
+ } else if err = db.DecrByIDs(ctx, starredRepoIDs, "num_stars", new(repo_model.Repository)); err != nil {
+ return fmt.Errorf("decrease repository num_stars: %w", err)
+ }
+ // ***** END: Star *****
+
+ // ***** START: Follow *****
+ followeeIDs, err := db.FindIDs(ctx, "follow", "follow.follow_id",
+ builder.Eq{"follow.user_id": u.ID})
+ if err != nil {
+ return fmt.Errorf("get all followees: %w", err)
+ } else if err = db.DecrByIDs(ctx, followeeIDs, "num_followers", new(user_model.User)); err != nil {
+ return fmt.Errorf("decrease user num_followers: %w", err)
+ }
+
+ followerIDs, err := db.FindIDs(ctx, "follow", "follow.user_id",
+ builder.Eq{"follow.follow_id": u.ID})
+ if err != nil {
+ return fmt.Errorf("get all followers: %w", err)
+ } else if err = db.DecrByIDs(ctx, followerIDs, "num_following", new(user_model.User)); err != nil {
+ return fmt.Errorf("decrease user num_following: %w", err)
+ }
+ // ***** END: Follow *****
+
+ if err = db.DeleteBeans(ctx,
+ &auth_model.AccessToken{UID: u.ID},
+ &repo_model.Collaboration{UserID: u.ID},
+ &access_model.Access{UserID: u.ID},
+ &repo_model.Watch{UserID: u.ID},
+ &repo_model.Star{UID: u.ID},
+ &user_model.Follow{UserID: u.ID},
+ &user_model.Follow{FollowID: u.ID},
+ &activities_model.Action{UserID: u.ID},
+ &issues_model.IssueUser{UID: u.ID},
+ &user_model.EmailAddress{UID: u.ID},
+ &user_model.UserOpenID{UID: u.ID},
+ &issues_model.Reaction{UserID: u.ID},
+ &organization.TeamUser{UID: u.ID},
+ &issues_model.Stopwatch{UserID: u.ID},
+ &user_model.Setting{UserID: u.ID},
+ &user_model.UserBadge{UserID: u.ID},
+ &pull_model.AutoMerge{DoerID: u.ID},
+ &pull_model.ReviewState{UserID: u.ID},
+ &user_model.Redirect{RedirectUserID: u.ID},
+ &actions_model.ActionRunner{OwnerID: u.ID},
+ &user_model.BlockedUser{BlockID: u.ID},
+ &user_model.BlockedUser{UserID: u.ID},
+ &actions_model.ActionRunnerToken{OwnerID: u.ID},
+ &auth_model.AuthorizationToken{UID: u.ID},
+ ); err != nil {
+ return fmt.Errorf("deleteBeans: %w", err)
+ }
+
+ if err := auth_model.DeleteOAuth2RelictsByUserID(ctx, u.ID); err != nil {
+ return err
+ }
+
+ if purge || (setting.Service.UserDeleteWithCommentsMaxTime != 0 &&
+ u.CreatedUnix.AsTime().Add(setting.Service.UserDeleteWithCommentsMaxTime).After(time.Now())) {
+ // Delete Comments
+ const batchSize = 50
+ for {
+ comments := make([]*issues_model.Comment, 0, batchSize)
+ if err = e.Where("type=? AND poster_id=?", issues_model.CommentTypeComment, u.ID).Limit(batchSize, 0).Find(&comments); err != nil {
+ return err
+ }
+ if len(comments) == 0 {
+ break
+ }
+
+ for _, comment := range comments {
+ if err = issues_model.DeleteComment(ctx, comment); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Delete Reactions
+ if err = issues_model.DeleteReaction(ctx, &issues_model.ReactionOptions{DoerID: u.ID}); err != nil {
+ return err
+ }
+ }
+
+ // ***** START: Issues *****
+ if purge {
+ const batchSize = 50
+
+ for {
+ issues := make([]*issues_model.Issue, 0, batchSize)
+ if err = e.Where("poster_id=?", u.ID).Limit(batchSize, 0).Find(&issues); err != nil {
+ return err
+ }
+ if len(issues) == 0 {
+ break
+ }
+
+ for _, issue := range issues {
+ // NOTE: Don't open git repositories just to remove the reference data,
+ // `git gc` is able to remove that reference which is run as a cron job
+ // by default. Also use the deleted user as doer to delete the issue.
+ if err = issue_service.DeleteIssue(ctx, u, nil, issue); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ // ***** END: Issues *****
+
+ // ***** START: Branch Protections *****
+ {
+ const batchSize = 50
+ for start := 0; ; start += batchSize {
+ protections := make([]*git_model.ProtectedBranch, 0, batchSize)
+ // @perf: We can't filter on DB side by u.ID, as those IDs are serialized as JSON strings.
+ // We could filter down with `WHERE repo_id IN (reposWithPushPermission(u))`,
+ // though that query will be quite complex and tricky to maintain (compare `getRepoAssignees()`).
+ // Also, as we didn't update branch protections when removing entries from `access` table,
+ // it's safer to iterate all protected branches.
+ if err = e.Limit(batchSize, start).Find(&protections); err != nil {
+ return fmt.Errorf("findProtectedBranches: %w", err)
+ }
+ if len(protections) == 0 {
+ break
+ }
+ for _, p := range protections {
+ if err := git_model.RemoveUserIDFromProtectedBranch(ctx, p, u.ID); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ // ***** END: Branch Protections *****
+
+ // ***** START: PublicKey *****
+ if _, err = db.DeleteByBean(ctx, &asymkey_model.PublicKey{OwnerID: u.ID}); err != nil {
+ return fmt.Errorf("deletePublicKeys: %w", err)
+ }
+ // ***** END: PublicKey *****
+
+ // ***** START: GPGPublicKey *****
+ keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{
+ OwnerID: u.ID,
+ })
+ if err != nil {
+ return fmt.Errorf("ListGPGKeys: %w", err)
+ }
+ // Delete GPGKeyImport(s).
+ for _, key := range keys {
+ if _, err = db.DeleteByBean(ctx, &asymkey_model.GPGKeyImport{KeyID: key.KeyID}); err != nil {
+ return fmt.Errorf("deleteGPGKeyImports: %w", err)
+ }
+ }
+ if _, err = db.DeleteByBean(ctx, &asymkey_model.GPGKey{OwnerID: u.ID}); err != nil {
+ return fmt.Errorf("deleteGPGKeys: %w", err)
+ }
+ // ***** END: GPGPublicKey *****
+
+ // Clear assignee.
+ if _, err = db.DeleteByBean(ctx, &issues_model.IssueAssignees{AssigneeID: u.ID}); err != nil {
+ return fmt.Errorf("clear assignee: %w", err)
+ }
+
+ // ***** START: ExternalLoginUser *****
+ if err = user_model.RemoveAllAccountLinks(ctx, u); err != nil {
+ return fmt.Errorf("ExternalLoginUser: %w", err)
+ }
+ // ***** END: ExternalLoginUser *****
+
+ if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil {
+ return fmt.Errorf("delete: %w", err)
+ }
+
+ return nil
+}
diff --git a/services/user/email.go b/services/user/email.go
new file mode 100644
index 0000000..e872526
--- /dev/null
+++ b/services/user/email.go
@@ -0,0 +1,232 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/mailer"
+)
+
+// AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address
+func AdminAddOrSetPrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
+ if strings.EqualFold(u.Email, emailStr) {
+ return nil
+ }
+
+ if err := user_model.ValidateEmailForAdmin(emailStr); err != nil {
+ return err
+ }
+
+ // Check if address exists already
+ email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
+ if err != nil && !errors.Is(err, util.ErrNotExist) {
+ return err
+ }
+ if email != nil && email.UID != u.ID {
+ return user_model.ErrEmailAlreadyUsed{Email: emailStr}
+ }
+
+ // Update old primary address
+ primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
+ if err != nil {
+ return err
+ }
+
+ primary.IsPrimary = false
+ if err := user_model.UpdateEmailAddress(ctx, primary); err != nil {
+ return err
+ }
+
+ // Insert new or update existing address
+ if email != nil {
+ email.IsPrimary = true
+ email.IsActivated = true
+ if err := user_model.UpdateEmailAddress(ctx, email); err != nil {
+ return err
+ }
+ } else {
+ email = &user_model.EmailAddress{
+ UID: u.ID,
+ Email: emailStr,
+ IsActivated: true,
+ IsPrimary: true,
+ }
+ if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
+ return err
+ }
+ }
+
+ u.Email = emailStr
+
+ return user_model.UpdateUserCols(ctx, u, "email")
+}
+
+func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
+ if strings.EqualFold(u.Email, emailStr) {
+ return nil
+ }
+
+ if err := user_model.ValidateEmail(emailStr); err != nil {
+ return err
+ }
+
+ if !u.IsOrganization() {
+ // Check if address exists already
+ email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
+ if err != nil && !errors.Is(err, util.ErrNotExist) {
+ return err
+ }
+ if email != nil {
+ if email.IsPrimary && email.UID == u.ID {
+ return nil
+ }
+ return user_model.ErrEmailAlreadyUsed{Email: emailStr}
+ }
+
+ // Remove old primary address
+ primary, err := user_model.GetPrimaryEmailAddressOfUser(ctx, u.ID)
+ if err != nil {
+ return err
+ }
+ if _, err := db.DeleteByID[user_model.EmailAddress](ctx, primary.ID); err != nil {
+ return err
+ }
+
+ // Insert new primary address
+ email = &user_model.EmailAddress{
+ UID: u.ID,
+ Email: emailStr,
+ IsActivated: true,
+ IsPrimary: true,
+ }
+ if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
+ return err
+ }
+ }
+
+ u.Email = emailStr
+
+ return user_model.UpdateUserCols(ctx, u, "email")
+}
+
+func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error {
+ for _, emailStr := range emails {
+ if err := user_model.ValidateEmail(emailStr); err != nil {
+ return err
+ }
+
+ // Check if address exists already
+ email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
+ if err != nil && !errors.Is(err, util.ErrNotExist) {
+ return err
+ }
+ if email != nil {
+ return user_model.ErrEmailAlreadyUsed{Email: emailStr}
+ }
+
+ // Insert new address
+ email = &user_model.EmailAddress{
+ UID: u.ID,
+ Email: emailStr,
+ IsActivated: !setting.Service.RegisterEmailConfirm,
+ IsPrimary: false,
+ }
+ if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// ReplaceInactivePrimaryEmail replaces the primary email of a given user, even if the primary is not yet activated.
+func ReplaceInactivePrimaryEmail(ctx context.Context, oldEmail string, email *user_model.EmailAddress) error {
+ user := &user_model.User{}
+ has, err := db.GetEngine(ctx).ID(email.UID).Get(user)
+ if err != nil {
+ return err
+ } else if !has {
+ return user_model.ErrUserNotExist{
+ UID: email.UID,
+ }
+ }
+
+ err = AddEmailAddresses(ctx, user, []string{email.Email})
+ if err != nil {
+ return err
+ }
+
+ err = MakeEmailAddressPrimary(ctx, user, email, false)
+ if err != nil {
+ return err
+ }
+
+ return DeleteEmailAddresses(ctx, user, []string{oldEmail})
+}
+
+func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error {
+ for _, emailStr := range emails {
+ // Check if address exists
+ email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID)
+ if err != nil {
+ return err
+ }
+ if email.IsPrimary {
+ return user_model.ErrPrimaryEmailCannotDelete{Email: emailStr}
+ }
+
+ // Remove address
+ if _, err := db.DeleteByID[user_model.EmailAddress](ctx, email.ID); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func MakeEmailAddressPrimary(ctx context.Context, u *user_model.User, newPrimaryEmail *user_model.EmailAddress, notify bool) error {
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ oldPrimaryEmail := u.Email
+
+ // 1. Update user table
+ u.Email = newPrimaryEmail.Email
+ if _, err = sess.ID(u.ID).Cols("email").Update(u); err != nil {
+ return err
+ }
+
+ // 2. Update old primary email
+ if _, err = sess.Where("uid=? AND is_primary=?", u.ID, true).Cols("is_primary").Update(&user_model.EmailAddress{
+ IsPrimary: false,
+ }); err != nil {
+ return err
+ }
+
+ // 3. update new primary email
+ newPrimaryEmail.IsPrimary = true
+ if _, err = sess.ID(newPrimaryEmail.ID).Cols("is_primary").Update(newPrimaryEmail); err != nil {
+ return err
+ }
+
+ if err := committer.Commit(); err != nil {
+ return err
+ }
+
+ if notify {
+ return mailer.SendPrimaryMailChange(u, oldPrimaryEmail)
+ }
+ return nil
+}
diff --git a/services/user/email_test.go b/services/user/email_test.go
new file mode 100644
index 0000000..86f31a8
--- /dev/null
+++ b/services/user/email_test.go
@@ -0,0 +1,178 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ organization_model "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/gobwas/glob"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 27})
+
+ emails, err := user_model.GetEmailAddresses(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, emails, 1)
+
+ primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.NotEqual(t, "new-primary@example.com", primary.Email)
+ assert.Equal(t, user.Email, primary.Email)
+
+ require.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary@example.com"))
+
+ primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "new-primary@example.com", primary.Email)
+ assert.Equal(t, user.Email, primary.Email)
+
+ emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, emails, 2)
+
+ setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")}
+ defer func() {
+ setting.Service.EmailDomainAllowList = []glob.Glob{}
+ }()
+
+ require.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary2@example2.com"))
+
+ primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "new-primary2@example2.com", primary.Email)
+ assert.Equal(t, user.Email, primary.Email)
+
+ require.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "user27@example.com"))
+
+ primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "user27@example.com", primary.Email)
+ assert.Equal(t, user.Email, primary.Email)
+
+ emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, emails, 3)
+}
+
+func TestReplacePrimaryEmailAddress(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ t.Run("User", func(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13})
+
+ emails, err := user_model.GetEmailAddresses(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, emails, 1)
+
+ primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.NotEqual(t, "primary-13@example.com", primary.Email)
+ assert.Equal(t, user.Email, primary.Email)
+
+ require.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com"))
+
+ primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "primary-13@example.com", primary.Email)
+ assert.Equal(t, user.Email, primary.Email)
+
+ emails, err = user_model.GetEmailAddresses(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, emails, 1)
+
+ require.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com"))
+ })
+
+ t.Run("Organization", func(t *testing.T) {
+ org := unittest.AssertExistsAndLoadBean(t, &organization_model.Organization{ID: 3})
+
+ assert.Equal(t, "org3@example.com", org.Email)
+
+ require.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, org.AsUser(), "primary-org@example.com"))
+
+ assert.Equal(t, "primary-org@example.com", org.Email)
+ })
+}
+
+func TestAddEmailAddresses(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ require.Error(t, AddEmailAddresses(db.DefaultContext, user, []string{" invalid email "}))
+
+ emails := []string{"user1234@example.com", "user5678@example.com"}
+
+ require.NoError(t, AddEmailAddresses(db.DefaultContext, user, emails))
+
+ err := AddEmailAddresses(db.DefaultContext, user, emails)
+ require.Error(t, err)
+ assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
+}
+
+func TestReplaceInactivePrimaryEmail(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ email := &user_model.EmailAddress{
+ Email: "user9999999@example.com",
+ UID: 9999999,
+ }
+ err := ReplaceInactivePrimaryEmail(db.DefaultContext, "user10@example.com", email)
+ require.Error(t, err)
+ assert.True(t, user_model.IsErrUserNotExist(err))
+
+ email = &user_model.EmailAddress{
+ Email: "user201@example.com",
+ UID: 10,
+ }
+ err = ReplaceInactivePrimaryEmail(db.DefaultContext, "user10@example.com", email)
+ require.NoError(t, err)
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
+ assert.Equal(t, "user201@example.com", user.Email)
+}
+
+func TestDeleteEmailAddresses(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ emails := []string{"user2-2@example.com"}
+
+ err := DeleteEmailAddresses(db.DefaultContext, user, emails)
+ require.NoError(t, err)
+
+ err = DeleteEmailAddresses(db.DefaultContext, user, emails)
+ require.Error(t, err)
+ assert.True(t, user_model.IsErrEmailAddressNotExist(err))
+
+ emails = []string{"user2@example.com"}
+
+ err = DeleteEmailAddresses(db.DefaultContext, user, emails)
+ require.Error(t, err)
+ assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err))
+}
+
+func TestMakeEmailAddressPrimary(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ newPrimaryEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}, "is_primary = false")
+
+ require.NoError(t, MakeEmailAddressPrimary(db.DefaultContext, user, newPrimaryEmail, false))
+
+ unittest.AssertExistsIf(t, true, &user_model.User{ID: 2, Email: newPrimaryEmail.Email})
+ unittest.AssertExistsIf(t, true, &user_model.EmailAddress{ID: 3, UID: user.ID}, "is_primary = false")
+ unittest.AssertExistsIf(t, true, &user_model.EmailAddress{ID: 35, UID: user.ID, IsPrimary: true})
+}
diff --git a/services/user/update.go b/services/user/update.go
new file mode 100644
index 0000000..26c9050
--- /dev/null
+++ b/services/user/update.go
@@ -0,0 +1,233 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models"
+ auth_model "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ password_module "code.gitea.io/gitea/modules/auth/password"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/mailer"
+)
+
+type UpdateOptions struct {
+ KeepEmailPrivate optional.Option[bool]
+ FullName optional.Option[string]
+ Website optional.Option[string]
+ Location optional.Option[string]
+ Description optional.Option[string]
+ Pronouns optional.Option[string]
+ AllowGitHook optional.Option[bool]
+ AllowImportLocal optional.Option[bool]
+ MaxRepoCreation optional.Option[int]
+ IsRestricted optional.Option[bool]
+ Visibility optional.Option[structs.VisibleType]
+ KeepActivityPrivate optional.Option[bool]
+ Language optional.Option[string]
+ Theme optional.Option[string]
+ DiffViewStyle optional.Option[string]
+ AllowCreateOrganization optional.Option[bool]
+ IsActive optional.Option[bool]
+ IsAdmin optional.Option[bool]
+ EmailNotificationsPreference optional.Option[string]
+ SetLastLogin bool
+ RepoAdminChangeTeamAccess optional.Option[bool]
+ EnableRepoUnitHints optional.Option[bool]
+}
+
+func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error {
+ cols := make([]string, 0, 20)
+
+ if opts.KeepEmailPrivate.Has() {
+ u.KeepEmailPrivate = opts.KeepEmailPrivate.Value()
+
+ cols = append(cols, "keep_email_private")
+ }
+
+ if opts.FullName.Has() {
+ u.FullName = opts.FullName.Value()
+
+ cols = append(cols, "full_name")
+ }
+ if opts.Pronouns.Has() {
+ u.Pronouns = opts.Pronouns.Value()
+
+ cols = append(cols, "pronouns")
+ }
+ if opts.Website.Has() {
+ u.Website = opts.Website.Value()
+
+ cols = append(cols, "website")
+ }
+ if opts.Location.Has() {
+ u.Location = opts.Location.Value()
+
+ cols = append(cols, "location")
+ }
+ if opts.Description.Has() {
+ u.Description = opts.Description.Value()
+
+ cols = append(cols, "description")
+ }
+ if opts.Language.Has() {
+ u.Language = opts.Language.Value()
+
+ cols = append(cols, "language")
+ }
+ if opts.Theme.Has() {
+ u.Theme = opts.Theme.Value()
+
+ cols = append(cols, "theme")
+ }
+ if opts.DiffViewStyle.Has() {
+ u.DiffViewStyle = opts.DiffViewStyle.Value()
+
+ cols = append(cols, "diff_view_style")
+ }
+ if opts.EnableRepoUnitHints.Has() {
+ u.EnableRepoUnitHints = opts.EnableRepoUnitHints.Value()
+
+ cols = append(cols, "enable_repo_unit_hints")
+ }
+
+ if opts.AllowGitHook.Has() {
+ u.AllowGitHook = opts.AllowGitHook.Value()
+
+ cols = append(cols, "allow_git_hook")
+ }
+ if opts.AllowImportLocal.Has() {
+ u.AllowImportLocal = opts.AllowImportLocal.Value()
+
+ cols = append(cols, "allow_import_local")
+ }
+
+ if opts.MaxRepoCreation.Has() {
+ u.MaxRepoCreation = opts.MaxRepoCreation.Value()
+
+ cols = append(cols, "max_repo_creation")
+ }
+
+ if opts.IsActive.Has() {
+ u.IsActive = opts.IsActive.Value()
+
+ cols = append(cols, "is_active")
+ }
+ if opts.IsRestricted.Has() {
+ u.IsRestricted = opts.IsRestricted.Value()
+
+ cols = append(cols, "is_restricted")
+ }
+ if opts.IsAdmin.Has() {
+ if !opts.IsAdmin.Value() && user_model.IsLastAdminUser(ctx, u) {
+ return models.ErrDeleteLastAdminUser{UID: u.ID}
+ }
+
+ u.IsAdmin = opts.IsAdmin.Value()
+
+ cols = append(cols, "is_admin")
+ }
+
+ if opts.Visibility.Has() {
+ if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) {
+ return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String())
+ }
+ u.Visibility = opts.Visibility.Value()
+
+ cols = append(cols, "visibility")
+ }
+ if opts.KeepActivityPrivate.Has() {
+ u.KeepActivityPrivate = opts.KeepActivityPrivate.Value()
+
+ cols = append(cols, "keep_activity_private")
+ }
+
+ if opts.AllowCreateOrganization.Has() {
+ u.AllowCreateOrganization = opts.AllowCreateOrganization.Value()
+
+ cols = append(cols, "allow_create_organization")
+ }
+ if opts.RepoAdminChangeTeamAccess.Has() {
+ u.RepoAdminChangeTeamAccess = opts.RepoAdminChangeTeamAccess.Value()
+
+ cols = append(cols, "repo_admin_change_team_access")
+ }
+
+ if opts.EmailNotificationsPreference.Has() {
+ u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value()
+
+ cols = append(cols, "email_notifications_preference")
+ }
+
+ if opts.SetLastLogin {
+ u.SetLastLogin()
+
+ cols = append(cols, "last_login_unix")
+ }
+
+ return user_model.UpdateUserCols(ctx, u, cols...)
+}
+
+type UpdateAuthOptions struct {
+ LoginSource optional.Option[int64]
+ LoginName optional.Option[string]
+ Password optional.Option[string]
+ MustChangePassword optional.Option[bool]
+ ProhibitLogin optional.Option[bool]
+}
+
+func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error {
+ if opts.LoginSource.Has() {
+ source, err := auth_model.GetSourceByID(ctx, opts.LoginSource.Value())
+ if err != nil {
+ return err
+ }
+
+ u.LoginType = source.Type
+ u.LoginSource = source.ID
+ }
+ if opts.LoginName.Has() {
+ u.LoginName = opts.LoginName.Value()
+ }
+
+ if opts.Password.Has() && (u.IsLocal() || u.IsOAuth2()) {
+ password := opts.Password.Value()
+
+ if len(password) < setting.MinPasswordLength {
+ return password_module.ErrMinLength
+ }
+ if !password_module.IsComplexEnough(password) {
+ return password_module.ErrComplexity
+ }
+ if err := password_module.IsPwned(ctx, password); err != nil {
+ return err
+ }
+
+ if err := u.SetPassword(password); err != nil {
+ return err
+ }
+ }
+
+ if opts.MustChangePassword.Has() {
+ u.MustChangePassword = opts.MustChangePassword.Value()
+ }
+ if opts.ProhibitLogin.Has() {
+ u.ProhibitLogin = opts.ProhibitLogin.Value()
+ }
+
+ if err := user_model.UpdateUserCols(ctx, u, "login_type", "login_source", "login_name", "passwd", "passwd_hash_algo", "salt", "must_change_password", "prohibit_login"); err != nil {
+ return err
+ }
+
+ if opts.Password.Has() {
+ return mailer.SendPasswordChange(u)
+ }
+
+ return nil
+}
diff --git a/services/user/update_test.go b/services/user/update_test.go
new file mode 100644
index 0000000..11379d4
--- /dev/null
+++ b/services/user/update_test.go
@@ -0,0 +1,121 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ password_module "code.gitea.io/gitea/modules/auth/password"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateUser(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ require.Error(t, UpdateUser(db.DefaultContext, admin, &UpdateOptions{
+ IsAdmin: optional.Some(false),
+ }))
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
+
+ opts := &UpdateOptions{
+ KeepEmailPrivate: optional.Some(false),
+ FullName: optional.Some("Changed Name"),
+ Website: optional.Some("https://gitea.com/"),
+ Location: optional.Some("location"),
+ Description: optional.Some("description"),
+ AllowGitHook: optional.Some(true),
+ AllowImportLocal: optional.Some(true),
+ MaxRepoCreation: optional.Some(10),
+ IsRestricted: optional.Some(true),
+ IsActive: optional.Some(false),
+ IsAdmin: optional.Some(true),
+ Visibility: optional.Some(structs.VisibleTypePrivate),
+ KeepActivityPrivate: optional.Some(true),
+ Language: optional.Some("lang"),
+ Theme: optional.Some("theme"),
+ DiffViewStyle: optional.Some("split"),
+ AllowCreateOrganization: optional.Some(false),
+ EmailNotificationsPreference: optional.Some("disabled"),
+ SetLastLogin: true,
+ }
+ require.NoError(t, UpdateUser(db.DefaultContext, user, opts))
+
+ assert.Equal(t, opts.KeepEmailPrivate.Value(), user.KeepEmailPrivate)
+ assert.Equal(t, opts.FullName.Value(), user.FullName)
+ assert.Equal(t, opts.Website.Value(), user.Website)
+ assert.Equal(t, opts.Location.Value(), user.Location)
+ assert.Equal(t, opts.Description.Value(), user.Description)
+ assert.Equal(t, opts.AllowGitHook.Value(), user.AllowGitHook)
+ assert.Equal(t, opts.AllowImportLocal.Value(), user.AllowImportLocal)
+ assert.Equal(t, opts.MaxRepoCreation.Value(), user.MaxRepoCreation)
+ assert.Equal(t, opts.IsRestricted.Value(), user.IsRestricted)
+ assert.Equal(t, opts.IsActive.Value(), user.IsActive)
+ assert.Equal(t, opts.IsAdmin.Value(), user.IsAdmin)
+ assert.Equal(t, opts.Visibility.Value(), user.Visibility)
+ assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
+ assert.Equal(t, opts.Language.Value(), user.Language)
+ assert.Equal(t, opts.Theme.Value(), user.Theme)
+ assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
+ assert.Equal(t, opts.AllowCreateOrganization.Value(), user.AllowCreateOrganization)
+ assert.Equal(t, opts.EmailNotificationsPreference.Value(), user.EmailNotificationsPreference)
+
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
+ assert.Equal(t, opts.KeepEmailPrivate.Value(), user.KeepEmailPrivate)
+ assert.Equal(t, opts.FullName.Value(), user.FullName)
+ assert.Equal(t, opts.Website.Value(), user.Website)
+ assert.Equal(t, opts.Location.Value(), user.Location)
+ assert.Equal(t, opts.Description.Value(), user.Description)
+ assert.Equal(t, opts.AllowGitHook.Value(), user.AllowGitHook)
+ assert.Equal(t, opts.AllowImportLocal.Value(), user.AllowImportLocal)
+ assert.Equal(t, opts.MaxRepoCreation.Value(), user.MaxRepoCreation)
+ assert.Equal(t, opts.IsRestricted.Value(), user.IsRestricted)
+ assert.Equal(t, opts.IsActive.Value(), user.IsActive)
+ assert.Equal(t, opts.IsAdmin.Value(), user.IsAdmin)
+ assert.Equal(t, opts.Visibility.Value(), user.Visibility)
+ assert.Equal(t, opts.KeepActivityPrivate.Value(), user.KeepActivityPrivate)
+ assert.Equal(t, opts.Language.Value(), user.Language)
+ assert.Equal(t, opts.Theme.Value(), user.Theme)
+ assert.Equal(t, opts.DiffViewStyle.Value(), user.DiffViewStyle)
+ assert.Equal(t, opts.AllowCreateOrganization.Value(), user.AllowCreateOrganization)
+ assert.Equal(t, opts.EmailNotificationsPreference.Value(), user.EmailNotificationsPreference)
+}
+
+func TestUpdateAuth(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
+ userCopy := *user
+
+ require.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
+ LoginName: optional.Some("new-login"),
+ }))
+ assert.Equal(t, "new-login", user.LoginName)
+
+ require.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
+ Password: optional.Some("%$DRZUVB576tfzgu"),
+ MustChangePassword: optional.Some(true),
+ }))
+ assert.True(t, user.MustChangePassword)
+ assert.NotEqual(t, userCopy.Passwd, user.Passwd)
+ assert.NotEqual(t, userCopy.Salt, user.Salt)
+
+ require.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
+ ProhibitLogin: optional.Some(true),
+ }))
+ assert.True(t, user.ProhibitLogin)
+
+ require.ErrorIs(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
+ Password: optional.Some("aaaa"),
+ }), password_module.ErrMinLength)
+}
diff --git a/services/user/user.go b/services/user/user.go
new file mode 100644
index 0000000..abaeb88
--- /dev/null
+++ b/services/user/user.go
@@ -0,0 +1,332 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ packages_model "code.gitea.io/gitea/models/packages"
+ repo_model "code.gitea.io/gitea/models/repo"
+ system_model "code.gitea.io/gitea/models/system"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/eventsource"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/agit"
+ org_service "code.gitea.io/gitea/services/org"
+ "code.gitea.io/gitea/services/packages"
+ container_service "code.gitea.io/gitea/services/packages/container"
+ repo_service "code.gitea.io/gitea/services/repository"
+)
+
+// RenameUser renames a user
+func RenameUser(ctx context.Context, u *user_model.User, newUserName string) error {
+ // Non-local users are not allowed to change their username.
+ if !u.IsOrganization() && !u.IsLocal() {
+ return user_model.ErrUserIsNotLocal{
+ UID: u.ID,
+ Name: u.Name,
+ }
+ }
+
+ if newUserName == u.Name {
+ return nil
+ }
+
+ if err := user_model.IsUsableUsername(newUserName); err != nil {
+ return err
+ }
+
+ onlyCapitalization := strings.EqualFold(newUserName, u.Name)
+ oldUserName := u.Name
+
+ if onlyCapitalization {
+ u.Name = newUserName
+ if err := user_model.UpdateUserCols(ctx, u, "name"); err != nil {
+ u.Name = oldUserName
+ return err
+ }
+ return repo_model.UpdateRepositoryOwnerNames(ctx, u.ID, newUserName)
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ isExist, err := user_model.IsUserExist(ctx, u.ID, newUserName)
+ if err != nil {
+ return err
+ }
+ if isExist {
+ return user_model.ErrUserAlreadyExist{
+ Name: newUserName,
+ }
+ }
+
+ if err = repo_model.UpdateRepositoryOwnerName(ctx, oldUserName, newUserName); err != nil {
+ return err
+ }
+
+ if err = user_model.NewUserRedirect(ctx, u.ID, oldUserName, newUserName); err != nil {
+ return err
+ }
+
+ if err := agit.UserNameChanged(ctx, u, newUserName); err != nil {
+ return err
+ }
+ if err := container_service.UpdateRepositoryNames(ctx, u, newUserName); err != nil {
+ return err
+ }
+
+ u.Name = newUserName
+ u.LowerName = strings.ToLower(newUserName)
+ if err := user_model.UpdateUserCols(ctx, u, "name", "lower_name"); err != nil {
+ u.Name = oldUserName
+ u.LowerName = strings.ToLower(oldUserName)
+ return err
+ }
+
+ // Do not fail if directory does not exist
+ if err = util.Rename(user_model.UserPath(oldUserName), user_model.UserPath(newUserName)); err != nil && !os.IsNotExist(err) {
+ u.Name = oldUserName
+ u.LowerName = strings.ToLower(oldUserName)
+ return fmt.Errorf("rename user directory: %w", err)
+ }
+
+ if err = committer.Commit(); err != nil {
+ u.Name = oldUserName
+ u.LowerName = strings.ToLower(oldUserName)
+ if err2 := util.Rename(user_model.UserPath(newUserName), user_model.UserPath(oldUserName)); err2 != nil && !os.IsNotExist(err2) {
+ log.Critical("Unable to rollback directory change during failed username change from: %s to: %s. DB Error: %v. Filesystem Error: %v", oldUserName, newUserName, err, err2)
+ return fmt.Errorf("failed to rollback directory change during failed username change from: %s to: %s. DB Error: %w. Filesystem Error: %v", oldUserName, newUserName, err, err2)
+ }
+ return err
+ }
+ return nil
+}
+
+// DeleteUser completely and permanently deletes everything of a user,
+// but issues/comments/pulls will be kept and shown as someone has been deleted,
+// unless the user is younger than USER_DELETE_WITH_COMMENTS_MAX_DAYS.
+func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
+ if u.IsOrganization() {
+ return fmt.Errorf("%s is an organization not a user", u.Name)
+ }
+
+ if user_model.IsLastAdminUser(ctx, u) {
+ return models.ErrDeleteLastAdminUser{UID: u.ID}
+ }
+
+ hasSSHKey, err := db.GetEngine(ctx).Where("owner_id = ? AND type != ?", u.ID, asymkey_model.KeyTypePrincipal).Table("public_key").Exist()
+ if err != nil {
+ return err
+ }
+
+ hasPrincipialSSHKey, err := db.GetEngine(ctx).Where("owner_id = ? AND type = ?", u.ID, asymkey_model.KeyTypePrincipal).Table("public_key").Exist()
+ if err != nil {
+ return err
+ }
+
+ if purge {
+ // Disable the user first
+ // NOTE: This is deliberately not within a transaction as it must disable the user immediately to prevent any further action by the user to be purged.
+ if err := user_model.UpdateUserCols(ctx, &user_model.User{
+ ID: u.ID,
+ IsActive: false,
+ IsRestricted: true,
+ IsAdmin: false,
+ ProhibitLogin: true,
+ Passwd: "",
+ Salt: "",
+ PasswdHashAlgo: "",
+ MaxRepoCreation: 0,
+ }, "is_active", "is_restricted", "is_admin", "prohibit_login", "max_repo_creation", "passwd", "salt", "passwd_hash_algo"); err != nil {
+ return fmt.Errorf("unable to disable user: %s[%d] prior to purge. UpdateUserCols: %w", u.Name, u.ID, err)
+ }
+
+ // Force any logged in sessions to log out
+ // FIXME: We also need to tell the session manager to log them out too.
+ eventsource.GetManager().SendMessage(u.ID, &eventsource.Event{
+ Name: "logout",
+ })
+
+ // Delete all repos belonging to this user
+ // Now this is not within a transaction because there are internal transactions within the DeleteRepository
+ // BUT: the db will still be consistent even if a number of repos have already been deleted.
+ // And in fact we want to capture any repositories that are being created in other transactions in the meantime
+ //
+ // An alternative option here would be write a DeleteAllRepositoriesForUserID function which would delete all of the repos
+ // but such a function would likely get out of date
+ err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, u)
+ if err != nil {
+ return err
+ }
+
+ // Remove from Organizations and delete last owner organizations
+ // Now this is not within a transaction because there are internal transactions within the DeleteOrganization
+ // BUT: the db will still be consistent even if a number of organizations memberships and organizations have already been deleted
+ // And in fact we want to capture any organization additions that are being created in other transactions in the meantime
+ //
+ // An alternative option here would be write a function which would delete all organizations but it seems
+ // but such a function would likely get out of date
+ for {
+ orgs, err := db.Find[organization.Organization](ctx, organization.FindOrgOptions{
+ ListOptions: db.ListOptions{
+ PageSize: repo_model.RepositoryListDefaultPageSize,
+ Page: 1,
+ },
+ UserID: u.ID,
+ IncludePrivate: true,
+ })
+ if err != nil {
+ return fmt.Errorf("unable to find org list for %s[%d]. Error: %w", u.Name, u.ID, err)
+ }
+ if len(orgs) == 0 {
+ break
+ }
+ for _, org := range orgs {
+ if err := models.RemoveOrgUser(ctx, org.ID, u.ID); err != nil {
+ if organization.IsErrLastOrgOwner(err) {
+ err = org_service.DeleteOrganization(ctx, org, true)
+ if err != nil {
+ return fmt.Errorf("unable to delete organization %d: %w", org.ID, err)
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("unable to remove user %s[%d] from org %s[%d]. Error: %w", u.Name, u.ID, org.Name, org.ID, err)
+ }
+ }
+ }
+ }
+
+ // Delete Packages
+ if setting.Packages.Enabled {
+ if _, err := packages.RemoveAllPackages(ctx, u.ID); err != nil {
+ return err
+ }
+ }
+
+ // Delete Federated Users
+ if setting.Federation.Enabled {
+ if err := user_model.DeleteFederatedUser(ctx, u.ID); err != nil {
+ return err
+ }
+ }
+ }
+
+ ctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ // Note: A user owns any repository or belongs to any organization
+ // cannot perform delete operation. This causes a race with the purge above
+ // however consistency requires that we ensure that this is the case
+
+ // Check ownership of repository.
+ count, err := repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{OwnerID: u.ID})
+ if err != nil {
+ return fmt.Errorf("GetRepositoryCount: %w", err)
+ } else if count > 0 {
+ return models.ErrUserOwnRepos{UID: u.ID}
+ }
+
+ // Check membership of organization.
+ count, err = organization.GetOrganizationCount(ctx, u)
+ if err != nil {
+ return fmt.Errorf("GetOrganizationCount: %w", err)
+ } else if count > 0 {
+ return models.ErrUserHasOrgs{UID: u.ID}
+ }
+
+ // Check ownership of packages.
+ if ownsPackages, err := packages_model.HasOwnerPackages(ctx, u.ID); err != nil {
+ return fmt.Errorf("HasOwnerPackages: %w", err)
+ } else if ownsPackages {
+ return models.ErrUserOwnPackages{UID: u.ID}
+ }
+
+ if err := deleteUser(ctx, u, purge); err != nil {
+ return fmt.Errorf("DeleteUser: %w", err)
+ }
+
+ if err := committer.Commit(); err != nil {
+ return err
+ }
+ committer.Close()
+
+ if hasSSHKey {
+ if err = asymkey_model.RewriteAllPublicKeys(ctx); err != nil {
+ return err
+ }
+ }
+
+ if hasPrincipialSSHKey {
+ if err = asymkey_model.RewriteAllPrincipalKeys(ctx); err != nil {
+ return err
+ }
+ }
+
+ // Note: There are something just cannot be roll back,
+ // so just keep error logs of those operations.
+ path := user_model.UserPath(u.Name)
+ if err := util.RemoveAll(path); err != nil {
+ err = fmt.Errorf("Failed to RemoveAll %s: %w", path, err)
+ _ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err))
+ return err
+ }
+
+ if u.Avatar != "" {
+ avatarPath := u.CustomAvatarRelativePath()
+ if err := storage.Avatars.Delete(avatarPath); err != nil {
+ err = fmt.Errorf("Failed to remove %s: %w", avatarPath, err)
+ _ = system_model.CreateNotice(ctx, system_model.NoticeTask, fmt.Sprintf("delete user '%s': %v", u.Name, err))
+ return err
+ }
+ }
+
+ return nil
+}
+
+// DeleteInactiveUsers deletes all inactive users and email addresses.
+func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error {
+ users, err := user_model.GetInactiveUsers(ctx, olderThan)
+ if err != nil {
+ return err
+ }
+
+ // FIXME: should only update authorized_keys file once after all deletions.
+ for _, u := range users {
+ select {
+ case <-ctx.Done():
+ return db.ErrCancelledf("Before delete inactive user %s", u.Name)
+ default:
+ }
+ if err := DeleteUser(ctx, u, false); err != nil {
+ // Ignore users that were set inactive by admin.
+ if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) ||
+ models.IsErrUserOwnPackages(err) || models.IsErrDeleteLastAdminUser(err) {
+ log.Warn("Inactive user %q has repositories, organizations or packages, skipping deletion: %v", u.Name, err)
+ continue
+ }
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/services/user/user_test.go b/services/user/user_test.go
new file mode 100644
index 0000000..ad5387c
--- /dev/null
+++ b/services/user/user_test.go
@@ -0,0 +1,264 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
+
+func TestDeleteUser(t *testing.T) {
+ test := func(userID int64) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+
+ ownedRepos := make([]*repo_model.Repository, 0, 10)
+ require.NoError(t, db.GetEngine(db.DefaultContext).Find(&ownedRepos, &repo_model.Repository{OwnerID: userID}))
+ if len(ownedRepos) > 0 {
+ err := DeleteUser(db.DefaultContext, user, false)
+ require.Error(t, err)
+ assert.True(t, models.IsErrUserOwnRepos(err))
+ return
+ }
+
+ orgUsers := make([]*organization.OrgUser, 0, 10)
+ require.NoError(t, db.GetEngine(db.DefaultContext).Find(&orgUsers, &organization.OrgUser{UID: userID}))
+ for _, orgUser := range orgUsers {
+ if err := models.RemoveOrgUser(db.DefaultContext, orgUser.OrgID, orgUser.UID); err != nil {
+ assert.True(t, organization.IsErrLastOrgOwner(err))
+ return
+ }
+ }
+ require.NoError(t, DeleteUser(db.DefaultContext, user, false))
+ unittest.AssertNotExistsBean(t, &user_model.User{ID: userID})
+ unittest.CheckConsistencyFor(t, &user_model.User{}, &repo_model.Repository{})
+ }
+ test(2)
+ test(4)
+ test(8)
+ test(11)
+
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
+ require.Error(t, DeleteUser(db.DefaultContext, org, false))
+}
+
+func TestPurgeUser(t *testing.T) {
+ defer unittest.OverrideFixtures(
+ unittest.FixturesOptions{
+ Dir: filepath.Join(setting.AppWorkPath, "models/fixtures/"),
+ Base: setting.AppWorkPath,
+ Dirs: []string{"services/user/TestPurgeUser/"},
+ },
+ )()
+ require.NoError(t, unittest.PrepareTestDatabase())
+ defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())()
+ defer test.MockVariableValue(&setting.SSH.CreateAuthorizedKeysFile, true)()
+ defer test.MockVariableValue(&setting.SSH.CreateAuthorizedPrincipalsFile, true)()
+ defer test.MockVariableValue(&setting.SSH.StartBuiltinServer, false)()
+ require.NoError(t, asymkey_model.RewriteAllPublicKeys(db.DefaultContext))
+ require.NoError(t, asymkey_model.RewriteAllPrincipalKeys(db.DefaultContext))
+
+ test := func(userID int64, modifySSHKey bool) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+
+ fAuthorizedKeys, err := os.Open(filepath.Join(setting.SSH.RootPath, "authorized_keys"))
+ require.NoError(t, err)
+ authorizedKeysStatBefore, err := fAuthorizedKeys.Stat()
+ require.NoError(t, err)
+ fAuthorizedPrincipals, err := os.Open(filepath.Join(setting.SSH.RootPath, "authorized_principals"))
+ require.NoError(t, err)
+ authorizedPrincipalsBefore, err := fAuthorizedPrincipals.Stat()
+ require.NoError(t, err)
+
+ require.NoError(t, DeleteUser(db.DefaultContext, user, true))
+
+ unittest.AssertNotExistsBean(t, &user_model.User{ID: userID})
+ unittest.CheckConsistencyFor(t, &user_model.User{}, &repo_model.Repository{})
+
+ fAuthorizedKeys, err = os.Open(filepath.Join(setting.SSH.RootPath, "authorized_keys"))
+ require.NoError(t, err)
+ fAuthorizedPrincipals, err = os.Open(filepath.Join(setting.SSH.RootPath, "authorized_principals"))
+ require.NoError(t, err)
+
+ authorizedKeysStatAfter, err := fAuthorizedKeys.Stat()
+ require.NoError(t, err)
+ authorizedPrincipalsAfter, err := fAuthorizedPrincipals.Stat()
+ require.NoError(t, err)
+
+ if modifySSHKey {
+ assert.Greater(t, authorizedKeysStatAfter.ModTime(), authorizedKeysStatBefore.ModTime())
+ assert.Greater(t, authorizedPrincipalsAfter.ModTime(), authorizedPrincipalsBefore.ModTime())
+ } else {
+ assert.Equal(t, authorizedKeysStatAfter.ModTime(), authorizedKeysStatBefore.ModTime())
+ assert.Equal(t, authorizedPrincipalsAfter.ModTime(), authorizedPrincipalsBefore.ModTime())
+ }
+ }
+ test(2, true)
+ test(4, false)
+ test(8, false)
+ test(11, false)
+
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
+ require.Error(t, DeleteUser(db.DefaultContext, org, false))
+}
+
+func TestCreateUser(t *testing.T) {
+ user := &user_model.User{
+ Name: "GiteaBot",
+ Email: "GiteaBot@gitea.io",
+ Passwd: ";p['////..-++']",
+ IsAdmin: false,
+ Theme: setting.UI.DefaultTheme,
+ MustChangePassword: false,
+ }
+
+ require.NoError(t, user_model.CreateUser(db.DefaultContext, user))
+
+ require.NoError(t, DeleteUser(db.DefaultContext, user, false))
+}
+
+func TestRenameUser(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 21})
+
+ t.Run("Non-Local", func(t *testing.T) {
+ u := &user_model.User{
+ Type: user_model.UserTypeIndividual,
+ LoginType: auth.OAuth2,
+ }
+ require.ErrorIs(t, RenameUser(db.DefaultContext, u, "user_rename"), user_model.ErrUserIsNotLocal{})
+ })
+
+ t.Run("Same username", func(t *testing.T) {
+ require.NoError(t, RenameUser(db.DefaultContext, user, user.Name))
+ })
+
+ t.Run("Non usable username", func(t *testing.T) {
+ usernames := []string{"--diff", "aa.png", ".well-known", "search", "aaa.atom"}
+ for _, username := range usernames {
+ t.Run(username, func(t *testing.T) {
+ require.Error(t, user_model.IsUsableUsername(username))
+ require.Error(t, RenameUser(db.DefaultContext, user, username))
+ })
+ }
+ })
+
+ t.Run("Only capitalization", func(t *testing.T) {
+ caps := strings.ToUpper(user.Name)
+ unittest.AssertNotExistsBean(t, &user_model.User{ID: user.ID, Name: caps})
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: user.Name})
+
+ require.NoError(t, RenameUser(db.DefaultContext, user, caps))
+
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID, Name: caps})
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: caps})
+ })
+
+ t.Run("Already exists", func(t *testing.T) {
+ existUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ require.ErrorIs(t, RenameUser(db.DefaultContext, user, existUser.Name), user_model.ErrUserAlreadyExist{Name: existUser.Name})
+ require.ErrorIs(t, RenameUser(db.DefaultContext, user, existUser.LowerName), user_model.ErrUserAlreadyExist{Name: existUser.LowerName})
+ newUsername := fmt.Sprintf("uSEr%d", existUser.ID)
+ require.ErrorIs(t, RenameUser(db.DefaultContext, user, newUsername), user_model.ErrUserAlreadyExist{Name: newUsername})
+ })
+
+ t.Run("Normal", func(t *testing.T) {
+ oldUsername := user.Name
+ newUsername := "User_Rename"
+
+ require.NoError(t, RenameUser(db.DefaultContext, user, newUsername))
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID, Name: newUsername, LowerName: strings.ToLower(newUsername)})
+
+ redirectUID, err := user_model.LookupUserRedirect(db.DefaultContext, oldUsername)
+ require.NoError(t, err)
+ assert.EqualValues(t, user.ID, redirectUID)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: user.Name})
+ })
+}
+
+func TestCreateUser_Issue5882(t *testing.T) {
+ // Init settings
+ _ = setting.Admin
+
+ passwd := ".//.;1;;//.,-=_"
+
+ tt := []struct {
+ user *user_model.User
+ disableOrgCreation bool
+ }{
+ {&user_model.User{Name: "GiteaBot", Email: "GiteaBot@gitea.io", Passwd: passwd, MustChangePassword: false}, false},
+ {&user_model.User{Name: "GiteaBot2", Email: "GiteaBot2@gitea.io", Passwd: passwd, MustChangePassword: false}, true},
+ }
+
+ setting.Service.DefaultAllowCreateOrganization = true
+
+ for _, v := range tt {
+ setting.Admin.DisableRegularOrgCreation = v.disableOrgCreation
+
+ require.NoError(t, user_model.CreateUser(db.DefaultContext, v.user))
+
+ u, err := user_model.GetUserByEmail(db.DefaultContext, v.user.Email)
+ require.NoError(t, err)
+
+ assert.Equal(t, !u.AllowCreateOrganization, v.disableOrgCreation)
+
+ require.NoError(t, DeleteUser(db.DefaultContext, v.user, false))
+ }
+}
+
+func TestDeleteInactiveUsers(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ // Add an inactive user older than a minute, with an associated email_address record.
+ oldUser := &user_model.User{Name: "OldInactive", LowerName: "oldinactive", Email: "old@example.com", CreatedUnix: timeutil.TimeStampNow().Add(-120)}
+ _, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(oldUser)
+ require.NoError(t, err)
+ oldEmail := &user_model.EmailAddress{UID: oldUser.ID, IsPrimary: true, Email: "old@example.com", LowerEmail: "old@example.com"}
+ err = db.Insert(db.DefaultContext, oldEmail)
+ require.NoError(t, err)
+
+ // Add an inactive user that's not older than a minute, with an associated email_address record.
+ newUser := &user_model.User{Name: "NewInactive", LowerName: "newinactive", Email: "new@example.com"}
+ err = db.Insert(db.DefaultContext, newUser)
+ require.NoError(t, err)
+ newEmail := &user_model.EmailAddress{UID: newUser.ID, IsPrimary: true, Email: "new@example.com", LowerEmail: "new@example.com"}
+ err = db.Insert(db.DefaultContext, newEmail)
+ require.NoError(t, err)
+
+ err = DeleteInactiveUsers(db.DefaultContext, time.Minute)
+ require.NoError(t, err)
+
+ // User older than a minute should be deleted along with their email address.
+ unittest.AssertExistsIf(t, false, oldUser)
+ unittest.AssertExistsIf(t, false, oldEmail)
+
+ // User not older than a minute shouldn't be deleted and their emaill address should still exist.
+ unittest.AssertExistsIf(t, true, newUser)
+ unittest.AssertExistsIf(t, true, newEmail)
+}