diff options
Diffstat (limited to 'services/user')
-rw-r--r-- | services/user/TestPurgeUser/public_key.yml | 11 | ||||
-rw-r--r-- | services/user/avatar.go | 73 | ||||
-rw-r--r-- | services/user/avatar_test.go | 81 | ||||
-rw-r--r-- | services/user/block.go | 95 | ||||
-rw-r--r-- | services/user/block_test.go | 92 | ||||
-rw-r--r-- | services/user/delete.go | 224 | ||||
-rw-r--r-- | services/user/email.go | 232 | ||||
-rw-r--r-- | services/user/email_test.go | 178 | ||||
-rw-r--r-- | services/user/update.go | 233 | ||||
-rw-r--r-- | services/user/update_test.go | 121 | ||||
-rw-r--r-- | services/user/user.go | 332 | ||||
-rw-r--r-- | services/user/user_test.go | 264 |
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) +} |