From df2c11a878719719b8600745888c570af93827be Mon Sep 17 00:00:00 2001
From: guillep2k <18600385+guillep2k@users.noreply.github.com>
Date: Thu, 10 Oct 2019 13:45:11 -0300
Subject: [PATCH] Ignore mentions for users with no access (#8395)

* Draft for ResolveMentionsByVisibility()

* Correct typo

* Resolve teams instead of orgs for mentions

* Create test for ResolveMentionsByVisibility

* Fix check for individual users and doer

* Test and fix team mentions

* Run all mentions through visibility filter

* Fix error check

* Simplify code, fix doer included in teams

* Simplify team id list build
---
 models/issue.go                 | 155 +++++++++++++++++++++++++-------
 models/issue_test.go            |  32 +++++++
 models/org_team.go              |   2 +-
 services/mailer/mail_comment.go |  13 ++-
 services/mailer/mail_issue.go   |  13 ++-
 5 files changed, 175 insertions(+), 40 deletions(-)

diff --git a/models/issue.go b/models/issue.go
index e4cc1291c2..f8fa1377a8 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -1477,46 +1477,18 @@ func getParticipantsByIssueID(e Engine, issueID int64) ([]*User, error) {
 	return users, e.In("id", userIDs).Find(&users)
 }
 
-// UpdateIssueMentions extracts mentioned people from content and
-// updates issue-user relations for them.
-func UpdateIssueMentions(ctx DBContext, issueID int64, mentions []string) error {
+// UpdateIssueMentions updates issue-user relations for mentioned users.
+func UpdateIssueMentions(ctx DBContext, issueID int64, mentions []*User) error {
 	if len(mentions) == 0 {
 		return nil
 	}
-
-	for i := range mentions {
-		mentions[i] = strings.ToLower(mentions[i])
+	ids := make([]int64, len(mentions))
+	for i, u := range mentions {
+		ids[i] = u.ID
 	}
-	users := make([]*User, 0, len(mentions))
-
-	if err := ctx.e.In("lower_name", mentions).Asc("lower_name").Find(&users); err != nil {
-		return fmt.Errorf("find mentioned users: %v", err)
-	}
-
-	ids := make([]int64, 0, len(mentions))
-	for _, user := range users {
-		ids = append(ids, user.ID)
-		if !user.IsOrganization() || user.NumMembers == 0 {
-			continue
-		}
-
-		memberIDs := make([]int64, 0, user.NumMembers)
-		orgUsers, err := getOrgUsersByOrgID(ctx.e, user.ID)
-		if err != nil {
-			return fmt.Errorf("GetOrgUsersByOrgID [%d]: %v", user.ID, err)
-		}
-
-		for _, orgUser := range orgUsers {
-			memberIDs = append(memberIDs, orgUser.ID)
-		}
-
-		ids = append(ids, memberIDs...)
-	}
-
 	if err := UpdateIssueUsersByMentions(ctx, issueID, ids); err != nil {
 		return fmt.Errorf("UpdateIssueUsersByMentions: %v", err)
 	}
-
 	return nil
 }
 
@@ -1909,3 +1881,120 @@ func (issue *Issue) updateClosedNum(e Engine) (err error) {
 	}
 	return
 }
+
+// ResolveMentionsByVisibility returns the users mentioned in an issue, removing those that
+// don't have access to reading it. Teams are expanded into their users, but organizations are ignored.
+func (issue *Issue) ResolveMentionsByVisibility(ctx DBContext, doer *User, mentions []string) (users []*User, err error) {
+	if len(mentions) == 0 {
+		return
+	}
+	if err = issue.loadRepo(ctx.e); err != nil {
+		return
+	}
+	resolved := make(map[string]bool, 20)
+	names := make([]string, 0, 20)
+	resolved[doer.LowerName] = true
+	for _, name := range mentions {
+		name := strings.ToLower(name)
+		if _, ok := resolved[name]; ok {
+			continue
+		}
+		resolved[name] = false
+		names = append(names, name)
+	}
+
+	if err := issue.Repo.getOwner(ctx.e); err != nil {
+		return nil, err
+	}
+
+	if issue.Repo.Owner.IsOrganization() {
+		// Since there can be users with names that match the name of a team,
+		// if the team exists and can read the issue, the team takes precedence.
+		teams := make([]*Team, 0, len(names))
+		if err := ctx.e.
+			Join("INNER", "team_repo", "team_repo.team_id = team.id").
+			Where("team_repo.repo_id=?", issue.Repo.ID).
+			In("team.lower_name", names).
+			Find(&teams); err != nil {
+			return nil, fmt.Errorf("find mentioned teams: %v", err)
+		}
+		if len(teams) != 0 {
+			checked := make([]int64, 0, len(teams))
+			unittype := UnitTypeIssues
+			if issue.IsPull {
+				unittype = UnitTypePullRequests
+			}
+			for _, team := range teams {
+				if team.Authorize >= AccessModeOwner {
+					checked = append(checked, team.ID)
+					resolved[team.LowerName] = true
+					continue
+				}
+				has, err := ctx.e.Get(&TeamUnit{OrgID: issue.Repo.Owner.ID, TeamID: team.ID, Type: unittype})
+				if err != nil {
+					return nil, fmt.Errorf("get team units (%d): %v", team.ID, err)
+				}
+				if has {
+					checked = append(checked, team.ID)
+					resolved[team.LowerName] = true
+				}
+			}
+			if len(checked) != 0 {
+				teamusers := make([]*User, 0, 20)
+				if err := ctx.e.
+					Join("INNER", "team_user", "team_user.uid = `user`.id").
+					In("`team_user`.team_id", checked).
+					And("`user`.is_active = ?", true).
+					And("`user`.prohibit_login = ?", false).
+					Find(&teamusers); err != nil {
+					return nil, fmt.Errorf("get teams users: %v", err)
+				}
+				if len(teamusers) > 0 {
+					users = make([]*User, 0, len(teamusers))
+					for _, user := range teamusers {
+						if already, ok := resolved[user.LowerName]; !ok || !already {
+							users = append(users, user)
+							resolved[user.LowerName] = true
+						}
+					}
+				}
+			}
+		}
+
+		// Remove names already in the list to avoid querying the database if pending names remain
+		names = make([]string, 0, len(resolved))
+		for name, already := range resolved {
+			if !already {
+				names = append(names, name)
+			}
+		}
+		if len(names) == 0 {
+			return
+		}
+	}
+
+	unchecked := make([]*User, 0, len(names))
+	if err := ctx.e.
+		Where("`user`.is_active = ?", true).
+		And("`user`.prohibit_login = ?", false).
+		In("`user`.lower_name", names).
+		Find(&unchecked); err != nil {
+		return nil, fmt.Errorf("find mentioned users: %v", err)
+	}
+	for _, user := range unchecked {
+		if already := resolved[user.LowerName]; already || user.IsOrganization() {
+			continue
+		}
+		// Normal users must have read access to the referencing issue
+		perm, err := getUserRepoPermission(ctx.e, issue.Repo, user)
+		if err != nil {
+			return nil, fmt.Errorf("getUserRepoPermission [%d]: %v", user.ID, err)
+		}
+		if !perm.CanReadIssuesOrPulls(issue.IsPull) {
+			continue
+		}
+		users = append(users, user)
+	}
+
+	return
+}
diff --git a/models/issue_test.go b/models/issue_test.go
index 9cd9ff0ad9..5b039bc1d5 100644
--- a/models/issue_test.go
+++ b/models/issue_test.go
@@ -366,3 +366,35 @@ func TestIssue_InsertIssue(t *testing.T) {
 	testInsertIssue(t, "my issue1", "special issue's comments?")
 	testInsertIssue(t, `my issue2, this is my son's love \n \r \ `, "special issue's '' comments?")
 }
+
+func TestIssue_ResolveMentions(t *testing.T) {
+	assert.NoError(t, PrepareTestDatabase())
+
+	testSuccess := func(owner, repo, doer string, mentions []string, expected []int64) {
+		o := AssertExistsAndLoadBean(t, &User{LowerName: owner}).(*User)
+		r := AssertExistsAndLoadBean(t, &Repository{OwnerID: o.ID, LowerName: repo}).(*Repository)
+		issue := &Issue{RepoID: r.ID}
+		d := AssertExistsAndLoadBean(t, &User{LowerName: doer}).(*User)
+		resolved, err := issue.ResolveMentionsByVisibility(DefaultDBContext(), d, mentions)
+		assert.NoError(t, err)
+		ids := make([]int64, len(resolved))
+		for i, user := range resolved {
+			ids[i] = user.ID
+		}
+		sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+		assert.EqualValues(t, expected, ids)
+	}
+
+	// Public repo, existing user
+	testSuccess("user2", "repo1", "user1", []string{"user5"}, []int64{5})
+	// Public repo, non-existing user
+	testSuccess("user2", "repo1", "user1", []string{"nonexisting"}, []int64{})
+	// Public repo, doer
+	testSuccess("user2", "repo1", "user1", []string{"user1"}, []int64{})
+	// Private repo, team member
+	testSuccess("user17", "big_test_private_4", "user20", []string{"user2"}, []int64{2})
+	// Private repo, not a team member
+	testSuccess("user17", "big_test_private_4", "user20", []string{"user5"}, []int64{})
+	// Private repo, whole team
+	testSuccess("user17", "big_test_private_4", "user15", []string{"owners"}, []int64{18})
+}
diff --git a/models/org_team.go b/models/org_team.go
index fc5d5834ef..9170ea2c2a 100644
--- a/models/org_team.go
+++ b/models/org_team.go
@@ -314,7 +314,7 @@ func (t *Team) UnitEnabled(tp UnitType) bool {
 
 func (t *Team) unitEnabled(e Engine, tp UnitType) bool {
 	if err := t.getUnits(e); err != nil {
-		log.Warn("Error loading repository (ID: %d) units: %s", t.ID, err.Error())
+		log.Warn("Error loading team (ID: %d) units: %s", t.ID, err.Error())
 	}
 
 	for _, unit := range t.Units {
diff --git a/services/mailer/mail_comment.go b/services/mailer/mail_comment.go
index cb477f887b..f64db04fff 100644
--- a/services/mailer/mail_comment.go
+++ b/services/mailer/mail_comment.go
@@ -19,11 +19,18 @@ func MailParticipantsComment(c *models.Comment, opType models.ActionType, issue
 }
 
 func mailParticipantsComment(ctx models.DBContext, c *models.Comment, opType models.ActionType, issue *models.Issue) (err error) {
-	mentions := markup.FindAllMentions(c.Content)
-	if err = models.UpdateIssueMentions(ctx, c.IssueID, mentions); err != nil {
+	rawMentions := markup.FindAllMentions(c.Content)
+	userMentions, err := issue.ResolveMentionsByVisibility(ctx, c.Poster, rawMentions)
+	if err != nil {
+		return fmt.Errorf("ResolveMentionsByVisibility [%d]: %v", c.IssueID, err)
+	}
+	if err = models.UpdateIssueMentions(ctx, c.IssueID, userMentions); err != nil {
 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", c.IssueID, err)
 	}
-
+	mentions := make([]string, len(userMentions))
+	for i, u := range userMentions {
+		mentions[i] = u.LowerName
+	}
 	if len(c.Content) > 0 {
 		if err = mailIssueCommentToParticipants(issue, c.Poster, c.Content, c, mentions); err != nil {
 			log.Error("mailIssueCommentToParticipants: %v", err)
diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go
index 92d2c5a879..da0249d595 100644
--- a/services/mailer/mail_issue.go
+++ b/services/mailer/mail_issue.go
@@ -123,11 +123,18 @@ func MailParticipants(issue *models.Issue, doer *models.User, opType models.Acti
 }
 
 func mailParticipants(ctx models.DBContext, issue *models.Issue, doer *models.User, opType models.ActionType) (err error) {
-	mentions := markup.FindAllMentions(issue.Content)
-
-	if err = models.UpdateIssueMentions(ctx, issue.ID, mentions); err != nil {
+	rawMentions := markup.FindAllMentions(issue.Content)
+	userMentions, err := issue.ResolveMentionsByVisibility(ctx, doer, rawMentions)
+	if err != nil {
+		return fmt.Errorf("ResolveMentionsByVisibility [%d]: %v", issue.ID, err)
+	}
+	if err = models.UpdateIssueMentions(ctx, issue.ID, userMentions); err != nil {
 		return fmt.Errorf("UpdateIssueMentions [%d]: %v", issue.ID, err)
 	}
+	mentions := make([]string, len(userMentions))
+	for i, u := range userMentions {
+		mentions[i] = u.LowerName
+	}
 
 	if len(issue.Content) > 0 {
 		if err = mailIssueCommentToParticipants(issue, doer, issue.Content, nil, mentions); err != nil {