diff --git a/models/activities/action.go b/models/activities/action.go index 15bd9a52ac..dd7e96cdf4 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -588,7 +588,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error { if repoChanged { // Add feeds for user self and all watchers. - watchers, err = repo_model.GetWatchers(ctx, act.RepoID) + watchers, err = repo_model.GetWatchersExcludeBlocked(ctx, act.RepoID, act.ActUserID) if err != nil { return fmt.Errorf("get watchers: %w", err) } diff --git a/models/activities/notification.go b/models/activities/notification.go index 7c794564b6..3ef5bc79c6 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -235,6 +235,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n for _, id := range issueUnWatches { toNotify.Remove(id) } + + // Remove users who have the notification author blocked. + blockedAuthorIDs, err := user_model.ListBlockedByUsersID(ctx, notificationAuthorID) + if err != nil { + return err + } + for _, id := range blockedAuthorIDs { + toNotify.Remove(id) + } } err = issue.LoadRepo(ctx) diff --git a/models/fixtures/comment.yml b/models/fixtures/comment.yml index bd64680c8c..28381eb4b0 100644 --- a/models/fixtures/comment.yml +++ b/models/fixtures/comment.yml @@ -66,3 +66,12 @@ tree_path: "README.md" created_unix: 946684812 invalidated: true + +- + id: 8 + type: 0 # comment + poster_id: 2 + issue_id: 4 # in repo_id 2 + content: "I just wanted to add.." + created_unix: 946684812 + updated_unix: 946684812 diff --git a/models/fixtures/forgejo_blocked_user.yml b/models/fixtures/forgejo_blocked_user.yml new file mode 100644 index 0000000000..88c378a846 --- /dev/null +++ b/models/fixtures/forgejo_blocked_user.yml @@ -0,0 +1,5 @@ +- + id: 1 + user_id: 4 + block_id: 1 + created_unix: 1671607299 diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index ccc1fe41fb..0c9b6ff406 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -61,7 +61,7 @@ priority: 0 is_closed: true is_pull: false - num_comments: 0 + num_comments: 1 created_unix: 946684830 updated_unix: 978307200 is_locked: false diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 373c1caa62..bc63969789 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -37,7 +37,7 @@ lower_name: repo2 name: repo2 default_branch: master - num_watches: 0 + num_watches: 1 num_stars: 1 num_forks: 0 num_issues: 2 diff --git a/models/fixtures/watch.yml b/models/fixtures/watch.yml index 1950ac99e7..c6c9726cc8 100644 --- a/models/fixtures/watch.yml +++ b/models/fixtures/watch.yml @@ -27,3 +27,9 @@ user_id: 11 repo_id: 1 mode: 3 # auto + +- + id: 6 + user_id: 4 + repo_id: 2 + mode: 1 # normal diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 4393d18bcf..9e9733e8f6 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -311,6 +311,8 @@ func TestIssue_ResolveMentions(t *testing.T) { testSuccess("user2", "repo1", "user1", []string{"nonexisting"}, []int64{}) // Public repo, doer testSuccess("user2", "repo1", "user1", []string{"user1"}, []int64{}) + // Public repo, blocked user + testSuccess("user2", "repo1", "user1", []string{"user4"}, []int64{}) // Private repo, team member testSuccess("org17", "big_test_private_4", "user20", []string{"user2"}, []int64{2}) // Private repo, not a team member diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 78f4657c44..a0cf92c3ad 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -619,9 +619,11 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u teamusers := make([]*user_model.User, 0, 20) if err := db.GetEngine(ctx). Join("INNER", "team_user", "team_user.uid = `user`.id"). + Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id"). In("`team_user`.team_id", checked). And("`user`.is_active = ?", true). And("`user`.prohibit_login = ?", false). + And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})). Find(&teamusers); err != nil { return nil, fmt.Errorf("get teams users: %w", err) } @@ -655,8 +657,10 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u unchecked := make([]*user_model.User, 0, len(mentionUsers)) if err := db.GetEngine(ctx). + Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id"). Where("`user`.is_active = ?", true). And("`user`.prohibit_login = ?", false). + And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})). In("`user`.lower_name", mentionUsers). Find(&unchecked); err != nil { return nil, fmt.Errorf("find mentioned users: %w", err) diff --git a/models/issues/reaction.go b/models/issues/reaction.go index bb47cf24ca..d5448636fe 100644 --- a/models/issues/reaction.go +++ b/models/issues/reaction.go @@ -240,25 +240,6 @@ func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, erro return reaction, nil } -// CreateIssueReaction creates a reaction on issue. -func CreateIssueReaction(ctx context.Context, doerID, issueID int64, content string) (*Reaction, error) { - return CreateReaction(ctx, &ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - }) -} - -// CreateCommentReaction creates a reaction on comment. -func CreateCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) (*Reaction, error) { - return CreateReaction(ctx, &ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - CommentID: commentID, - }) -} - // DeleteReaction deletes reaction for issue or comment. func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { reaction := &Reaction{ diff --git a/models/issues/reaction_test.go b/models/issues/reaction_test.go index 5dc8e1a5f3..eb59e36ecd 100644 --- a/models/issues/reaction_test.go +++ b/models/issues/reaction_test.go @@ -19,11 +19,14 @@ import ( func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) { var reaction *issues_model.Reaction var err error - if commentID == 0 { - reaction, err = issues_model.CreateIssueReaction(db.DefaultContext, doerID, issueID, content) - } else { - reaction, err = issues_model.CreateCommentReaction(db.DefaultContext, doerID, issueID, commentID, content) - } + // NOTE: This doesn't do user blocking checking. + reaction, err = issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{ + DoerID: doerID, + IssueID: issueID, + CommentID: commentID, + Type: content, + }) + assert.NoError(t, err) assert.NotNil(t, reaction) } diff --git a/models/repo/collaboration.go b/models/repo/collaboration.go index 2018ae2a7d..ac113d7165 100644 --- a/models/repo/collaboration.go +++ b/models/repo/collaboration.go @@ -137,6 +137,19 @@ func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid in }) } +// GetCollaboratorWithUser returns all collaborator IDs of collabUserID on +// repositories of ownerID. +func GetCollaboratorWithUser(ctx context.Context, ownerID, collabUserID int64) ([]int64, error) { + collabsID := make([]int64, 0, 8) + err := db.GetEngine(ctx).Table("collaboration").Select("collaboration.`id`"). + Join("INNER", "repository", "repository.id = collaboration.repo_id"). + Where("repository.`owner_id` = ?", ownerID). + And("collaboration.`user_id` = ?", collabUserID). + Find(&collabsID) + + return collabsID, err +} + // IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) { if repo.OwnerID == userID { diff --git a/models/repo/collaboration_test.go b/models/repo/collaboration_test.go index 38114c307f..ef8d884b9e 100644 --- a/models/repo/collaboration_test.go +++ b/models/repo/collaboration_test.go @@ -11,6 +11,7 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "github.com/stretchr/testify/assert" ) @@ -156,3 +157,23 @@ func TestRepo_GetCollaboration(t *testing.T) { assert.NoError(t, err) assert.Nil(t, collab) } + +func TestGetCollaboratorWithUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user16 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16}) + user15 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) + user18 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18}) + + collabs, err := repo_model.GetCollaboratorWithUser(db.DefaultContext, user16.ID, user15.ID) + assert.NoError(t, err) + assert.Len(t, collabs, 2) + assert.EqualValues(t, 5, collabs[0]) + assert.EqualValues(t, 7, collabs[1]) + + collabs, err = repo_model.GetCollaboratorWithUser(db.DefaultContext, user16.ID, user18.ID) + assert.NoError(t, err) + assert.Len(t, collabs, 2) + assert.EqualValues(t, 6, collabs[0]) + assert.EqualValues(t, 8, collabs[1]) +} diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index dd2ef62201..5d6e24e2a5 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -177,3 +177,16 @@ func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull boo Limit(30). Find(&users) } + +// GetWatchedRepoIDsOwnedBy returns the repos owned by a particular user watched by a particular user +func GetWatchedRepoIDsOwnedBy(ctx context.Context, userID, ownedByUserID int64) ([]int64, error) { + repoIDs := make([]int64, 0, 10) + err := db.GetEngine(ctx). + Table("repository"). + Select("`repository`.id"). + Join("LEFT", "watch", "`repository`.id=`watch`.repo_id"). + Where("`watch`.user_id=?", userID). + And("`watch`.mode<>?", WatchModeDont). + And("`repository`.owner_id=?", ownedByUserID).Find(&repoIDs) + return repoIDs, err +} diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go index 7816b0262a..ad794beb9b 100644 --- a/models/repo/user_repo_test.go +++ b/models/repo/user_repo_test.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "github.com/stretchr/testify/assert" ) @@ -71,3 +72,15 @@ func TestRepoGetReviewers(t *testing.T) { assert.NoError(t, err) assert.Len(t, reviewers, 1) } + +func GetWatchedRepoIDsOwnedBy(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(db.DefaultContext, user1.ID, user2.ID) + assert.NoError(t, err) + assert.Len(t, repoIDs, 1) + assert.EqualValues(t, 1, repoIDs[0]) +} diff --git a/models/repo/watch.go b/models/repo/watch.go index fba66d6dcb..c3495a580a 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -10,6 +10,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" ) // WatchMode specifies what kind of watch the user has on a repository @@ -142,6 +144,21 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { Find(&watches) } +// GetWatchersExcludeBlocked returns all watchers of given repository, whereby +// the doer isn't blocked by one of the watchers. +func GetWatchersExcludeBlocked(ctx context.Context, repoID, doerID int64) ([]*Watch, error) { + watches := make([]*Watch, 0, 10) + return watches, db.GetEngine(ctx). + Join("INNER", "`user`", "`user`.id = `watch`.user_id"). + Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `watch`.user_id"). + Where("`watch`.repo_id=?", repoID). + And("`watch`.mode<>?", WatchModeDont). + And("`user`.is_active=?", true). + And("`user`.prohibit_login=?", false). + And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doerID})). + Find(&watches) +} + // GetRepoWatchersIDs returns IDs of watchers for a given repo ID // but avoids joining with `user` for performance reasons // User permissions must be verified elsewhere if required @@ -184,3 +201,9 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error } return watchRepoMode(ctx, watch, WatchModeAuto) } + +// UnwatchRepos will unwatch the user from all given repositories. +func UnwatchRepos(ctx context.Context, userID int64, repoIDs []int64) error { + _, err := db.GetEngine(ctx).Where("user_id=?", userID).In("repo_id", repoIDs).Delete(&Watch{}) + return err +} diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 7aa899291c..f2529fb9b3 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -43,6 +43,24 @@ func TestGetWatchers(t *testing.T) { assert.Len(t, watches, 0) } +func TestGetWatchersExcludeBlocked(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + watches, err := repo_model.GetWatchersExcludeBlocked(db.DefaultContext, repo.ID, 1) + assert.NoError(t, err) + + // One watchers are inactive and one watcher is blocked, thus minus 2 + assert.Len(t, watches, repo.NumWatches-2) + for _, watch := range watches { + assert.EqualValues(t, repo.ID, watch.RepoID) + } + + watches, err = repo_model.GetWatchersExcludeBlocked(db.DefaultContext, unittest.NonexistentID, 1) + assert.NoError(t, err) + assert.Len(t, watches, 0) +} + func TestRepository_GetWatchers(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -137,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) { assert.NoError(t, repo_model.WatchRepoMode(db.DefaultContext, 12, 1, repo_model.WatchModeNone)) unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0) } + +func TestUnwatchRepos(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1}) + unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2}) + + err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2}) + assert.NoError(t, err) + + unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 1}) + unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 2}) +} diff --git a/models/user/block.go b/models/user/block.go new file mode 100644 index 0000000000..189cacc2a2 --- /dev/null +++ b/models/user/block.go @@ -0,0 +1,91 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "errors" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" +) + +// ErrBlockedByUser defines an error stating that the user is not allowed to perform the action because they are blocked. +var ErrBlockedByUser = errors.New("user is blocked by the poster or repository owner") + +// BlockedUser represents a blocked user entry. +type BlockedUser struct { + ID int64 `xorm:"pk autoincr"` + // UID of the one who got blocked. + BlockID int64 `xorm:"index"` + // UID of the one who did the block action. + UserID int64 `xorm:"index"` + + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// TableName provides the real table name +func (*BlockedUser) TableName() string { + return "forgejo_blocked_user" +} + +func init() { + db.RegisterModel(new(BlockedUser)) +} + +// IsBlocked returns if userID has blocked blockID. +func IsBlocked(ctx context.Context, userID, blockID int64) bool { + has, _ := db.GetEngine(ctx).Exist(&BlockedUser{UserID: userID, BlockID: blockID}) + return has +} + +// IsBlockedMultiple returns if one of the userIDs has blocked blockID. +func IsBlockedMultiple(ctx context.Context, userIDs []int64, blockID int64) bool { + has, _ := db.GetEngine(ctx).In("user_id", userIDs).Exist(&BlockedUser{BlockID: blockID}) + return has +} + +// UnblockUser removes the blocked user entry. +func UnblockUser(ctx context.Context, userID, blockID int64) error { + _, err := db.GetEngine(ctx).Delete(&BlockedUser{UserID: userID, BlockID: blockID}) + return err +} + +// CountBlockedUsers returns the number of users the user has blocked. +func CountBlockedUsers(ctx context.Context, userID int64) (int64, error) { + return db.GetEngine(ctx).Where("user_id=?", userID).Count(&BlockedUser{}) +} + +// ListBlockedUsers returns the users that the user has blocked. +// The created_unix field of the user struct is overridden by the creation_unix +// field of blockeduser. +func ListBlockedUsers(ctx context.Context, userID int64, opts db.ListOptions) ([]*User, error) { + sess := db.GetEngine(ctx). + Select("`forgejo_blocked_user`.created_unix, `user`.*"). + Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id"). + Where("`forgejo_blocked_user`.user_id=?", userID) + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, &opts) + users := make([]*User, 0, opts.PageSize) + + return users, sess.Find(&users) + } + + users := make([]*User, 0, 8) + return users, sess.Find(&users) +} + +// ListBlockedByUsersID returns the ids of the users that blocked the user. +func ListBlockedByUsersID(ctx context.Context, userID int64) ([]int64, error) { + users := make([]int64, 0, 8) + err := db.GetEngine(ctx). + Table("user"). + Select("`user`.id"). + Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.user_id"). + Where("`forgejo_blocked_user`.block_id=?", userID). + Find(&users) + + return users, err +} diff --git a/models/user/block_test.go b/models/user/block_test.go new file mode 100644 index 0000000000..629c0c975a --- /dev/null +++ b/models/user/block_test.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +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 TestIsBlocked(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlocked(db.DefaultContext, 1, 1)) + assert.False(t, user_model.IsBlocked(db.DefaultContext, 3, 2)) +} + +func TestIsBlockedMultiple(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4}, 1)) + assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4, 3, 4, 5}, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{1}, 1)) + assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{3, 4, 1}, 2)) +} + +func TestUnblockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) + + assert.NoError(t, user_model.UnblockUser(db.DefaultContext, 4, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) +} + +func TestListBlockedUsers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + blockedUsers, err := user_model.ListBlockedUsers(db.DefaultContext, 4, db.ListOptions{}) + assert.NoError(t, err) + if assert.Len(t, blockedUsers, 1) { + assert.EqualValues(t, 1, blockedUsers[0].ID) + // The function returns the created Unix of the block, not that of the user. + assert.EqualValues(t, 1671607299, blockedUsers[0].CreatedUnix) + } +} + +func TestListBlockedByUsersID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + blockedByUserIDs, err := user_model.ListBlockedByUsersID(db.DefaultContext, 1) + assert.NoError(t, err) + if assert.Len(t, blockedByUserIDs, 1) { + assert.EqualValues(t, 4, blockedByUserIDs[0]) + } +} + +func TestCountBlockedUsers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + count, err := user_model.CountBlockedUsers(db.DefaultContext, 4) + assert.NoError(t, err) + assert.EqualValues(t, 1, count) + + count, err = user_model.CountBlockedUsers(db.DefaultContext, 1) + assert.NoError(t, err) + assert.EqualValues(t, 0, count) +} diff --git a/models/user/follow.go b/models/user/follow.go index f4dd2891ff..9c3283b888 100644 --- a/models/user/follow.go +++ b/models/user/follow.go @@ -34,6 +34,10 @@ func FollowUser(ctx context.Context, userID, followID int64) (err error) { return nil } + if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) { + return ErrBlockedByUser + } + ctx, committer, err := db.TxContext(ctx) if err != nil { return err diff --git a/models/user/user_test.go b/models/user/user_test.go index 971117482c..c0082f8927 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -457,6 +457,12 @@ func TestFollowUser(t *testing.T) { assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2)) + // Blocked user. + assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 1, 4)) + assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 4, 1)) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 1, FollowID: 4}) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 4, FollowID: 1}) + unittest.CheckConsistencyFor(t, &user_model.User{}) } diff --git a/modules/repository/collaborator.go b/modules/repository/collaborator.go index f5cdc35045..4099a178f2 100644 --- a/modules/repository/collaborator.go +++ b/modules/repository/collaborator.go @@ -14,6 +14,10 @@ import ( ) func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error { + if user_model.IsBlocked(ctx, repo.OwnerID, u.ID) || user_model.IsBlocked(ctx, u.ID, repo.OwnerID) { + return user_model.ErrBlockedByUser + } + return db.WithTx(ctx, func(ctx context.Context) error { collaboration := &repo_model.Collaboration{ RepoID: repo.ID, diff --git a/modules/repository/collaborator_test.go b/modules/repository/collaborator_test.go index 622f6abce4..e623dbdaa4 100644 --- a/modules/repository/collaborator_test.go +++ b/modules/repository/collaborator_test.go @@ -33,6 +33,33 @@ func TestRepository_AddCollaborator(t *testing.T) { testSuccess(3, 4) } +func TestRepository_AddCollaborator_IsBlocked(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(repoID, userID int64) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) + + // Owner blocked user. + unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID}) + assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser) + unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID}) + _, err := db.DeleteByBean(db.DefaultContext, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID}) + assert.NoError(t, err) + + // User has owner blocked. + unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: userID, BlockID: repo.OwnerID}) + assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser) + unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID}) + } + // Ensure idempotency (public repository). + testSuccess(1, 4) + testSuccess(1, 4) + // Add collaborator to private repository. + testSuccess(3, 4) +} + func TestRepoPermissionPublicNonOrgRepo(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/modules/structs/moderation.go b/modules/structs/moderation.go new file mode 100644 index 0000000000..c1e55085a7 --- /dev/null +++ b/modules/structs/moderation.go @@ -0,0 +1,13 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// BlockedUser represents a blocked user. +type BlockedUser struct { + BlockID int64 `json:"block_id"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 36d9bf4566..4cfe7326fb 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -594,6 +594,12 @@ joined_on = Joined on %s repositories = Repositories activity = Public Activity followers = Followers +block_user = Block User +block_user.detail = Please understand that if you block this user, other actions will be taken. Such as: +block_user.detail_1 = You are being unfollowed from this user. +block_user.detail_2 = This user cannot interact with your repositories, created issues and comments. +block_user.detail_3 = This user cannot add you as a collaborator, nor can you add them as a collaborator. +follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you. starred = Starred Repositories watched = Watched Repositories code = Code @@ -602,6 +608,8 @@ overview = Overview following = Following follow = Follow unfollow = Unfollow +block = Block +unblock = Unblock user_bio = Biography disabled_public_activity = This user has disabled the public visibility of the activity. email_visibility.limited = Your email address is visible to all authenticated users @@ -631,6 +639,7 @@ account_link = Linked Accounts organization = Organizations uid = UID webauthn = Security Keys +blocked_users = Blocked Users public_profile = Public Profile biography_placeholder = Tell us a little bit about yourself! (You can use Markdown) @@ -900,6 +909,7 @@ hooks.desc = Add webhooks which will be triggered for all repositoriesCANNOT be undone. @@ -922,6 +932,10 @@ visibility.limited_tooltip = Visible only to authenticated users visibility.private = Private visibility.private_tooltip = Visible only to members of organizations you have joined +blocked_since = Blocked since %s +user_unblock_success = The user has been unblocked successfully. +user_block_success = The user has been blocked successfully. + [repo] new_repo_helper = A repository contains all project files, including revision history. Already hosting one elsewhere? Migrate repository. owner = Owner @@ -1674,6 +1688,8 @@ issues.content_history.delete_from_history = Delete from history issues.content_history.delete_from_history_confirm = Delete from history? issues.content_history.options = Options issues.reference_link = Reference: %s +issues.blocked_by_user = You cannot create a issue on this repository because you are blocked by the repository owner. +issues.comment.blocked_by_user = You cannot create a comment on this issue because you are blocked by the repository owner or the poster of the issue. compare.compare_base = base compare.compare_head = compare @@ -1753,6 +1769,7 @@ pulls.reject_count_n = "%d change requests" pulls.waiting_count_1 = "%d waiting review" pulls.waiting_count_n = "%d waiting reviews" pulls.wrong_commit_id = "commit id must be a commit id on the target branch" +pulls.blocked_by_user = You cannot create a pull request on this repository because you are blocked by the repository owner. pulls.no_merge_desc = This pull request cannot be merged because all repository merge options are disabled. pulls.no_merge_helper = Enable merge options in the repository settings or merge the pull request manually. @@ -2120,6 +2137,8 @@ settings.add_collaborator_success = The collaborator has been added. settings.add_collaborator_inactive_user = Cannot add an inactive user as a collaborator. settings.add_collaborator_owner = Cannot add an owner as a collaborator. settings.add_collaborator_duplicate = The collaborator is already added to this repository. +settings.add_collaborator_blocked_our = Cannot add the collaborator, because the repository owner has blocked them. +settings.add_collaborator_blocked_them = Cannot add the collaborator, because they have blocked the repository owner. settings.delete_collaborator = Remove settings.collaborator_deletion = Remove Collaborator settings.collaborator_deletion_desc = Removing a collaborator will revoke their access to this repository. Continue? @@ -2583,6 +2602,7 @@ team_access_desc = Repository access team_permission_desc = Permission team_unit_desc = Allow Access to Repository Sections team_unit_disabled = (Disabled) +follow_blocked_user = You cannot follow this organisation because this organisation has blocked you. form.name_reserved = The organization name "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e5e5667abe..39f5195ea6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1028,6 +1028,14 @@ func Routes() *web.Route { Delete(user.DeleteHook) }, reqWebhooksEnabled()) + m.Group("", func() { + m.Get("/list_blocked", user.ListBlockedUsers) + m.Group("", func() { + m.Put("/block/{username}", user.BlockUser) + m.Put("/unblock/{username}", user.UnblockUser) + }, context_service.UserAssignmentAPI()) + }) + m.Group("/avatar", func() { m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) m.Delete("", user.DeleteAvatar) @@ -1467,6 +1475,14 @@ func Routes() *web.Route { m.Delete("", org.DeleteAvatar) }, reqToken(), reqOrgOwnership()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) + + m.Group("", func() { + m.Get("/list_blocked", org.ListBlockedUsers) + m.Group("", func() { + m.Put("/block/{username}", org.BlockUser) + m.Put("/unblock/{username}", org.UnblockUser) + }, context_service.UserAssignmentAPI()) + }, reqToken(), reqOrgOwnership()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true)) m.Group("/teams/{teamid}", func() { m.Combo("").Get(reqToken(), org.GetTeam). diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 6fb8ecd403..8ddaea3e35 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -5,6 +5,7 @@ package org import ( + "fmt" "net/http" activities_model "code.gitea.io/gitea/models/activities" @@ -457,3 +458,99 @@ func ListOrgActivityFeeds(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) } + +// ListBlockedUsers list the organization's blocked users. +func ListBlockedUsers(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/list_blocked organization orgListBlockedUsers + // --- + // summary: List the organization's blocked users + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/BlockedUserList" + + utils.ListUserBlockedUsers(ctx, ctx.ContextUser) +} + +// BlockUser blocks a user from the organization. +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/block/{username} organization orgBlockUser + // --- + // summary: Blocks a user from the organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.BlockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser) +} + +// UnblockUser unblocks a user from the organization. +func UnblockUser(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/unblock/{username} organization orgUnblockUser + // --- + // summary: Unblock a user from the organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.UnblockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser) +} diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index 2538bcdbc6..20f6941e9a 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -160,6 +160,8 @@ func AddCollaborator(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "422": // "$ref": "#/responses/validationError" + // "403": + // "$ref": "#/responses/forbidden" form := web.GetForm(ctx).(*api.AddCollaboratorOption) @@ -179,7 +181,11 @@ func AddCollaborator(ctx *context.APIContext) { } if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil { - ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "AddCollaborator", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + } return } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index b58f3a6fa7..8e96b1c68c 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -5,6 +5,7 @@ package repo import ( + "errors" "fmt" "net/http" "strconv" @@ -688,7 +689,10 @@ func CreateIssue(ctx *context.APIContext) { } if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "BlockedByUser", err) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) return } diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index a24ef75ae6..8a5fc5cc76 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -380,7 +380,11 @@ func CreateIssueComment(ctx *context.APIContext) { comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil) if err != nil { - ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "CreateIssueComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) + } return } diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index 29c99184e7..2a42c71783 100644 --- a/routers/api/v1/repo/issue_reaction.go +++ b/routers/api/v1/repo/issue_reaction.go @@ -8,11 +8,13 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" ) // GetIssueCommentReactions list reactions of a comment from an issue @@ -202,9 +204,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp if isCreateType { // PostIssueCommentReaction part - reaction, err := issues_model.CreateCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction) + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ @@ -418,9 +420,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i if isCreateType { // PostIssueReaction part - reaction, err := issues_model.CreateIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Reaction) + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index d6b9dddd9d..d8bdb6b0d9 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -422,7 +422,10 @@ func CreatePullRequest(ctx *context.APIContext) { } if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "BlockedByUser", err) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) return } diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 3e23aa4d5a..263e335873 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -414,3 +414,10 @@ type swaggerRepoNewIssuePinsAllowed struct { // in:body Body api.NewIssuePinsAllowed `json:"body"` } + +// BlockedUserList +// swagger:response BlockedUserList +type swaggerBlockedUserList struct { + // in:body + Body []api.BlockedUser `json:"body"` +} diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index 5815ed4f0b..783cee8584 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -5,6 +5,7 @@ package user import ( + "errors" "net/http" user_model "code.gitea.io/gitea/models/user" @@ -223,8 +224,14 @@ func Follow(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" + // "403": + // "$ref": "#/responses/forbidden" if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "BlockedByUser", err) + return + } ctx.Error(http.StatusInternalServerError, "FollowUser", err) return } diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 6359138369..47b95eed1b 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -5,6 +5,7 @@ package user import ( + "fmt" "net/http" activities_model "code.gitea.io/gitea/models/activities" @@ -202,3 +203,84 @@ func ListUserActivityFeeds(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) } + +// ListBlockedUsers list the authenticated user's blocked users. +func ListBlockedUsers(ctx *context.APIContext) { + // swagger:operation GET /user/list_blocked user userListBlockedUsers + // --- + // summary: List the authenticated user's blocked users + // produces: + // - application/json + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/BlockedUserList" + + utils.ListUserBlockedUsers(ctx, ctx.Doer) +} + +// BlockUser blocks a user from the doer. +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/block/{username} user userBlockUser + // --- + // summary: Blocks a user from the doer. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.BlockUser(ctx, ctx.Doer, ctx.ContextUser) +} + +// UnblockUser unblocks a user from the doer. +func UnblockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/unblock/{username} user userUnblockUser + // --- + // summary: Unblocks a user from the doer. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.UnblockUser(ctx, ctx.Doer, ctx.ContextUser) +} diff --git a/routers/api/v1/utils/block.go b/routers/api/v1/utils/block.go new file mode 100644 index 0000000000..187d69044e --- /dev/null +++ b/routers/api/v1/utils/block.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + user_service "code.gitea.io/gitea/services/user" +) + +// ListUserBlockedUsers lists the blocked users of the provided doer. +func ListUserBlockedUsers(ctx *context.APIContext, doer *user_model.User) { + count, err := user_model.CountBlockedUsers(ctx, doer.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + blockedUsers, err := user_model.ListBlockedUsers(ctx, doer.ID, GetListOptions(ctx)) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiBlockedUsers := make([]*api.BlockedUser, len(blockedUsers)) + for i, blockedUser := range blockedUsers { + apiBlockedUsers[i] = &api.BlockedUser{ + BlockID: blockedUser.ID, + Created: blockedUser.CreatedUnix.AsTime(), + } + if err != nil { + ctx.InternalServerError(err) + return + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiBlockedUsers) +} + +// BlockUser blocks the blockUser from the doer. +func BlockUser(ctx *context.APIContext, doer, blockUser *user_model.User) { + err := user_service.BlockUser(ctx, doer.ID, blockUser.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// UnblockUser unblocks the blockUser from the doer. +func UnblockUser(ctx *context.APIContext, doer, blockUser *user_model.User) { + err := user_model.UnblockUser(ctx, doer.ID, blockUser.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/org/setting/blocked_users.go b/routers/web/org/setting/blocked_users.go new file mode 100644 index 0000000000..9f0c868aa2 --- /dev/null +++ b/routers/web/org/setting/blocked_users.go @@ -0,0 +1,79 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/routers/utils" + user_service "code.gitea.io/gitea/services/user" +) + +const tplBlockedUsers = "org/settings/blocked_users" + +// BlockedUsers renders the blocked users page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsSettingsBlockedUsers"] = true + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + + ctx.HTML(http.StatusOK, tplBlockedUsers) +} + +// BlockedUsersBlock blocks a particular user from the organization. +func BlockedUsersBlock(ctx *context.Context) { + uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname"))) + u, err := user_model.GetUserByName(ctx, uname) + if err != nil { + ctx.ServerError("GetUserByName", err) + return + } + + if u.IsOrganization() { + ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name)) + return + } + + if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil { + ctx.ServerError("BlockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_block_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users") +} + +// BlockedUsersUnblock unblocks a particular user from the organization. +func BlockedUsersUnblock(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.FormInt64("user_id")) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + + if u.IsOrganization() { + ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name)) + return + } + + if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil { + ctx.ServerError("UnblockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users") +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 94300da868..6548bbe248 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1230,7 +1230,10 @@ func NewIssuePost(ctx *context.Context) { } if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.RenderWithErr(ctx.Tr("repo.issues.blocked_by_user"), tplIssueNew, form) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) return } @@ -3086,7 +3089,11 @@ func NewComment(ctx *context.Context) { comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) if err != nil { - ctx.ServerError("CreateIssueComment", err) + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Flash.Error(ctx.Tr("repo.issues.comment.blocked_by_user")) + } else { + ctx.ServerError("CreateIssueComment", err) + } return } @@ -3226,7 +3233,7 @@ func ChangeIssueReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content) + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content) if err != nil { if issues_model.IsErrForbiddenIssueReaction(err) { ctx.ServerError("ChangeIssueReaction", err) @@ -3328,7 +3335,7 @@ func ChangeCommentReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content) + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Content) if err != nil { if issues_model.IsErrForbiddenIssueReaction(err) { ctx.ServerError("ChangeIssueReaction", err) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index ec109ed665..8891e59f78 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1430,7 +1430,11 @@ func CompareAndPullRequestPost(ctx *context.Context) { // instead of 500. if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Flash.Error(ctx.Tr("repo.pulls.blocked_by_user")) + ctx.Redirect(ctx.Link) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) return } else if git.IsErrPushRejected(err) { diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go index e217697cc0..98ee1a5431 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -4,6 +4,7 @@ package setting import ( + "errors" "net/http" "strings" @@ -102,7 +103,18 @@ func CollaborationPost(ctx *context.Context) { } if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil { - ctx.ServerError("AddCollaborator", err) + if !errors.Is(err, user_model.ErrBlockedByUser) { + ctx.ServerError("AddCollaborator", err) + return + } + + // To give an good error message, be precise on who has blocked who. + if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our")) + } else { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them")) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") return } diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 066d2ef2a9..1ed6858b99 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -103,13 +103,15 @@ func TestCollaborationPost(t *testing.T) { ctx.Req.Form.Set("collaborator", "user4") u := &user_model.User{ + ID: 2, LowerName: "user2", Type: user_model.UserTypeIndividual, } re := &repo_model.Repository{ - ID: 2, - Owner: u, + ID: 2, + Owner: u, + OwnerID: u.ID, } repo := &context.Repository{ @@ -161,13 +163,15 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { ctx.Req.Form.Set("collaborator", "user4") u := &user_model.User{ + ID: 2, LowerName: "user2", Type: user_model.UserTypeIndividual, } re := &repo_model.Repository{ - ID: 2, - Owner: u, + ID: 2, + Owner: u, + OwnerID: u.ID, } repo := &context.Repository{ diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 6d1901dd2b..33bddab6d7 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -31,6 +31,7 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { prepareContextForCommonProfile(ctx) ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID) ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate ctx.Data["UserLocationMapURL"] = setting.Service.UserLocationMapURL diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 48a4b94c19..1166e2f2de 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -5,6 +5,7 @@ package user import ( + "errors" "fmt" "net/http" "strings" @@ -23,6 +24,7 @@ import ( "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/org" shared_user "code.gitea.io/gitea/routers/web/shared/user" + user_service "code.gitea.io/gitea/services/user" ) // OwnerProfile render profile page for a user or a organization (aka, repo owner) @@ -290,16 +292,45 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi // Action response for follow/unfollow user request func Action(ctx *context.Context) { var err error - switch ctx.FormString("action") { + var redirectViaJSON bool + action := ctx.FormString("action") + + if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") { + log.Error("Cannot perform this action on an organization %q", ctx.FormString("action")) + ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + switch action { case "follow": err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) case "unfollow": err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + case "block": + err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + redirectViaJSON = true + case "unblock": + err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) } if err != nil { - log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) - ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + if !errors.Is(err, user_model.ErrBlockedByUser) { + log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) + ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + if ctx.ContextUser.IsOrganization() { + ctx.Flash.Error(ctx.Tr("org.follow_blocked_user")) + } else { + ctx.Flash.Error(ctx.Tr("user.follow_blocked_user")) + } + } + + if redirectViaJSON { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.ContextUser.HomeLink(), + }) return } ctx.JSONOK() diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go new file mode 100644 index 0000000000..ed1c340fb9 --- /dev/null +++ b/routers/web/user/setting/blocked_users.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users" +) + +// BlockedUsers render the blocked users list page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsBlockedUsers"] = true + ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users" + ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users" + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +// UnblockUser unblocks a particular user for the doer. +func UnblockUser(ctx *context.Context) { + if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil { + ctx.ServerError("UnblockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") +} diff --git a/routers/web/web.go b/routers/web/web.go index c3c7a8c1aa..4d75577e58 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -630,6 +630,11 @@ func registerRoutes(m *web.Route) { }) addWebhookEditRoutes() }, webhooksEnabled) + + m.Group("/blocked_users", func() { + m.Get("", user_setting.BlockedUsers) + m.Post("/unblock", user_setting.UnblockUser) + }) }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) m.Group("/user", func() { @@ -887,6 +892,12 @@ func registerRoutes(m *web.Route) { m.Methods("GET,POST", "/delete", org.SettingsDelete) + m.Group("/blocked_users", func() { + m.Get("", org_setting.BlockedUsers) + m.Post("/block", org_setting.BlockedUsersBlock) + m.Post("/unblock", org_setting.BlockedUsersUnblock) + }) + m.Group("/packages", func() { m.Get("", org.Packages) m.Group("/rules", func() { diff --git a/services/issue/comments.go b/services/issue/comments.go index 8de085026e..cd17641090 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -46,6 +46,11 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod // CreateIssueComment creates a plain issue comment. func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) { + // Check if doer is blocked by the poster of the issue. + if user_model.IsBlocked(ctx, issue.PosterID, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ Type: issues_model.CommentTypeComment, Doer: doer, diff --git a/services/issue/issue.go b/services/issue/issue.go index b577fa189c..627c6d4bce 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -24,6 +24,11 @@ import ( // NewIssue creates new issue with labels for repository. func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error { + // Check if the user is not blocked by the repo's owner. + if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) { + return user_model.ErrBlockedByUser + } + if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil { return err } diff --git a/services/issue/reaction.go b/services/issue/reaction.go new file mode 100644 index 0000000000..dbb4735de2 --- /dev/null +++ b/services/issue/reaction.go @@ -0,0 +1,47 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package issue + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" +) + +// CreateIssueReaction creates a reaction on issue. +func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) { + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + // Check if the doer is blocked by the issue's poster or repository owner. + if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, issue.Repo.OwnerID}, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + }) +} + +// CreateCommentReaction creates a reaction on comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) { + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + // Check if the doer is blocked by the issue's poster, the comment's poster or repository owner. + if user_model.IsBlockedMultiple(ctx, []int64{comment.PosterID, issue.PosterID, issue.Repo.OwnerID}, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + CommentID: comment.ID, + }) +} diff --git a/services/pull/pull.go b/services/pull/pull.go index 2f5143903a..49a0ee6d26 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -38,6 +38,11 @@ var pullWorkingPool = sync.NewExclusivePool() // NewPullRequest creates new pull request with labels for repository. func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error { + // Check if the doer is not blocked by the repository's owner. + if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) { + return user_model.ErrBlockedByUser + } + prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) if err != nil { if !git_model.IsErrBranchNotExist(err) { diff --git a/services/user/block.go b/services/user/block.go new file mode 100644 index 0000000000..06cdd27176 --- /dev/null +++ b/services/user/block.go @@ -0,0 +1,70 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" +) + +// BlockUser adds a blocked user entry for userID to block blockID. +// TODO: Figure out if instance admins should be immune to blocking. +// TODO: Add more mechanism like removing blocked user as collaborator on +// repositories where the user is an owner. +func BlockUser(ctx context.Context, userID, blockID int64) error { + if userID == blockID || user_model.IsBlocked(ctx, userID, blockID) { + return nil + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + // Add the blocked user entry. + _, err = db.GetEngine(ctx).Insert(&user_model.BlockedUser{UserID: userID, BlockID: blockID}) + if err != nil { + return err + } + + // Unfollow the user from the block's perspective. + err = user_model.UnfollowUser(ctx, blockID, userID) + if err != nil { + return err + } + + // Unfollow the user from the doer's perspective. + err = user_model.UnfollowUser(ctx, userID, blockID) + if err != nil { + return err + } + + // Blocked user unwatch all repository owned by the doer. + repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID) + if err != nil { + return err + } + + err = repo_model.UnwatchRepos(ctx, blockID, repoIDs) + if err != nil { + return err + } + + // Remove blocked user as collaborator from repositories the user owns as an + // individual. + collabsID, err := repo_model.GetCollaboratorWithUser(ctx, userID, blockID) + if err != nil { + return err + } + + _, err = db.GetEngine(ctx).In("id", collabsID).Delete(&repo_model.Collaboration{}) + if err != nil { + return err + } + + return committer.Commit() +} diff --git a/services/user/block_test.go b/services/user/block_test.go new file mode 100644 index 0000000000..245dd959b9 --- /dev/null +++ b/services/user/block_test.go @@ -0,0 +1,73 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +// TestBlockUser will ensure that when you block a user, certain actions have +// been taken, like unfollowing each other etc. +func TestBlockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + t.Run("Follow", func(t *testing.T) { + defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID) + + // Follow each other. + assert.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID)) + assert.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID)) + + assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID)) + + // Ensure they aren't following each other anymore. + assert.False(t, user_model.IsFollowing(db.DefaultContext, doer.ID, blockedUser.ID)) + assert.False(t, user_model.IsFollowing(db.DefaultContext, blockedUser.ID, doer.ID)) + }) + + t.Run("Watch", func(t *testing.T) { + defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID) + + // Blocked user watch repository of doer. + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID}) + assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true)) + + assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID)) + + // Ensure blocked user isn't following doer's repository. + assert.False(t, repo_model.IsWatching(db.DefaultContext, blockedUser.ID, repo.ID)) + }) + + t.Run("Collaboration", func(t *testing.T) { + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22, OwnerID: doer.ID}) + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21, OwnerID: doer.ID}) + defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID) + + isBlockedUserCollab := func(repo *repo_model.Repository) bool { + isCollaborator, err := repo_model.IsCollaborator(db.DefaultContext, repo.ID, blockedUser.ID) + assert.NoError(t, err) + return isCollaborator + } + + assert.True(t, isBlockedUserCollab(repo1)) + assert.True(t, isBlockedUserCollab(repo2)) + + assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID)) + + assert.False(t, isBlockedUserCollab(repo1)) + assert.False(t, isBlockedUserCollab(repo2)) + }) +} diff --git a/services/user/delete.go b/services/user/delete.go index 01e3c37b39..0f33d712a4 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -90,6 +90,8 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &pull_model.AutoMerge{DoerID: u.ID}, &pull_model.ReviewState{UserID: u.ID}, &user_model.Redirect{RedirectUserID: u.ID}, + &user_model.BlockedUser{BlockID: u.ID}, + &user_model.BlockedUser{UserID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index ee3237d45b..cc5424425d 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -1,5 +1,10 @@ {{template "base/head" .}}
+ {{if .Flash}} +
+ {{template "base/alert" .}} +
+ {{end}}
{{ctx.AvatarUtils.Avatar .Org 140 "org-avatar"}}
diff --git a/templates/org/settings/blocked_users.tmpl b/templates/org/settings/blocked_users.tmpl new file mode 100644 index 0000000000..4133a43c69 --- /dev/null +++ b/templates/org/settings/blocked_users.tmpl @@ -0,0 +1,21 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked-users")}} +
+
+
+ {{.CsrfTokenHtml}} + +
+ +
+ +
+
+
+ {{template "shared/blocked_users_list" dict "locale" .locale "BlockedUsers" .BlockedUsers}} +
+
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 64ae20f0a3..a46e6821ad 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -38,6 +38,9 @@
{{end}} + + {{.locale.Tr "settings.blocked_users"}} + {{ctx.Locale.Tr "org.settings.delete"}} diff --git a/templates/shared/blocked_users_list.tmpl b/templates/shared/blocked_users_list.tmpl new file mode 100644 index 0000000000..ba399159e3 --- /dev/null +++ b/templates/shared/blocked_users_list.tmpl @@ -0,0 +1,28 @@ +
+ {{range .BlockedUsers}} +
+
+ {{ctx.AvatarUtils.Avatar . 48}} +
+
+
+ {{template "shared/user/name" .}} +
+
+ {{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}} +
+
+
+
+ {{$.CsrfTokenHtml}} + + +
+
+
+ {{else}} +
+ {{$.locale.Tr "settings.blocked_users_none"}} +
+ {{end}} +
diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl index a637a9a5f9..e4d70fc36a 100644 --- a/templates/shared/user/profile_big_avatar.tmpl +++ b/templates/shared/user/profile_big_avatar.tmpl @@ -121,6 +121,18 @@ {{end}} +
  • + {{if $.IsBlocked}} + + {{else}} + + {{end}} +
  • {{end}}
    diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 7e1aef315d..7495577604 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1806,6 +1806,45 @@ } } }, + "/orgs/{org}/block/{username}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Blocks a user from the organization", + "operationId": "orgBlockUser", + "parameters": [ + { + "type": "string", + "description": "name of the org", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/orgs/{org}/hooks": { "get": { "produces": [ @@ -2200,6 +2239,44 @@ } } }, + "/orgs/{org}/list_blocked": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List the organization's blocked users", + "operationId": "orgListBlockedUsers", + "parameters": [ + { + "type": "string", + "description": "name of the org", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/BlockedUserList" + } + } + } + }, "/orgs/{org}/members": { "get": { "produces": [ @@ -2691,6 +2768,45 @@ } } }, + "/orgs/{org}/unblock/{username}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Unblock a user from the organization", + "operationId": "orgUnblockUser", + "parameters": [ + { + "type": "string", + "description": "name of the org", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/packages/{owner}": { "get": { "produces": [ @@ -4191,6 +4307,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -14823,6 +14942,38 @@ } } }, + "/user/block/{username}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Blocks a user from the doer.", + "operationId": "userBlockUser", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/user/emails": { "get": { "produces": [ @@ -15000,6 +15151,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } @@ -15475,6 +15629,37 @@ } } }, + "/user/list_blocked": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List the authenticated user's blocked users", + "operationId": "userListBlockedUsers", + "parameters": [ + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/BlockedUserList" + } + } + } + }, "/user/orgs": { "get": { "produces": [ @@ -15885,6 +16070,38 @@ } } }, + "/user/unblock/{username}": { + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Unblocks a user from the doer.", + "operationId": "userUnblockUser", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/users/search": { "get": { "produces": [ @@ -16848,6 +17065,23 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "BlockedUser": { + "type": "object", + "title": "BlockedUser represents a blocked user.", + "properties": { + "block_id": { + "type": "integer", + "format": "int64", + "x-go-name": "BlockID" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Branch": { "description": "Branch represents a repository branch", "type": "object", @@ -23166,6 +23400,15 @@ } } }, + "BlockedUserList": { + "description": "BlockedUserList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/BlockedUser" + } + } + }, "Branch": { "description": "Branch", "schema": { diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 426b5f042a..7e6c24a136 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -1,6 +1,7 @@ {{template "base/head" .}}
    + {{template "base/alert" .}}
    {{template "shared/user/profile_big_avatar" .}} @@ -39,4 +40,20 @@
    + + + {{template "base/footer" .}} diff --git a/templates/user/settings/blocked_users.tmpl b/templates/user/settings/blocked_users.tmpl new file mode 100644 index 0000000000..b7a35311c5 --- /dev/null +++ b/templates/user/settings/blocked_users.tmpl @@ -0,0 +1,10 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings blocked-users")}} +
    +

    + {{.locale.Tr "settings.blocked_users"}} +

    +
    + {{template "shared/blocked_users_list" dict "locale" .locale "BlockedUsers" .BlockedUsers}} +
    +
    +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index a690d00352..8c5d60494d 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -51,5 +51,8 @@ {{ctx.Locale.Tr "settings.repos"}} + + {{.locale.Tr "settings.blocked_users"}} +
    diff --git a/tests/integration/api_block_test.go b/tests/integration/api_block_test.go new file mode 100644 index 0000000000..48ee51bffa --- /dev/null +++ b/tests/integration/api_block_test.go @@ -0,0 +1,228 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUserBlock(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user4" + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + t.Run("BlockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/user2?token=%s", token)) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2}) + }) + + t.Run("ListBlocked", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/list_blocked?token=%s", token)) + resp := MakeRequest(t, req, http.StatusOK) + + // One user just got blocked and the other one is defined in the fixtures. + assert.Equal(t, "2", resp.Header().Get("X-Total-Count")) + + var blockedUsers []api.BlockedUser + DecodeJSON(t, resp, &blockedUsers) + assert.Len(t, blockedUsers, 2) + assert.EqualValues(t, 1, blockedUsers[0].BlockID) + assert.EqualValues(t, 2, blockedUsers[1].BlockID) + }) + + t.Run("UnblockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/user2?token=%s", token)) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2}) + }) + + t.Run("Organization as target", func(t *testing.T) { + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", org.Name, token)) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: org.ID}) + }) + + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/%s?token=%s", org.Name, token)) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) +} + +func TestAPIOrgBlock(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user5" + org := "user6" + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("BlockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token)) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2}) + }) + + t.Run("ListBlocked", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token)) + resp := MakeRequest(t, req, http.StatusOK) + + assert.Equal(t, "1", resp.Header().Get("X-Total-Count")) + + var blockedUsers []api.BlockedUser + DecodeJSON(t, resp, &blockedUsers) + assert.Len(t, blockedUsers, 1) + assert.EqualValues(t, 2, blockedUsers[0].BlockID) + }) + + t.Run("UnblockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2?token=%s", org, token)) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2}) + }) + + t.Run("Organization as target", func(t *testing.T) { + targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/%s?token=%s", org, targetOrg.Name, token)) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: targetOrg.ID}) + }) + + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/%s?token=%s", org, targetOrg.Name, token)) + MakeRequest(t, req, http.StatusUnprocessableEntity) + }) + }) + + t.Run("Read scope token", func(t *testing.T) { + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization) + + t.Run("Write action", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token)) + MakeRequest(t, req, http.StatusForbidden) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2}) + }) + + t.Run("Read action", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token)) + MakeRequest(t, req, http.StatusOK) + }) + }) + + t.Run("Not as owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + org := "user3" + user := "user4" // Part of org team with write perms. + + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + t.Run("Block user", func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token)) + MakeRequest(t, req, http.StatusForbidden) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 3, BlockID: 2}) + }) + + t.Run("Unblock user", func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2?token=%s", org, token)) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("List blocked users", func(t *testing.T) { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token)) + MakeRequest(t, req, http.StatusForbidden) + }) + }) +} + +// TestAPIBlock_AddCollaborator ensures that the doer and blocked user cannot +// add each others as collaborators via the API. +func TestAPIBlock_AddCollaborator(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user1 := "user10" + user2 := "user2" + perm := "write" + collabOption := &api.AddCollaboratorOption{Permission: &perm} + + // User1 blocks User2. + session := loginUser(t, user1) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", user2, token)) + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 10, BlockID: 2}) + + t.Run("BlockedUser Add Doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2}) + session := loginUser(t, user2) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user2, repo.Name, user1, token), collabOption) + session.MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("Doer Add BlockedUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: 10}) + session := loginUser(t, user1) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user1, repo.Name, user2, token), collabOption) + session.MakeRequest(t, req, http.StatusForbidden) + }) +} diff --git a/tests/integration/api_nodeinfo_test.go b/tests/integration/api_nodeinfo_test.go index 4cbd25f5de..fb35d72ac2 100644 --- a/tests/integration/api_nodeinfo_test.go +++ b/tests/integration/api_nodeinfo_test.go @@ -34,6 +34,6 @@ func TestNodeinfo(t *testing.T) { assert.Equal(t, "gitea", nodeinfo.Software.Name) assert.Equal(t, 25, nodeinfo.Usage.Users.Total) assert.Equal(t, 20, nodeinfo.Usage.LocalPosts) - assert.Equal(t, 2, nodeinfo.Usage.LocalComments) + assert.Equal(t, 3, nodeinfo.Usage.LocalComments) }) } diff --git a/tests/integration/api_user_follow_test.go b/tests/integration/api_user_follow_test.go index 62717af90e..bf6560b103 100644 --- a/tests/integration/api_user_follow_test.go +++ b/tests/integration/api_user_follow_test.go @@ -19,7 +19,7 @@ func TestAPIFollow(t *testing.T) { defer tests.PrepareTestEnv(t)() user1 := "user4" - user2 := "user1" + user2 := "user10" session1 := loginUser(t, user1) token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser) diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go new file mode 100644 index 0000000000..fee6d4b6f9 --- /dev/null +++ b/tests/integration/block_test.go @@ -0,0 +1,369 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "testing" + + issue_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + forgejo_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func BlockUser(t *testing.T, doer, blockedUser *user_model.User) { + t.Helper() + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}) + + session := loginUser(t, doer.Name) + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "block", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + type redirect struct { + Redirect string `json:"redirect"` + } + + var respBody redirect + DecodeJSON(t, resp, &respBody) + assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect) + assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) +} + +// TestBlockUser ensures that users can execute blocking related actions can +// happen under the correct conditions. +func TestBlockUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + session := loginUser(t, doer.Name) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + BlockUser(t, doer, blockedUser) + }) + + // Unblock user. + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "unblock", + }) + session.MakeRequest(t, req, http.StatusOK) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}) + }) + + t.Run("Organization as target", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+targetOrg.Name), + "action": "block", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + assert.Contains(t, resp.Body.String(), "Action \\\"block\\\" failed") + }) + + t.Run("Unblock", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+targetOrg.Name), + "action": "unblock", + }) + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + assert.Contains(t, resp.Body.String(), "Action \\\"unblock\\\" failed") + }) + }) +} + +// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user. +func TestBlockUserFromOrganization(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization}) + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}) + session := loginUser(t, doer.Name) + + t.Run("Block user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "uname": blockedUser.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})) + }) + + t.Run("Unblock user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "user_id": strconv.FormatInt(blockedUser.ID, 10), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}) + }) + + t.Run("Organization as target", func(t *testing.T) { + targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization}) + + t.Run("Block", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "uname": targetOrg.Name, + }) + session.MakeRequest(t, req, http.StatusInternalServerError) + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: targetOrg.ID}) + }) + + t.Run("Unblock", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{ + "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"), + "user_id": strconv.FormatInt(targetOrg.ID, 10), + }) + session.MakeRequest(t, req, http.StatusInternalServerError) + }) + }) +} + +// TestBlockActions ensures that certain actions cannot be performed as a doer +// and as a blocked user and are handled cleanly after the blocking has taken +// place. +func TestBlockActions(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID}) + issue4 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, RepoID: repo2.ID}) + issue4URL := fmt.Sprintf("/%s/issues/%d", repo2.FullName(), issue4.Index) + // NOTE: Sessions shouldn't be shared, because in some situations flash + // messages are persistent and that would interfere with accurate test + // results. + + BlockUser(t, doer, blockedUser) + + // Ensures that issue creation on doer's ownen repositories are blocked. + t.Run("Issue creation", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + link := fmt.Sprintf("%s/issues/new", repo2.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "title": "Title", + "content": "Hello!", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + assert.Contains(t, + htmlDoc.doc.Find(".ui.negative.message").Text(), + translation.NewLocale("en-US").Tr("repo.issues.blocked_by_user"), + ) + }) + + // Ensures that comment creation on doer's owned repositories and doer's + // posted issues are blocked. + t.Run("Comment creation", func(t *testing.T) { + expectedFlash := "error%3DYou%2Bcannot%2Bcreate%2Ba%2Bcomment%2Bon%2Bthis%2Bissue%2Bbecause%2Byou%2Bare%2Bblocked%2Bby%2Bthe%2Brepository%2Bowner%2Bor%2Bthe%2Bposter%2Bof%2Bthe%2Bissue." + + t.Run("Blocked by repository owner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/comments"), map[string]string{ + "_csrf": GetCSRF(t, session, issue4URL), + "content": "Not a kind comment", + }) + session.MakeRequest(t, req, http.StatusOK) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, expectedFlash, flashCookie.Value) + }) + + t.Run("Blocked by issue poster", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5}) + issue15 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 15, RepoID: repo5.ID, PosterID: doer.ID}) + + session := loginUser(t, blockedUser.Name) + issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo5.OwnerName), url.PathEscape(repo5.Name), issue15.Index) + + req := NewRequestWithValues(t, "POST", path.Join(issueURL, "/comments"), map[string]string{ + "_csrf": GetCSRF(t, session, issueURL), + "content": "Not a kind comment", + }) + session.MakeRequest(t, req, http.StatusOK) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, expectedFlash, flashCookie.Value) + }) + }) + + // Ensures that reactions on doer's owned issues and doer's owned comments are + // blocked. + t.Run("Add a reaction", func(t *testing.T) { + type reactionResponse struct { + Empty bool `json:"empty"` + } + + t.Run("On a issue", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/reactions/react"), map[string]string{ + "_csrf": GetCSRF(t, session, issue4URL), + "content": "eyes", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + var respBody reactionResponse + DecodeJSON(t, resp, &respBody) + + assert.EqualValues(t, true, respBody.Empty) + }) + + t.Run("On a comment", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 8, PosterID: doer.ID, IssueID: issue4.ID}) + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/comments/%d/reactions/react", repo2.FullName(), comment.ID), map[string]string{ + "_csrf": GetCSRF(t, session, issue4URL), + "content": "eyes", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + var respBody reactionResponse + DecodeJSON(t, resp, &respBody) + + assert.EqualValues(t, true, respBody.Empty) + }) + }) + + // Ensures that the doer and blocked user cannot follow each other. + t.Run("Follow", func(t *testing.T) { + // Sanity checks to make sure doing these tests are valid. + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID}) + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID}) + + // Doer cannot follow blocked user. + t.Run("Doer follow blocked user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, doer.Name) + + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "follow", + }) + session.MakeRequest(t, req, http.StatusOK) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value) + + // Assert it still doesn't exist. + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID}) + }) + + // Blocked user cannot follow doer. + t.Run("Blocked user follow doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, blockedUser.Name) + + req := NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+doer.Name), + "action": "follow", + }) + session.MakeRequest(t, req, http.StatusOK) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value) + + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID}) + }) + }) + + // Ensures that the doer and blocked user cannot add each each other as collaborators. + t.Run("Add collaborator", func(t *testing.T) { + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + + BlockUser(t, doer, blockedUser) + + t.Run("Doer Add BlockedUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, doer.Name) + link := fmt.Sprintf("/%s/settings/collaboration", repo2.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "collaborator": blockedUser.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthe%2Brepository%2Bowner%2Bhas%2Bblocked%2Bthem.", flashCookie.Value) + }) + + t.Run("BlockedUser Add doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: blockedUser.ID}) + + session := loginUser(t, blockedUser.Name) + link := fmt.Sprintf("/%s/settings/collaboration", repo.FullName()) + + req := NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": GetCSRF(t, session, link), + "collaborator": doer.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthey%2Bhave%2Bblocked%2Bthe%2Brepository%2Bowner.", flashCookie.Value) + }) + }) +} diff --git a/web_src/css/org.css b/web_src/css/org.css index d2bf0ff606..76512e0077 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -167,6 +167,22 @@ border-bottom: 1px solid var(--color-secondary); } +.organization.teams .repositories .item, +.organization.teams .members .item { + padding: 10px 19px; +} + +.organization.teams .repositories .item:not(:last-child), +.organization.teams .members .item:not(:last-child) { + border-bottom: 1px solid var(--color-secondary); +} + +.organization.teams .repositories .item .button, +.organization.teams .members .item .button { + padding: 9px 10px; + margin: 0; +} + .org-team-navbar .active.item { background: var(--color-box-body) !important; } diff --git a/web_src/css/user.css b/web_src/css/user.css index af8a2f5adc..9157a53e7c 100644 --- a/web_src/css/user.css +++ b/web_src/css/user.css @@ -36,6 +36,19 @@ width: 100%; } +.user.profile .ui.card .extra.content > ul > li .svg { + margin-left: 1px; + margin-right: 5px; +} + +.user.profile .ui.card .extra.content > ul > li.follow .ui.button, +.user.profile .ui.card .extra.content > ul > li.block .ui.button { + align-items: center; + display: flex; + justify-content: center; + width: 100%; +} + .user.profile .ui.card #profile-avatar { padding: 1rem 1rem 0.25rem; justify-content: center;