From dc9499bdf9ed49c25962a83e0fa5baba711e92db Mon Sep 17 00:00:00 2001 From: Gusted Date: Sun, 12 Mar 2023 13:28:18 +0100 Subject: [PATCH 1/3] [MODERATION] user blocking - Add the ability to block a user via their profile page. - This will unstar their repositories and visa versa. - Blocked users cannot create issues or pull requests on your the doer's repositories (mind that this is not the case for organizations). - Blocked users cannot comment on the doer's opened issues or pull requests. - Blocked users cannot add reactions to doer's comments. - Blocked users cannot cause a notification trough mentioning the doer. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/540 (cherry picked from commit 687d852480388897db4d7b0cb397cf7135ab97b1) (cherry picked from commit 0c32a4fde531018f74e01d9db6520895fcfa10cc) (cherry picked from commit 1791130e3cb8470b9b39742e0004d5e4c7d1e64d) (cherry picked from commit 00f411819f62c02016d46602ab4daf49effe0550) (cherry picked from commit e0c039b0e899e787a8df1efdd6b47388d93e08fa) (cherry picked from commit b5a058ef0039e95be23893e6fefdcb62a7de071a) (cherry picked from commit 5ff5460d28a482526da7e77bffb18d08de14aaaa) (cherry picked from commit 97bc6e619d2970839b8692b7b025ff0ec1c96d12) --- models/activities/action.go | 2 +- models/activities/notification.go | 9 ++ models/fixtures/forgejo_blocked_user.yml | 5 + models/forgejo_migrations/migrate.go | 5 +- models/forgejo_migrations/v1_20/v1.go | 21 +++ models/issues/issue_test.go | 2 + models/issues/issue_update.go | 4 + models/issues/reaction.go | 23 +-- models/issues/reaction_test.go | 15 +- models/repo/watch.go | 17 +++ models/repo/watch_test.go | 18 +++ models/user/block.go | 78 ++++++++++ models/user/block_test.go | 63 ++++++++ models/user/follow.go | 10 +- models/user/user_test.go | 6 +- options/locale/locale_en-US.ini | 9 ++ routers/api/v1/repo/issue.go | 6 +- routers/api/v1/repo/issue_comment.go | 6 +- routers/api/v1/repo/issue_reaction.go | 10 +- routers/api/v1/repo/pull.go | 5 +- routers/api/v1/user/follower.go | 4 +- routers/web/repo/issue.go | 9 +- routers/web/repo/pull.go | 6 +- routers/web/user/profile.go | 22 ++- routers/web/user/setting/blocked_users.go | 34 +++++ routers/web/web.go | 2 + services/issue/comments.go | 5 + services/issue/issue.go | 5 + services/issue/reaction.go | 47 ++++++ services/pull/pull.go | 5 + services/user/block.go | 40 ++++++ services/user/delete.go | 2 + templates/user/profile.tmpl | 30 ++++ templates/user/settings/blocked_users.tmpl | 16 +++ templates/user/settings/navbar.tmpl | 3 + tests/integration/block_test.go | 158 +++++++++++++++++++++ web_src/css/user.css | 6 +- 37 files changed, 656 insertions(+), 52 deletions(-) create mode 100644 models/fixtures/forgejo_blocked_user.yml create mode 100644 models/forgejo_migrations/v1_20/v1.go create mode 100644 models/user/block.go create mode 100644 models/user/block_test.go create mode 100644 routers/web/user/setting/blocked_users.go create mode 100644 services/issue/reaction.go create mode 100644 services/user/block.go create mode 100644 templates/user/settings/blocked_users.tmpl create mode 100644 tests/integration/block_test.go diff --git a/models/activities/action.go b/models/activities/action.go index 57f579372f..2e8a9c1de2 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -580,7 +580,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 75276a0443..b888294557 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/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/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 88bbef70c7..713db4a090 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -8,6 +8,7 @@ import ( "fmt" "os" + forgejo_v1_20 "code.gitea.io/gitea/models/forgejo_migrations/v1_20" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -34,7 +35,9 @@ func NewMigration(desc string, fn func(*xorm.Engine) error) *Migration { // This is a sequence of additional Forgejo migrations. // Add new migrations to the bottom of the list. -var migrations = []*Migration{} +var migrations = []*Migration{ + NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser), +} // GetCurrentDBVersion returns the current Forgejo database version. func GetCurrentDBVersion(x *xorm.Engine) (int64, error) { diff --git a/models/forgejo_migrations/v1_20/v1.go b/models/forgejo_migrations/v1_20/v1.go new file mode 100644 index 0000000000..1097613655 --- /dev/null +++ b/models/forgejo_migrations/v1_20/v1.go @@ -0,0 +1,21 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgejo_v1_20 //nolint:revive + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddForgejoBlockedUser(x *xorm.Engine) error { + type ForgejoBlockedUser struct { + ID int64 `xorm:"pk autoincr"` + BlockID int64 `xorm:"index"` + UserID int64 `xorm:"index"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + return x.Sync(new(ForgejoBlockedUser)) +} diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 7f1eab1971..186d3e18c5 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -452,6 +452,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("user17", "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 9453ddc085..58fea50e1d 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -608,9 +608,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) } @@ -644,8 +646,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 293dfa3fd1..a005b45364 100644 --- a/models/issues/reaction.go +++ b/models/issues/reaction.go @@ -218,12 +218,12 @@ type ReactionOptions struct { } // CreateReaction creates reaction for issue or comment. -func CreateReaction(opts *ReactionOptions) (*Reaction, error) { +func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { if !setting.UI.ReactionsLookup.Contains(opts.Type) { return nil, ErrForbiddenIssueReaction{opts.Type} } - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return nil, err } @@ -240,25 +240,6 @@ func CreateReaction(opts *ReactionOptions) (*Reaction, error) { return reaction, nil } -// CreateIssueReaction creates a reaction on issue. -func CreateIssueReaction(doerID, issueID int64, content string) (*Reaction, error) { - return CreateReaction(&ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - }) -} - -// CreateCommentReaction creates a reaction on comment. -func CreateCommentReaction(doerID, issueID, commentID int64, content string) (*Reaction, error) { - return CreateReaction(&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 ddd0e2d04c..80b4e64b95 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(doerID, issueID, content) - } else { - reaction, err = issues_model.CreateCommentReaction(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) } @@ -49,7 +52,7 @@ func TestIssueAddDuplicateReaction(t *testing.T) { addReaction(t, user1.ID, issue1ID, 0, "heart") - reaction, err := issues_model.CreateReaction(&issues_model.ReactionOptions{ + reaction, err := issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{ DoerID: user1.ID, IssueID: issue1ID, Type: "heart", diff --git a/models/repo/watch.go b/models/repo/watch.go index 00f313ca7c..6ff3a3f7b3 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 diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 8b8c6d6250..b6ae2a0ef5 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()) diff --git a/models/user/block.go b/models/user/block.go new file mode 100644 index 0000000000..64dd93ed38 --- /dev/null +++ b/models/user/block.go @@ -0,0 +1,78 @@ +// 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 +} + +// ListBlockedUsers returns the users that the user has blocked. +func ListBlockedUsers(ctx context.Context, userID int64) ([]*User, error) { + users := make([]*User, 0, 8) + err := db.GetEngine(ctx). + Select("`user`.*"). + Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id"). + Where("`forgejo_blocked_user`.user_id=?", userID). + Find(&users) + + return users, err +} + +// 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..d800eeaade --- /dev/null +++ b/models/user/block_test.go @@ -0,0 +1,63 @@ +// 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) + assert.NoError(t, err) + if assert.Len(t, blockedUsers, 1) { + assert.EqualValues(t, 1, blockedUsers[0].ID) + } +} + +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]) + } +} diff --git a/models/user/follow.go b/models/user/follow.go index 7efecc26a7..936efbc164 100644 --- a/models/user/follow.go +++ b/models/user/follow.go @@ -4,6 +4,8 @@ package user import ( + "context" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/timeutil" ) @@ -27,12 +29,12 @@ func IsFollowing(userID, followID int64) bool { } // FollowUser marks someone be another's follower. -func FollowUser(userID, followID int64) (err error) { +func FollowUser(ctx context.Context, userID, followID int64) (err error) { if userID == followID || IsFollowing(userID, followID) { return nil } - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } @@ -53,12 +55,12 @@ func FollowUser(userID, followID int64) (err error) { } // UnfollowUser unmarks someone as another's follower. -func UnfollowUser(userID, followID int64) (err error) { +func UnfollowUser(ctx context.Context, userID, followID int64) (err error) { if userID == followID || !IsFollowing(userID, followID) { return nil } - ctx, committer, err := db.TxContext(db.DefaultContext) + 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 44eaf63556..d5f0e80510 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -449,13 +449,13 @@ func TestFollowUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) testSuccess := func(followerID, followedID int64) { - assert.NoError(t, user_model.FollowUser(followerID, followedID)) + assert.NoError(t, user_model.FollowUser(db.DefaultContext, followerID, followedID)) unittest.AssertExistsAndLoadBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID}) } testSuccess(4, 2) testSuccess(5, 2) - assert.NoError(t, user_model.FollowUser(2, 2)) + assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2)) unittest.CheckConsistencyFor(t, &user_model.User{}) } @@ -464,7 +464,7 @@ func TestUnfollowUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) testSuccess := func(followerID, followedID int64) { - assert.NoError(t, user_model.UnfollowUser(followerID, followedID)) + assert.NoError(t, user_model.UnfollowUser(db.DefaultContext, followerID, followedID)) unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID}) } testSuccess(4, 2) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 9437714f41..b346971397 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -590,11 +590,17 @@ overview = Overview following = Following follow = Follow unfollow = Unfollow +block = Block +unblock = Unblock heatmap.loading = Loading Heatmap… 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 email_visibility.private = Your email address is only visible to you and administrators +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. form.name_reserved = The username "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username. @@ -618,6 +624,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 @@ -1629,6 +1636,7 @@ 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. compare.compare_base = base compare.compare_head = compare @@ -1701,6 +1709,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. diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 49252f7a4b..3438443a2f 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" @@ -652,7 +653,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 c2392126db..2c03c7075e 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -364,7 +364,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 921f6e53f9..12be5c388f 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 @@ -196,9 +198,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp if isCreateType { // PostIssueCommentReaction part - reaction, err := issues_model.CreateCommentReaction(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{ @@ -406,9 +408,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i if isCreateType { // PostIssueReaction part - reaction, err := issues_model.CreateIssueReaction(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 a507c1f44d..91e6527d85 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -418,7 +418,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/user/follower.go b/routers/api/v1/user/follower.go index bc03b22ea7..364507711d 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -218,7 +218,7 @@ func Follow(ctx *context.APIContext) { // "204": // "$ref": "#/responses/empty" - if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { ctx.Error(http.StatusInternalServerError, "FollowUser", err) return } @@ -240,7 +240,7 @@ func Unfollow(ctx *context.APIContext) { // "204": // "$ref": "#/responses/empty" - if err := user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { ctx.Error(http.StatusInternalServerError, "UnfollowUser", err) return } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index d436abba2f..140b437574 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1162,7 +1162,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 } @@ -3061,7 +3064,7 @@ func ChangeIssueReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateIssueReaction(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) @@ -3163,7 +3166,7 @@ func ChangeCommentReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateCommentReaction(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 e570c1753e..29f12187ab 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1271,7 +1271,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/user/profile.go b/routers/web/user/profile.go index 6f9f84d60d..d91bf23f91 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/org" + user_service "code.gitea.io/gitea/services/user" ) // Profile render user's profile page @@ -58,8 +59,10 @@ func Profile(ctx *context.Context) { } var isFollowing bool + var isBlocked bool if ctx.Doer != nil { isFollowing = user_model.IsFollowing(ctx.Doer.ID, ctx.ContextUser.ID) + isBlocked = user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID) } ctx.Data["Title"] = ctx.ContextUser.DisplayName() @@ -67,6 +70,7 @@ func Profile(ctx *context.Context) { ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["OpenIDs"] = openIDs ctx.Data["IsFollowing"] = isFollowing + ctx.Data["IsBlocked"] = isBlocked if setting.Service.EnableUserHeatmap { data, err := activities_model.GetUserHeatmapDataByUser(ctx.ContextUser, ctx.Doer) @@ -351,17 +355,31 @@ func Profile(ctx *context.Context) { // Action response for follow/unfollow user request func Action(ctx *context.Context) { var err error + var redirectViaJSON bool switch ctx.FormString("action") { case "follow": - err = user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID) + err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) case "unfollow": - err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID) + 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 { ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err) return } + + if redirectViaJSON { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.ContextUser.HomeLink(), + }) + return + } + // FIXME: We should check this URL and make sure that it's a valid Gitea URL ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.ContextUser.HomeLink()) } diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go new file mode 100644 index 0000000000..ea6ccf74d9 --- /dev/null +++ b/routers/web/user/setting/blocked_users.go @@ -0,0 +1,34 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + 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) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} diff --git a/routers/web/web.go b/routers/web/web.go index 1b5bc2e342..46f4080d7d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -516,6 +516,8 @@ func registerRoutes(m *web.Route) { }) addWebhookEditRoutes() }, webhooksEnabled) + + m.Get("/blocked_users", user_setting.BlockedUsers) }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) m.Group("/user", func() { diff --git a/services/issue/comments.go b/services/issue/comments.go index 4a181499bc..6bc4453aa0 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -66,6 +66,11 @@ func CreateRefComment(doer *user_model.User, repo *repo_model.Repository, issue // 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 := CreateComment(ctx, &issues_model.CreateCommentOptions{ Type: issues_model.CommentTypeComment, Doer: doer, diff --git a/services/issue/issue.go b/services/issue/issue.go index ce2b1c0976..0b2539c3fa 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -22,6 +22,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(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 8f2befa8ff..26107096de 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -36,6 +36,11 @@ var pullWorkingPool = sync.NewExclusivePool() // NewPullRequest creates new pull request with labels for repository. func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *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, pull.PosterID) { + return user_model.ErrBlockedByUser + } + if err := TestPatch(pr); err != nil { return err } diff --git a/services/user/block.go b/services/user/block.go new file mode 100644 index 0000000000..eff3242784 --- /dev/null +++ b/services/user/block.go @@ -0,0 +1,40 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" + 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 block's perspective. + err = user_model.UnfollowUser(ctx, blockID, userID) + if err != nil { + return err + } + + return committer.Commit() +} 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/user/profile.tmpl b/templates/user/profile.tmpl index 51d4a46a92..58aab7167c 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -115,6 +115,21 @@ {{end}} +
  • + {{if $.IsBlocked}} +
    + {{$.CsrfTokenHtml}} + +
    + {{else}} +
    + +
    + {{end}} +
  • {{end}} @@ -156,4 +171,19 @@ + + {{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..fd0cb07883 --- /dev/null +++ b/templates/user/settings/blocked_users.tmpl @@ -0,0 +1,16 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings blocked_users")}} +
    +

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

    +
    +
    + {{range .BlockedUsers}} +
    + {{avatar $.Context . 28 "gt-mr-3"}}{{.Name}} +
    + {{end}} +
    +
    +
    +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 4ef2abeaab..e03ad7e9a9 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -48,5 +48,8 @@ {{.locale.Tr "settings.repos"}} + + {{.locale.Tr "settings.blocked_users"}} + diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go new file mode 100644 index 0000000000..03a5b14712 --- /dev/null +++ b/tests/integration/block_test.go @@ -0,0 +1,158 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "testing" + + "code.gitea.io/gitea/models/db" + 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" + "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.EqualValues(t, true, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) +} + +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}) + BlockUser(t, doer, blockedUser) + + // Unblock user. + session := loginUser(t, doer.Name) + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "unblock", + }) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + + loc := resp.Header().Get("Location") + assert.EqualValues(t, "/"+blockedUser.Name, loc) + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}) +} + +func TestBlockIssueCreation(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}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID}) + BlockUser(t, doer, blockedUser) + + session := loginUser(t, blockedUser.Name) + req := NewRequest(t, "GET", "/"+repo.OwnerName+"/"+repo.Name+"/issues/new") + resp := session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action") + assert.True(t, exists) + req = NewRequestWithValues(t, "POST", link, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "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"), + ) +} + +func TestBlockIssueReaction(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}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, PosterID: doer.ID, RepoID: repo.ID}) + issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), issue.Index) + + BlockUser(t, doer, blockedUser) + + session := loginUser(t, blockedUser.Name) + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/react"), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": "eyes", + }) + resp = session.MakeRequest(t, req, http.StatusOK) + type reactionResponse struct { + Empty bool `json:"empty"` + } + + var respBody reactionResponse + DecodeJSON(t, resp, &respBody) + + assert.EqualValues(t, true, respBody.Empty) +} + +func TestBlockCommentReaction(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 1, RepoID: repo.ID}) + comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 3, PosterID: doer.ID, IssueID: issue.ID}) + _ = comment.LoadIssue(db.DefaultContext) + issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), issue.Index) + + BlockUser(t, doer, blockedUser) + + session := loginUser(t, blockedUser.Name) + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + req = NewRequestWithValues(t, "POST", path.Join(repo.Link(), "/comments/", strconv.FormatInt(comment.ID, 10), "/reactions/react"), map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + "content": "eyes", + }) + resp = session.MakeRequest(t, req, http.StatusOK) + type reactionResponse struct { + Empty bool `json:"empty"` + } + + var respBody reactionResponse + DecodeJSON(t, resp, &respBody) + + assert.EqualValues(t, true, respBody.Empty) +} diff --git a/web_src/css/user.css b/web_src/css/user.css index 54a966e7cb..71b4232b58 100644 --- a/web_src/css/user.css +++ b/web_src/css/user.css @@ -34,7 +34,11 @@ margin-right: 5px; } -.user.profile .ui.card .extra.content > ul > li.follow .ui.button { +.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%; } From cdf6318f5169e5639c4ef13c11678500df79ed69 Mon Sep 17 00:00:00 2001 From: Gusted Date: Fri, 9 Jun 2023 08:07:03 +0000 Subject: [PATCH 2/3] [MODERATION] organization blocking a user (#802) - Resolves #476 - Follow up for: #540 - Ensure that the doer and blocked person cannot follow each other. - Ensure that the block person cannot watch doer's repositories. - Add unblock button to the blocked user list. - Add blocked since information to the blocked user list. - Add extra testing to moderation code. - Blocked user will unwatch doer's owned repository upon blocking. - Add flash messages to let the user know the block/unblock action was successful. - Add "You haven't blocked any users" message. - Add organization blocking a user. Co-authored-by: Gusted Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/802 (cherry picked from commit 0505a1042197bd9136b58bc70ec7400a23471585) (cherry picked from commit 37b4e6ef9b85e97d651cf350c9f3ea272ee8d76a) (cherry picked from commit 217475385a815298dcbd8029e0cc8cb2c5877bae) (cherry picked from commit f2c38ce5c2f6cf4008aa1929539063715b50562c) (cherry picked from commit 1edfb68137d8c322a7a9a7c7196fc8f01ff1a889) (cherry picked from commit 2cbc12dc740e6fefc196b7fea6ac8a0ffbbfbeef) (cherry picked from commit 79ff020f182327986dcfd874bc49d4fe32efc29a) --- models/fixtures/repository.yml | 2 +- models/fixtures/watch.yml | 8 ++- models/repo/user_repo.go | 13 +++++ models/repo/user_repo_test.go | 13 +++++ models/repo/watch.go | 6 +++ models/repo/watch_test.go | 13 +++++ models/user/block.go | 4 +- models/user/follow.go | 15 ++++-- models/user/user_test.go | 6 +++ options/locale/locale_en-US.ini | 7 +++ routers/api/v1/user/follower.go | 7 +++ routers/web/org/setting/blocked_users.go | 61 ++++++++++++++++++++++ routers/web/user/profile.go | 13 ++++- routers/web/user/setting/blocked_users.go | 11 ++++ routers/web/web.go | 11 +++- services/user/block.go | 20 ++++++- services/user/block_test.go | 41 +++++++++++++++ templates/org/home.tmpl | 5 ++ templates/org/settings/blocked_users.tmpl | 40 ++++++++++++++ templates/org/settings/navbar.tmpl | 3 ++ templates/swagger/v1_json.tmpl | 3 ++ templates/user/profile.tmpl | 1 + templates/user/settings/blocked_users.tmpl | 17 +++++- tests/integration/api_user_follow_test.go | 2 +- tests/integration/block_test.go | 56 +++++++++++++++++++- web_src/css/org.css | 15 ++++-- 26 files changed, 375 insertions(+), 18 deletions(-) create mode 100644 routers/web/org/setting/blocked_users.go create mode 100644 services/user/block_test.go create mode 100644 templates/org/settings/blocked_users.tmpl diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 050a9e2d06..d50c89838d 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 c29f6bb65a..c6c9726cc8 100644 --- a/models/fixtures/watch.yml +++ b/models/fixtures/watch.yml @@ -26,4 +26,10 @@ id: 5 user_id: 11 repo_id: 1 - mode: 3 # auto + mode: 3 # auto + +- + id: 6 + user_id: 4 + repo_id: 2 + mode: 1 # normal 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 6ff3a3f7b3..53b35c0e51 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -201,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 b6ae2a0ef5..02d0d3b0dd 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -155,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) { assert.NoError(t, repo_model.WatchRepoMode(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 index 64dd93ed38..838bc7431e 100644 --- a/models/user/block.go +++ b/models/user/block.go @@ -53,10 +53,12 @@ func UnblockUser(ctx context.Context, userID, blockID int64) error { } // 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) ([]*User, error) { users := make([]*User, 0, 8) err := db.GetEngine(ctx). - Select("`user`.*"). + 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). Find(&users) diff --git a/models/user/follow.go b/models/user/follow.go index 936efbc164..5b3ff489ca 100644 --- a/models/user/follow.go +++ b/models/user/follow.go @@ -24,16 +24,25 @@ func init() { // IsFollowing returns true if user is following followID. func IsFollowing(userID, followID int64) bool { - has, _ := db.GetEngine(db.DefaultContext).Get(&Follow{UserID: userID, FollowID: followID}) + return IsFollowingCtx(db.DefaultContext, userID, followID) +} + +// IsFollowingCtx returns true if user is following followID. +func IsFollowingCtx(ctx context.Context, userID, followID int64) bool { + has, _ := db.GetEngine(ctx).Get(&Follow{UserID: userID, FollowID: followID}) return has } // FollowUser marks someone be another's follower. func FollowUser(ctx context.Context, userID, followID int64) (err error) { - if userID == followID || IsFollowing(userID, followID) { + if userID == followID || IsFollowingCtx(ctx, userID, followID) { return nil } + if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) { + return ErrBlockedByUser + } + ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -56,7 +65,7 @@ func FollowUser(ctx context.Context, userID, followID int64) (err error) { // UnfollowUser unmarks someone as another's follower. func UnfollowUser(ctx context.Context, userID, followID int64) (err error) { - if userID == followID || !IsFollowing(userID, followID) { + if userID == followID || !IsFollowingCtx(ctx, userID, followID) { return nil } diff --git a/models/user/user_test.go b/models/user/user_test.go index d5f0e80510..e21e9ad52e 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/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index b346971397..d9592b9663 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -601,6 +601,7 @@ 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. +follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you. form.name_reserved = The username "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username. @@ -892,6 +893,7 @@ hooks.desc = Add webhooks which will be triggered for all repositoriesCANNOT be undone. @@ -914,6 +916,10 @@ visibility.limited_tooltip = Visible to authenticated users only visibility.private = Private visibility.private_tooltip = Visible only to organization members +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 have it elsewhere? Migrate repository. owner = Owner @@ -2524,6 +2530,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/user/follower.go b/routers/api/v1/user/follower.go index 364507711d..bc1e6724f7 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" @@ -217,8 +218,14 @@ func Follow(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "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/web/org/setting/blocked_users.go b/routers/web/org/setting/blocked_users.go new file mode 100644 index 0000000000..eae6f81fa0 --- /dev/null +++ b/routers/web/org/setting/blocked_users.go @@ -0,0 +1,61 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strings" + + 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) + 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 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) { + if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, ctx.FormInt64("user_id")); err != nil { + ctx.ServerError("BlockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users") +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index d91bf23f91..d103ed4ae2 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" @@ -369,8 +370,16 @@ func Action(ctx *context.Context) { } if err != nil { - ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err) - return + if !errors.Is(err, user_model.ErrBlockedByUser) { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err) + 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 { diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go index ea6ccf74d9..134becf969 100644 --- a/routers/web/user/setting/blocked_users.go +++ b/routers/web/user/setting/blocked_users.go @@ -32,3 +32,14 @@ func BlockedUsers(ctx *context.Context) { 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 46f4080d7d..8e64be56c3 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -517,7 +517,10 @@ func registerRoutes(m *web.Route) { addWebhookEditRoutes() }, webhooksEnabled) - m.Get("/blocked_users", user_setting.BlockedUsers) + 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() { @@ -770,6 +773,12 @@ func registerRoutes(m *web.Route) { addSettingsSecretsRoutes() }, actions.MustEnableActions) + m.Group("/blocked_users", func() { + m.Get("", org_setting.BlockedUsers) + m.Post("/block", org_setting.BlockedUsersBlock) + m.Post("/unblock", org_setting.BlockedUsersUnblock) + }) + m.RouteMethods("/delete", "GET,POST", org.SettingsDelete) m.Group("/packages", func() { diff --git a/services/user/block.go b/services/user/block.go index eff3242784..05f9a376a7 100644 --- a/services/user/block.go +++ b/services/user/block.go @@ -6,6 +6,7 @@ import ( "context" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" ) @@ -30,11 +31,28 @@ func BlockUser(ctx context.Context, userID, blockID int64) error { return err } - // Unfollow the user from block's perspective. + // 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 + } + return committer.Commit() } diff --git a/services/user/block_test.go b/services/user/block_test.go new file mode 100644 index 0000000000..8a0a3c4739 --- /dev/null +++ b/services/user/block_test.go @@ -0,0 +1,41 @@ +// 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}) + + // 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)) + + // 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 they aren't following each other anymore. + assert.False(t, user_model.IsFollowing(doer.ID, blockedUser.ID)) + assert.False(t, user_model.IsFollowing(blockedUser.ID, doer.ID)) + + // Ensure blocked user isn't following doer's repository. + assert.False(t, repo_model.IsWatching(blockedUser.ID, repo.ID)) +} diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index d540f80352..2e53385f36 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -1,5 +1,10 @@ {{template "base/head" .}}
    + {{if .Flash}} +
    + {{template "base/alert" .}} +
    + {{end}}
    {{avatar $.Context .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..e551ee5d67 --- /dev/null +++ b/templates/org/settings/blocked_users.tmpl @@ -0,0 +1,40 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked-users")}} +
    +
    +
    + {{.CsrfTokenHtml}} + +
    + +
    + +
    +
    +
    + {{range .BlockedUsers}} +
    + {{avatar $.Context . 48 "gt-mr-3 gt-mb-0"}} +
    + {{.Name}} + {{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}} +
    +
    +
    + {{$.CsrfTokenHtml}} + + +
    +
    +
    + {{else}} +
    + {{$.locale.Tr "settings.blocked_users_none"}} +
    + {{end}} +
    +
    +{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 6bea9f5f60..20c3279b7b 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -35,6 +35,9 @@
    {{end}} + + {{.locale.Tr "settings.blocked_users"}} + {{.locale.Tr "org.settings.delete"}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e7d1cc1fe5..d1f21b3cf9 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13966,6 +13966,9 @@ "responses": { "204": { "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" } } }, diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 58aab7167c..ddce4bbdab 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -1,6 +1,7 @@ {{template "base/head" .}}
    + {{template "base/alert" .}}
    diff --git a/templates/user/settings/blocked_users.tmpl b/templates/user/settings/blocked_users.tmpl index fd0cb07883..dc90970ec2 100644 --- a/templates/user/settings/blocked_users.tmpl +++ b/templates/user/settings/blocked_users.tmpl @@ -6,8 +6,23 @@
    {{range .BlockedUsers}} +
    + {{avatar $.Context . 28 "gt-mr-3"}} +
    + {{.Name}} + {{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}} +
    +
    +
    + {{$.CsrfTokenHtml}} + + +
    +
    +
    + {{else}}
    - {{avatar $.Context . 28 "gt-mr-3"}}{{.Name}} + {{$.locale.Tr "settings.blocked_users_none"}}
    {{end}}
    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 index 03a5b14712..b8001f4968 100644 --- a/tests/integration/block_test.go +++ b/tests/integration/block_test.go @@ -41,7 +41,7 @@ func BlockUser(t *testing.T, doer, blockedUser *user_model.User) { var respBody redirect DecodeJSON(t, resp, &respBody) assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect) - assert.EqualValues(t, true, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) + assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) } func TestBlockUser(t *testing.T) { @@ -156,3 +156,57 @@ func TestBlockCommentReaction(t *testing.T) { assert.EqualValues(t, true, respBody.Empty) } + +// TestBlockFollow ensures that the doer and blocked user cannot follow each other. +func TestBlockFollow(t *testing.T) { + defer tests.PrepareTestEnv(t)() + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + BlockUser(t, doer, blockedUser) + + // Doer cannot follow blocked user. + 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.StatusSeeOther) + + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID}) + + // Blocked user cannot follow doer. + 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.StatusSeeOther) + + unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID}) +} + +// 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) + 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})) + + 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}) +} diff --git a/web_src/css/org.css b/web_src/css/org.css index 9711ed25ad..30322a2c99 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -191,30 +191,35 @@ } .organization.teams .repositories .item, -.organization.teams .members .item { +.organization.teams .members .item, +.organization.settings .blocked-users .item { padding: 10px 19px; } .organization.teams .repositories .item:not(:last-child), -.organization.teams .members .item:not(:last-child) { +.organization.teams .members .item:not(:last-child), +.organization.settings .blocked-users .item:not(:last-child) { border-bottom: 1px solid var(--color-secondary); } .organization.teams .repositories .item .button, -.organization.teams .members .item .button { +.organization.teams .members .item .button, +.organization.settings .blocked-users .item button { padding: 9px 10px; margin: 0; } .organization.teams #add-repo-form input, .organization.teams #repo-multiple-form input, -.organization.teams #add-member-form input { +.organization.teams #add-member-form input, +.organization.settings #block-user-form input { margin-left: 0; } .organization.teams #add-repo-form .ui.button, .organization.teams #repo-multiple-form .ui.button, -.organization.teams #add-member-form .ui.button { +.organization.teams #add-member-form .ui.button, +.organization.settings #block-user-form .ui.button { margin-left: 5px; margin-top: -3px; } From 73776d61958cbc4929c485f18da2c0a91237291d Mon Sep 17 00:00:00 2001 From: Gusted Date: Fri, 9 Jun 2023 22:18:15 +0200 Subject: [PATCH 3/3] [MODERATION] add user blocking API - Follow up for: #540, #802 - Add API routes for user blocking from user and organization perspective. - The new routes have integration testing. - The new model functions have unit tests. - Actually quite boring to write and to read this pull request. (cherry picked from commit f3afaf15c7e34038363c9ce8e1ef957ec1e22b06) (cherry picked from commit 6d754db3e5faff93a58fab2867737f81f40f6599) (cherry picked from commit d0fc8bc9d3b6bb189a2ab634a5329253af9b4629) (cherry picked from commit 9a53b0d1a07455596622cb02716b476b6aaa95e4) (cherry picked from commit 44a2a4fd48678058777d6db46c13a2c7298497d4) (cherry picked from commit 182025db9cc76073bdb0221dfd1fb3b2b66f7fd4) (cherry picked from commit 558a35963eddd672f1911393a649ab08a9283e5b) --- models/user/block.go | 23 ++- models/user/block_test.go | 14 +- modules/structs/moderation.go | 13 ++ routers/api/v1/api.go | 12 ++ routers/api/v1/org/org.go | 92 +++++++++ routers/api/v1/swagger/repo.go | 7 + routers/api/v1/user/user.go | 77 ++++++++ routers/api/v1/utils/block.go | 65 +++++++ routers/web/org/setting/blocked_users.go | 3 +- routers/web/user/setting/blocked_users.go | 3 +- templates/swagger/v1_json.tmpl | 225 ++++++++++++++++++++++ tests/integration/api_block_test.go | 101 ++++++++++ 12 files changed, 626 insertions(+), 9 deletions(-) create mode 100644 modules/structs/moderation.go create mode 100644 routers/api/v1/utils/block.go create mode 100644 tests/integration/api_block_test.go diff --git a/models/user/block.go b/models/user/block.go index 838bc7431e..189cacc2a2 100644 --- a/models/user/block.go +++ b/models/user/block.go @@ -52,18 +52,29 @@ func UnblockUser(ctx context.Context, userID, blockID int64) error { 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) ([]*User, error) { - users := make([]*User, 0, 8) - err := db.GetEngine(ctx). +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). - Find(&users) + Where("`forgejo_blocked_user`.user_id=?", userID) - return users, err + 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. diff --git a/models/user/block_test.go b/models/user/block_test.go index d800eeaade..a368bb35d7 100644 --- a/models/user/block_test.go +++ b/models/user/block_test.go @@ -45,7 +45,7 @@ func TestUnblockUser(t *testing.T) { func TestListBlockedUsers(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - blockedUsers, err := user_model.ListBlockedUsers(db.DefaultContext, 4) + 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) @@ -61,3 +61,15 @@ func TestListBlockedByUsersID(t *testing.T) { 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/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/routers/api/v1/api.go b/routers/api/v1/api.go index 3a1203628a..2b31b46e70 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -905,6 +905,12 @@ func Routes(ctx gocontext.Context) *web.Route { Patch(bind(api.EditHookOption{}), user.EditHook). Delete(user.DeleteHook) }, reqWebhooksEnabled()) + + m.Group("", func() { + m.Get("/list_blocked", user.ListBlockedUsers) + m.Put("/block/{username}", user.BlockUser) + m.Put("/unblock/{username}", user.UnblockUser) + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Repositories (requires repo scope, org scope) @@ -1321,6 +1327,12 @@ func Routes(ctx gocontext.Context) *web.Route { Delete(org.DeleteHook) }, reqToken(), reqOrgOwnership(), reqWebhooksEnabled()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) + + m.Group("", func() { + m.Get("/list_blocked", reqToken(), reqOrgOwnership(), org.ListBlockedUsers) + m.Put("/block/{username}", reqToken(), reqOrgOwnership(), org.BlockUser) + m.Put("/unblock/{username}", reqToken(), reqOrgOwnership(), org.UnblockUser) + }, 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 4e30ad1762..3a297b1b9d 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -437,3 +437,95 @@ 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" + + user := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + + utils.BlockUser(ctx, ctx.Org.Organization.AsUser(), user) +} + +// 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" + + user := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + + utils.UnblockUser(ctx, ctx.Org.Organization.AsUser(), user) +} 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/user.go b/routers/api/v1/user/user.go index 2a2361be67..595ca04d62 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -202,3 +202,80 @@ 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" + + user := GetUserByParams(ctx) + if ctx.Written() { + return + } + + utils.BlockUser(ctx, ctx.Doer, user) +} + +// 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" + + user := GetUserByParams(ctx) + if ctx.Written() { + return + } + + utils.UnblockUser(ctx, ctx.Doer, user) +} 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 index eae6f81fa0..ebec1f1df5 100644 --- a/routers/web/org/setting/blocked_users.go +++ b/routers/web/org/setting/blocked_users.go @@ -7,6 +7,7 @@ import ( "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" @@ -20,7 +21,7 @@ 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) + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{}) if err != nil { ctx.ServerError("ListBlockedUsers", err) return diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go index 134becf969..ed1c340fb9 100644 --- a/routers/web/user/setting/blocked_users.go +++ b/routers/web/user/setting/blocked_users.go @@ -6,6 +6,7 @@ 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" @@ -23,7 +24,7 @@ func BlockedUsers(ctx *context.Context) { 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) + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{}) if err != nil { ctx.ServerError("ListBlockedUsers", err) return diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d1f21b3cf9..fef00324aa 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1595,6 +1595,42 @@ } } }, + "/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" + } + } + } + }, "/orgs/{org}/hooks": { "get": { "produces": [ @@ -1959,6 +1995,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": [ @@ -2423,6 +2497,42 @@ } } }, + "/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" + } + } + } + }, "/packages/{owner}": { "get": { "produces": [ @@ -13790,6 +13900,35 @@ } } }, + "/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" + } + } + } + }, "/user/emails": { "get": { "produces": [ @@ -14439,6 +14578,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": [ @@ -14840,6 +15010,35 @@ } } }, + "/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" + } + } + } + }, "/users/search": { "get": { "produces": [ @@ -15770,6 +15969,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", @@ -21953,6 +22169,15 @@ } } }, + "BlockedUserList": { + "description": "BlockedUserList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/BlockedUser" + } + } + }, "Branch": { "description": "Branch", "schema": { diff --git a/tests/integration/api_block_test.go b/tests/integration/api_block_test.go new file mode 100644 index 0000000000..2118080c7a --- /dev/null +++ b/tests/integration/api_block_test.go @@ -0,0 +1,101 @@ +// 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" + "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}) + }) +} + +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}) + }) +}