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 57f579372..2e8a9c1de 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 75276a044..b88829455 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 000000000..88c378a84 --- /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 88bbef70c..713db4a09 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 000000000..109761365 --- /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 7f1eab197..186d3e18c 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 9453ddc08..58fea50e1 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 293dfa3fd..a005b4536 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 ddd0e2d04..80b4e64b9 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 00f313ca7..6ff3a3f7b 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 8b8c6d625..b6ae2a0ef 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 000000000..64dd93ed3 --- /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 000000000..d800eeaad --- /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 7efecc26a..936efbc16 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 44eaf6355..d5f0e8051 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 9437714f4..b34697139 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 49252f7a4..3438443a2 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 c2392126d..2c03c7075 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 921f6e53f..12be5c388 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 a507c1f44..91e6527d8 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 bc03b22ea..364507711 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 d436abba2..140b43757 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 e570c1753..29f12187a 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 6f9f84d60..d91bf23f9 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 000000000..ea6ccf74d --- /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 1b5bc2e34..46f4080d7 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 4a181499b..6bc4453aa 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 ce2b1c097..0b2539c3f 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 000000000..dbb4735de --- /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 8f2befa8f..26107096d 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 000000000..eff324278 --- /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 01e3c37b3..0f33d712a 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 51d4a46a9..58aab7167 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 000000000..fd0cb0788 --- /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 4ef2abeaa..e03ad7e9a 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 000000000..03a5b1471 --- /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 54a966e7c..71b4232b5 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 050a9e2d0..d50c89838 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 c29f6bb65..c6c9726cc 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 dd2ef6220..5d6e24e2a 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 7816b0262..ad794beb9 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 6ff3a3f7b..53b35c0e5 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 b6ae2a0ef..02d0d3b0d 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 64dd93ed3..838bc7431 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 936efbc16..5b3ff489c 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 d5f0e8051..e21e9ad52 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 b34697139..d9592b966 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 364507711..bc1e6724f 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 000000000..eae6f81fa --- /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 d91bf23f9..d103ed4ae 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 ea6ccf74d..134becf96 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 46f4080d7..8e64be56c 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 eff324278..05f9a376a 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 000000000..8a0a3c473 --- /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 d540f8035..2e53385f3 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 000000000..e551ee5d6 --- /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 6bea9f5f6..20c3279b7 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 e7d1cc1fe..d1f21b3cf 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 58aab7167..ddce4bbda 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 fd0cb0788..dc90970ec 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 62717af90..bf6560b10 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 03a5b1471..b8001f496 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 9711ed25a..30322a2c9 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 838bc7431..189cacc2a 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 d800eeaad..a368bb35d 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 000000000..c1e55085a --- /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 3a1203628..2b31b46e7 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 4e30ad176..3a297b1b9 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 3e23aa4d5..263e33587 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 2a2361be6..595ca04d6 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 000000000..187d69044 --- /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 eae6f81fa..ebec1f1df 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 134becf96..ed1c340fb 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 d1f21b3cf..fef00324a 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 000000000..2118080c7 --- /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}) + }) +}