初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"errors"
"fmt"
"io"
"os"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/avatar"
"gitea.dev/modules/log"
"gitea.dev/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 fmt.Errorf("UploadAvatar: failed to process user avatar image: %w", err)
}
return db.WithTx(ctx, func(ctx context.Context) error {
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("UploadAvatar: failed to update user avatar: %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("UploadAvatar: failed to save user avatar %s: %w", u.CustomAvatarRelativePath(), err)
}
return nil
})
}
// 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
View File
@@ -0,0 +1,302 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
org_model "gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
repo_service "gitea.dev/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
})
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/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(t.Context(), user1, user2, user1))
// Blocker can't be blockee
assert.False(t, CanBlockUser(t.Context(), user1, user2, user2))
// Can't block already blocked user
assert.False(t, CanBlockUser(t.Context(), user1, user2, user29))
// Blockee can't be an organization
assert.False(t, CanBlockUser(t.Context(), user1, user2, org3))
// Doer must be blocker or admin
assert.False(t, CanBlockUser(t.Context(), user2, user4, user29))
// Organization can't block a member
assert.False(t, CanBlockUser(t.Context(), user1, org3, user4))
// Doer must be organization owner or admin if blocker is an organization
assert.False(t, CanBlockUser(t.Context(), user4, org3, user2))
assert.True(t, CanBlockUser(t.Context(), user1, user2, user4))
assert.True(t, CanBlockUser(t.Context(), user2, user2, user4))
assert.True(t, CanBlockUser(t.Context(), 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(t.Context(), user1, user2, user1))
// Can't unblock not blocked user
assert.False(t, CanUnblockUser(t.Context(), user1, user2, user28))
// Doer must be blocker or admin
assert.False(t, CanUnblockUser(t.Context(), user28, user2, user29))
// Doer must be organization owner or admin if blocker is an organization
assert.False(t, CanUnblockUser(t.Context(), user2, org17, user28))
assert.True(t, CanUnblockUser(t.Context(), user1, user2, user29))
assert.True(t, CanUnblockUser(t.Context(), user2, user2, user29))
assert.True(t, CanUnblockUser(t.Context(), user1, org17, user28))
}
+201
View File
@@ -0,0 +1,201 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"fmt"
"time"
actions_model "gitea.dev/models/actions"
activities_model "gitea.dev/models/activities"
asymkey_model "gitea.dev/models/asymkey"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
access_model "gitea.dev/models/perm/access"
pull_model "gitea.dev/models/pull"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
_ "image/jpeg" // Needed for jpeg support
"xorm.io/builder"
)
// deleteUser deletes models associated to a 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
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"errors"
"strings"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
// ReplacePrimaryEmailAddress replaces the user's primary email address with the given email address.
// It also updates the user's email field to match the new primary email address.
func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error {
// FIXME: this check is from old logic, but it is not right, there are far more user types, not only "organization"
if u.IsOrganization() {
return util.NewInvalidArgumentErrorf("user %s is an organization", u.Name)
}
if strings.EqualFold(u.Email, emailStr) {
return nil
}
if err := user_model.ValidateEmail(emailStr); err != nil {
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
// 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
if _, err := user_model.InsertEmailAddress(ctx, &user_model.EmailAddress{
UID: u.ID,
Email: emailStr,
IsActivated: true,
IsPrimary: true,
}); 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
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestUserEmail(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("PrimaryEmailAddress", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 13})
emails, err := user_model.GetEmailAddresses(t.Context(), user.ID)
assert.NoError(t, err)
assert.Len(t, emails, 1)
primary, err := user_model.GetPrimaryEmailAddressOfUser(t.Context(), 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(t.Context(), user, "primary-13@example.com"))
primary, err = user_model.GetPrimaryEmailAddressOfUser(t.Context(), 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(t.Context(), user.ID)
assert.NoError(t, err)
assert.Len(t, emails, 1)
assert.NoError(t, ReplacePrimaryEmailAddress(t.Context(), user, "primary-13@example.com"))
})
t.Run("AddEmailAddresses", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.Error(t, AddEmailAddresses(t.Context(), user, []string{" invalid email "}))
emails := []string{"user1234@example.com", "user5678@example.com"}
assert.NoError(t, AddEmailAddresses(t.Context(), user, emails))
err := AddEmailAddresses(t.Context(), user, emails)
assert.Error(t, err)
assert.True(t, user_model.IsErrEmailAlreadyUsed(err))
})
t.Run("DeleteEmailAddresses", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
emails := []string{"user2-2@example.com"}
err := DeleteEmailAddresses(t.Context(), user, emails)
assert.NoError(t, err)
err = DeleteEmailAddresses(t.Context(), user, emails)
assert.Error(t, err)
assert.True(t, user_model.IsErrEmailAddressNotExist(err))
emails = []string{"user2@example.com"}
err = DeleteEmailAddresses(t.Context(), user, emails)
assert.Error(t, err)
assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err))
})
}
+246
View File
@@ -0,0 +1,246 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"fmt"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
password_module "gitea.dev/modules/auth/password"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/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
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
password_module "gitea.dev/modules/auth/password"
"gitea.dev/modules/optional"
"gitea.dev/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(t.Context(), admin, &UpdateOptions{
IsAdmin: UpdateOptionFieldFromValue(false),
}))
assert.NoError(t, UpdateUser(t.Context(), 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(t.Context(), 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(t.Context(), user, &UpdateAuthOptions{
LoginName: optional.Some("new-login"),
}))
assert.Equal(t, "new-login", user.LoginName)
assert.NoError(t, UpdateAuth(t.Context(), 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(t.Context(), user, &UpdateAuthOptions{
ProhibitLogin: optional.Some(true),
}))
assert.True(t, user.ProhibitLogin)
assert.ErrorIs(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{
Password: optional.Some("aaaa"),
}), password_module.ErrMinLength)
}
+314
View File
@@ -0,0 +1,314 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"fmt"
"os"
"strings"
"time"
"gitea.dev/models/db"
"gitea.dev/models/organization"
packages_model "gitea.dev/models/packages"
repo_model "gitea.dev/models/repo"
system_model "gitea.dev/models/system"
user_model "gitea.dev/models/user"
"gitea.dev/modules/eventsource"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/storage"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/agit"
asymkey_service "gitea.dev/services/asymkey"
org_service "gitea.dev/services/org"
"gitea.dev/services/packages"
container_service "gitea.dev/services/packages/container"
repo_service "gitea.dev/services/repository"
)
// RenameUser renames a user
func RenameUser(ctx context.Context, u *user_model.User, newUserName string, doer *user_model.User) error {
if newUserName == u.Name {
return nil
}
// Non-local users are not allowed to change their own username, but admins are
isExternalUser := !u.IsOrganization() && !u.IsLocal()
if isExternalUser && !doer.IsAdmin {
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.Error("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
}
}
}
if err := db.WithTx(ctx, func(ctx context.Context) error {
// 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)
}
// Finally delete any unlinked attachments, this will also delete the attached files
if err := deleteUserUnlinkedAttachments(ctx, u); err != nil {
return fmt.Errorf("deleteUserUnlinkedAttachments: %w", err)
}
return nil
}); err != nil {
return err
}
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))
}
}
return nil
}
func deleteUserUnlinkedAttachments(ctx context.Context, u *user_model.User) error {
attachments, err := repo_model.GetUnlinkedAttachmentsByUserID(ctx, u.ID)
if err != nil {
return fmt.Errorf("GetUnlinkedAttachmentsByUserID: %w", err)
}
for _, attach := range attachments {
if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil {
return fmt.Errorf("DeleteAttachment ID[%d]: %w", attach.ID, err)
}
}
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
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"fmt"
"strings"
"testing"
"time"
"gitea.dev/models/auth"
"gitea.dev/models/db"
"gitea.dev/models/organization"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
org_service "gitea.dev/services/org"
"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) {
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(t.Context()).Find(&ownedRepos, &repo_model.Repository{OwnerID: userID}))
if len(ownedRepos) > 0 {
err := DeleteUser(t.Context(), 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(t.Context()).Find(&orgUsers, &organization.OrgUser{UID: userID}))
for _, orgUser := range orgUsers {
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: orgUser.OrgID})
if err := org_service.RemoveOrgUser(t.Context(), org, user); err != nil {
assert.True(t, organization.IsErrLastOrgOwner(err))
return
}
}
assert.NoError(t, DeleteUser(t.Context(), 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(t.Context(), org, false))
}
func TestDeleteUserUnlinkedAttachments(t *testing.T) {
t.Run("DeleteExisting", func(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 10})
assert.NoError(t, deleteUserUnlinkedAttachments(t.Context(), user))
unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: 10})
})
t.Run("NoUnlinkedAttachments", func(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, deleteUserUnlinkedAttachments(t.Context(), user))
})
}
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(t.Context(), 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(t.Context(), 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(t.Context(), user, &user_model.Meta{}))
assert.NoError(t, DeleteUser(t.Context(), user, false))
}
func TestRenameUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 21})
t.Run("External user", func(t *testing.T) {
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1, IsAdmin: true})
externalUser := &user_model.User{
Name: "external_user",
Email: "external_user@gitea.io",
LoginType: auth.LDAP,
}
require.NoError(t, user_model.CreateUser(t.Context(), externalUser, &user_model.Meta{}))
err := RenameUser(t.Context(), externalUser, externalUser.Name+"_changed", externalUser)
assert.True(t, user_model.IsErrUserIsNotLocal(err), "external user is not allowed to rename themselves")
err = RenameUser(t.Context(), externalUser, externalUser.Name+"_changed", adminUser)
assert.NoError(t, err, "admin can rename external user")
})
t.Run("Same username", func(t *testing.T) {
assert.NoError(t, RenameUser(t.Context(), user, user.Name, user))
})
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(t.Context(), user, username, user), "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(t.Context(), user, caps, user))
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(t.Context(), user, existUser.Name, user), user_model.ErrUserAlreadyExist{Name: existUser.Name})
assert.ErrorIs(t, RenameUser(t.Context(), user, existUser.LowerName, user), user_model.ErrUserAlreadyExist{Name: existUser.LowerName})
newUsername := fmt.Sprintf("uSEr%d", existUser.ID)
assert.ErrorIs(t, RenameUser(t.Context(), user, newUsername, user), user_model.ErrUserAlreadyExist{Name: newUsername})
})
t.Run("Normal", func(t *testing.T) {
oldUsername := user.Name
newUsername := "User_Rename"
assert.NoError(t, RenameUser(t.Context(), user, newUsername, user))
unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID, Name: newUsername, LowerName: strings.ToLower(newUsername)})
redirectUID, err := user_model.LookupUserRedirect(t.Context(), 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(t.Context(), v.user, &user_model.Meta{}))
u, err := user_model.GetUserByEmail(t.Context(), v.user.Email)
assert.NoError(t, err)
assert.Equal(t, !u.AllowCreateOrganization, v.disableOrgCreation)
assert.NoError(t, DeleteUser(t.Context(), 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(t.Context()).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(t.Context(), 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(t.Context(), 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"})
}