use user id in noreply emails (#36550)
This implements id based hidden emails in format of `user+id@NoReplyAddress` resolves: https://github.com/go-gitea/gitea/issues/33471 --- The change is not breaking however it is recommended for users to move to this newer type of no reply address --------- Co-authored-by: Lauris B <lauris@nix.lv>
This commit is contained in:
+62
-20
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -212,7 +213,7 @@ func (u *User) SetLastLogin() {
|
|||||||
|
|
||||||
// GetPlaceholderEmail returns an noreply email
|
// GetPlaceholderEmail returns an noreply email
|
||||||
func (u *User) GetPlaceholderEmail() string {
|
func (u *User) GetPlaceholderEmail() string {
|
||||||
return fmt.Sprintf("%s@%s", u.LowerName, setting.Service.NoReplyAddress)
|
return fmt.Sprintf("%s+%d@%s", u.LowerName, u.ID, setting.Service.NoReplyAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEmail returns a noreply email, if the user has set to keep his
|
// GetEmail returns a noreply email, if the user has set to keep his
|
||||||
@@ -1197,14 +1198,18 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro
|
|||||||
|
|
||||||
needCheckEmails := make(container.Set[string])
|
needCheckEmails := make(container.Set[string])
|
||||||
needCheckUserNames := make(container.Set[string])
|
needCheckUserNames := make(container.Set[string])
|
||||||
|
needCheckUserIDs := make(container.Set[int64])
|
||||||
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
|
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
emailLower := strings.ToLower(email)
|
emailLower := strings.ToLower(email)
|
||||||
if noReplyUserNameLower, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
|
needCheckEmails.Add(emailLower)
|
||||||
needCheckUserNames.Add(noReplyUserNameLower)
|
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
|
||||||
needCheckEmails.Add(emailLower)
|
name, id := parseLocalPartToNameID(localPart)
|
||||||
} else {
|
if id != 0 {
|
||||||
needCheckEmails.Add(emailLower)
|
needCheckUserIDs.Add(id)
|
||||||
|
} else if name != "" {
|
||||||
|
needCheckUserNames.Add(name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,16 +1239,57 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
users := make(map[int64]*User, len(needCheckUserNames))
|
usersByIDs := make(map[int64]*User)
|
||||||
if err := db.GetEngine(ctx).In("lower_name", needCheckUserNames.Values()).Find(&users); err != nil {
|
if len(needCheckUserIDs) > 0 || len(needCheckUserNames) > 0 {
|
||||||
return nil, err
|
cond := builder.NewCond()
|
||||||
|
if len(needCheckUserIDs) > 0 {
|
||||||
|
cond = cond.Or(builder.In("id", needCheckUserIDs.Values()))
|
||||||
|
}
|
||||||
|
if len(needCheckUserNames) > 0 {
|
||||||
|
cond = cond.Or(builder.In("lower_name", needCheckUserNames.Values()))
|
||||||
|
}
|
||||||
|
if err := db.GetEngine(ctx).Where(cond).Find(&usersByIDs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, user := range users {
|
|
||||||
results[strings.ToLower(user.GetPlaceholderEmail())] = user
|
usersByName := make(map[string]*User)
|
||||||
|
for _, user := range usersByIDs {
|
||||||
|
usersByName[user.LowerName] = user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, email := range emails {
|
||||||
|
emailLower := strings.ToLower(email)
|
||||||
|
if _, ok := results[emailLower]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, id := parseLocalPartToNameID(localPart)
|
||||||
|
if user, ok := usersByIDs[id]; ok {
|
||||||
|
results[emailLower] = user
|
||||||
|
} else if user, ok := usersByName[name]; ok {
|
||||||
|
results[emailLower] = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &EmailUserMap{results}, nil
|
return &EmailUserMap{results}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseLocalPartToNameID attempts to unparse local-part of email that's in format user+id
|
||||||
|
// returns user and id if possible
|
||||||
|
func parseLocalPartToNameID(localPart string) (string, int64) {
|
||||||
|
var id int64
|
||||||
|
name, idstr, hasPlus := strings.Cut(localPart, "+")
|
||||||
|
if hasPlus {
|
||||||
|
id, _ = strconv.ParseInt(idstr, 10, 64)
|
||||||
|
}
|
||||||
|
return name, id
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserByEmail returns the user object by given e-mail if exists.
|
// GetUserByEmail returns the user object by given e-mail if exists.
|
||||||
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||||
if len(email) == 0 {
|
if len(email) == 0 {
|
||||||
@@ -1262,16 +1308,12 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finally, if email address is the protected email address:
|
// Finally, if email address is the protected email address:
|
||||||
if before, ok := strings.CutSuffix(email, "@"+setting.Service.NoReplyAddress); ok {
|
if localPart, ok := strings.CutSuffix(email, strings.ToLower("@"+setting.Service.NoReplyAddress)); ok {
|
||||||
username := before
|
name, id := parseLocalPartToNameID(localPart)
|
||||||
user := &User{}
|
if id != 0 {
|
||||||
has, err := db.GetEngine(ctx).Where("lower_name=?", username).Get(user)
|
return GetUserByID(ctx, id)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if has {
|
|
||||||
return user, nil
|
|
||||||
}
|
}
|
||||||
|
return GetUserByName(ctx, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrUserNotExist{Name: email}
|
return nil, ErrUserNotExist{Name: email}
|
||||||
|
|||||||
+37
-10
@@ -51,12 +51,27 @@ func TestOAuth2Application_LoadUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestUserEmails(t *testing.T) {
|
func TestUserEmails(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
defer test.MockVariableValue(&setting.Service.NoReplyAddress, "NoReply.gitea.internal")()
|
||||||
t.Run("GetUserEmailsByNames", func(t *testing.T) {
|
t.Run("GetUserEmailsByNames", func(t *testing.T) {
|
||||||
// ignore none active user email
|
// ignore not active user email
|
||||||
assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user9"}))
|
assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user9"}))
|
||||||
assert.ElementsMatch(t, []string{"user8@example.com", "user5@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user5"}))
|
assert.ElementsMatch(t, []string{"user8@example.com", "user5@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "user5"}))
|
||||||
assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "org7"}))
|
assert.ElementsMatch(t, []string{"user8@example.com"}, user_model.GetUserEmailsByNames(t.Context(), []string{"user8", "org7"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
Email string
|
||||||
|
UID int64
|
||||||
|
}{
|
||||||
|
{"UseR1@example.com", 1},
|
||||||
|
{"user1-2@example.COM", 1},
|
||||||
|
{"USER2@" + setting.Service.NoReplyAddress, 2},
|
||||||
|
{"user2+2@" + setting.Service.NoReplyAddress, 2},
|
||||||
|
{"oldUser2UsernameWhichDoesNotMatterForQuery+2@" + setting.Service.NoReplyAddress, 2},
|
||||||
|
{"badUser+99999@" + setting.Service.NoReplyAddress, 0},
|
||||||
|
{"user4@example.com", 4},
|
||||||
|
{"no-such", 0},
|
||||||
|
}
|
||||||
t.Run("GetUsersByEmails", func(t *testing.T) {
|
t.Run("GetUsersByEmails", func(t *testing.T) {
|
||||||
defer test.MockVariableValue(&setting.Service.NoReplyAddress, "NoReply.gitea.internal")()
|
defer test.MockVariableValue(&setting.Service.NoReplyAddress, "NoReply.gitea.internal")()
|
||||||
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
||||||
@@ -70,15 +85,27 @@ func TestUserEmails(t *testing.T) {
|
|||||||
require.NotNil(t, user)
|
require.NotNil(t, user)
|
||||||
assert.Equal(t, uid, user.ID)
|
assert.Equal(t, uid, user.ID)
|
||||||
}
|
}
|
||||||
cases := []struct {
|
for _, c := range cases {
|
||||||
Email string
|
t.Run(c.Email, func(t *testing.T) {
|
||||||
UID int64
|
testGetUserByEmail(t, c.Email, c.UID)
|
||||||
}{
|
})
|
||||||
{"UseR1@example.com", 1},
|
}
|
||||||
{"user1-2@example.COM", 1},
|
|
||||||
{"USER2@" + setting.Service.NoReplyAddress, 2},
|
t.Run("NoReplyConflict", func(t *testing.T) {
|
||||||
{"user4@example.com", 4},
|
setting.Service.NoReplyAddress = "example.com"
|
||||||
{"no-such", 0},
|
testGetUserByEmail(t, "user1-2@example.COM", 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
t.Run("GetUserByEmail", func(t *testing.T) {
|
||||||
|
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
||||||
|
user, err := user_model.GetUserByEmail(t.Context(), email)
|
||||||
|
if uid == 0 {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, user)
|
||||||
|
} else {
|
||||||
|
require.NotNil(t, user)
|
||||||
|
assert.Equal(t, uid, user.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.Email, func(t *testing.T) {
|
t.Run(c.Email, func(t *testing.T) {
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func testEditorWebGitCommitEmail(t *testing.T) {
|
|||||||
t.Run("DefaultEmailKeepPrivate", func(t *testing.T) {
|
t.Run("DefaultEmailKeepPrivate", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
paramsForKeepPrivate["commit_email"] = ""
|
paramsForKeepPrivate["commit_email"] = ""
|
||||||
resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org")
|
resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2+2@noreply.example.org")
|
||||||
})
|
})
|
||||||
t.Run("ChooseEmail", func(t *testing.T) {
|
t.Run("ChooseEmail", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|||||||
@@ -132,14 +132,14 @@ func getExpectedFileResponseForRepoFilesCreate(commitID string, lastCommit *git.
|
|||||||
Author: &api.CommitUser{
|
Author: &api.CommitUser{
|
||||||
Identity: api.Identity{
|
Identity: api.Identity{
|
||||||
Name: "User Two",
|
Name: "User Two",
|
||||||
Email: "user2@noreply.example.org",
|
Email: "user2+2@noreply.example.org",
|
||||||
},
|
},
|
||||||
Date: time.Now().UTC().Format(time.RFC3339),
|
Date: time.Now().UTC().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
Committer: &api.CommitUser{
|
Committer: &api.CommitUser{
|
||||||
Identity: api.Identity{
|
Identity: api.Identity{
|
||||||
Name: "User Two",
|
Name: "User Two",
|
||||||
Email: "user2@noreply.example.org",
|
Email: "user2+2@noreply.example.org",
|
||||||
},
|
},
|
||||||
Date: time.Now().UTC().Format(time.RFC3339),
|
Date: time.Now().UTC().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
@@ -202,14 +202,14 @@ func getExpectedFileResponseForRepoFilesUpdate(commitID, filename, lastCommitSHA
|
|||||||
Author: &api.CommitUser{
|
Author: &api.CommitUser{
|
||||||
Identity: api.Identity{
|
Identity: api.Identity{
|
||||||
Name: "User Two",
|
Name: "User Two",
|
||||||
Email: "user2@noreply.example.org",
|
Email: "user2+2@noreply.example.org",
|
||||||
},
|
},
|
||||||
Date: time.Now().UTC().Format(time.RFC3339),
|
Date: time.Now().UTC().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
Committer: &api.CommitUser{
|
Committer: &api.CommitUser{
|
||||||
Identity: api.Identity{
|
Identity: api.Identity{
|
||||||
Name: "User Two",
|
Name: "User Two",
|
||||||
Email: "user2@noreply.example.org",
|
Email: "user2+2@noreply.example.org",
|
||||||
},
|
},
|
||||||
Date: time.Now().UTC().Format(time.RFC3339),
|
Date: time.Now().UTC().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
@@ -312,13 +312,13 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str
|
|||||||
Author: &api.CommitUser{
|
Author: &api.CommitUser{
|
||||||
Identity: api.Identity{
|
Identity: api.Identity{
|
||||||
Name: "User Two",
|
Name: "User Two",
|
||||||
Email: "user2@noreply.example.org",
|
Email: "user2+2@noreply.example.org",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Committer: &api.CommitUser{
|
Committer: &api.CommitUser{
|
||||||
Identity: api.Identity{
|
Identity: api.Identity{
|
||||||
Name: "User Two",
|
Name: "User Two",
|
||||||
Email: "user2@noreply.example.org",
|
Email: "user2+2@noreply.example.org",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Parents: []*api.CommitMeta{
|
Parents: []*api.CommitMeta{
|
||||||
|
|||||||
Reference in New Issue
Block a user