From e53f955c888ebaafc863a6e463da87f70f5605da Mon Sep 17 00:00:00 2001 From: Gusted Date: Sun, 12 Mar 2023 13:28:18 +0100 Subject: [PATCH] [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 37858b7e8fb6ba6c6ea0ac2562285b3b144efa19) (cherry picked from commit a3e2bfd7e9eab82cc2c17061f6bb4e386a108c46) (cherry picked from commit 7009b9fe87696b6182fab65ae82bf5a25cd39971) Conflicts: https://codeberg.org/forgejo/forgejo/pulls/1014 routers/web/user/profile.go templates/user/profile.tmpl (cherry picked from commit b2aec3479177e725cfc7cbbb9d94753226928d1c) (cherry picked from commit e2f1b73752f6bd3f830297d8f4ac438837471226) [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 c17c121f2cf1f00e2a8d6fd6847705df47d0771e) [MODERATION] organization blocking a user (#802) (squash) Changes to adapt to: 6bbccdd177 Improve AJAX link and modal confirm dialog (#25210) Refs: https://codeberg.org/forgejo/forgejo/pulls/882/files#issuecomment-945962 Refs: https://codeberg.org/forgejo/forgejo/pulls/882#issue-330561 (cherry picked from commit 523635f83cb2a1a4386769b79326088c5c4bbec7) (cherry picked from commit 4743eaa6a0be0ef47de5b17c211dfe8bad1b7af9) (cherry picked from commit eff5b43d2e843d5d537756d4fa58a8a010b6b527) Conflicts: https://codeberg.org/forgejo/forgejo/pulls/1014 routers/web/user/profile.go (cherry picked from commit 9d359be5ed11237088ccf6328571939af814984e) (cherry picked from commit b1f3069a22a03734cffbfcd503ce004ba47561b7) [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 2a89ddc0acffa9aea0f02b721934ef9e2b496a88) (cherry picked from commit 4a147bff7e963ab9dffcfaefa5c2c01c59b4c732) Conflicts: routers/api/v1/api.go templates/swagger/v1_json.tmpl (cherry picked from commit bb8c33918569f65f25b014f0d7fe6ac20f9036fc) (cherry picked from commit 5a11569a011b7d0a14391e2b5c07d0af825d7b0e) (cherry picked from commit 2373c801ee6b84c368b498b16e6ad18650b38f42) [MODERATION] restore redirect on unblock ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.ContextUser.HomeLink()) was replaced by ctx.JSONOK() in 128d77a3a Following up fixes for "Fix inconsistent user profile layout across tabs" (#25739) thus changing the behavior (nicely spotted by the tests). This restores it. (cherry picked from commit 597c243707c3c86e7256faf1e6ba727224554de3) (cherry picked from commit cfa539e590127b4b953b010fba3dea21c82a1714) [MODERATION] Add test case (squash) - Add an test case, to test an property of the function. (cherry picked from commit 70dadb1916bfef8ba8cbc4e9b042cc8740f45e28) [MODERATION] Block adding collaborators - Ensure that the doer and blocked user cannot add each other as collaborators to repositories. - The Web UI gets an detailed message of the specific situation, the API gets an generic Forbidden code. - Unit tests has been added. - Integration testing for Web and API has been added. - This commit doesn't introduce removing each other as collaborators on the block action, due to the complexity of database calls that needs to be figured out. That deserves its own commit and test code. (cherry picked from commit 747be949a1b3cd06f6586512f1af4630e55d7ad4) [MODERATION] move locale_en-US.ini strings to avoid conflicts Conflicts: web_src/css/org.css web_src/css/user.css https://codeberg.org/forgejo/forgejo/pulls/1180 --- models/activities/action.go | 2 +- models/activities/notification.go | 9 + models/fixtures/forgejo_blocked_user.yml | 5 + models/fixtures/repository.yml | 2 +- models/fixtures/watch.yml | 8 +- 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/user_repo.go | 13 + models/repo/user_repo_test.go | 13 + models/repo/watch.go | 23 ++ models/repo/watch_test.go | 31 +++ models/user/block.go | 91 +++++++ models/user/block_test.go | 77 ++++++ models/user/follow.go | 25 +- models/user/user_test.go | 12 +- modules/repository/collaborator.go | 4 + modules/repository/collaborator_test.go | 27 ++ modules/structs/moderation.go | 13 + options/locale/locale_en-US.ini | 19 ++ routers/api/v1/api.go | 12 + routers/api/v1/org/org.go | 92 +++++++ routers/api/v1/repo/collaborators.go | 8 +- 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/swagger/repo.go | 7 + routers/api/v1/user/follower.go | 11 +- routers/api/v1/user/user.go | 77 ++++++ routers/api/v1/utils/block.go | 65 +++++ routers/web/org/setting/blocked_users.go | 62 +++++ routers/web/repo/issue.go | 9 +- routers/web/repo/pull.go | 6 +- routers/web/repo/setting/collaboration.go | 14 +- routers/web/repo/setting/settings_test.go | 12 +- routers/web/shared/user/header.go | 1 + routers/web/user/profile.go | 33 ++- routers/web/user/setting/blocked_users.go | 46 ++++ routers/web/web.go | 11 + services/issue/comments.go | 5 + services/issue/issue.go | 5 + services/issue/reaction.go | 47 ++++ services/pull/pull.go | 5 + services/user/block.go | 58 ++++ services/user/block_test.go | 41 +++ services/user/delete.go | 2 + templates/org/home.tmpl | 5 + templates/org/settings/blocked_users.tmpl | 40 +++ templates/org/settings/navbar.tmpl | 3 + templates/shared/user/profile_big_avatar.tmpl | 12 + templates/swagger/v1_json.tmpl | 231 ++++++++++++++++ templates/user/profile.tmpl | 17 ++ templates/user/settings/blocked_users.tmpl | 31 +++ templates/user/settings/navbar.tmpl | 3 + tests/integration/api_block_test.go | 141 ++++++++++ tests/integration/api_user_follow_test.go | 2 +- tests/integration/block_test.go | 257 ++++++++++++++++++ web_src/css/org.css | 19 ++ web_src/css/user.css | 13 + 63 files changed, 1808 insertions(+), 66 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 modules/structs/moderation.go create mode 100644 routers/api/v1/utils/block.go create mode 100644 routers/web/org/setting/blocked_users.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 services/user/block_test.go create mode 100644 templates/org/settings/blocked_users.tmpl create mode 100644 templates/user/settings/blocked_users.tmpl create mode 100644 tests/integration/api_block_test.go create mode 100644 tests/integration/block_test.go diff --git a/models/activities/action.go b/models/activities/action.go index 7f22605d0d..c19d142628 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 e0af2ee8bb..7da064fee7 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -235,6 +235,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n for _, id := range issueUnWatches { toNotify.Remove(id) } + + // Remove users who have the notification author blocked. + blockedAuthorIDs, err := user_model.ListBlockedByUsersID(ctx, notificationAuthorID) + if err != nil { + return err + } + for _, id := range blockedAuthorIDs { + toNotify.Remove(id) + } } err = issue.LoadRepo(ctx) diff --git a/models/fixtures/forgejo_blocked_user.yml b/models/fixtures/forgejo_blocked_user.yml new file mode 100644 index 0000000000..88c378a846 --- /dev/null +++ b/models/fixtures/forgejo_blocked_user.yml @@ -0,0 +1,5 @@ +- + id: 1 + user_id: 4 + block_id: 1 + created_unix: 1671607299 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index 15668e6cae..ee8bdde5eb 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -37,7 +37,7 @@ lower_name: repo2 name: repo2 default_branch: master - num_watches: 0 + num_watches: 1 num_stars: 1 num_forks: 0 num_issues: 2 diff --git a/models/fixtures/watch.yml b/models/fixtures/watch.yml index c29f6bb65a..c6c9726cc8 100644 --- a/models/fixtures/watch.yml +++ b/models/fixtures/watch.yml @@ -26,4 +26,10 @@ id: 5 user_id: 11 repo_id: 1 - mode: 3 # auto + mode: 3 # auto + +- + id: 6 + user_id: 4 + repo_id: 2 + mode: 1 # normal diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 88bbef70c7..713db4a090 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -8,6 +8,7 @@ import ( "fmt" "os" + forgejo_v1_20 "code.gitea.io/gitea/models/forgejo_migrations/v1_20" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -34,7 +35,9 @@ func NewMigration(desc string, fn func(*xorm.Engine) error) *Migration { // This is a sequence of additional Forgejo migrations. // Add new migrations to the bottom of the list. -var migrations = []*Migration{} +var migrations = []*Migration{ + NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser), +} // GetCurrentDBVersion returns the current Forgejo database version. func GetCurrentDBVersion(x *xorm.Engine) (int64, error) { diff --git a/models/forgejo_migrations/v1_20/v1.go b/models/forgejo_migrations/v1_20/v1.go new file mode 100644 index 0000000000..1097613655 --- /dev/null +++ b/models/forgejo_migrations/v1_20/v1.go @@ -0,0 +1,21 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgejo_v1_20 //nolint:revive + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddForgejoBlockedUser(x *xorm.Engine) error { + type ForgejoBlockedUser struct { + ID int64 `xorm:"pk autoincr"` + BlockID int64 `xorm:"index"` + UserID int64 `xorm:"index"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + } + + return x.Sync(new(ForgejoBlockedUser)) +} diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 0f2ceadc6b..14fc4cd499 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -429,6 +429,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 9607b21a67..f3876702be 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -608,9 +608,11 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u teamusers := make([]*user_model.User, 0, 20) if err := db.GetEngine(ctx). Join("INNER", "team_user", "team_user.uid = `user`.id"). + Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id"). In("`team_user`.team_id", checked). And("`user`.is_active = ?", true). And("`user`.prohibit_login = ?", false). + And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})). Find(&teamusers); err != nil { return nil, fmt.Errorf("get teams users: %w", err) } @@ -644,8 +646,10 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u unchecked := make([]*user_model.User, 0, len(mentionUsers)) if err := db.GetEngine(ctx). + Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id"). Where("`user`.is_active = ?", true). And("`user`.prohibit_login = ?", false). + And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})). In("`user`.lower_name", mentionUsers). Find(&unchecked); err != nil { return nil, fmt.Errorf("find mentioned users: %w", err) diff --git a/models/issues/reaction.go b/models/issues/reaction.go index 293dfa3fd1..a005b45364 100644 --- a/models/issues/reaction.go +++ b/models/issues/reaction.go @@ -218,12 +218,12 @@ type ReactionOptions struct { } // CreateReaction creates reaction for issue or comment. -func CreateReaction(opts *ReactionOptions) (*Reaction, error) { +func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { if !setting.UI.ReactionsLookup.Contains(opts.Type) { return nil, ErrForbiddenIssueReaction{opts.Type} } - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return nil, err } @@ -240,25 +240,6 @@ func CreateReaction(opts *ReactionOptions) (*Reaction, error) { return reaction, nil } -// CreateIssueReaction creates a reaction on issue. -func CreateIssueReaction(doerID, issueID int64, content string) (*Reaction, error) { - return CreateReaction(&ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - }) -} - -// CreateCommentReaction creates a reaction on comment. -func CreateCommentReaction(doerID, issueID, commentID int64, content string) (*Reaction, error) { - return CreateReaction(&ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - CommentID: commentID, - }) -} - // DeleteReaction deletes reaction for issue or comment. func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { reaction := &Reaction{ diff --git a/models/issues/reaction_test.go b/models/issues/reaction_test.go index ddd0e2d04c..80b4e64b95 100644 --- a/models/issues/reaction_test.go +++ b/models/issues/reaction_test.go @@ -19,11 +19,14 @@ import ( func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) { var reaction *issues_model.Reaction var err error - if commentID == 0 { - reaction, err = issues_model.CreateIssueReaction(doerID, issueID, content) - } else { - reaction, err = issues_model.CreateCommentReaction(doerID, issueID, commentID, content) - } + // NOTE: This doesn't do user blocking checking. + reaction, err = issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{ + DoerID: doerID, + IssueID: issueID, + CommentID: commentID, + Type: content, + }) + assert.NoError(t, err) assert.NotNil(t, reaction) } @@ -49,7 +52,7 @@ func TestIssueAddDuplicateReaction(t *testing.T) { addReaction(t, user1.ID, issue1ID, 0, "heart") - reaction, err := issues_model.CreateReaction(&issues_model.ReactionOptions{ + reaction, err := issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{ DoerID: user1.ID, IssueID: issue1ID, Type: "heart", diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index dd2ef62201..5d6e24e2a5 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -177,3 +177,16 @@ func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull boo Limit(30). Find(&users) } + +// GetWatchedRepoIDsOwnedBy returns the repos owned by a particular user watched by a particular user +func GetWatchedRepoIDsOwnedBy(ctx context.Context, userID, ownedByUserID int64) ([]int64, error) { + repoIDs := make([]int64, 0, 10) + err := db.GetEngine(ctx). + Table("repository"). + Select("`repository`.id"). + Join("LEFT", "watch", "`repository`.id=`watch`.repo_id"). + Where("`watch`.user_id=?", userID). + And("`watch`.mode<>?", WatchModeDont). + And("`repository`.owner_id=?", ownedByUserID).Find(&repoIDs) + return repoIDs, err +} diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go index 7816b0262a..ad794beb9b 100644 --- a/models/repo/user_repo_test.go +++ b/models/repo/user_repo_test.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "github.com/stretchr/testify/assert" ) @@ -71,3 +72,15 @@ func TestRepoGetReviewers(t *testing.T) { assert.NoError(t, err) assert.Len(t, reviewers, 1) } + +func GetWatchedRepoIDsOwnedBy(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(db.DefaultContext, user1.ID, user2.ID) + assert.NoError(t, err) + assert.Len(t, repoIDs, 1) + assert.EqualValues(t, 1, repoIDs[0]) +} diff --git a/models/repo/watch.go b/models/repo/watch.go index 00f313ca7c..53b35c0e51 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -10,6 +10,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" ) // WatchMode specifies what kind of watch the user has on a repository @@ -142,6 +144,21 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { Find(&watches) } +// GetWatchersExcludeBlocked returns all watchers of given repository, whereby +// the doer isn't blocked by one of the watchers. +func GetWatchersExcludeBlocked(ctx context.Context, repoID, doerID int64) ([]*Watch, error) { + watches := make([]*Watch, 0, 10) + return watches, db.GetEngine(ctx). + Join("INNER", "`user`", "`user`.id = `watch`.user_id"). + Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `watch`.user_id"). + Where("`watch`.repo_id=?", repoID). + And("`watch`.mode<>?", WatchModeDont). + And("`user`.is_active=?", true). + And("`user`.prohibit_login=?", false). + And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doerID})). + Find(&watches) +} + // GetRepoWatchersIDs returns IDs of watchers for a given repo ID // but avoids joining with `user` for performance reasons // User permissions must be verified elsewhere if required @@ -184,3 +201,9 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error } return watchRepoMode(ctx, watch, WatchModeAuto) } + +// UnwatchRepos will unwatch the user from all given repositories. +func UnwatchRepos(ctx context.Context, userID int64, repoIDs []int64) error { + _, err := db.GetEngine(ctx).Where("user_id=?", userID).In("repo_id", repoIDs).Delete(&Watch{}) + return err +} diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 8b8c6d6250..02d0d3b0dd 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -43,6 +43,24 @@ func TestGetWatchers(t *testing.T) { assert.Len(t, watches, 0) } +func TestGetWatchersExcludeBlocked(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + watches, err := repo_model.GetWatchersExcludeBlocked(db.DefaultContext, repo.ID, 1) + assert.NoError(t, err) + + // One watchers are inactive and one watcher is blocked, thus minus 2 + assert.Len(t, watches, repo.NumWatches-2) + for _, watch := range watches { + assert.EqualValues(t, repo.ID, watch.RepoID) + } + + watches, err = repo_model.GetWatchersExcludeBlocked(db.DefaultContext, unittest.NonexistentID, 1) + assert.NoError(t, err) + assert.Len(t, watches, 0) +} + func TestRepository_GetWatchers(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -137,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) { assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone)) unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0) } + +func TestUnwatchRepos(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1}) + unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2}) + + err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2}) + assert.NoError(t, err) + + unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 1}) + unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 2}) +} diff --git a/models/user/block.go b/models/user/block.go new file mode 100644 index 0000000000..189cacc2a2 --- /dev/null +++ b/models/user/block.go @@ -0,0 +1,91 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "errors" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" +) + +// ErrBlockedByUser defines an error stating that the user is not allowed to perform the action because they are blocked. +var ErrBlockedByUser = errors.New("user is blocked by the poster or repository owner") + +// BlockedUser represents a blocked user entry. +type BlockedUser struct { + ID int64 `xorm:"pk autoincr"` + // UID of the one who got blocked. + BlockID int64 `xorm:"index"` + // UID of the one who did the block action. + UserID int64 `xorm:"index"` + + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +// TableName provides the real table name +func (*BlockedUser) TableName() string { + return "forgejo_blocked_user" +} + +func init() { + db.RegisterModel(new(BlockedUser)) +} + +// IsBlocked returns if userID has blocked blockID. +func IsBlocked(ctx context.Context, userID, blockID int64) bool { + has, _ := db.GetEngine(ctx).Exist(&BlockedUser{UserID: userID, BlockID: blockID}) + return has +} + +// IsBlockedMultiple returns if one of the userIDs has blocked blockID. +func IsBlockedMultiple(ctx context.Context, userIDs []int64, blockID int64) bool { + has, _ := db.GetEngine(ctx).In("user_id", userIDs).Exist(&BlockedUser{BlockID: blockID}) + return has +} + +// UnblockUser removes the blocked user entry. +func UnblockUser(ctx context.Context, userID, blockID int64) error { + _, err := db.GetEngine(ctx).Delete(&BlockedUser{UserID: userID, BlockID: blockID}) + return err +} + +// CountBlockedUsers returns the number of users the user has blocked. +func CountBlockedUsers(ctx context.Context, userID int64) (int64, error) { + return db.GetEngine(ctx).Where("user_id=?", userID).Count(&BlockedUser{}) +} + +// ListBlockedUsers returns the users that the user has blocked. +// The created_unix field of the user struct is overridden by the creation_unix +// field of blockeduser. +func ListBlockedUsers(ctx context.Context, userID int64, opts db.ListOptions) ([]*User, error) { + sess := db.GetEngine(ctx). + Select("`forgejo_blocked_user`.created_unix, `user`.*"). + Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id"). + Where("`forgejo_blocked_user`.user_id=?", userID) + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, &opts) + users := make([]*User, 0, opts.PageSize) + + return users, sess.Find(&users) + } + + users := make([]*User, 0, 8) + return users, sess.Find(&users) +} + +// ListBlockedByUsersID returns the ids of the users that blocked the user. +func ListBlockedByUsersID(ctx context.Context, userID int64) ([]int64, error) { + users := make([]int64, 0, 8) + err := db.GetEngine(ctx). + Table("user"). + Select("`user`.id"). + Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.user_id"). + Where("`forgejo_blocked_user`.block_id=?", userID). + Find(&users) + + return users, err +} diff --git a/models/user/block_test.go b/models/user/block_test.go new file mode 100644 index 0000000000..629c0c975a --- /dev/null +++ b/models/user/block_test.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestIsBlocked(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlocked(db.DefaultContext, 1, 1)) + assert.False(t, user_model.IsBlocked(db.DefaultContext, 3, 2)) +} + +func TestIsBlockedMultiple(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4}, 1)) + assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4, 3, 4, 5}, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{1}, 1)) + assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{3, 4, 1}, 2)) +} + +func TestUnblockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) + + assert.NoError(t, user_model.UnblockUser(db.DefaultContext, 4, 1)) + + // Simple test cases to ensure the function can also respond with false. + assert.False(t, user_model.IsBlocked(db.DefaultContext, 4, 1)) +} + +func TestListBlockedUsers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + blockedUsers, err := user_model.ListBlockedUsers(db.DefaultContext, 4, db.ListOptions{}) + assert.NoError(t, err) + if assert.Len(t, blockedUsers, 1) { + assert.EqualValues(t, 1, blockedUsers[0].ID) + // The function returns the created Unix of the block, not that of the user. + assert.EqualValues(t, 1671607299, blockedUsers[0].CreatedUnix) + } +} + +func TestListBlockedByUsersID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + blockedByUserIDs, err := user_model.ListBlockedByUsersID(db.DefaultContext, 1) + assert.NoError(t, err) + if assert.Len(t, blockedByUserIDs, 1) { + assert.EqualValues(t, 4, blockedByUserIDs[0]) + } +} + +func TestCountBlockedUsers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + count, err := user_model.CountBlockedUsers(db.DefaultContext, 4) + assert.NoError(t, err) + assert.EqualValues(t, 1, count) + + count, err = user_model.CountBlockedUsers(db.DefaultContext, 1) + assert.NoError(t, err) + assert.EqualValues(t, 0, count) +} diff --git a/models/user/follow.go b/models/user/follow.go index 7efecc26a7..5b3ff489ca 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" ) @@ -22,17 +24,26 @@ 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(userID, followID int64) (err error) { - if userID == followID || IsFollowing(userID, followID) { +func FollowUser(ctx context.Context, userID, followID int64) (err error) { + if userID == followID || IsFollowingCtx(ctx, userID, followID) { return nil } - ctx, committer, err := db.TxContext(db.DefaultContext) + if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) { + return ErrBlockedByUser + } + + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } @@ -53,12 +64,12 @@ func FollowUser(userID, followID int64) (err error) { } // UnfollowUser unmarks someone as another's follower. -func UnfollowUser(userID, followID int64) (err error) { - if userID == followID || !IsFollowing(userID, followID) { +func UnfollowUser(ctx context.Context, userID, followID int64) (err error) { + if userID == followID || !IsFollowingCtx(ctx, userID, followID) { return nil } - ctx, committer, err := db.TxContext(db.DefaultContext) + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } diff --git a/models/user/user_test.go b/models/user/user_test.go index 44eaf63556..e21e9ad52e 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -449,13 +449,19 @@ 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)) + + // 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{}) } @@ -464,7 +470,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/modules/repository/collaborator.go b/modules/repository/collaborator.go index f5cdc35045..4099a178f2 100644 --- a/modules/repository/collaborator.go +++ b/modules/repository/collaborator.go @@ -14,6 +14,10 @@ import ( ) func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error { + if user_model.IsBlocked(ctx, repo.OwnerID, u.ID) || user_model.IsBlocked(ctx, u.ID, repo.OwnerID) { + return user_model.ErrBlockedByUser + } + return db.WithTx(ctx, func(ctx context.Context) error { collaboration := &repo_model.Collaboration{ RepoID: repo.ID, diff --git a/modules/repository/collaborator_test.go b/modules/repository/collaborator_test.go index c772806819..b051aa2ac7 100644 --- a/modules/repository/collaborator_test.go +++ b/modules/repository/collaborator_test.go @@ -33,6 +33,33 @@ func TestRepository_AddCollaborator(t *testing.T) { testSuccess(3, 4) } +func TestRepository_AddCollaborator_IsBlocked(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + testSuccess := func(repoID, userID int64) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) + + // Owner blocked user. + unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID}) + assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser) + unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID}) + _, err := db.DeleteByBean(db.DefaultContext, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID}) + assert.NoError(t, err) + + // User has owner blocked. + unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: userID, BlockID: repo.OwnerID}) + assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser) + unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID}) + } + // Ensure idempotency (public repository). + testSuccess(1, 4) + testSuccess(1, 4) + // Add collaborator to private repository. + testSuccess(3, 4) +} + func TestRepoPermissionPublicNonOrgRepo(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/modules/structs/moderation.go b/modules/structs/moderation.go new file mode 100644 index 0000000000..c1e55085a7 --- /dev/null +++ b/modules/structs/moderation.go @@ -0,0 +1,13 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// BlockedUser represents a blocked user. +type BlockedUser struct { + BlockID int64 `json:"block_id"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 3256d2ba91..674ed2ea9e 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -588,6 +588,12 @@ joined_on = Joined on %s repositories = Repositories activity = Public Activity followers = Followers +block_user = Block User +block_user.detail = Please understand that if you block this user, other actions will be taken. Such as: +block_user.detail_1 = You are being unfollowed from this user. +block_user.detail_2 = This user cannot interact with your repositories, created issues and comments. +block_user.detail_3 = This user cannot add you as a collaborator, nor can you add them as a collaborator. +follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you. starred = Starred Repositories watched = Watched Repositories code = Code @@ -596,6 +602,8 @@ 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. @@ -625,6 +633,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 @@ -893,6 +902,7 @@ hooks.desc = Add webhooks which will be triggered for all repositoriesCANNOT be undone. @@ -915,6 +925,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 @@ -1641,6 +1655,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 @@ -1720,6 +1735,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. @@ -2077,6 +2093,8 @@ settings.add_collaborator_success = The collaborator has been added. settings.add_collaborator_inactive_user = Cannot add an inactive user as a collaborator. settings.add_collaborator_owner = Cannot add an owner as a collaborator. settings.add_collaborator_duplicate = The collaborator is already added to this repository. +settings.add_collaborator_blocked_our = Cannot add the collaborator, because the repository owner has blocked them. +settings.add_collaborator_blocked_them = Cannot add the collaborator, because they have blocked the repository owner. settings.delete_collaborator = Remove settings.collaborator_deletion = Remove Collaborator settings.collaborator_deletion_desc = Removing a collaborator will revoke their access to this repository. Continue? @@ -2536,6 +2554,7 @@ team_access_desc = Repository access team_permission_desc = Permission team_unit_desc = Allow Access to Repository Sections team_unit_disabled = (Disabled) +follow_blocked_user = You cannot follow this organisation because this organisation has blocked you. form.name_reserved = The organization name "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 397eb10558..3cedbfd708 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -900,6 +900,12 @@ func Routes() *web.Route { 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) + }) + m.Group("/avatar", func() { m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) m.Delete("", user.DeleteAvatar) @@ -1328,6 +1334,12 @@ func Routes() *web.Route { m.Delete("", org.DeleteAvatar) }, reqToken(), reqOrgOwnership()) 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 b0666c87f8..28afc8fc02 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -447,3 +447,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/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index 942d4c799f..cfa45e9995 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -156,6 +156,8 @@ func AddCollaborator(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "422": // "$ref": "#/responses/validationError" + // "403": + // "$ref": "#/responses/forbidden" form := web.GetForm(ctx).(*api.AddCollaboratorOption) @@ -175,7 +177,11 @@ func AddCollaborator(ctx *context.APIContext) { } if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil { - ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "AddCollaborator", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + } return } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 861e63a9b8..6eb7d9dc56 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" @@ -678,7 +679,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 1f8e16147f..feca265008 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 49e5a74aa3..a5e77d09f3 100644 --- a/routers/api/v1/repo/issue_reaction.go +++ b/routers/api/v1/repo/issue_reaction.go @@ -8,11 +8,13 @@ import ( "net/http" issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" ) // GetIssueCommentReactions list reactions of a comment from an issue @@ -196,9 +198,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp if isCreateType { // PostIssueCommentReaction part - reaction, err := issues_model.CreateCommentReaction(ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction) + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ @@ -406,9 +408,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i if isCreateType { // PostIssueReaction part - reaction, err := issues_model.CreateIssueReaction(ctx.Doer.ID, issue.ID, form.Reaction) + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index a507c1f44d..91e6527d85 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -418,7 +418,10 @@ func CreatePullRequest(ctx *context.APIContext) { } if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "BlockedByUser", err) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) return } diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index 3e23aa4d5a..263e335873 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -414,3 +414,10 @@ type swaggerRepoNewIssuePinsAllowed struct { // in:body Body api.NewIssuePinsAllowed `json:"body"` } + +// BlockedUserList +// swagger:response BlockedUserList +type swaggerBlockedUserList struct { + // in:body + Body []api.BlockedUser `json:"body"` +} diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index bc03b22ea7..bc1e6724f7 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -5,6 +5,7 @@ package user import ( + "errors" "net/http" user_model "code.gitea.io/gitea/models/user" @@ -217,8 +218,14 @@ func Follow(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" - if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + 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 } @@ -240,7 +247,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/api/v1/user/user.go b/routers/api/v1/user/user.go index 2a2361be67..595ca04d62 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -202,3 +202,80 @@ func ListUserActivityFeeds(ctx *context.APIContext) { ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) } + +// ListBlockedUsers list the authenticated user's blocked users. +func ListBlockedUsers(ctx *context.APIContext) { + // swagger:operation GET /user/list_blocked user userListBlockedUsers + // --- + // summary: List the authenticated user's blocked users + // produces: + // - application/json + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/BlockedUserList" + + utils.ListUserBlockedUsers(ctx, ctx.Doer) +} + +// BlockUser blocks a user from the doer. +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/block/{username} user userBlockUser + // --- + // summary: Blocks a user from the doer. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + user := GetUserByParams(ctx) + if ctx.Written() { + return + } + + utils.BlockUser(ctx, ctx.Doer, user) +} + +// UnblockUser unblocks a user from the doer. +func UnblockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/unblock/{username} user userUnblockUser + // --- + // summary: Unblocks a user from the doer. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + user := GetUserByParams(ctx) + if ctx.Written() { + return + } + + utils.UnblockUser(ctx, ctx.Doer, user) +} diff --git a/routers/api/v1/utils/block.go b/routers/api/v1/utils/block.go new file mode 100644 index 0000000000..187d69044e --- /dev/null +++ b/routers/api/v1/utils/block.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + user_service "code.gitea.io/gitea/services/user" +) + +// ListUserBlockedUsers lists the blocked users of the provided doer. +func ListUserBlockedUsers(ctx *context.APIContext, doer *user_model.User) { + count, err := user_model.CountBlockedUsers(ctx, doer.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + blockedUsers, err := user_model.ListBlockedUsers(ctx, doer.ID, GetListOptions(ctx)) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiBlockedUsers := make([]*api.BlockedUser, len(blockedUsers)) + for i, blockedUser := range blockedUsers { + apiBlockedUsers[i] = &api.BlockedUser{ + BlockID: blockedUser.ID, + Created: blockedUser.CreatedUnix.AsTime(), + } + if err != nil { + ctx.InternalServerError(err) + return + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiBlockedUsers) +} + +// BlockUser blocks the blockUser from the doer. +func BlockUser(ctx *context.APIContext, doer, blockUser *user_model.User) { + err := user_service.BlockUser(ctx, doer.ID, blockUser.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// UnblockUser unblocks the blockUser from the doer. +func UnblockUser(ctx *context.APIContext, doer, blockUser *user_model.User) { + err := user_model.UnblockUser(ctx, doer.ID, blockUser.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/org/setting/blocked_users.go b/routers/web/org/setting/blocked_users.go new file mode 100644 index 0000000000..ebec1f1df5 --- /dev/null +++ b/routers/web/org/setting/blocked_users.go @@ -0,0 +1,62 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +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" + user_service "code.gitea.io/gitea/services/user" +) + +const tplBlockedUsers = "org/settings/blocked_users" + +// BlockedUsers renders the blocked users page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsSettingsBlockedUsers"] = true + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + + ctx.HTML(http.StatusOK, tplBlockedUsers) +} + +// BlockedUsersBlock blocks a particular user from the organization. +func BlockedUsersBlock(ctx *context.Context) { + uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname"))) + u, err := user_model.GetUserByName(ctx, uname) + if err != nil { + ctx.ServerError("GetUserByName", err) + return + } + + if 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/repo/issue.go b/routers/web/repo/issue.go index 7bddabd10a..b9826afdb1 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1199,7 +1199,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 } @@ -3164,7 +3167,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) @@ -3266,7 +3269,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 d76e90bf24..e9f1c07fdc 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1414,7 +1414,11 @@ func CompareAndPullRequestPost(ctx *context.Context) { // instead of 500. if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Flash.Error(ctx.Tr("repo.pulls.blocked_by_user")) + ctx.Redirect(ctx.Link) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) return } else if git.IsErrPushRejected(err) { diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go index b708422cbd..0738d72f2f 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -4,6 +4,7 @@ package setting import ( + "errors" "net/http" "strings" @@ -102,7 +103,18 @@ func CollaborationPost(ctx *context.Context) { } if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil { - ctx.ServerError("AddCollaborator", err) + if !errors.Is(err, user_model.ErrBlockedByUser) { + ctx.ServerError("AddCollaborator", err) + return + } + + // To give an good error message, be precise on who has blocked who. + if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our")) + } else { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them")) + } + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") return } diff --git a/routers/web/repo/setting/settings_test.go b/routers/web/repo/setting/settings_test.go index 6f7c844ce7..46180ac372 100644 --- a/routers/web/repo/setting/settings_test.go +++ b/routers/web/repo/setting/settings_test.go @@ -102,13 +102,15 @@ func TestCollaborationPost(t *testing.T) { ctx.Req.Form.Set("collaborator", "user4") u := &user_model.User{ + ID: 2, LowerName: "user2", Type: user_model.UserTypeIndividual, } re := &repo_model.Repository{ - ID: 2, - Owner: u, + ID: 2, + Owner: u, + OwnerID: u.ID, } repo := &context.Repository{ @@ -160,13 +162,15 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) { ctx.Req.Form.Set("collaborator", "user4") u := &user_model.User{ + ID: 2, LowerName: "user2", Type: user_model.UserTypeIndividual, } re := &repo_model.Repository{ - ID: 2, - Owner: u, + ID: 2, + Owner: u, + OwnerID: u.ID, } repo := &context.Repository{ diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 516c853b02..fa7f915cf4 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -31,6 +31,7 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { prepareContextForCommonProfile(ctx) ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx.Doer.ID, ctx.ContextUser.ID) + ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID) ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate // Show OpenID URIs diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 7f6e41d30f..d165a63396 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -5,6 +5,7 @@ package user import ( + "errors" "fmt" "net/http" "strings" @@ -23,6 +24,7 @@ import ( "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/org" shared_user "code.gitea.io/gitea/routers/web/shared/user" + user_service "code.gitea.io/gitea/services/user" ) // OwnerProfile render profile page for a user or a organization (aka, repo owner) @@ -284,17 +286,38 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi // 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 { - log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) - ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + if !errors.Is(err, user_model.ErrBlockedByUser) { + log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err) + ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action"))) + return + } + + if ctx.ContextUser.IsOrganization() { + ctx.Flash.Error(ctx.Tr("org.follow_blocked_user")) + } else { + ctx.Flash.Error(ctx.Tr("user.follow_blocked_user")) + } + } + + if redirectViaJSON { + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.ContextUser.HomeLink(), + }) return } - ctx.JSONOK() + ctx.RedirectToFirst(ctx.FormString("redirect_to"), ctx.ContextUser.HomeLink()) } diff --git a/routers/web/user/setting/blocked_users.go b/routers/web/user/setting/blocked_users.go new file mode 100644 index 0000000000..ed1c340fb9 --- /dev/null +++ b/routers/web/user/setting/blocked_users.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users" +) + +// BlockedUsers render the blocked users list page. +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings.blocked_users") + ctx.Data["PageIsBlockedUsers"] = true + ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users" + ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users" + + blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{}) + if err != nil { + ctx.ServerError("ListBlockedUsers", err) + return + } + + ctx.Data["BlockedUsers"] = blockedUsers + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +// UnblockUser unblocks a particular user for the doer. +func UnblockUser(ctx *context.Context) { + if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil { + ctx.ServerError("UnblockUser", err) + return + } + + ctx.Flash.Success(ctx.Tr("settings.user_unblock_success")) + ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") +} diff --git a/routers/web/web.go b/routers/web/web.go index 67b3a47153..5f5a9b1f1c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -521,6 +521,11 @@ func registerRoutes(m *web.Route) { }) addWebhookEditRoutes() }, webhooksEnabled) + + m.Group("/blocked_users", func() { + m.Get("", user_setting.BlockedUsers) + m.Post("/unblock", user_setting.UnblockUser) + }) }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) m.Group("/user", func() { @@ -776,6 +781,12 @@ func registerRoutes(m *web.Route) { m.Methods("GET,POST", "/delete", org.SettingsDelete) + m.Group("/blocked_users", func() { + m.Get("", org_setting.BlockedUsers) + m.Post("/block", org_setting.BlockedUsersBlock) + m.Post("/unblock", org_setting.BlockedUsersUnblock) + }) + m.Group("/packages", func() { m.Get("", org.Packages) m.Group("/rules", func() { diff --git a/services/issue/comments.go b/services/issue/comments.go index ed05f725ff..ddd2460e07 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -66,6 +66,11 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod // CreateIssueComment creates a plain issue comment. func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) { + // Check if doer is blocked by the poster of the issue. + if user_model.IsBlocked(ctx, issue.PosterID, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + comment, err := CreateComment(ctx, &issues_model.CreateCommentOptions{ Type: issues_model.CommentTypeComment, Doer: doer, diff --git a/services/issue/issue.go b/services/issue/issue.go index b6c6a26cbd..b4c60afbbe 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -22,6 +22,11 @@ import ( // NewIssue creates new issue with labels for repository. func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error { + // Check if the user is not blocked by the repo's owner. + if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) { + return user_model.ErrBlockedByUser + } + if err := issues_model.NewIssue(repo, issue, labelIDs, uuids); err != nil { return err } diff --git a/services/issue/reaction.go b/services/issue/reaction.go new file mode 100644 index 0000000000..dbb4735de2 --- /dev/null +++ b/services/issue/reaction.go @@ -0,0 +1,47 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package issue + +import ( + "context" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" +) + +// CreateIssueReaction creates a reaction on issue. +func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) { + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + // Check if the doer is blocked by the issue's poster or repository owner. + if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, issue.Repo.OwnerID}, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + }) +} + +// CreateCommentReaction creates a reaction on comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) { + if err := issue.LoadRepo(ctx); err != nil { + return nil, err + } + + // Check if the doer is blocked by the issue's poster, the comment's poster or repository owner. + if user_model.IsBlockedMultiple(ctx, []int64{comment.PosterID, issue.PosterID, issue.Repo.OwnerID}, doer.ID) { + return nil, user_model.ErrBlockedByUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + CommentID: comment.ID, + }) +} diff --git a/services/pull/pull.go b/services/pull/pull.go index cf49d2fe20..e323b5f145 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -39,6 +39,11 @@ var pullWorkingPool = sync.NewExclusivePool() // NewPullRequest creates new pull request with labels for repository. func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error { + // Check if the doer is not blocked by the repository's owner. + if user_model.IsBlocked(ctx, repo.OwnerID, pull.PosterID) { + return user_model.ErrBlockedByUser + } + if err := TestPatch(pr); err != nil { return err } diff --git a/services/user/block.go b/services/user/block.go new file mode 100644 index 0000000000..05f9a376a7 --- /dev/null +++ b/services/user/block.go @@ -0,0 +1,58 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" +) + +// BlockUser adds a blocked user entry for userID to block blockID. +// TODO: Figure out if instance admins should be immune to blocking. +// TODO: Add more mechanism like removing blocked user as collaborator on +// repositories where the user is an owner. +func BlockUser(ctx context.Context, userID, blockID int64) error { + if userID == blockID || user_model.IsBlocked(ctx, userID, blockID) { + return nil + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + // Add the blocked user entry. + _, err = db.GetEngine(ctx).Insert(&user_model.BlockedUser{UserID: userID, BlockID: blockID}) + if err != nil { + return err + } + + // Unfollow the user from the block's perspective. + err = user_model.UnfollowUser(ctx, blockID, userID) + if err != nil { + return err + } + + // Unfollow the user from the doer's perspective. + err = user_model.UnfollowUser(ctx, userID, blockID) + if err != nil { + return err + } + + // Blocked user unwatch all repository owned by the doer. + repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID) + if err != nil { + return err + } + + err = repo_model.UnwatchRepos(ctx, blockID, repoIDs) + if err != nil { + return err + } + + return committer.Commit() +} diff --git a/services/user/block_test.go b/services/user/block_test.go new file mode 100644 index 0000000000..8a0a3c4739 --- /dev/null +++ b/services/user/block_test.go @@ -0,0 +1,41 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +// TestBlockUser will ensure that when you block a user, certain actions have +// been taken, like unfollowing each other etc. +func TestBlockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + // Follow each other. + assert.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID)) + assert.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID)) + + // Blocked user watch repository of doer. + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID}) + assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true)) + + assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID)) + + // Ensure they aren't following each other anymore. + assert.False(t, user_model.IsFollowing(doer.ID, blockedUser.ID)) + assert.False(t, user_model.IsFollowing(blockedUser.ID, doer.ID)) + + // Ensure blocked user isn't following doer's repository. + assert.False(t, repo_model.IsWatching(blockedUser.ID, repo.ID)) +} diff --git a/services/user/delete.go b/services/user/delete.go index 01e3c37b39..0f33d712a4 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -90,6 +90,8 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &pull_model.AutoMerge{DoerID: u.ID}, &pull_model.ReviewState{UserID: u.ID}, &user_model.Redirect{RedirectUserID: u.ID}, + &user_model.BlockedUser{BlockID: u.ID}, + &user_model.BlockedUser{UserID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 445df520a9..aba67d0ade 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -1,5 +1,10 @@ {{template "base/head" .}}
+ {{if .Flash}} +
+ {{template "base/alert" .}} +
+ {{end}}
{{avatar $.Context .Org 140 "org-avatar"}}
diff --git a/templates/org/settings/blocked_users.tmpl b/templates/org/settings/blocked_users.tmpl new file mode 100644 index 0000000000..904f6fd186 --- /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 e22d1b0f80..9528258826 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -38,6 +38,9 @@
{{end}} + + {{.locale.Tr "settings.blocked_users"}} + {{.locale.Tr "org.settings.delete"}} diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl index 97afbb6580..d76665d735 100644 --- a/templates/shared/user/profile_big_avatar.tmpl +++ b/templates/shared/user/profile_big_avatar.tmpl @@ -117,6 +117,18 @@ {{end}} +
  • + {{if $.IsBlocked}} + + {{else}} + + {{end}} +
  • {{end}}
    diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8cf5332baf..5dcae18d95 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1655,6 +1655,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": [ @@ -2019,6 +2055,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": [ @@ -2483,6 +2557,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": [ @@ -3841,6 +3951,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "422": { "$ref": "#/responses/validationError" } @@ -13962,6 +14075,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": [ @@ -14138,6 +14280,9 @@ "responses": { "204": { "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" } } }, @@ -14608,6 +14753,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": [ @@ -15009,6 +15185,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": [ @@ -15939,6 +16144,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", @@ -22164,6 +22386,15 @@ } } }, + "BlockedUserList": { + "description": "BlockedUserList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/BlockedUser" + } + } + }, "Branch": { "description": "Branch", "schema": { diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 396d9ebcd4..05e95a1a34 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -1,6 +1,7 @@ {{template "base/head" .}}
    + {{template "base/alert" .}}
    {{template "shared/user/profile_big_avatar" .}} @@ -41,4 +42,20 @@
    + + {{template "base/footer" .}} diff --git a/templates/user/settings/blocked_users.tmpl b/templates/user/settings/blocked_users.tmpl new file mode 100644 index 0000000000..dc90970ec2 --- /dev/null +++ b/templates/user/settings/blocked_users.tmpl @@ -0,0 +1,31 @@ +{{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}} + {{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}} +
    +
    +
    + {{$.CsrfTokenHtml}} + + +
    +
    +
    + {{else}} +
    + {{$.locale.Tr "settings.blocked_users_none"}} +
    + {{end}} +
    +
    +
    +{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 274540c5bd..32fb77f3e0 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -51,5 +51,8 @@ {{.locale.Tr "settings.repos"}} + + {{.locale.Tr "settings.blocked_users"}} +
    diff --git a/tests/integration/api_block_test.go b/tests/integration/api_block_test.go new file mode 100644 index 0000000000..b07dd2455a --- /dev/null +++ b/tests/integration/api_block_test.go @@ -0,0 +1,141 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUserBlock(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := "user4" + session := loginUser(t, user) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + t.Run("BlockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/user2?token=%s", token)) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2}) + }) + + t.Run("ListBlocked", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/list_blocked?token=%s", token)) + resp := MakeRequest(t, req, http.StatusOK) + + // One user just got blocked and the other one is defined in the fixtures. + assert.Equal(t, "2", resp.Header().Get("X-Total-Count")) + + var blockedUsers []api.BlockedUser + DecodeJSON(t, resp, &blockedUsers) + assert.Len(t, blockedUsers, 2) + assert.EqualValues(t, 1, blockedUsers[0].BlockID) + assert.EqualValues(t, 2, blockedUsers[1].BlockID) + }) + + t.Run("UnblockUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/user2?token=%s", token)) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2}) + }) +} + +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}) + }) +} + +// TestAPIBlock_AddCollaborator ensures that the doer and blocked user cannot +// add each others as collaborators via the API. +func TestAPIBlock_AddCollaborator(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user1 := "user10" + user2 := "user2" + perm := "write" + collabOption := &api.AddCollaboratorOption{Permission: &perm} + + // User1 blocks User2. + session := loginUser(t, user1) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", user2, token)) + MakeRequest(t, req, http.StatusNoContent) + unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 10, BlockID: 2}) + + t.Run("BlockedUser Add Doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2}) + session := loginUser(t, user2) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user2, repo.Name, user1, token), collabOption) + session.MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("Doer Add BlockedUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: 10}) + session := loginUser(t, user1) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user1, repo.Name, user2, token), collabOption) + session.MakeRequest(t, req, http.StatusForbidden) + }) +} diff --git a/tests/integration/api_user_follow_test.go b/tests/integration/api_user_follow_test.go index 62717af90e..bf6560b103 100644 --- a/tests/integration/api_user_follow_test.go +++ b/tests/integration/api_user_follow_test.go @@ -19,7 +19,7 @@ func TestAPIFollow(t *testing.T) { defer tests.PrepareTestEnv(t)() user1 := "user4" - user2 := "user1" + user2 := "user10" session1 := loginUser(t, user1) token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser) diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go new file mode 100644 index 0000000000..be1097f0f8 --- /dev/null +++ b/tests/integration/block_test.go @@ -0,0 +1,257 @@ +// 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" + forgejo_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func BlockUser(t *testing.T, doer, blockedUser *user_model.User) { + t.Helper() + + unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}) + + session := loginUser(t, doer.Name) + req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{ + "_csrf": GetCSRF(t, session, "/"+blockedUser.Name), + "action": "block", + }) + resp := session.MakeRequest(t, req, http.StatusOK) + + type redirect struct { + Redirect string `json:"redirect"` + } + + var respBody redirect + DecodeJSON(t, resp, &respBody) + assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect) + assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})) +} + +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) +} + +// 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}) +} + +// TestBlockAddCollaborator ensures that the doer and blocked user cannot add each each other as collaborators. +func TestBlockAddCollaborator(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + BlockUser(t, user1, user2) + + t.Run("BlockedUser Add Doer", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: user2.ID}) + + session := loginUser(t, user2.Name) + req := NewRequestWithValues(t, "POST", path.Join(repo.Link(), "/settings/collaboration"), map[string]string{ + "_csrf": GetCSRF(t, session, path.Join(repo.Link(), "/settings/collaboration")), + "collaborator": user1.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthey%2Bhave%2Bblocked%2Bthe%2Brepository%2Bowner.", flashCookie.Value) + }) + + t.Run("Doer Add BlockedUser", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: user1.ID}) + + session := loginUser(t, user1.Name) + req := NewRequestWithValues(t, "POST", path.Join(repo.Link(), "/settings/collaboration"), map[string]string{ + "_csrf": GetCSRF(t, session, path.Join(repo.Link(), "/settings/collaboration")), + "collaborator": user2.Name, + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + flashCookie := session.GetCookie(forgejo_context.CookieNameFlash) + assert.NotNil(t, flashCookie) + assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthe%2Brepository%2Bowner%2Bhas%2Bblocked%2Bthem.", flashCookie.Value) + }) +} diff --git a/web_src/css/org.css b/web_src/css/org.css index 4bb3b0cd57..f0bfd88b5a 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -168,6 +168,25 @@ border-bottom: 1px solid var(--color-secondary); } +.organization.teams .repositories .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.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.settings .blocked-users .item button { + padding: 9px 10px; + margin: 0; +} + .org-team-navbar .active.item { background: var(--color-box-body) !important; } diff --git a/web_src/css/user.css b/web_src/css/user.css index 33e64bbb7c..9cc84b1825 100644 --- a/web_src/css/user.css +++ b/web_src/css/user.css @@ -36,6 +36,19 @@ width: 100%; } +.user.profile .ui.card .extra.content > ul > li .svg { + margin-left: 1px; + margin-right: 5px; +} + +.user.profile .ui.card .extra.content > ul > li.follow .ui.button, +.user.profile .ui.card .extra.content > ul > li.block .ui.button { + align-items: center; + display: flex; + justify-content: center; + width: 100%; +} + .user.profile .ui.card #profile-avatar { padding: 1rem 1rem 0.25rem; justify-content: center;