first-commit
This commit is contained in:
73
services/user/avatar.go
Normal file
73
services/user/avatar.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
302
services/user/block.go
Normal file
302
services/user/block.go
Normal file
@@ -0,0 +1,302 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
org_model "code.gitea.io/gitea/models/organization"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
repo_service "code.gitea.io/gitea/services/repository"
|
||||
)
|
||||
|
||||
func CanBlockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool {
|
||||
if blocker.ID == blockee.ID {
|
||||
return false
|
||||
}
|
||||
if doer.ID == blockee.ID {
|
||||
return false
|
||||
}
|
||||
|
||||
if blockee.IsOrganization() {
|
||||
return false
|
||||
}
|
||||
|
||||
if user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if blocker.IsOrganization() {
|
||||
org := org_model.OrgFromUser(blocker)
|
||||
if isMember, _ := org.IsOrgMember(ctx, blockee.ID); isMember {
|
||||
return false
|
||||
}
|
||||
if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin {
|
||||
return false
|
||||
}
|
||||
} else if !doer.IsAdmin && doer.ID != blocker.ID {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func CanUnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool {
|
||||
if doer.ID == blockee.ID {
|
||||
return false
|
||||
}
|
||||
|
||||
if !user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if blocker.IsOrganization() {
|
||||
org := org_model.OrgFromUser(blocker)
|
||||
if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin {
|
||||
return false
|
||||
}
|
||||
} else if !doer.IsAdmin && doer.ID != blocker.ID {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func BlockUser(ctx context.Context, doer, blocker, blockee *user_model.User, note string) error {
|
||||
if blockee.IsOrganization() {
|
||||
return user_model.ErrBlockOrganization
|
||||
}
|
||||
|
||||
if !CanBlockUser(ctx, doer, blocker, blockee) {
|
||||
return user_model.ErrCanNotBlock
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// unfollow each other
|
||||
if err := user_model.UnfollowUser(ctx, blocker.ID, blockee.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := user_model.UnfollowUser(ctx, blockee.ID, blocker.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unstar each other
|
||||
if err := unstarRepos(ctx, blocker, blockee); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unstarRepos(ctx, blockee, blocker); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unwatch each others repositories
|
||||
if err := unwatchRepos(ctx, blocker, blockee); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unwatchRepos(ctx, blockee, blocker); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// unassign each other from issues
|
||||
if err := unassignIssues(ctx, blocker, blockee); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unassignIssues(ctx, blockee, blocker); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove each other from repository collaborations
|
||||
if err := removeCollaborations(ctx, blocker, blockee); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeCollaborations(ctx, blockee, blocker); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// cancel each other repository transfers
|
||||
if err := cancelRepositoryTransfers(ctx, doer, blocker, blockee); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cancelRepositoryTransfers(ctx, doer, blockee, blocker); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Insert(ctx, &user_model.Blocking{
|
||||
BlockerID: blocker.ID,
|
||||
BlockeeID: blockee.ID,
|
||||
Note: note,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func unstarRepos(ctx context.Context, starrer, repoOwner *user_model.User) error {
|
||||
opts := &repo_model.StarredReposOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 25,
|
||||
},
|
||||
StarrerID: starrer.ID,
|
||||
RepoOwnerID: repoOwner.ID,
|
||||
}
|
||||
|
||||
for {
|
||||
repos, err := repo_model.GetStarredRepos(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
if err := repo_model.StarRepo(ctx, starrer, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
opts.Page++
|
||||
}
|
||||
}
|
||||
|
||||
func unwatchRepos(ctx context.Context, watcher, repoOwner *user_model.User) error {
|
||||
opts := &repo_model.WatchedReposOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 25,
|
||||
},
|
||||
WatcherID: watcher.ID,
|
||||
RepoOwnerID: repoOwner.ID,
|
||||
}
|
||||
|
||||
for {
|
||||
repos, _, err := repo_model.GetWatchedRepos(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, repo := range repos {
|
||||
if err := repo_model.WatchRepo(ctx, watcher, repo, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
opts.Page++
|
||||
}
|
||||
}
|
||||
|
||||
func cancelRepositoryTransfers(ctx context.Context, doer, sender, recipient *user_model.User) error {
|
||||
transfers, err := repo_model.GetPendingRepositoryTransfers(ctx, &repo_model.PendingRepositoryTransferOptions{
|
||||
SenderID: sender.ID,
|
||||
RecipientID: recipient.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, transfer := range transfers {
|
||||
if err := repo_service.CancelRepositoryTransfer(ctx, transfer, doer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func unassignIssues(ctx context.Context, assignee, repoOwner *user_model.User) error {
|
||||
opts := &issues_model.AssignedIssuesOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 25,
|
||||
},
|
||||
AssigneeID: assignee.ID,
|
||||
RepoOwnerID: repoOwner.ID,
|
||||
}
|
||||
|
||||
for {
|
||||
issues, _, err := issues_model.GetAssignedIssues(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
if err := issue.LoadAssignees(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, _, err := issues_model.ToggleIssueAssignee(ctx, issue, assignee, assignee.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
opts.Page++
|
||||
}
|
||||
}
|
||||
|
||||
func removeCollaborations(ctx context.Context, repoOwner, collaborator *user_model.User) error {
|
||||
opts := &repo_model.FindCollaborationOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 25,
|
||||
},
|
||||
CollaboratorID: collaborator.ID,
|
||||
RepoOwnerID: repoOwner.ID,
|
||||
}
|
||||
|
||||
for {
|
||||
collaborations, _, err := repo_model.GetCollaborators(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(collaborations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, collaboration := range collaborations {
|
||||
repo, err := repo_model.GetRepositoryByID(ctx, collaboration.Collaboration.RepoID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := repo_service.DeleteCollaboration(ctx, repo, collaborator); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
opts.Page++
|
||||
}
|
||||
}
|
||||
|
||||
func UnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) error {
|
||||
if blockee.IsOrganization() {
|
||||
return user_model.ErrBlockOrganization
|
||||
}
|
||||
|
||||
if !CanUnblockUser(ctx, doer, blocker, blockee) {
|
||||
return user_model.ErrCanNotUnblock
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if block != nil {
|
||||
_, err = db.DeleteByID[user_model.Blocking](ctx, block.ID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
66
services/user/block_test.go
Normal file
66
services/user/block_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCanBlockUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
|
||||
org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
|
||||
|
||||
// Doer can't self block
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user1))
|
||||
// Blocker can't be blockee
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user2))
|
||||
// Can't block already blocked user
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user29))
|
||||
// Blockee can't be an organization
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, org3))
|
||||
// Doer must be blocker or admin
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user2, user4, user29))
|
||||
// Organization can't block a member
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user1, org3, user4))
|
||||
// Doer must be organization owner or admin if blocker is an organization
|
||||
assert.False(t, CanBlockUser(db.DefaultContext, user4, org3, user2))
|
||||
|
||||
assert.True(t, CanBlockUser(db.DefaultContext, user1, user2, user4))
|
||||
assert.True(t, CanBlockUser(db.DefaultContext, user2, user2, user4))
|
||||
assert.True(t, CanBlockUser(db.DefaultContext, user2, org3, user29))
|
||||
}
|
||||
|
||||
func TestCanUnblockUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
|
||||
org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
|
||||
|
||||
// Doer can't self unblock
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user1))
|
||||
// Can't unblock not blocked user
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user28))
|
||||
// Doer must be blocker or admin
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user28, user2, user29))
|
||||
// Doer must be organization owner or admin if blocker is an organization
|
||||
assert.False(t, CanUnblockUser(db.DefaultContext, user2, org17, user28))
|
||||
|
||||
assert.True(t, CanUnblockUser(db.DefaultContext, user1, user2, user29))
|
||||
assert.True(t, CanUnblockUser(db.DefaultContext, user2, user2, user29))
|
||||
assert.True(t, CanUnblockUser(db.DefaultContext, user1, org17, user28))
|
||||
}
|
201
services/user/delete.go
Normal file
201
services/user/delete.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// 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"
|
||||
|
||||
"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.Blocking{BlockerID: u.ID},
|
||||
&user_model.Blocking{BlockeeID: u.ID},
|
||||
&actions_model.ActionRunnerToken{OwnerID: 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: 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 := auth_model.DeleteAuthTokensByUserID(ctx, u.ID); err != nil {
|
||||
return fmt.Errorf("DeleteAuthTokensByUserID: %w", err)
|
||||
}
|
||||
|
||||
if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
167
services/user/email.go
Normal file
167
services/user/email.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
143
services/user/email_test.go
Normal file
143
services/user/email_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 27})
|
||||
|
||||
emails, err := user_model.GetEmailAddresses(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 1)
|
||||
|
||||
primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, "new-primary@example.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary@example.com"))
|
||||
|
||||
primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.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)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 2)
|
||||
|
||||
setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")}
|
||||
defer func() {
|
||||
setting.Service.EmailDomainAllowList = []glob.Glob{}
|
||||
}()
|
||||
|
||||
assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary2@example2.com"))
|
||||
|
||||
primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "new-primary2@example2.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "user27@example.com"))
|
||||
|
||||
primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.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)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 3)
|
||||
}
|
||||
|
||||
func TestReplacePrimaryEmailAddress(t *testing.T) {
|
||||
assert.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)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 1)
|
||||
|
||||
primary, err := user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, "primary-13@example.com", primary.Email)
|
||||
assert.Equal(t, user.Email, primary.Email)
|
||||
|
||||
assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com"))
|
||||
|
||||
primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID)
|
||||
assert.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)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, emails, 1)
|
||||
|
||||
assert.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)
|
||||
|
||||
assert.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) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
assert.Error(t, AddEmailAddresses(db.DefaultContext, user, []string{" invalid email "}))
|
||||
|
||||
emails := []string{"user1234@example.com", "user5678@example.com"}
|
||||
|
||||
assert.NoError(t, AddEmailAddresses(db.DefaultContext, user, emails))
|
||||
|
||||
err := AddEmailAddresses(db.DefaultContext, user, emails)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
|
||||
}
|
||||
|
||||
func TestDeleteEmailAddresses(t *testing.T) {
|
||||
assert.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)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = DeleteEmailAddresses(db.DefaultContext, user, emails)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrEmailAddressNotExist(err))
|
||||
|
||||
emails = []string{"user2@example.com"}
|
||||
|
||||
err = DeleteEmailAddresses(db.DefaultContext, user, emails)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err))
|
||||
}
|
246
services/user/update.go
Normal file
246
services/user/update.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
type UpdateOptionField[T any] struct {
|
||||
FieldValue T
|
||||
FromSync bool
|
||||
}
|
||||
|
||||
func UpdateOptionFieldFromValue[T any](value T) optional.Option[UpdateOptionField[T]] {
|
||||
return optional.Some(UpdateOptionField[T]{FieldValue: value})
|
||||
}
|
||||
|
||||
func UpdateOptionFieldFromSync[T any](value T) optional.Option[UpdateOptionField[T]] {
|
||||
return optional.Some(UpdateOptionField[T]{FieldValue: value, FromSync: true})
|
||||
}
|
||||
|
||||
func UpdateOptionFieldFromPtr[T any](value *T) optional.Option[UpdateOptionField[T]] {
|
||||
if value == nil {
|
||||
return optional.None[UpdateOptionField[T]]()
|
||||
}
|
||||
return UpdateOptionFieldFromValue(*value)
|
||||
}
|
||||
|
||||
type UpdateOptions struct {
|
||||
KeepEmailPrivate optional.Option[bool]
|
||||
FullName optional.Option[string]
|
||||
Website optional.Option[string]
|
||||
Location optional.Option[string]
|
||||
Description 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[UpdateOptionField[bool]]
|
||||
EmailNotificationsPreference optional.Option[string]
|
||||
SetLastLogin bool
|
||||
RepoAdminChangeTeamAccess 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.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.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().FieldValue /* true */ {
|
||||
u.IsAdmin = opts.IsAdmin.Value().FieldValue // set IsAdmin=true
|
||||
cols = append(cols, "is_admin")
|
||||
} else if !user_model.IsLastAdminUser(ctx, u) /* not the last admin */ {
|
||||
u.IsAdmin = opts.IsAdmin.Value().FieldValue // it's safe to change it from false to true (not the last admin)
|
||||
cols = append(cols, "is_admin")
|
||||
} else /* IsAdmin=false but this is the last admin user */ { //nolint:gocritic // make it easier to read
|
||||
if !opts.IsAdmin.Value().FromSync {
|
||||
return user_model.ErrDeleteLastAdminUser{UID: u.ID}
|
||||
}
|
||||
// else: syncing from external-source, this user is the last admin, so skip the "IsAdmin=false" change
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
deleteAuthTokens := false
|
||||
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
|
||||
}
|
||||
|
||||
deleteAuthTokens = true
|
||||
}
|
||||
|
||||
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 deleteAuthTokens {
|
||||
return auth_model.DeleteAuthTokensByUserID(ctx, u.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
124
services/user/update_test.go
Normal file
124
services/user/update_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
|
||||
assert.Error(t, UpdateUser(db.DefaultContext, admin, &UpdateOptions{
|
||||
IsAdmin: UpdateOptionFieldFromValue(false),
|
||||
}))
|
||||
|
||||
assert.NoError(t, UpdateUser(db.DefaultContext, admin, &UpdateOptions{
|
||||
IsAdmin: UpdateOptionFieldFromSync(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: UpdateOptionFieldFromValue(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,
|
||||
}
|
||||
assert.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().FieldValue, 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().FieldValue, 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) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28})
|
||||
userCopy := *user
|
||||
|
||||
assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
|
||||
LoginName: optional.Some("new-login"),
|
||||
}))
|
||||
assert.Equal(t, "new-login", user.LoginName)
|
||||
|
||||
assert.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)
|
||||
|
||||
assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
|
||||
ProhibitLogin: optional.Some(true),
|
||||
}))
|
||||
assert.True(t, user.ProhibitLogin)
|
||||
|
||||
assert.ErrorIs(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{
|
||||
Password: optional.Some("aaaa"),
|
||||
}), password_module.ErrMinLength)
|
||||
}
|
309
services/user/user.go
Normal file
309
services/user/user.go
Normal file
@@ -0,0 +1,309 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wechat_model "code.gitea.io/gitea/models/wechat"
|
||||
|
||||
"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/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/services/agit"
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
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 {
|
||||
if newUserName == u.Name {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 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 u.IsActive && user_model.IsLastAdminUser(ctx, u) {
|
||||
return user_model.ErrDeleteLastAdminUser{UID: u.ID}
|
||||
}
|
||||
|
||||
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,
|
||||
IncludeVisibility: structs.VisibleTypePrivate,
|
||||
})
|
||||
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 := org_service.RemoveOrgUser(ctx, org, u); 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 repo_model.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 organization.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 packages_model.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 err = asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = asymkey_service.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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户微信绑定
|
||||
_ = wechat_model.DeleteWechatUser(ctx, u)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteInactiveUsers deletes all inactive users and their email addresses.
|
||||
func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error {
|
||||
inactiveUsers, 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 inactiveUsers {
|
||||
if err = DeleteUser(ctx, u, false); err != nil {
|
||||
// Ignore inactive users that were ever active but then were set inactive by admin
|
||||
if repo_model.IsErrUserOwnRepos(err) || organization.IsErrUserHasOrgs(err) || packages_model.IsErrUserOwnPackages(err) {
|
||||
log.Warn("Inactive user %q has repositories, organizations or packages, skipping deletion: %v", u.Name, err)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return db.ErrCancelledf("when deleting inactive user %q", u.Name)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil // TODO: there could be still inactive users left, and the number would increase gradually
|
||||
}
|
210
services/user/user_test.go
Normal file
210
services/user/user_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/timeutil"
|
||||
org_service "code.gitea.io/gitea/services/org"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
test := func(userID int64) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
|
||||
|
||||
ownedRepos := make([]*repo_model.Repository, 0, 10)
|
||||
assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&ownedRepos, &repo_model.Repository{OwnerID: userID}))
|
||||
if len(ownedRepos) > 0 {
|
||||
err := DeleteUser(db.DefaultContext, user, false)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, repo_model.IsErrUserOwnRepos(err))
|
||||
return
|
||||
}
|
||||
|
||||
orgUsers := make([]*organization.OrgUser, 0, 10)
|
||||
assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&orgUsers, &organization.OrgUser{UID: userID}))
|
||||
for _, orgUser := range orgUsers {
|
||||
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: orgUser.OrgID})
|
||||
if err := org_service.RemoveOrgUser(db.DefaultContext, org, user); err != nil {
|
||||
assert.True(t, organization.IsErrLastOrgOwner(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
assert.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})
|
||||
assert.Error(t, DeleteUser(db.DefaultContext, org, false))
|
||||
}
|
||||
|
||||
func TestPurgeUser(t *testing.T) {
|
||||
test := func(userID int64) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
|
||||
|
||||
err := DeleteUser(db.DefaultContext, user, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
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})
|
||||
assert.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,
|
||||
}
|
||||
|
||||
assert.NoError(t, user_model.CreateUser(db.DefaultContext, user, &user_model.Meta{}))
|
||||
|
||||
assert.NoError(t, DeleteUser(db.DefaultContext, user, false))
|
||||
}
|
||||
|
||||
func TestRenameUser(t *testing.T) {
|
||||
assert.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,
|
||||
}
|
||||
assert.ErrorIs(t, RenameUser(db.DefaultContext, u, "user_rename"), user_model.ErrUserIsNotLocal{})
|
||||
})
|
||||
|
||||
t.Run("Same username", func(t *testing.T) {
|
||||
assert.NoError(t, RenameUser(db.DefaultContext, user, user.Name))
|
||||
})
|
||||
|
||||
t.Run("Non usable username", func(t *testing.T) {
|
||||
usernames := []string{"--diff", ".well-known", "gitea-actions", "aaa.atom", "aa.png"}
|
||||
for _, username := range usernames {
|
||||
assert.Error(t, user_model.IsUsableUsername(username), "non-usable username: %s", username)
|
||||
assert.Error(t, RenameUser(db.DefaultContext, user, username), "non-usable username: %s", 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})
|
||||
|
||||
assert.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})
|
||||
|
||||
assert.ErrorIs(t, RenameUser(db.DefaultContext, user, existUser.Name), user_model.ErrUserAlreadyExist{Name: existUser.Name})
|
||||
assert.ErrorIs(t, RenameUser(db.DefaultContext, user, existUser.LowerName), user_model.ErrUserAlreadyExist{Name: existUser.LowerName})
|
||||
newUsername := fmt.Sprintf("uSEr%d", existUser.ID)
|
||||
assert.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"
|
||||
|
||||
assert.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)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(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
|
||||
|
||||
assert.NoError(t, user_model.CreateUser(db.DefaultContext, v.user, &user_model.Meta{}))
|
||||
|
||||
u, err := user_model.GetUserByEmail(db.DefaultContext, v.user.Email)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, !u.AllowCreateOrganization, v.disableOrgCreation)
|
||||
|
||||
assert.NoError(t, DeleteUser(db.DefaultContext, v.user, false))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteInactiveUsers(t *testing.T) {
|
||||
addUser := func(name, email string, createdUnix timeutil.TimeStamp, active bool) {
|
||||
inactiveUser := &user_model.User{Name: name, LowerName: strings.ToLower(name), Email: email, CreatedUnix: createdUnix, IsActive: active}
|
||||
_, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(inactiveUser)
|
||||
assert.NoError(t, err)
|
||||
inactiveUserEmail := &user_model.EmailAddress{UID: inactiveUser.ID, IsPrimary: true, Email: email, LowerEmail: strings.ToLower(email), IsActivated: active}
|
||||
err = db.Insert(db.DefaultContext, inactiveUserEmail)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
addUser("user-inactive-10", "user-inactive-10@test.com", timeutil.TimeStampNow().Add(-600), false)
|
||||
addUser("user-inactive-5", "user-inactive-5@test.com", timeutil.TimeStampNow().Add(-300), false)
|
||||
addUser("user-active-10", "user-active-10@test.com", timeutil.TimeStampNow().Add(-600), true)
|
||||
addUser("user-active-5", "user-active-5@test.com", timeutil.TimeStampNow().Add(-300), true)
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-10"})
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"})
|
||||
assert.NoError(t, DeleteInactiveUsers(db.DefaultContext, 8*time.Minute))
|
||||
unittest.AssertNotExistsBean(t, &user_model.User{Name: "user-inactive-10"})
|
||||
unittest.AssertNotExistsBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"})
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-5"})
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-10"})
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-active-5"})
|
||||
}
|
Reference in New Issue
Block a user