From c337ff0ec70618ef2ead7850f90ab2a8458db192 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 4 Mar 2024 09:16:03 +0100 Subject: [PATCH] Add user blocking (#29028) Fixes #17453 This PR adds the abbility to block a user from a personal account or organization to restrict how the blocked user can interact with the blocker. The docs explain what's the consequence of blocking a user. Screenshots: ![grafik](https://github.com/go-gitea/gitea/assets/1666336/4ed884f3-e06a-4862-afd3-3b8aa2488dc6) ![grafik](https://github.com/go-gitea/gitea/assets/1666336/ae6d4981-f252-4f50-a429-04f0f9f1cdf1) ![grafik](https://github.com/go-gitea/gitea/assets/1666336/ca153599-5b0f-4b4a-90fe-18bdfd6f0b6b) --------- Co-authored-by: Lauris BH --- docs/content/usage/blocking-users.en-us.md | 56 ++++ models/fixtures/access.yml | 50 +-- models/fixtures/collaboration.yml | 12 + models/fixtures/issue_assignees.yml | 4 + models/fixtures/repo_transfer.yml | 16 + models/fixtures/repository.yml | 8 +- models/fixtures/star.yml | 10 + models/fixtures/user.yml | 2 +- models/fixtures/user_blocking.yml | 19 ++ models/fixtures/watch.yml | 12 + models/issues/assignees.go | 21 ++ models/issues/issue_update.go | 9 + models/issues/issue_xref.go | 4 + models/issues/reaction.go | 19 -- models/migrations/migrations.go | 2 + models/migrations/v1_22/v288.go | 26 ++ models/org.go | 36 +- models/org_team.go | 79 ++--- models/org_team_test.go | 100 +++--- models/org_test.go | 49 ++- models/organization/org.go | 1 + models/organization/team_user.go | 8 - models/perm/access/access.go | 4 +- models/repo/collaboration.go | 55 +++- models/repo/collaboration_test.go | 37 +-- models/repo/repo_test.go | 17 +- models/repo/star.go | 20 +- models/repo/star_test.go | 40 ++- models/repo/user_repo.go | 97 ++++-- models/repo/user_repo_test.go | 6 +- models/repo/watch.go | 28 +- models/repo/watch_test.go | 26 +- models/repo_transfer.go | 37 ++- models/user/block.go | 123 +++++++ models/user/follow.go | 14 +- models/user/user.go | 2 +- models/user/user_test.go | 17 +- modules/repository/collaborator.go | 8 + modules/repository/create.go | 2 +- options/locale/locale_en-US.ini | 33 ++ routers/api/v1/api.go | 20 +- routers/api/v1/org/block.go | 116 +++++++ routers/api/v1/org/member.go | 2 +- routers/api/v1/org/team.go | 13 +- routers/api/v1/repo/collaborators.go | 24 +- routers/api/v1/repo/fork.go | 2 + routers/api/v1/repo/issue.go | 14 +- routers/api/v1/repo/issue_comment.go | 14 +- .../api/v1/repo/issue_comment_attachment.go | 10 +- routers/api/v1/repo/issue_reaction.go | 12 +- routers/api/v1/repo/pull.go | 10 +- routers/api/v1/repo/transfer.go | 7 +- routers/api/v1/shared/block.go | 98 ++++++ routers/api/v1/user/block.go | 96 ++++++ routers/api/v1/user/follower.go | 11 +- routers/api/v1/user/star.go | 27 +- routers/api/v1/user/watch.go | 27 +- routers/web/org/block.go | 38 +++ routers/web/org/members.go | 22 +- routers/web/org/teams.go | 17 +- routers/web/repo/issue.go | 37 ++- routers/web/repo/pull.go | 20 +- routers/web/repo/repo.go | 16 +- routers/web/repo/setting/collaboration.go | 26 +- routers/web/repo/setting/setting.go | 3 + routers/web/shared/user/block.go | 76 +++++ routers/web/shared/user/header.go | 8 + routers/web/user/profile.go | 2 +- routers/web/user/setting/block.go | 38 +++ routers/web/web.go | 10 + services/auth/source/source_group_sync.go | 4 +- services/forms/user_form.go | 11 + services/issue/comments.go | 26 ++ services/issue/commit.go | 4 + services/issue/content.go | 13 +- services/issue/issue.go | 41 ++- services/issue/reaction.go | 50 +++ .../issue}/reaction_test.go | 90 +++-- services/pull/pull.go | 8 + services/repository/collaboration.go | 16 +- services/repository/collaboration_test.go | 11 +- services/repository/delete.go | 14 +- services/repository/fork.go | 8 + services/repository/transfer.go | 12 +- services/user/block.go | 308 ++++++++++++++++++ services/user/block_test.go | 66 ++++ services/user/delete.go | 2 + services/user/user.go | 2 +- services/user/user_test.go | 3 +- templates/org/settings/blocked_users.tmpl | 5 + templates/org/settings/navbar.tmpl | 3 + templates/repo/diff/box.tmpl | 1 + templates/repo/issue/view_content.tmpl | 1 + .../repo/issue/view_content/context_menu.tmpl | 35 +- templates/shared/user/block_user_dialog.tmpl | 23 ++ templates/shared/user/blocked_users.tmpl | 83 +++++ templates/shared/user/profile_big_avatar.tmpl | 37 ++- templates/swagger/v1_json.tmpl | 283 ++++++++++++++++ templates/user/settings/blocked_users.tmpl | 5 + templates/user/settings/navbar.tmpl | 3 + tests/integration/api_comment_test.go | 26 ++ tests/integration/api_issue_reaction_test.go | 7 + tests/integration/api_issue_test.go | 10 +- .../integration/api_repo_collaborator_test.go | 7 + tests/integration/api_user_block_test.go | 243 ++++++++++++++ tests/integration/api_user_follow_test.go | 8 + tests/integration/api_user_star_test.go | 8 + tests/integration/api_user_watch_test.go | 8 + tests/integration/auth_ldap_test.go | 6 +- 109 files changed, 2873 insertions(+), 543 deletions(-) create mode 100644 docs/content/usage/blocking-users.en-us.md create mode 100644 models/fixtures/user_blocking.yml create mode 100644 models/migrations/v1_22/v288.go create mode 100644 models/user/block.go create mode 100644 routers/api/v1/org/block.go create mode 100644 routers/api/v1/shared/block.go create mode 100644 routers/api/v1/user/block.go create mode 100644 routers/web/org/block.go create mode 100644 routers/web/shared/user/block.go create mode 100644 routers/web/user/setting/block.go create mode 100644 services/issue/reaction.go rename {models/issues => services/issue}/reaction_test.go (65%) 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/shared/user/block_user_dialog.tmpl create mode 100644 templates/shared/user/blocked_users.tmpl create mode 100644 templates/user/settings/blocked_users.tmpl create mode 100644 tests/integration/api_user_block_test.go diff --git a/docs/content/usage/blocking-users.en-us.md b/docs/content/usage/blocking-users.en-us.md new file mode 100644 index 00000000000..b59bbe4d624 --- /dev/null +++ b/docs/content/usage/blocking-users.en-us.md @@ -0,0 +1,56 @@ +--- +date: "2024-01-31T00:00:00+00:00" +title: "Blocking a user" +slug: "blocking-user" +sidebar_position: 25 +toc: false +draft: false +aliases: + - /en-us/webhooks +menu: + sidebar: + parent: "usage" + name: "Blocking a user" + sidebar_position: 30 + identifier: "blocking-user" +--- + +# Blocking a user + +Gitea supports blocking of users to restrict how they can interact with you and your content. + +You can block a user in your account settings, from the user's profile or from comments created by the user. +The user is not directly notified about the block, but they can notice they are blocked when they attempt to interact with you. +Organization owners can block anyone who is not a member of the organization too. +If a blocked user has admin permissions, they can still perform all actions even if blocked. + +### When you block a user + +- the user stops following you +- you stop following the user +- the user's stars are removed from your repositories +- your stars are removed from their repositories +- the user stops watching your repositories +- you stop watching their repositories +- the user's issue assignments are removed from your repositories +- your issue assignments are removed from their repositories +- the user is removed as a collaborator on your repositories +- you are removed as a collaborator on their repositories +- any pending repository transfers to or from the blocked user are canceled + +### When you block a user, the user cannot + +- follow you +- watch your repositories +- star your repositories +- fork your repositories +- transfer repositories to you +- open issues or pull requests on your repositories +- comment on issues or pull requests you've created +- comment on issues or pull requests on your repositories +- react to your comments on issues or pull requests +- react to comments on issues or pull requests on your repositories +- assign you to issues or pull requests +- add you as a collaborator on their repositories +- send you notifications by @mentioning your username +- be added as team member (if blocked by an organization) diff --git a/models/fixtures/access.yml b/models/fixtures/access.yml index 641c453eb77..4171e31fef7 100644 --- a/models/fixtures/access.yml +++ b/models/fixtures/access.yml @@ -42,120 +42,132 @@ - id: 8 - user_id: 15 + user_id: 10 repo_id: 21 mode: 2 - id: 9 + user_id: 10 + repo_id: 32 + mode: 2 + +- + id: 10 + user_id: 15 + repo_id: 21 + mode: 2 + +- + id: 11 user_id: 15 repo_id: 22 mode: 2 - - id: 10 + id: 12 user_id: 15 repo_id: 23 mode: 4 - - id: 11 + id: 13 user_id: 15 repo_id: 24 mode: 4 - - id: 12 + id: 14 user_id: 15 repo_id: 32 mode: 2 - - id: 13 + id: 15 user_id: 18 repo_id: 21 mode: 2 - - id: 14 + id: 16 user_id: 18 repo_id: 22 mode: 2 - - id: 15 + id: 17 user_id: 18 repo_id: 23 mode: 4 - - id: 16 + id: 18 user_id: 18 repo_id: 24 mode: 4 - - id: 17 + id: 19 user_id: 20 repo_id: 24 mode: 1 - - id: 18 + id: 20 user_id: 20 repo_id: 27 mode: 4 - - id: 19 + id: 21 user_id: 20 repo_id: 28 mode: 4 - - id: 20 + id: 22 user_id: 29 repo_id: 4 mode: 2 - - id: 21 + id: 23 user_id: 29 repo_id: 24 mode: 1 - - id: 22 + id: 24 user_id: 31 repo_id: 27 mode: 4 - - id: 23 + id: 25 user_id: 31 repo_id: 28 mode: 4 - - id: 24 + id: 26 user_id: 38 repo_id: 60 mode: 2 - - id: 25 + id: 27 user_id: 38 repo_id: 61 mode: 1 - - id: 26 + id: 28 user_id: 39 repo_id: 61 mode: 1 - - id: 27 + id: 29 user_id: 40 repo_id: 61 mode: 4 diff --git a/models/fixtures/collaboration.yml b/models/fixtures/collaboration.yml index 7603bdad32c..4c3ac367f6b 100644 --- a/models/fixtures/collaboration.yml +++ b/models/fixtures/collaboration.yml @@ -51,3 +51,15 @@ repo_id: 60 user_id: 38 mode: 2 # write + +- + id: 10 + repo_id: 21 + user_id: 10 + mode: 2 # write + +- + id: 11 + repo_id: 32 + user_id: 10 + mode: 2 # write diff --git a/models/fixtures/issue_assignees.yml b/models/fixtures/issue_assignees.yml index e5d36f921a7..c40ecad6764 100644 --- a/models/fixtures/issue_assignees.yml +++ b/models/fixtures/issue_assignees.yml @@ -14,3 +14,7 @@ id: 4 assignee_id: 2 issue_id: 17 +- + id: 5 + assignee_id: 10 + issue_id: 6 diff --git a/models/fixtures/repo_transfer.yml b/models/fixtures/repo_transfer.yml index b841b5e983a..db92c952482 100644 --- a/models/fixtures/repo_transfer.yml +++ b/models/fixtures/repo_transfer.yml @@ -5,3 +5,19 @@ repo_id: 3 created_unix: 1553610671 updated_unix: 1553610671 + +- + id: 2 + doer_id: 16 + recipient_id: 10 + repo_id: 21 + created_unix: 1553610671 + updated_unix: 1553610671 + +- + id: 3 + doer_id: 3 + recipient_id: 10 + repo_id: 32 + created_unix: 1553610671 + updated_unix: 1553610671 diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index d094fe82d8d..e5c6224c96f 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -614,8 +614,8 @@ owner_name: user16 lower_name: big_test_public_3 name: big_test_public_3 - num_watches: 0 - num_stars: 0 + num_watches: 1 + num_stars: 1 num_forks: 0 num_issues: 0 num_closed_issues: 0 @@ -945,8 +945,8 @@ owner_name: org3 lower_name: repo21 name: repo21 - num_watches: 0 - num_stars: 0 + num_watches: 1 + num_stars: 1 num_forks: 0 num_issues: 2 num_closed_issues: 0 diff --git a/models/fixtures/star.yml b/models/fixtures/star.yml index 860f26b8e22..39b51b3736f 100644 --- a/models/fixtures/star.yml +++ b/models/fixtures/star.yml @@ -7,3 +7,13 @@ id: 2 uid: 2 repo_id: 4 + +- + id: 3 + uid: 10 + repo_id: 21 + +- + id: 4 + uid: 10 + repo_id: 32 diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 16b687ae04d..a3de535508b 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -361,7 +361,7 @@ use_custom_avatar: false num_followers: 0 num_following: 0 - num_stars: 0 + num_stars: 2 num_repos: 3 num_teams: 0 num_members: 0 diff --git a/models/fixtures/user_blocking.yml b/models/fixtures/user_blocking.yml new file mode 100644 index 00000000000..2ec9d99df52 --- /dev/null +++ b/models/fixtures/user_blocking.yml @@ -0,0 +1,19 @@ +- + id: 1 + blocker_id: 2 + blockee_id: 29 + +- + id: 2 + blocker_id: 17 + blockee_id: 28 + +- + id: 3 + blocker_id: 2 + blockee_id: 34 + +- + id: 4 + blocker_id: 50 + blockee_id: 34 diff --git a/models/fixtures/watch.yml b/models/fixtures/watch.yml index 1950ac99e7f..18bcd2ed2b4 100644 --- a/models/fixtures/watch.yml +++ b/models/fixtures/watch.yml @@ -27,3 +27,15 @@ user_id: 11 repo_id: 1 mode: 3 # auto + +- + id: 6 + user_id: 10 + repo_id: 21 + mode: 1 # normal + +- + id: 7 + user_id: 10 + repo_id: 32 + mode: 1 # normal diff --git a/models/issues/assignees.go b/models/issues/assignees.go index 60f32d95578..30234be07a6 100644 --- a/models/issues/assignees.go +++ b/models/issues/assignees.go @@ -64,6 +64,27 @@ func IsUserAssignedToIssue(ctx context.Context, issue *Issue, user *user_model.U return db.Exist[IssueAssignees](ctx, builder.Eq{"assignee_id": user.ID, "issue_id": issue.ID}) } +type AssignedIssuesOptions struct { + db.ListOptions + AssigneeID int64 + RepoOwnerID int64 +} + +func (opts *AssignedIssuesOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.AssigneeID != 0 { + cond = cond.And(builder.In("issue.id", builder.Select("issue_id").From("issue_assignees").Where(builder.Eq{"assignee_id": opts.AssigneeID}))) + } + if opts.RepoOwnerID != 0 { + cond = cond.And(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"owner_id": opts.RepoOwnerID}))) + } + return cond +} + +func GetAssignedIssues(ctx context.Context, opts *AssignedIssuesOptions) ([]*Issue, int64, error) { + return db.FindAndCount[Issue](ctx, opts) +} + // ToggleIssueAssignee changes a user between assigned and not assigned for this issue, and make issue comment for it. func ToggleIssueAssignee(ctx context.Context, issue *Issue, doer *user_model.User, assigneeID int64) (removed bool, comment *Comment, err error) { ctx, committer, err := db.TxContext(ctx) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index b258dc882cb..ef96e1ee50e 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -517,6 +517,15 @@ func FindAndUpdateIssueMentions(ctx context.Context, issue *Issue, doer *user_mo if err != nil { return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err) } + + notBlocked := make([]*user_model.User, 0, len(mentions)) + for _, user := range mentions { + if !user_model.IsUserBlockedBy(ctx, doer, user.ID) { + notBlocked = append(notBlocked, user) + } + } + mentions = notBlocked + if err = UpdateIssueMentions(ctx, issue.ID, mentions); err != nil { return nil, fmt.Errorf("UpdateIssueMentions [%d]: %w", issue.ID, err) } diff --git a/models/issues/issue_xref.go b/models/issues/issue_xref.go index cfc3c1683c6..e2e35859df1 100644 --- a/models/issues/issue_xref.go +++ b/models/issues/issue_xref.go @@ -214,6 +214,10 @@ func (issue *Issue) verifyReferencedIssue(stdCtx context.Context, ctx *crossRefe if !perm.CanReadIssuesOrPulls(refIssue.IsPull) { return nil, references.XRefActionNone, nil } + if user_model.IsUserBlockedBy(stdCtx, ctx.Doer, refIssue.PosterID, refIssue.Repo.OwnerID) { + return nil, references.XRefActionNone, nil + } + // Accept close/reopening actions only if the poster is able to close the // referenced issue manually at this moment. The only exception is // the poster of a new PR referencing an issue on the same repo: then the merger diff --git a/models/issues/reaction.go b/models/issues/reaction.go index bb47cf24cac..d5448636fe8 100644 --- a/models/issues/reaction.go +++ b/models/issues/reaction.go @@ -240,25 +240,6 @@ func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, erro return reaction, nil } -// CreateIssueReaction creates a reaction on issue. -func CreateIssueReaction(ctx context.Context, doerID, issueID int64, content string) (*Reaction, error) { - return CreateReaction(ctx, &ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - }) -} - -// CreateCommentReaction creates a reaction on comment. -func CreateCommentReaction(ctx context.Context, doerID, issueID, commentID int64, content string) (*Reaction, error) { - return CreateReaction(ctx, &ReactionOptions{ - Type: content, - DoerID: doerID, - IssueID: issueID, - CommentID: commentID, - }) -} - // DeleteReaction deletes reaction for issue or comment. func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { reaction := &Reaction{ diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 516eb53f62c..9d288ec2bdf 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -560,6 +560,8 @@ var migrations = []Migration{ NewMigration("Add support for SHA256 git repositories", v1_22.AdjustDBForSha256), // v287 -> v288 NewMigration("Use Slug instead of ID for Badges", v1_22.UseSlugInsteadOfIDForBadges), + // v288 -> v289 + NewMigration("Add user_blocking table", v1_22.AddUserBlockingTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_22/v288.go b/models/migrations/v1_22/v288.go new file mode 100644 index 00000000000..7c93bfcc663 --- /dev/null +++ b/models/migrations/v1_22/v288.go @@ -0,0 +1,26 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_22 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +type Blocking struct { + ID int64 `xorm:"pk autoincr"` + BlockerID int64 `xorm:"UNIQUE(block)"` + BlockeeID int64 `xorm:"UNIQUE(block)"` + Note string + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +func (*Blocking) TableName() string { + return "user_blocking" +} + +func AddUserBlockingTable(x *xorm.Engine) error { + return x.Sync(&Blocking{}) +} diff --git a/models/org.go b/models/org.go index 5f61f05b16a..69cc47137eb 100644 --- a/models/org.go +++ b/models/org.go @@ -12,15 +12,16 @@ import ( "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" ) // RemoveOrgUser removes user from given organization. -func RemoveOrgUser(ctx context.Context, orgID, userID int64) error { +func RemoveOrgUser(ctx context.Context, org *organization.Organization, user *user_model.User) error { ou := new(organization.OrgUser) has, err := db.GetEngine(ctx). - Where("uid=?", userID). - And("org_id=?", orgID). + Where("uid=?", user.ID). + And("org_id=?", org.ID). Get(ou) if err != nil { return fmt.Errorf("get org-user: %w", err) @@ -28,13 +29,8 @@ func RemoveOrgUser(ctx context.Context, orgID, userID int64) error { return nil } - org, err := organization.GetOrgByID(ctx, orgID) - if err != nil { - return fmt.Errorf("GetUserByID [%d]: %w", orgID, err) - } - // Check if the user to delete is the last member in owner team. - if isOwner, err := organization.IsOrganizationOwner(ctx, orgID, userID); err != nil { + if isOwner, err := organization.IsOrganizationOwner(ctx, org.ID, user.ID); err != nil { return err } else if isOwner { t, err := organization.GetOwnerTeam(ctx, org.ID) @@ -45,8 +41,8 @@ func RemoveOrgUser(ctx context.Context, orgID, userID int64) error { if err := t.LoadMembers(ctx); err != nil { return err } - if t.Members[0].ID == userID { - return organization.ErrLastOrgOwner{UID: userID} + if t.Members[0].ID == user.ID { + return organization.ErrLastOrgOwner{UID: user.ID} } } } @@ -59,28 +55,32 @@ func RemoveOrgUser(ctx context.Context, orgID, userID int64) error { if _, err := db.DeleteByID[organization.OrgUser](ctx, ou.ID); err != nil { return err - } else if _, err = db.Exec(ctx, "UPDATE `user` SET num_members=num_members-1 WHERE id=?", orgID); err != nil { + } else if _, err = db.Exec(ctx, "UPDATE `user` SET num_members=num_members-1 WHERE id=?", org.ID); err != nil { return err } // Delete all repository accesses and unwatch them. - env, err := organization.AccessibleReposEnv(ctx, org, userID) + env, err := organization.AccessibleReposEnv(ctx, org, user.ID) if err != nil { return fmt.Errorf("AccessibleReposEnv: %w", err) } repoIDs, err := env.RepoIDs(1, org.NumRepos) if err != nil { - return fmt.Errorf("GetUserRepositories [%d]: %w", userID, err) + return fmt.Errorf("GetUserRepositories [%d]: %w", user.ID, err) } for _, repoID := range repoIDs { - if err = repo_model.WatchRepo(ctx, userID, repoID, false); err != nil { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return err + } + if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil { return err } } if len(repoIDs) > 0 { if _, err = db.GetEngine(ctx). - Where("user_id = ?", userID). + Where("user_id = ?", user.ID). In("repo_id", repoIDs). Delete(new(access_model.Access)); err != nil { return err @@ -88,12 +88,12 @@ func RemoveOrgUser(ctx context.Context, orgID, userID int64) error { } // Delete member in their teams. - teams, err := organization.GetUserOrgTeams(ctx, org.ID, userID) + teams, err := organization.GetUserOrgTeams(ctx, org.ID, user.ID) if err != nil { return err } for _, t := range teams { - if err = removeTeamMember(ctx, t, userID); err != nil { + if err = removeTeamMember(ctx, t, user); err != nil { return err } } diff --git a/models/org_team.go b/models/org_team.go index 1a452436c3e..aecf0d80fd8 100644 --- a/models/org_team.go +++ b/models/org_team.go @@ -44,7 +44,7 @@ func AddRepository(ctx context.Context, t *organization.Team, repo *repo_model.R return fmt.Errorf("getMembers: %w", err) } for _, u := range t.Members { - if err = repo_model.WatchRepo(ctx, u.ID, repo.ID, true); err != nil { + if err = repo_model.WatchRepo(ctx, u, repo, true); err != nil { return fmt.Errorf("watchRepo: %w", err) } } @@ -125,7 +125,7 @@ func removeAllRepositories(ctx context.Context, t *organization.Team) (err error continue } - if err = repo_model.WatchRepo(ctx, user.ID, repo.ID, false); err != nil { + if err = repo_model.WatchRepo(ctx, user, repo, false); err != nil { return err } @@ -341,7 +341,7 @@ func DeleteTeam(ctx context.Context, t *organization.Team) error { } for _, tm := range t.Members { - if err := removeInvalidOrgUser(ctx, tm.ID, t.OrgID); err != nil { + if err := removeInvalidOrgUser(ctx, t.OrgID, tm); err != nil { return err } } @@ -356,19 +356,23 @@ func DeleteTeam(ctx context.Context, t *organization.Team) error { // AddTeamMember adds new membership of given team to given organization, // the user will have membership to given organization automatically when needed. -func AddTeamMember(ctx context.Context, team *organization.Team, userID int64) error { - isAlreadyMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, userID) +func AddTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { + if user_model.IsUserBlockedBy(ctx, user, team.OrgID) { + return user_model.ErrBlockedUser + } + + isAlreadyMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) if err != nil || isAlreadyMember { return err } - if err := organization.AddOrgUser(ctx, team.OrgID, userID); err != nil { + if err := organization.AddOrgUser(ctx, team.OrgID, user.ID); err != nil { return err } err = db.WithTx(ctx, func(ctx context.Context) error { // check in transaction - isAlreadyMember, err = organization.IsTeamMember(ctx, team.OrgID, team.ID, userID) + isAlreadyMember, err = organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) if err != nil || isAlreadyMember { return err } @@ -376,7 +380,7 @@ func AddTeamMember(ctx context.Context, team *organization.Team, userID int64) e sess := db.GetEngine(ctx) if err := db.Insert(ctx, &organization.TeamUser{ - UID: userID, + UID: user.ID, OrgID: team.OrgID, TeamID: team.ID, }); err != nil { @@ -392,7 +396,7 @@ func AddTeamMember(ctx context.Context, team *organization.Team, userID int64) e subQuery := builder.Select("repo_id").From("team_repo"). Where(builder.Eq{"team_id": team.ID}) - if _, err := sess.Where("user_id=?", userID). + if _, err := sess.Where("user_id=?", user.ID). In("repo_id", subQuery). And("mode < ?", team.AccessMode). SetExpr("mode", team.AccessMode). @@ -402,14 +406,14 @@ func AddTeamMember(ctx context.Context, team *organization.Team, userID int64) e // for not exist access var repoIDs []int64 - accessSubQuery := builder.Select("repo_id").From("access").Where(builder.Eq{"user_id": userID}) + accessSubQuery := builder.Select("repo_id").From("access").Where(builder.Eq{"user_id": user.ID}) if err := sess.SQL(subQuery.And(builder.NotIn("repo_id", accessSubQuery))).Find(&repoIDs); err != nil { return fmt.Errorf("select id accesses: %w", err) } accesses := make([]*access_model.Access, 0, 100) for i, repoID := range repoIDs { - accesses = append(accesses, &access_model.Access{RepoID: repoID, UserID: userID, Mode: team.AccessMode}) + accesses = append(accesses, &access_model.Access{RepoID: repoID, UserID: user.ID, Mode: team.AccessMode}) if (i%100 == 0 || i == len(repoIDs)-1) && len(accesses) > 0 { if err = db.Insert(ctx, accesses); err != nil { return fmt.Errorf("insert new user accesses: %w", err) @@ -430,10 +434,11 @@ func AddTeamMember(ctx context.Context, team *organization.Team, userID int64) e if err := team.LoadRepositories(ctx); err != nil { log.Error("team.LoadRepositories failed: %v", err) } + // FIXME: in the goroutine, it can't access the "ctx", it could only use db.DefaultContext at the moment go func(repos []*repo_model.Repository) { for _, repo := range repos { - if err = repo_model.WatchRepo(db.DefaultContext, userID, repo.ID, true); err != nil { + if err = repo_model.WatchRepo(db.DefaultContext, user, repo, true); err != nil { log.Error("watch repo failed: %v", err) } } @@ -443,16 +448,16 @@ func AddTeamMember(ctx context.Context, team *organization.Team, userID int64) e return nil } -func removeTeamMember(ctx context.Context, team *organization.Team, userID int64) error { +func removeTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { e := db.GetEngine(ctx) - isMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, userID) + isMember, err := organization.IsTeamMember(ctx, team.OrgID, team.ID, user.ID) if err != nil || !isMember { return err } // Check if the user to delete is the last member in owner team. if team.IsOwnerTeam() && team.NumMembers == 1 { - return organization.ErrLastOrgOwner{UID: userID} + return organization.ErrLastOrgOwner{UID: user.ID} } team.NumMembers-- @@ -462,7 +467,7 @@ func removeTeamMember(ctx context.Context, team *organization.Team, userID int64 } if _, err := e.Delete(&organization.TeamUser{ - UID: userID, + UID: user.ID, OrgID: team.OrgID, TeamID: team.ID, }); err != nil { @@ -476,76 +481,76 @@ func removeTeamMember(ctx context.Context, team *organization.Team, userID int64 // Delete access to team repositories. for _, repo := range team.Repos { - if err := access_model.RecalculateUserAccess(ctx, repo, userID); err != nil { + if err := access_model.RecalculateUserAccess(ctx, repo, user.ID); err != nil { return err } // Remove watches from now unaccessible - if err := ReconsiderWatches(ctx, repo, userID); err != nil { + if err := ReconsiderWatches(ctx, repo, user); err != nil { return err } // Remove issue assignments from now unaccessible - if err := ReconsiderRepoIssuesAssignee(ctx, repo, userID); err != nil { + if err := ReconsiderRepoIssuesAssignee(ctx, repo, user); err != nil { return err } } - return removeInvalidOrgUser(ctx, userID, team.OrgID) + return removeInvalidOrgUser(ctx, team.OrgID, user) } -func removeInvalidOrgUser(ctx context.Context, userID, orgID int64) error { +func removeInvalidOrgUser(ctx context.Context, orgID int64, user *user_model.User) error { // Check if the user is a member of any team in the organization. if count, err := db.GetEngine(ctx).Count(&organization.TeamUser{ - UID: userID, + UID: user.ID, OrgID: orgID, }); err != nil { return err } else if count == 0 { - return RemoveOrgUser(ctx, orgID, userID) + org, err := organization.GetOrgByID(ctx, orgID) + if err != nil { + return err + } + + return RemoveOrgUser(ctx, org, user) } return nil } // RemoveTeamMember removes member from given team of given organization. -func RemoveTeamMember(ctx context.Context, team *organization.Team, userID int64) error { +func RemoveTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error { ctx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if err := removeTeamMember(ctx, team, userID); err != nil { + if err := removeTeamMember(ctx, team, user); err != nil { return err } return committer.Commit() } -func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, uid int64) error { - user, err := user_model.GetUserByID(ctx, uid) - if err != nil { - return err - } - +func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error { if canAssigned, err := access_model.CanBeAssigned(ctx, user, repo, true); err != nil || canAssigned { return err } - if _, err := db.GetEngine(ctx).Where(builder.Eq{"assignee_id": uid}). + if _, err := db.GetEngine(ctx).Where(builder.Eq{"assignee_id": user.ID}). In("issue_id", builder.Select("id").From("issue").Where(builder.Eq{"repo_id": repo.ID})). Delete(&issues_model.IssueAssignees{}); err != nil { - return fmt.Errorf("Could not delete assignee[%d] %w", uid, err) + return fmt.Errorf("Could not delete assignee[%d] %w", user.ID, err) } return nil } -func ReconsiderWatches(ctx context.Context, repo *repo_model.Repository, uid int64) error { - if has, err := access_model.HasAccess(ctx, uid, repo); err != nil || has { +func ReconsiderWatches(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error { + if has, err := access_model.HasAccess(ctx, user.ID, repo); err != nil || has { return err } - if err := repo_model.WatchRepo(ctx, uid, repo.ID, false); err != nil { + if err := repo_model.WatchRepo(ctx, user, repo, false); err != nil { return err } // Remove all IssueWatches a user has subscribed to in the repository - return issues_model.RemoveIssueWatchersByRepoID(ctx, uid, repo.ID) + return issues_model.RemoveIssueWatchersByRepoID(ctx, user.ID, repo.ID) } diff --git a/models/org_team_test.go b/models/org_team_test.go index e4b7b917e85..cf2c8be536d 100644 --- a/models/org_team_test.go +++ b/models/org_team_test.go @@ -21,33 +21,42 @@ import ( func TestTeam_AddMember(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - test := func(teamID, userID int64) { - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.NoError(t, AddTeamMember(db.DefaultContext, team, userID)) - unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: userID, TeamID: teamID}) - unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &user_model.User{ID: team.OrgID}) + test := func(team *organization.Team, user *user_model.User) { + assert.NoError(t, AddTeamMember(db.DefaultContext, team, user)) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID}) + unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID}, &user_model.User{ID: team.OrgID}) } - test(1, 2) - test(1, 4) - test(3, 2) + + team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) + team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + test(team1, user2) + test(team1, user4) + test(team3, user2) } func TestTeam_RemoveMember(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - testSuccess := func(teamID, userID int64) { - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.NoError(t, RemoveTeamMember(db.DefaultContext, team, userID)) - unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: userID, TeamID: teamID}) - unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}) + testSuccess := func(team *organization.Team, user *user_model.User) { + assert.NoError(t, RemoveTeamMember(db.DefaultContext, team, user)) + unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID}) + unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID}) } - testSuccess(1, 4) - testSuccess(2, 2) - testSuccess(3, 2) - testSuccess(3, unittest.NonexistentID) - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) - err := RemoveTeamMember(db.DefaultContext, team, 2) + team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) + team2 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + testSuccess(team1, user4) + testSuccess(team2, user2) + testSuccess(team3, user2) + + err := RemoveTeamMember(db.DefaultContext, team1, user2) assert.True(t, organization.IsErrLastOrgOwner(err)) } @@ -120,33 +129,42 @@ func TestDeleteTeam(t *testing.T) { func TestAddTeamMember(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - test := func(teamID, userID int64) { - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.NoError(t, AddTeamMember(db.DefaultContext, team, userID)) - unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: userID, TeamID: teamID}) - unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &user_model.User{ID: team.OrgID}) + test := func(team *organization.Team, user *user_model.User) { + assert.NoError(t, AddTeamMember(db.DefaultContext, team, user)) + unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID}) + unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID}, &user_model.User{ID: team.OrgID}) } - test(1, 2) - test(1, 4) - test(3, 2) + + team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) + team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + test(team1, user2) + test(team1, user4) + test(team3, user2) } func TestRemoveTeamMember(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - testSuccess := func(teamID, userID int64) { - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.NoError(t, RemoveTeamMember(db.DefaultContext, team, userID)) - unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: userID, TeamID: teamID}) - unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}) + testSuccess := func(team *organization.Team, user *user_model.User) { + assert.NoError(t, RemoveTeamMember(db.DefaultContext, team, user)) + unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: user.ID, TeamID: team.ID}) + unittest.CheckConsistencyFor(t, &organization.Team{ID: team.ID}) } - testSuccess(1, 4) - testSuccess(2, 2) - testSuccess(3, 2) - testSuccess(3, unittest.NonexistentID) - team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) - err := RemoveTeamMember(db.DefaultContext, team, 2) + team1 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) + team2 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}) + team3 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 3}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + testSuccess(team1, user4) + testSuccess(team2, user2) + testSuccess(team3, user2) + + err := RemoveTeamMember(db.DefaultContext, team1, user2) assert.True(t, organization.IsErrLastOrgOwner(err)) } @@ -155,15 +173,15 @@ func TestRepository_RecalculateAccesses3(t *testing.T) { team5 := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 5}) user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) - has, err := db.GetEngine(db.DefaultContext).Get(&access_model.Access{UserID: 29, RepoID: 23}) + has, err := db.GetEngine(db.DefaultContext).Get(&access_model.Access{UserID: user29.ID, RepoID: 23}) assert.NoError(t, err) assert.False(t, has) // adding user29 to team5 should add an explicit access row for repo 23 // even though repo 23 is public - assert.NoError(t, AddTeamMember(db.DefaultContext, team5, user29.ID)) + assert.NoError(t, AddTeamMember(db.DefaultContext, team5, user29)) - has, err = db.GetEngine(db.DefaultContext).Get(&access_model.Access{UserID: 29, RepoID: 23}) + has, err = db.GetEngine(db.DefaultContext).Get(&access_model.Access{UserID: user29.ID, RepoID: 23}) assert.NoError(t, err) assert.True(t, has) } diff --git a/models/org_test.go b/models/org_test.go index d10a1dc218d..247530406d6 100644 --- a/models/org_test.go +++ b/models/org_test.go @@ -16,22 +16,27 @@ import ( func TestUser_RemoveMember(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) // remove a user that is a member - unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{UID: 4, OrgID: 3}) + unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{UID: user4.ID, OrgID: org.ID}) prevNumMembers := org.NumMembers - assert.NoError(t, RemoveOrgUser(db.DefaultContext, org.ID, 4)) - unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: 4, OrgID: 3}) - org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + assert.NoError(t, RemoveOrgUser(db.DefaultContext, org, user4)) + unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: user4.ID, OrgID: org.ID}) + + org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: org.ID}) assert.Equal(t, prevNumMembers-1, org.NumMembers) // remove a user that is not a member - unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: 5, OrgID: 3}) + unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: user5.ID, OrgID: org.ID}) prevNumMembers = org.NumMembers - assert.NoError(t, RemoveOrgUser(db.DefaultContext, org.ID, 5)) - unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: 5, OrgID: 3}) - org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + assert.NoError(t, RemoveOrgUser(db.DefaultContext, org, user5)) + unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: user5.ID, OrgID: org.ID}) + + org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: org.ID}) assert.Equal(t, prevNumMembers, org.NumMembers) unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{}) @@ -39,23 +44,31 @@ func TestUser_RemoveMember(t *testing.T) { func TestRemoveOrgUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - testSuccess := func(orgID, userID int64) { - org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: orgID}) + + testSuccess := func(org *organization.Organization, user *user_model.User) { expectedNumMembers := org.NumMembers - if unittest.BeanExists(t, &organization.OrgUser{OrgID: orgID, UID: userID}) { + if unittest.BeanExists(t, &organization.OrgUser{OrgID: org.ID, UID: user.ID}) { expectedNumMembers-- } - assert.NoError(t, RemoveOrgUser(db.DefaultContext, orgID, userID)) - unittest.AssertNotExistsBean(t, &organization.OrgUser{OrgID: orgID, UID: userID}) - org = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: orgID}) + assert.NoError(t, RemoveOrgUser(db.DefaultContext, org, user)) + unittest.AssertNotExistsBean(t, &organization.OrgUser{OrgID: org.ID, UID: user.ID}) + org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: org.ID}) assert.EqualValues(t, expectedNumMembers, org.NumMembers) } - testSuccess(3, 4) - testSuccess(3, 4) - err := RemoveOrgUser(db.DefaultContext, 7, 5) + org3 := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + org7 := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 7}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + testSuccess(org3, user4) + + org3 = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) + testSuccess(org3, user4) + + err := RemoveOrgUser(db.DefaultContext, org7, user5) assert.Error(t, err) assert.True(t, organization.IsErrLastOrgOwner(err)) - unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{OrgID: 7, UID: 5}) + unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{OrgID: org7.ID, UID: user5.ID}) unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{}) } diff --git a/models/organization/org.go b/models/organization/org.go index b4919defb43..a3082e9ac74 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -400,6 +400,7 @@ func DeleteOrganization(ctx context.Context, org *Organization) error { &TeamUnit{OrgID: org.ID}, &TeamInvite{OrgID: org.ID}, &secret_model.Secret{OwnerID: org.ID}, + &user_model.Blocking{BlockerID: org.ID}, ); err != nil { return fmt.Errorf("DeleteBeans: %w", err) } diff --git a/models/organization/team_user.go b/models/organization/team_user.go index ab767db2000..d6d0a5054dd 100644 --- a/models/organization/team_user.go +++ b/models/organization/team_user.go @@ -30,14 +30,6 @@ func IsTeamMember(ctx context.Context, orgID, teamID, userID int64) (bool, error Exist() } -// GetTeamUsersByTeamID returns team users for a team -func GetTeamUsersByTeamID(ctx context.Context, teamID int64) ([]*TeamUser, error) { - teamUsers := make([]*TeamUser, 0, 10) - return teamUsers, db.GetEngine(ctx). - Where("team_id=?", teamID). - Find(&teamUsers) -} - // SearchMembersOptions holds the search options type SearchMembersOptions struct { db.ListOptions diff --git a/models/perm/access/access.go b/models/perm/access/access.go index 3e2568b4b42..b422a086149 100644 --- a/models/perm/access/access.go +++ b/models/perm/access/access.go @@ -128,9 +128,9 @@ func refreshAccesses(ctx context.Context, repo *repo_model.Repository, accessMap // refreshCollaboratorAccesses retrieves repository collaborations with their access modes. func refreshCollaboratorAccesses(ctx context.Context, repoID int64, accessMap map[int64]*userAccess) error { - collaborators, err := repo_model.GetCollaborators(ctx, repoID, db.ListOptions{}) + collaborators, _, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{RepoID: repoID}) if err != nil { - return fmt.Errorf("getCollaborations: %w", err) + return fmt.Errorf("GetCollaborators: %w", err) } for _, c := range collaborators { if c.User.IsGhost() { diff --git a/models/repo/collaboration.go b/models/repo/collaboration.go index 72880826147..272c6ac05bd 100644 --- a/models/repo/collaboration.go +++ b/models/repo/collaboration.go @@ -36,14 +36,44 @@ type Collaborator struct { Collaboration *Collaboration } +type FindCollaborationOptions struct { + db.ListOptions + RepoID int64 + RepoOwnerID int64 + CollaboratorID int64 +} + +func (opts *FindCollaborationOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID != 0 { + cond = cond.And(builder.Eq{"collaboration.repo_id": opts.RepoID}) + } + if opts.RepoOwnerID != 0 { + cond = cond.And(builder.Eq{"repository.owner_id": opts.RepoOwnerID}) + } + if opts.CollaboratorID != 0 { + cond = cond.And(builder.Eq{"collaboration.user_id": opts.CollaboratorID}) + } + return cond +} + +func (opts *FindCollaborationOptions) ToJoins() []db.JoinFunc { + if opts.RepoOwnerID != 0 { + return []db.JoinFunc{ + func(e db.Engine) error { + e.Join("INNER", "repository", "repository.id = collaboration.repo_id") + return nil + }, + } + } + return nil +} + // GetCollaborators returns the collaborators for a repository -func GetCollaborators(ctx context.Context, repoID int64, listOptions db.ListOptions) ([]*Collaborator, error) { - collaborations, err := db.Find[Collaboration](ctx, FindCollaborationOptions{ - ListOptions: listOptions, - RepoID: repoID, - }) +func GetCollaborators(ctx context.Context, opts *FindCollaborationOptions) ([]*Collaborator, int64, error) { + collaborations, total, err := db.FindAndCount[Collaboration](ctx, opts) if err != nil { - return nil, fmt.Errorf("db.Find[Collaboration]: %w", err) + return nil, 0, fmt.Errorf("db.FindAndCount[Collaboration]: %w", err) } collaborators := make([]*Collaborator, 0, len(collaborations)) @@ -54,7 +84,7 @@ func GetCollaborators(ctx context.Context, repoID int64, listOptions db.ListOpti usersMap := make(map[int64]*user_model.User) if err := db.GetEngine(ctx).In("id", userIDs).Find(&usersMap); err != nil { - return nil, fmt.Errorf("Find users map by user ids: %w", err) + return nil, 0, fmt.Errorf("Find users map by user ids: %w", err) } for _, c := range collaborations { @@ -67,7 +97,7 @@ func GetCollaborators(ctx context.Context, repoID int64, listOptions db.ListOpti Collaboration: c, }) } - return collaborators, nil + return collaborators, total, nil } // GetCollaboration get collaboration for a repository id with a user id @@ -88,15 +118,6 @@ func IsCollaborator(ctx context.Context, repoID, userID int64) (bool, error) { return db.GetEngine(ctx).Get(&Collaboration{RepoID: repoID, UserID: userID}) } -type FindCollaborationOptions struct { - db.ListOptions - RepoID int64 -} - -func (opts FindCollaborationOptions) ToConds() builder.Cond { - return builder.And(builder.Eq{"repo_id": opts.RepoID}) -} - // ChangeCollaborationAccessMode sets new access mode for the collaboration. func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid int64, mode perm.AccessMode) error { // Discard invalid input diff --git a/models/repo/collaboration_test.go b/models/repo/collaboration_test.go index 21a99dd5573..639050f5fd0 100644 --- a/models/repo/collaboration_test.go +++ b/models/repo/collaboration_test.go @@ -19,7 +19,7 @@ func TestRepository_GetCollaborators(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) test := func(repoID int64) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) - collaborators, err := repo_model.GetCollaborators(db.DefaultContext, repo.ID, db.ListOptions{}) + collaborators, _, err := repo_model.GetCollaborators(db.DefaultContext, &repo_model.FindCollaborationOptions{RepoID: repo.ID}) assert.NoError(t, err) expectedLen, err := db.GetEngine(db.DefaultContext).Count(&repo_model.Collaboration{RepoID: repoID}) assert.NoError(t, err) @@ -37,11 +37,17 @@ func TestRepository_GetCollaborators(t *testing.T) { // Test db.ListOptions repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22}) - collaborators1, err := repo_model.GetCollaborators(db.DefaultContext, repo.ID, db.ListOptions{PageSize: 1, Page: 1}) + collaborators1, _, err := repo_model.GetCollaborators(db.DefaultContext, &repo_model.FindCollaborationOptions{ + ListOptions: db.ListOptions{PageSize: 1, Page: 1}, + RepoID: repo.ID, + }) assert.NoError(t, err) assert.Len(t, collaborators1, 1) - collaborators2, err := repo_model.GetCollaborators(db.DefaultContext, repo.ID, db.ListOptions{PageSize: 1, Page: 2}) + collaborators2, _, err := repo_model.GetCollaborators(db.DefaultContext, &repo_model.FindCollaborationOptions{ + ListOptions: db.ListOptions{PageSize: 1, Page: 2}, + RepoID: repo.ID, + }) assert.NoError(t, err) assert.Len(t, collaborators2, 1) @@ -85,31 +91,6 @@ func TestRepository_ChangeCollaborationAccessMode(t *testing.T) { unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) } -func TestRepository_CountCollaborators(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) - count, err := db.Count[repo_model.Collaboration](db.DefaultContext, repo_model.FindCollaborationOptions{ - RepoID: repo1.ID, - }) - assert.NoError(t, err) - assert.EqualValues(t, 2, count) - - repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22}) - count, err = db.Count[repo_model.Collaboration](db.DefaultContext, repo_model.FindCollaborationOptions{ - RepoID: repo2.ID, - }) - assert.NoError(t, err) - assert.EqualValues(t, 2, count) - - // Non-existent repository. - count, err = db.Count[repo_model.Collaboration](db.DefaultContext, repo_model.FindCollaborationOptions{ - RepoID: unittest.NonexistentID, - }) - assert.NoError(t, err) - assert.EqualValues(t, 0, count) -} - func TestRepository_IsOwnerMemberCollaborator(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/models/repo/repo_test.go b/models/repo/repo_test.go index 1a870224bf5..c13b698abf1 100644 --- a/models/repo/repo_test.go +++ b/models/repo/repo_test.go @@ -64,16 +64,17 @@ func TestRepoAPIURL(t *testing.T) { func TestWatchRepo(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - const repoID = 3 - const userID = 2 - assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, userID, repoID, true)) - unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{RepoID: repoID, UserID: userID}) - unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, userID, repoID, false)) - unittest.AssertNotExistsBean(t, &repo_model.Watch{RepoID: repoID, UserID: userID}) - unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}) + assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{RepoID: repo.ID, UserID: user.ID}) + unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) + + assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, user, repo, false)) + unittest.AssertNotExistsBean(t, &repo_model.Watch{RepoID: repo.ID, UserID: user.ID}) + unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) } func TestMetas(t *testing.T) { diff --git a/models/repo/star.go b/models/repo/star.go index 60737149da8..4c66855525f 100644 --- a/models/repo/star.go +++ b/models/repo/star.go @@ -24,26 +24,30 @@ func init() { } // StarRepo or unstar repository. -func StarRepo(ctx context.Context, userID, repoID int64, star bool) error { +func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star bool) error { ctx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - staring := IsStaring(ctx, userID, repoID) + staring := IsStaring(ctx, doer.ID, repo.ID) if star { + if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) { + return user_model.ErrBlockedUser + } + if staring { return nil } - if err := db.Insert(ctx, &Star{UID: userID, RepoID: repoID}); err != nil { + if err := db.Insert(ctx, &Star{UID: doer.ID, RepoID: repo.ID}); err != nil { return err } - if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars + 1 WHERE id = ?", repoID); err != nil { + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars + 1 WHERE id = ?", repo.ID); err != nil { return err } - if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars + 1 WHERE id = ?", userID); err != nil { + if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars + 1 WHERE id = ?", doer.ID); err != nil { return err } } else { @@ -51,13 +55,13 @@ func StarRepo(ctx context.Context, userID, repoID int64, star bool) error { return nil } - if _, err := db.DeleteByBean(ctx, &Star{UID: userID, RepoID: repoID}); err != nil { + if _, err := db.DeleteByBean(ctx, &Star{UID: doer.ID, RepoID: repo.ID}); err != nil { return err } - if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars - 1 WHERE id = ?", repoID); err != nil { + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars - 1 WHERE id = ?", repo.ID); err != nil { return err } - if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars - 1 WHERE id = ?", userID); err != nil { + if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars - 1 WHERE id = ?", doer.ID); err != nil { return err } } diff --git a/models/repo/star_test.go b/models/repo/star_test.go index 62eac4e29a8..aaac89d975d 100644 --- a/models/repo/star_test.go +++ b/models/repo/star_test.go @@ -9,21 +9,24 @@ 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" ) func TestStarRepo(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - const userID = 2 - const repoID = 1 - unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) - assert.NoError(t, repo_model.StarRepo(db.DefaultContext, userID, repoID, true)) - unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) - assert.NoError(t, repo_model.StarRepo(db.DefaultContext, userID, repoID, true)) - unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) - assert.NoError(t, repo_model.StarRepo(db.DefaultContext, userID, repoID, false)) - unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.StarRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.StarRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.StarRepo(db.DefaultContext, user, repo, false)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) } func TestIsStaring(t *testing.T) { @@ -54,17 +57,18 @@ func TestRepository_GetStargazers2(t *testing.T) { func TestClearRepoStars(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - const userID = 2 - const repoID = 1 - unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) - assert.NoError(t, repo_model.StarRepo(db.DefaultContext, userID, repoID, true)) - unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) - assert.NoError(t, repo_model.StarRepo(db.DefaultContext, userID, repoID, false)) - unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) - assert.NoError(t, repo_model.ClearRepoStars(db.DefaultContext, repoID)) - unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID, RepoID: repoID}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.StarRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.StarRepo(db.DefaultContext, user, repo, false)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.ClearRepoStars(db.DefaultContext, repo.ID)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + gazers, err := repo_model.GetStargazers(db.DefaultContext, repo, db.ListOptions{Page: 0}) assert.NoError(t, err) assert.Len(t, gazers, 0) diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index 30c9db7474d..68622476578 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -16,47 +16,82 @@ import ( "xorm.io/builder" ) -// GetStarredRepos returns the repos starred by a particular user -func GetStarredRepos(ctx context.Context, userID int64, private bool, listOptions db.ListOptions) ([]*Repository, error) { - sess := db.GetEngine(ctx). - Where("star.uid=?", userID). - Join("LEFT", "star", "`repository`.id=`star`.repo_id") - if !private { - sess = sess.And("is_private=?", false) - } +type StarredReposOptions struct { + db.ListOptions + StarrerID int64 + RepoOwnerID int64 + IncludePrivate bool +} - if listOptions.Page != 0 { - sess = db.SetSessionPagination(sess, &listOptions) +func (opts *StarredReposOptions) ToConds() builder.Cond { + var cond builder.Cond = builder.Eq{ + "star.uid": opts.StarrerID, + } + if opts.RepoOwnerID != 0 { + cond = cond.And(builder.Eq{ + "repository.owner_id": opts.RepoOwnerID, + }) + } + if !opts.IncludePrivate { + cond = cond.And(builder.Eq{ + "repository.is_private": false, + }) + } + return cond +} - repos := make([]*Repository, 0, listOptions.PageSize) - return repos, sess.Find(&repos) +func (opts *StarredReposOptions) ToJoins() []db.JoinFunc { + return []db.JoinFunc{ + func(e db.Engine) error { + e.Join("INNER", "star", "`repository`.id=`star`.repo_id") + return nil + }, } +} + +// GetStarredRepos returns the repos starred by a particular user +func GetStarredRepos(ctx context.Context, opts *StarredReposOptions) ([]*Repository, error) { + return db.Find[Repository](ctx, opts) +} - repos := make([]*Repository, 0, 10) - return repos, sess.Find(&repos) +type WatchedReposOptions struct { + db.ListOptions + WatcherID int64 + RepoOwnerID int64 + IncludePrivate bool } -// GetWatchedRepos returns the repos watched by a particular user -func GetWatchedRepos(ctx context.Context, userID int64, private bool, listOptions db.ListOptions) ([]*Repository, int64, error) { - sess := db.GetEngine(ctx). - Where("watch.user_id=?", userID). - And("`watch`.mode<>?", WatchModeDont). - Join("LEFT", "watch", "`repository`.id=`watch`.repo_id") - if !private { - sess = sess.And("is_private=?", false) +func (opts *WatchedReposOptions) ToConds() builder.Cond { + var cond builder.Cond = builder.Eq{ + "watch.user_id": opts.WatcherID, } + if opts.RepoOwnerID != 0 { + cond = cond.And(builder.Eq{ + "repository.owner_id": opts.RepoOwnerID, + }) + } + if !opts.IncludePrivate { + cond = cond.And(builder.Eq{ + "repository.is_private": false, + }) + } + return cond.And(builder.Neq{ + "watch.mode": WatchModeDont, + }) +} - if listOptions.Page != 0 { - sess = db.SetSessionPagination(sess, &listOptions) - - repos := make([]*Repository, 0, listOptions.PageSize) - total, err := sess.FindAndCount(&repos) - return repos, total, err +func (opts *WatchedReposOptions) ToJoins() []db.JoinFunc { + return []db.JoinFunc{ + func(e db.Engine) error { + e.Join("INNER", "watch", "`repository`.id=`watch`.repo_id") + return nil + }, } +} - repos := make([]*Repository, 0, 10) - total, err := sess.FindAndCount(&repos) - return repos, total, err +// GetWatchedRepos returns the repos watched by a particular user +func GetWatchedRepos(ctx context.Context, opts *WatchedReposOptions) ([]*Repository, int64, error) { + return db.FindAndCount[Repository](ctx, opts) } // GetRepoAssignees returns all users that have write access and can be assigned to issues diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go index 7816b0262a5..591dcea5b54 100644 --- a/models/repo/user_repo_test.go +++ b/models/repo/user_repo_test.go @@ -25,10 +25,8 @@ func TestRepoAssignees(t *testing.T) { repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21}) users, err = repo_model.GetRepoAssignees(db.DefaultContext, repo21) assert.NoError(t, err) - assert.Len(t, users, 3) - assert.Equal(t, users[0].ID, int64(15)) - assert.Equal(t, users[1].ID, int64(18)) - assert.Equal(t, users[2].ID, int64(16)) + assert.Len(t, users, 4) + assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID}) } func TestRepoGetReviewers(t *testing.T) { diff --git a/models/repo/watch.go b/models/repo/watch.go index 80da4030cbc..a616544cae3 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -104,29 +104,23 @@ func watchRepoMode(ctx context.Context, watch Watch, mode WatchMode) (err error) return err } -// WatchRepoMode watch repository in specific mode. -func WatchRepoMode(ctx context.Context, userID, repoID int64, mode WatchMode) (err error) { - var watch Watch - if watch, err = GetWatch(ctx, userID, repoID); err != nil { - return err - } - return watchRepoMode(ctx, watch, mode) -} - // WatchRepo watch or unwatch repository. -func WatchRepo(ctx context.Context, userID, repoID int64, doWatch bool) (err error) { - var watch Watch - if watch, err = GetWatch(ctx, userID, repoID); err != nil { +func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doWatch bool) error { + watch, err := GetWatch(ctx, doer.ID, repo.ID) + if err != nil { return err } if !doWatch && watch.Mode == WatchModeAuto { - err = watchRepoMode(ctx, watch, WatchModeDont) + return watchRepoMode(ctx, watch, WatchModeDont) } else if !doWatch { - err = watchRepoMode(ctx, watch, WatchModeNone) - } else { - err = watchRepoMode(ctx, watch, WatchModeNormal) + return watchRepoMode(ctx, watch, WatchModeNone) } - return err + + if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) { + return user_model.ErrBlockedUser + } + + return watchRepoMode(ctx, watch, WatchModeNormal) } // GetWatchers returns all watchers of given repository. diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 7aa899291c2..a95a2679616 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_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" "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" @@ -64,6 +65,8 @@ func TestWatchIfAuto(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user12 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 12}) + watchers, err := repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1}) assert.NoError(t, err) assert.Len(t, watchers, repo.NumWatches) @@ -105,7 +108,7 @@ func TestWatchIfAuto(t *testing.T) { assert.Len(t, watchers, prevCount+1) // Should remove watch, inhibit from adding auto - assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, 12, 1, false)) + assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, user12, repo, false)) watchers, err = repo_model.GetRepoWatchers(db.DefaultContext, repo.ID, db.ListOptions{Page: 1}) assert.NoError(t, err) assert.Len(t, watchers, prevCount) @@ -116,24 +119,3 @@ func TestWatchIfAuto(t *testing.T) { assert.NoError(t, err) assert.Len(t, watchers, prevCount) } - -func TestWatchRepoMode(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0) - - assert.NoError(t, repo_model.WatchRepoMode(db.DefaultContext, 12, 1, repo_model.WatchModeAuto)) - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 1) - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1, Mode: repo_model.WatchModeAuto}, 1) - - assert.NoError(t, repo_model.WatchRepoMode(db.DefaultContext, 12, 1, repo_model.WatchModeNormal)) - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 1) - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1, Mode: repo_model.WatchModeNormal}, 1) - - assert.NoError(t, repo_model.WatchRepoMode(db.DefaultContext, 12, 1, repo_model.WatchModeDont)) - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 1) - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1, Mode: repo_model.WatchModeDont}, 1) - - assert.NoError(t, repo_model.WatchRepoMode(db.DefaultContext, 12, 1, repo_model.WatchModeNone)) - unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0) -} diff --git a/models/repo_transfer.go b/models/repo_transfer.go index 676e2dbb633..747ec2f2488 100644 --- a/models/repo_transfer.go +++ b/models/repo_transfer.go @@ -13,6 +13,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" ) // RepoTransfer is used to manage repository transfers @@ -94,21 +96,46 @@ func (r *RepoTransfer) CanUserAcceptTransfer(ctx context.Context, u *user_model. return allowed } +type PendingRepositoryTransferOptions struct { + RepoID int64 + SenderID int64 + RecipientID int64 +} + +func (opts *PendingRepositoryTransferOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID != 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } + if opts.SenderID != 0 { + cond = cond.And(builder.Eq{"doer_id": opts.SenderID}) + } + if opts.RecipientID != 0 { + cond = cond.And(builder.Eq{"recipient_id": opts.RecipientID}) + } + return cond +} + +func GetPendingRepositoryTransfers(ctx context.Context, opts *PendingRepositoryTransferOptions) ([]*RepoTransfer, error) { + transfers := make([]*RepoTransfer, 0, 10) + return transfers, db.GetEngine(ctx). + Where(opts.ToConds()). + Find(&transfers) +} + // GetPendingRepositoryTransfer fetches the most recent and ongoing transfer // process for the repository func GetPendingRepositoryTransfer(ctx context.Context, repo *repo_model.Repository) (*RepoTransfer, error) { - transfer := new(RepoTransfer) - - has, err := db.GetEngine(ctx).Where("repo_id = ? ", repo.ID).Get(transfer) + transfers, err := GetPendingRepositoryTransfers(ctx, &PendingRepositoryTransferOptions{RepoID: repo.ID}) if err != nil { return nil, err } - if !has { + if len(transfers) != 1 { return nil, ErrNoPendingRepoTransfer{RepoID: repo.ID} } - return transfer, nil + return transfers[0], nil } func DeleteRepositoryTransfer(ctx context.Context, repoID int64) error { diff --git a/models/user/block.go b/models/user/block.go new file mode 100644 index 00000000000..5f2b65a1990 --- /dev/null +++ b/models/user/block.go @@ -0,0 +1,123 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +var ( + ErrBlockOrganization = util.NewInvalidArgumentErrorf("cannot block an organization") + ErrCanNotBlock = util.NewInvalidArgumentErrorf("cannot block the user") + ErrCanNotUnblock = util.NewInvalidArgumentErrorf("cannot unblock the user") + ErrBlockedUser = util.NewPermissionDeniedErrorf("user is blocked") +) + +type Blocking struct { + ID int64 `xorm:"pk autoincr"` + BlockerID int64 `xorm:"UNIQUE(block)"` + Blocker *User `xorm:"-"` + BlockeeID int64 `xorm:"UNIQUE(block)"` + Blockee *User `xorm:"-"` + Note string + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +func (*Blocking) TableName() string { + return "user_blocking" +} + +func init() { + db.RegisterModel(new(Blocking)) +} + +func UpdateBlockingNote(ctx context.Context, id int64, note string) error { + _, err := db.GetEngine(ctx).ID(id).Cols("note").Update(&Blocking{Note: note}) + return err +} + +func IsUserBlockedBy(ctx context.Context, blockee *User, blockerIDs ...int64) bool { + if len(blockerIDs) == 0 { + return false + } + + if blockee.IsAdmin { + return false + } + + cond := builder.Eq{"user_blocking.blockee_id": blockee.ID}. + And(builder.In("user_blocking.blocker_id", blockerIDs)) + + has, _ := db.GetEngine(ctx).Where(cond).Exist(&Blocking{}) + return has +} + +type FindBlockingOptions struct { + db.ListOptions + BlockerID int64 + BlockeeID int64 +} + +func (opts *FindBlockingOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.BlockerID != 0 { + cond = cond.And(builder.Eq{"user_blocking.blocker_id": opts.BlockerID}) + } + if opts.BlockeeID != 0 { + cond = cond.And(builder.Eq{"user_blocking.blockee_id": opts.BlockeeID}) + } + return cond +} + +func FindBlockings(ctx context.Context, opts *FindBlockingOptions) ([]*Blocking, int64, error) { + return db.FindAndCount[Blocking](ctx, opts) +} + +func GetBlocking(ctx context.Context, blockerID, blockeeID int64) (*Blocking, error) { + blocks, _, err := FindBlockings(ctx, &FindBlockingOptions{ + BlockerID: blockerID, + BlockeeID: blockeeID, + }) + if err != nil { + return nil, err + } + if len(blocks) == 0 { + return nil, nil + } + return blocks[0], nil +} + +type BlockingList []*Blocking + +func (blocks BlockingList) LoadAttributes(ctx context.Context) error { + ids := make(container.Set[int64], len(blocks)*2) + for _, b := range blocks { + ids.Add(b.BlockerID) + ids.Add(b.BlockeeID) + } + + userList, err := GetUsersByIDs(ctx, ids.Values()) + if err != nil { + return err + } + + userMap := make(map[int64]*User, len(userList)) + for _, u := range userList { + userMap[u.ID] = u + } + + for _, b := range blocks { + b.Blocker = userMap[b.BlockerID] + b.Blockee = userMap[b.BlockeeID] + } + + return nil +} diff --git a/models/user/follow.go b/models/user/follow.go index f4dd2891ff4..cf9672109a5 100644 --- a/models/user/follow.go +++ b/models/user/follow.go @@ -29,26 +29,30 @@ func IsFollowing(ctx context.Context, userID, followID int64) bool { } // FollowUser marks someone be another's follower. -func FollowUser(ctx context.Context, userID, followID int64) (err error) { - if userID == followID || IsFollowing(ctx, userID, followID) { +func FollowUser(ctx context.Context, user, follow *User) (err error) { + if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) { return nil } + if IsUserBlockedBy(ctx, user, follow.ID) || IsUserBlockedBy(ctx, follow, user.ID) { + return ErrBlockedUser + } + ctx, committer, err := db.TxContext(ctx) if err != nil { return err } defer committer.Close() - if err = db.Insert(ctx, &Follow{UserID: userID, FollowID: followID}); err != nil { + if err = db.Insert(ctx, &Follow{UserID: user.ID, FollowID: follow.ID}); err != nil { return err } - if _, err = db.Exec(ctx, "UPDATE `user` SET num_followers = num_followers + 1 WHERE id = ?", followID); err != nil { + if _, err = db.Exec(ctx, "UPDATE `user` SET num_followers = num_followers + 1 WHERE id = ?", follow.ID); err != nil { return err } - if _, err = db.Exec(ctx, "UPDATE `user` SET num_following = num_following + 1 WHERE id = ?", userID); err != nil { + if _, err = db.Exec(ctx, "UPDATE `user` SET num_following = num_following + 1 WHERE id = ?", user.ID); err != nil { return err } return committer.Commit() diff --git a/models/user/user.go b/models/user/user.go index a898e71a2d7..2e1d6af1763 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1167,7 +1167,7 @@ func IsUserVisibleToViewer(ctx context.Context, u, viewer *User) bool { return false } - // If they follow - they see each over + // If they follow - they see each other follower := IsFollowing(ctx, u.ID, viewer.ID) if follower { return true diff --git a/models/user/user_test.go b/models/user/user_test.go index f522f743d59..f4efd071ead 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -399,14 +399,19 @@ func TestGetUserByOpenID(t *testing.T) { func TestFollowUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - testSuccess := func(followerID, followedID int64) { - assert.NoError(t, user_model.FollowUser(db.DefaultContext, followerID, followedID)) - unittest.AssertExistsAndLoadBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID}) + testSuccess := func(follower, followed *user_model.User) { + assert.NoError(t, user_model.FollowUser(db.DefaultContext, follower, followed)) + unittest.AssertExistsAndLoadBean(t, &user_model.Follow{UserID: follower.ID, FollowID: followed.ID}) } - testSuccess(4, 2) - testSuccess(5, 2) - assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2)) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) + + testSuccess(user4, user2) + testSuccess(user5, user2) + + assert.NoError(t, user_model.FollowUser(db.DefaultContext, user2, user2)) unittest.CheckConsistencyFor(t, &user_model.User{}) } diff --git a/modules/repository/collaborator.go b/modules/repository/collaborator.go index ebe14e3a4c2..f71c58fbdf5 100644 --- a/modules/repository/collaborator.go +++ b/modules/repository/collaborator.go @@ -16,6 +16,14 @@ import ( ) func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error { + if err := repo.LoadOwner(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, u, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, repo.Owner, u.ID) { + return user_model.ErrBlockedUser + } + return db.WithTx(ctx, func(ctx context.Context) error { has, err := db.Exist[repo_model.Collaboration](ctx, builder.Eq{ "repo_id": repo.ID, diff --git a/modules/repository/create.go b/modules/repository/create.go index f009c0880d7..4f18b9b3fa6 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -153,7 +153,7 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re } if setting.Service.AutoWatchNewRepos { - if err = repo_model.WatchRepo(ctx, doer.ID, repo.ID, true); err != nil { + if err = repo_model.WatchRepo(ctx, doer, repo, true); err != nil { return fmt.Errorf("WatchRepo: %w", err) } } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c8c8f2dfebc..255fed28ad1 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -632,6 +632,30 @@ form.name_reserved = The username "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username. form.name_chars_not_allowed = User name "%s" contains invalid characters. +block.block = Block +block.block.user = Block user +block.block.org = Block user for organization +block.block.failure = Failed to block user: %s +block.unblock = Unblock +block.unblock.failure = Failed to unblock user: %s +block.blocked = You have blocked this user. +block.title = Block a user +block.info = Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user. +block.info_1 = Blocking a user prevents the following actions on your account and your repositories: +block.info_2 = following your account +block.info_3 = send you notifications by @mentioning your username +block.info_4 = inviting you as a collaborator to their repositories +block.info_5 = starring, forking or watching on repositories +block.info_6 = opening and commenting on issues or pull requests +block.info_7 = reacting on your comments in issues or pull requests +block.user_to_block = User to block +block.note = Note +block.note.title = Optional note: +block.note.info = The note is not visible to the blocked user. +block.note.edit = Edit note +block.list = Blocked users +block.list.none = You have not blocked any users. + [settings] profile = Profile account = Account @@ -969,6 +993,7 @@ fork_visibility_helper = The visibility of a forked repository cannot be changed fork_branch = Branch to be cloned to the fork all_branches = All branches fork_no_valid_owners = This repository can not be forked because there are no valid owners. +fork.blocked_user = Cannot fork the repository because you are blocked by the repository owner. use_template = Use this template open_with_editor = Open with %s download_zip = Download ZIP @@ -1144,6 +1169,7 @@ watch = Watch unstar = Unstar star = Star fork = Fork +action.blocked_user = Cannot perform action because you are blocked by the repository owner. download_archive = Download Repository more_operations = More Operations @@ -1394,6 +1420,8 @@ issues.new.assignees = Assignees issues.new.clear_assignees = Clear assignees issues.new.no_assignees = No Assignees issues.new.no_reviewers = No reviewers +issues.new.blocked_user = Cannot create issue because you are blocked by the repository owner. +issues.edit.blocked_user = Cannot edit content because you are blocked by the poster or repository owner. issues.choose.get_started = Get Started issues.choose.open_external_link = Open issues.choose.blank = Default @@ -1509,6 +1537,7 @@ issues.close_comment_issue = Comment and Close issues.reopen_issue = Reopen issues.reopen_comment_issue = Comment and Reopen issues.create_comment = Comment +issues.comment.blocked_user = Cannot create or edit comment because you are blocked by the poster or repository owner. issues.closed_at = `closed this issue %[2]s` issues.reopened_at = `reopened this issue %[2]s` issues.commit_ref_at = `referenced this issue from a commit %[2]s` @@ -1707,6 +1736,7 @@ compare.compare_head = compare pulls.desc = Enable pull requests and code reviews. pulls.new = New Pull Request +pulls.new.blocked_user = Cannot create pull request because you are blocked by the repository owner. pulls.view = View Pull Request pulls.compare_changes = New Pull Request pulls.allow_edits_from_maintainers = Allow edits from maintainers @@ -2120,6 +2150,7 @@ settings.convert_fork_succeed = The fork has been converted into a regular repos settings.transfer = Transfer Ownership settings.transfer.rejected = Repository transfer was rejected. settings.transfer.success = Repository transfer was successful. +settings.transfer.blocked_user = Cannot transfer repository because you are blocked by the new owner. settings.transfer_abort = Cancel transfer settings.transfer_abort_invalid = You cannot cancel a non existent repository transfer. settings.transfer_abort_success = The repository transfer to %s was successfully canceled. @@ -2165,6 +2196,7 @@ 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_user = The collaborator is blocked by the repository owner or vice versa. settings.delete_collaborator = Remove settings.collaborator_deletion = Remove Collaborator settings.collaborator_deletion_desc = Removing a collaborator will revoke their access to this repository. Continue? @@ -2731,6 +2763,7 @@ teams.add_nonexistent_repo = "The repository you're trying to add doesn't exist, teams.add_duplicate_users = User is already a team member. teams.repos.none = No repositories could be accessed by this team. teams.members.none = No members on this team. +teams.members.blocked_user = Cannot add the user because it is blocked by the organization. teams.specific_repositories = Specific repositories teams.specific_repositories_helper = Members will only have access to repositories explicitly added to the team. Selecting this will not automatically remove repositories already added with All repositories. teams.all_repositories = All repositories diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 1587d413f55..c65650c3884 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1027,7 +1027,16 @@ func Routes() *web.Route { m.Group("/avatar", func() { m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) m.Delete("", user.DeleteAvatar) - }, reqToken()) + }) + + m.Group("/blocks", func() { + m.Get("", user.ListBlocks) + m.Group("/{username}", func() { + m.Get("", user.CheckUserBlock) + m.Put("", user.BlockUser) + m.Delete("", user.UnblockUser) + }, context.UserAssignmentAPI()) + }) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Repositories (requires repo scope, org scope) @@ -1477,6 +1486,15 @@ func Routes() *web.Route { m.Delete("", org.DeleteAvatar) }, reqToken(), reqOrgOwnership()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) + + m.Group("/blocks", func() { + m.Get("", org.ListBlocks) + m.Group("/{username}", func() { + m.Get("", org.CheckUserBlock) + m.Put("", org.BlockUser) + m.Delete("", 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/block.go b/routers/api/v1/org/block.go new file mode 100644 index 00000000000..69a5222a20a --- /dev/null +++ b/routers/api/v1/org/block.go @@ -0,0 +1,116 @@ +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package org + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +func ListBlocks(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/blocks organization organizationListBlocks + // --- + // summary: List users blocked by the organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // 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 + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/UserList" + + shared.ListBlocks(ctx, ctx.Org.Organization.AsUser()) +} + +func CheckUserBlock(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/blocks/{username} organization organizationCheckUserBlock + // --- + // summary: Check if a user is blocked by the organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: user to check + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + shared.CheckUserBlock(ctx, ctx.Org.Organization.AsUser()) +} + +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/blocks/{username} organization organizationBlockUser + // --- + // summary: Block a user + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: user to block + // type: string + // required: true + // - name: note + // in: query + // description: optional note for the block + // type: string + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.BlockUser(ctx, ctx.Org.Organization.AsUser()) +} + +func UnblockUser(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/blocks/{username} organization organizationUnblockUser + // --- + // summary: Unblock a user + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: user to unblock + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.UnblockUser(ctx, ctx.Doer, ctx.Org.Organization.AsUser()) +} diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go index fb66d4c3f5b..9db9ad964b6 100644 --- a/routers/api/v1/org/member.go +++ b/routers/api/v1/org/member.go @@ -318,7 +318,7 @@ func DeleteMember(ctx *context.APIContext) { if ctx.Written() { return } - if err := models.RemoveOrgUser(ctx, ctx.Org.Organization.ID, member.ID); err != nil { + if err := models.RemoveOrgUser(ctx, ctx.Org.Organization, member); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveOrgUser", err) } ctx.Status(http.StatusNoContent) diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index b62a386fd77..015af774e34 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -15,6 +15,7 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" @@ -486,6 +487,8 @@ func AddTeamMember(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" @@ -493,8 +496,12 @@ func AddTeamMember(ctx *context.APIContext) { if ctx.Written() { return } - if err := models.AddTeamMember(ctx, ctx.Org.Team, u.ID); err != nil { - ctx.Error(http.StatusInternalServerError, "AddMember", err) + if err := models.AddTeamMember(ctx, ctx.Org.Team, u); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "AddTeamMember", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddTeamMember", err) + } return } ctx.Status(http.StatusNoContent) @@ -530,7 +537,7 @@ func RemoveTeamMember(ctx *context.APIContext) { return } - if err := models.RemoveTeamMember(ctx, ctx.Org.Team, u.ID); err != nil { + if err := models.RemoveTeamMember(ctx, ctx.Org.Team, u); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveTeamMember", err) return } diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index 7d48d715169..4ce14f7d018 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -8,7 +8,6 @@ import ( "errors" "net/http" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" @@ -54,15 +53,10 @@ func ListCollaborators(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - count, err := db.Count[repo_model.Collaboration](ctx, repo_model.FindCollaborationOptions{ - RepoID: ctx.Repo.Repository.ID, + collaborators, total, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, }) - if err != nil { - ctx.InternalServerError(err) - return - } - - collaborators, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, utils.GetListOptions(ctx)) if err != nil { ctx.Error(http.StatusInternalServerError, "ListCollaborators", err) return @@ -73,7 +67,7 @@ func ListCollaborators(ctx *context.APIContext) { users[i] = convert.ToUser(ctx, collaborator.User, ctx.Doer) } - ctx.SetTotalCountHeader(count) + ctx.SetTotalCountHeader(total) ctx.JSON(http.StatusOK, users) } @@ -159,6 +153,8 @@ func AddCollaborator(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "422": @@ -182,7 +178,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.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "AddCollaborator", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + } return } @@ -237,7 +237,7 @@ func DeleteCollaborator(ctx *context.APIContext) { return } - if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator.ID); err != nil { + if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteCollaboration", err) return } diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go index 212cc7a93b5..a1e3c9804ba 100644 --- a/routers/api/v1/repo/fork.go +++ b/routers/api/v1/repo/fork.go @@ -149,6 +149,8 @@ func CreateFork(ctx *context.APIContext) { if err != nil { if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) { ctx.Error(http.StatusConflict, "ForkRepository", err) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "ForkRepository", err) } else { ctx.Error(http.StatusInternalServerError, "ForkRepository", err) } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 1b2ecd474be..d43711e362c 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" @@ -653,6 +654,7 @@ func CreateIssue(ctx *context.APIContext) { // "$ref": "#/responses/validationError" // "423": // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.CreateIssueOption) var deadlineUnix timeutil.TimeStamp if form.Deadline != nil && ctx.Repo.CanWrite(unit.TypeIssues) { @@ -710,9 +712,11 @@ 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) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) - return + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "NewIssue", err) + } else { + ctx.Error(http.StatusInternalServerError, "NewIssue", err) } - ctx.Error(http.StatusInternalServerError, "NewIssue", err) return } @@ -848,7 +852,11 @@ func EditIssue(ctx *context.APIContext) { err = issue_service.UpdateAssignees(ctx, issue, oneAssignee, form.Assignees, ctx.Doer) if err != nil { - ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateAssignees", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) + } return } } diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 6209e960af4..21aabadf3dd 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -382,6 +382,7 @@ func CreateIssueComment(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "423": // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.CreateIssueCommentOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -401,7 +402,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.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "CreateIssueComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) + } return } @@ -522,6 +527,7 @@ func EditIssueComment(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "423": // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.EditIssueCommentOption) editIssueComment(ctx, *form) } @@ -610,7 +616,11 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) oldContent := comment.Content comment.Content = form.Body if err := issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil { - ctx.Error(http.StatusInternalServerError, "UpdateComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateComment", err) + } return } diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go index e7436db7982..4096cbf07b0 100644 --- a/routers/api/v1/repo/issue_comment_attachment.go +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -4,10 +4,12 @@ package repo import ( + "errors" "net/http" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -154,6 +156,8 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { // "$ref": "#/responses/Attachment" // "400": // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/error" // "423": @@ -199,7 +203,11 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { } if err = issue_service.UpdateComment(ctx, comment, ctx.Doer, comment.Content); err != nil { - ctx.ServerError("UpdateComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateComment", err) + } else { + ctx.ServerError("UpdateComment", err) + } return } diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go index 799c6878126..3ff3d19f13e 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" 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/context" "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" ) // GetIssueCommentReactions list reactions of a comment from an issue @@ -218,9 +220,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp if isCreateType { // PostIssueCommentReaction part - reaction, err := issues_model.CreateCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction) + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ @@ -434,9 +436,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i if isCreateType { // PostIssueReaction part - reaction, err := issues_model.CreateIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Reaction) + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.Error(http.StatusForbidden, err.Error(), err) } else if issues_model.IsErrReactionAlreadyExist(err) { ctx.JSON(http.StatusOK, api.Reaction{ @@ -445,7 +447,7 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i Created: reaction.CreatedUnix.AsTime(), }) } else { - ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err) + ctx.Error(http.StatusInternalServerError, "CreateIssueReaction", err) } return } diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 8f9848f71d1..4cb94b11a29 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -362,6 +362,8 @@ func CreatePullRequest(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/PullRequest" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" // "409": @@ -510,9 +512,11 @@ func CreatePullRequest(ctx *context.APIContext) { if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) - return + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "NewPullRequest", err) } - ctx.Error(http.StatusInternalServerError, "NewPullRequest", err) return } @@ -630,6 +634,8 @@ func EditPullRequest(ctx *context.APIContext) { if err != nil { if user_model.IsErrUserNotExist(err) { ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateAssignees", err) } else { ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) } diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go index 4f05c0df518..776b336761f 100644 --- a/routers/api/v1/repo/transfer.go +++ b/routers/api/v1/repo/transfer.go @@ -4,6 +4,7 @@ package repo import ( + "errors" "fmt" "net/http" @@ -117,7 +118,11 @@ func Transfer(ctx *context.APIContext) { return } - ctx.InternalServerError(err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.InternalServerError(err) + } return } diff --git a/routers/api/v1/shared/block.go b/routers/api/v1/shared/block.go new file mode 100644 index 00000000000..a1e65625ed3 --- /dev/null +++ b/routers/api/v1/shared/block.go @@ -0,0 +1,98 @@ +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "errors" + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + user_service "code.gitea.io/gitea/services/user" +) + +func ListBlocks(ctx *context.APIContext, blocker *user_model.User) { + blocks, total, err := user_model.FindBlockings(ctx, &user_model.FindBlockingOptions{ + ListOptions: utils.GetListOptions(ctx), + BlockerID: blocker.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindBlockings", err) + return + } + + if err := user_model.BlockingList(blocks).LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + users := make([]*api.User, 0, len(blocks)) + for _, b := range blocks { + users = append(users, convert.ToUser(ctx, b.Blockee, blocker)) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, &users) +} + +func CheckUserBlock(ctx *context.APIContext, blocker *user_model.User) { + blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) + if err != nil { + ctx.NotFound("GetUserByName", err) + return + } + + status := http.StatusNotFound + blocking, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBlocking", err) + return + } + if blocking != nil { + status = http.StatusNoContent + } + + ctx.Status(status) +} + +func BlockUser(ctx *context.APIContext, blocker *user_model.User) { + blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) + if err != nil { + ctx.NotFound("GetUserByName", err) + return + } + + if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, ctx.FormString("note")); err != nil { + if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Error(http.StatusBadRequest, "BlockUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "BlockUser", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +func UnblockUser(ctx *context.APIContext, doer, blocker *user_model.User) { + blockee, err := user_model.GetUserByName(ctx, ctx.Params("username")) + if err != nil { + ctx.NotFound("GetUserByName", err) + return + } + + if err := user_service.UnblockUser(ctx, doer, blocker, blockee); err != nil { + if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Error(http.StatusBadRequest, "UnblockUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "UnblockUser", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/user/block.go b/routers/api/v1/user/block.go new file mode 100644 index 00000000000..7231e9add7a --- /dev/null +++ b/routers/api/v1/user/block.go @@ -0,0 +1,96 @@ +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +func ListBlocks(ctx *context.APIContext) { + // swagger:operation GET /user/blocks user userListBlocks + // --- + // summary: List users blocked by the authenticated user + // 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 + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/UserList" + + shared.ListBlocks(ctx, ctx.Doer) +} + +func CheckUserBlock(ctx *context.APIContext) { + // swagger:operation GET /user/blocks/{username} user userCheckUserBlock + // --- + // summary: Check if a user is blocked by the authenticated user + // parameters: + // - name: username + // in: path + // description: user to check + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + shared.CheckUserBlock(ctx, ctx.Doer) +} + +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/blocks/{username} user userBlockUser + // --- + // summary: Block a user + // parameters: + // - name: username + // in: path + // description: user to block + // type: string + // required: true + // - name: note + // in: query + // description: optional note for the block + // type: string + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.BlockUser(ctx, ctx.Doer) +} + +func UnblockUser(ctx *context.APIContext) { + // swagger:operation DELETE /user/blocks/{username} user userUnblockUser + // --- + // summary: Unblock a user + // parameters: + // - name: username + // in: path + // description: user to unblock + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.UnblockUser(ctx, ctx.Doer, ctx.Doer) +} diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index 398c6b25673..6abb70de194 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" @@ -221,11 +222,17 @@ func Follow(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { - ctx.Error(http.StatusInternalServerError, "FollowUser", err) + if err := user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "FollowUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "FollowUser", err) + } return } ctx.Status(http.StatusNoContent) diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index e624884db36..ad9ed9548d0 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -5,10 +5,9 @@ package user import ( - std_context "context" + "errors" "net/http" - "code.gitea.io/gitea/models/db" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -20,8 +19,12 @@ import ( // getStarredRepos returns the repos that the user with the specified userID has // starred -func getStarredRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, error) { - starredRepos, err := repo_model.GetStarredRepos(ctx, user.ID, private, listOptions) +func getStarredRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, error) { + starredRepos, err := repo_model.GetStarredRepos(ctx, &repo_model.StarredReposOptions{ + ListOptions: utils.GetListOptions(ctx), + StarrerID: user.ID, + IncludePrivate: private, + }) if err != nil { return nil, err } @@ -65,7 +68,7 @@ func GetStarredRepos(ctx *context.APIContext) { // "$ref": "#/responses/notFound" private := ctx.ContextUser.ID == ctx.Doer.ID - repos, err := getStarredRepos(ctx, ctx.ContextUser, private, utils.GetListOptions(ctx)) + repos, err := getStarredRepos(ctx, ctx.ContextUser, private) if err != nil { ctx.Error(http.StatusInternalServerError, "getStarredRepos", err) return @@ -95,7 +98,7 @@ func GetMyStarredRepos(ctx *context.APIContext) { // "200": // "$ref": "#/responses/RepositoryList" - repos, err := getStarredRepos(ctx, ctx.Doer, true, utils.GetListOptions(ctx)) + repos, err := getStarredRepos(ctx, ctx.Doer, true) if err != nil { ctx.Error(http.StatusInternalServerError, "getStarredRepos", err) } @@ -152,12 +155,18 @@ func Star(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - err := repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) if err != nil { - ctx.Error(http.StatusInternalServerError, "StarRepo", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "StarRepo", err) + } return } ctx.Status(http.StatusNoContent) @@ -185,7 +194,7 @@ func Unstar(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err := repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) if err != nil { ctx.Error(http.StatusInternalServerError, "StarRepo", err) return diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 706f4cc66bc..2cc23ae4763 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -4,10 +4,9 @@ package user import ( - std_context "context" + "errors" "net/http" - "code.gitea.io/gitea/models/db" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" @@ -18,8 +17,12 @@ import ( ) // getWatchedRepos returns the repos that the user with the specified userID is watching -func getWatchedRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, int64, error) { - watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, user.ID, private, listOptions) +func getWatchedRepos(ctx *context.APIContext, user *user_model.User, private bool) ([]*api.Repository, int64, error) { + watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, &repo_model.WatchedReposOptions{ + ListOptions: utils.GetListOptions(ctx), + WatcherID: user.ID, + IncludePrivate: private, + }) if err != nil { return nil, 0, err } @@ -63,7 +66,7 @@ func GetWatchedRepos(ctx *context.APIContext) { // "$ref": "#/responses/notFound" private := ctx.ContextUser.ID == ctx.Doer.ID - repos, total, err := getWatchedRepos(ctx, ctx.ContextUser, private, utils.GetListOptions(ctx)) + repos, total, err := getWatchedRepos(ctx, ctx.ContextUser, private) if err != nil { ctx.Error(http.StatusInternalServerError, "getWatchedRepos", err) } @@ -92,7 +95,7 @@ func GetMyWatchedRepos(ctx *context.APIContext) { // "200": // "$ref": "#/responses/RepositoryList" - repos, total, err := getWatchedRepos(ctx, ctx.Doer, true, utils.GetListOptions(ctx)) + repos, total, err := getWatchedRepos(ctx, ctx.Doer, true) if err != nil { ctx.Error(http.StatusInternalServerError, "getWatchedRepos", err) } @@ -157,12 +160,18 @@ func Watch(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/WatchInfo" + // "403": + // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) if err != nil { - ctx.Error(http.StatusInternalServerError, "WatchRepo", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "BlockedUser", err) + } else { + ctx.Error(http.StatusInternalServerError, "WatchRepo", err) + } return } ctx.JSON(http.StatusOK, api.WatchInfo{ @@ -197,7 +206,7 @@ func Unwatch(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) if err != nil { ctx.Error(http.StatusInternalServerError, "UnwatchRepo", err) return diff --git a/routers/web/org/block.go b/routers/web/org/block.go new file mode 100644 index 00000000000..d40458e2506 --- /dev/null +++ b/routers/web/org/block.go @@ -0,0 +1,38 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsBlockedUsers base.TplName = "org/settings/blocked_users" +) + +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("user.block.list") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsBlockedUsers"] = true + + shared_user.BlockedUsers(ctx, ctx.ContextUser) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +func BlockedUsersPost(ctx *context.Context) { + shared_user.BlockedUsersPost(ctx, ctx.ContextUser) + if ctx.Written() { + return + } + + ctx.Redirect(ctx.ContextUser.OrganisationLink() + "/settings/blocked_users") +} diff --git a/routers/web/org/members.go b/routers/web/org/members.go index 9a3d60e122b..63ac57cf0dc 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" @@ -78,40 +79,43 @@ func Members(ctx *context.Context) { // MembersAction response for operation to a member of organization func MembersAction(ctx *context.Context) { - uid := ctx.FormInt64("uid") - if uid == 0 { + member, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid")) + if err != nil { + log.Error("GetUserByID: %v", err) + } + if member == nil { ctx.Redirect(ctx.Org.OrgLink + "/members") return } org := ctx.Org.Organization - var err error + switch ctx.Params(":action") { case "private": - if ctx.Doer.ID != uid && !ctx.Org.IsOwner { + if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner { ctx.Error(http.StatusNotFound) return } - err = organization.ChangeOrgUserStatus(ctx, org.ID, uid, false) + err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, false) case "public": - if ctx.Doer.ID != uid && !ctx.Org.IsOwner { + if ctx.Doer.ID != member.ID && !ctx.Org.IsOwner { ctx.Error(http.StatusNotFound) return } - err = organization.ChangeOrgUserStatus(ctx, org.ID, uid, true) + err = organization.ChangeOrgUserStatus(ctx, org.ID, member.ID, true) case "remove": if !ctx.Org.IsOwner { ctx.Error(http.StatusNotFound) return } - err = models.RemoveOrgUser(ctx, org.ID, uid) + err = models.RemoveOrgUser(ctx, org, member) if organization.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) ctx.JSONRedirect(ctx.Org.OrgLink + "/members") return } case "leave": - err = models.RemoveOrgUser(ctx, org.ID, ctx.Doer.ID) + err = models.RemoveOrgUser(ctx, org, ctx.Doer) if err == nil { ctx.Flash.Success(ctx.Tr("form.organization_leave_success", org.DisplayName())) ctx.JSON(http.StatusOK, map[string]any{ diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index fd7486cacdb..144d9b1b43d 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -5,6 +5,7 @@ package org import ( + "errors" "fmt" "net/http" "net/url" @@ -77,9 +78,9 @@ func TeamsAction(ctx *context.Context) { ctx.Error(http.StatusNotFound) return } - err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + err = models.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer) case "leave": - err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer.ID) + err = models.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer) if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) @@ -100,13 +101,13 @@ func TeamsAction(ctx *context.Context) { return } - uid := ctx.FormInt64("uid") - if uid == 0 { + user, _ := user_model.GetUserByID(ctx, ctx.FormInt64("uid")) + if user == nil { ctx.Redirect(ctx.Org.OrgLink + "/teams") return } - err = models.RemoveTeamMember(ctx, ctx.Org.Team, uid) + err = models.RemoveTeamMember(ctx, ctx.Org.Team, user) if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) @@ -161,7 +162,7 @@ func TeamsAction(ctx *context.Context) { if ctx.Org.Team.IsMember(ctx, u.ID) { ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) } else { - err = models.AddTeamMember(ctx, ctx.Org.Team, u.ID) + err = models.AddTeamMember(ctx, ctx.Org.Team, u) } page = "team" @@ -189,6 +190,8 @@ func TeamsAction(ctx *context.Context) { if err != nil { if org_model.IsErrLastOrgOwner(err) { ctx.Flash.Error(ctx.Tr("form.last_org_owner")) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Flash.Error(ctx.Tr("org.teams.members.blocked_user")) } else { log.Error("Action(%s): %v", ctx.Params(":action"), err) ctx.JSON(http.StatusOK, map[string]any{ @@ -590,7 +593,7 @@ func TeamInvitePost(ctx *context.Context) { return } - if err := models.AddTeamMember(ctx, team, ctx.Doer.ID); err != nil { + if err := models.AddTeamMember(ctx, team, ctx.Doer); err != nil { ctx.ServerError("AddTeamMember", err) return } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index b8c7f70aa6d..45fd01f4da5 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -57,6 +57,7 @@ import ( issue_service "code.gitea.io/gitea/services/issue" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -1258,9 +1259,11 @@ func NewIssuePost(ctx *context.Context) { if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) - return + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.new.blocked_user")) + } else { + ctx.ServerError("NewIssue", err) } - ctx.ServerError("NewIssue", err) return } @@ -2047,6 +2050,10 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["Tags"] = tags + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + ctx.HTML(http.StatusOK, tplIssueView) } @@ -2250,7 +2257,11 @@ func UpdateIssueContent(ctx *context.Context) { } if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, ctx.Req.FormValue("content")); err != nil { - ctx.ServerError("ChangeContent", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.edit.blocked_user")) + } else { + ctx.ServerError("ChangeContent", err) + } return } @@ -3108,7 +3119,11 @@ func NewComment(ctx *context.Context) { comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) if err != nil { - ctx.ServerError("CreateIssueComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) + } else { + ctx.ServerError("CreateIssueComment", err) + } return } @@ -3152,7 +3167,11 @@ func UpdateCommentContent(ctx *context.Context) { return } if err = issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil { - ctx.ServerError("UpdateComment", err) + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) + } else { + ctx.ServerError("UpdateComment", err) + } return } @@ -3260,9 +3279,9 @@ func ChangeIssueReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content) + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.ServerError("ChangeIssueReaction", err) return } @@ -3367,9 +3386,9 @@ func ChangeCommentReaction(ctx *context.Context) { switch ctx.Params(":action") { case "react": - reaction, err := issues_model.CreateCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Content) + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.ServerError("ChangeIssueReaction", err) return } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index bf52d76e956..ed063715e50 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -47,6 +47,7 @@ import ( notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" "github.com/gobwas/glob" ) @@ -308,6 +309,8 @@ func ForkPost(ctx *context.Context) { ctx.RenderWithErr(ctx.Tr("repo.form.name_reserved", err.(db.ErrNameReserved).Name), tplFork, &form) case db.IsErrNamePatternNotAllowed(err): ctx.RenderWithErr(ctx.Tr("repo.form.name_pattern_not_allowed", err.(db.ErrNamePatternNotAllowed).Pattern), tplFork, &form) + case errors.Is(err, user_model.ErrBlockedUser): + ctx.RenderWithErr(ctx.Tr("repo.fork.blocked_user"), tplFork, form) default: ctx.ServerError("ForkPost", err) } @@ -1065,6 +1068,10 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi } upload.AddUploadContext(ctx, "comment") + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + ctx.HTML(http.StatusOK, tplPullFiles) } @@ -1483,7 +1490,6 @@ func CompareAndPullRequestPost(ctx *context.Context) { if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) - return } else if git.IsErrPushRejected(err) { pushrejErr := err.(*git.ErrPushRejected) message := pushrejErr.Message @@ -1501,9 +1507,17 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } ctx.JSONError(flashError) - return + } else if errors.Is(err, user_model.ErrBlockedUser) { + flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ + "Message": ctx.Tr("repo.pulls.push_rejected"), + "Summary": ctx.Tr("repo.pulls.new.blocked_user"), + }) + if err != nil { + ctx.ServerError("CompareAndPullRequest.HTMLString", err) + return + } + ctx.JSONError(flashError) } - ctx.ServerError("NewPullRequest", err) return } diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 49779efa375..f0caf199a29 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -313,13 +313,13 @@ func Action(ctx *context.Context) { var err error switch ctx.Params(":action") { case "watch": - err = repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) case "unwatch": - err = repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) case "star": - err = repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) case "unstar": - err = repo_model.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) case "accept_transfer": err = acceptOrRejectRepoTransfer(ctx, true) case "reject_transfer": @@ -336,8 +336,12 @@ func Action(ctx *context.Context) { } if err != nil { - ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) - return + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Flash.Error(ctx.Tr("repo.action.blocked_user")) + } else { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } } switch ctx.Params(":action") { diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go index 6bfd4855667..31f9f76d0f9 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -4,10 +4,10 @@ package setting import ( + "errors" "net/http" "strings" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" @@ -27,7 +27,7 @@ func Collaboration(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.collaboration") ctx.Data["PageIsSettingsCollaboration"] = true - users, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, db.ListOptions{}) + users, _, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{RepoID: ctx.Repo.Repository.ID}) if err != nil { ctx.ServerError("GetCollaborators", err) return @@ -101,7 +101,12 @@ 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.ErrBlockedUser) { + ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator.blocked_user")) + ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") + } else { + ctx.ServerError("AddCollaborator", err) + } return } @@ -126,10 +131,19 @@ func ChangeCollaborationAccessMode(ctx *context.Context) { // DeleteCollaboration delete a collaboration for a repository func DeleteCollaboration(ctx *context.Context) { - if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, ctx.FormInt64("id")); err != nil { - ctx.Flash.Error("DeleteCollaboration: " + err.Error()) + if collaborator, err := user_model.GetUserByID(ctx, ctx.FormInt64("id")); err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + } else { + ctx.ServerError("GetUserByName", err) + return + } } else { - ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) + if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator); err != nil { + ctx.Flash.Error("DeleteCollaboration: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) + } } ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration") diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 3af0ddb5789..992a980d9e6 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -5,6 +5,7 @@ package setting import ( + "errors" "fmt" "net/http" "strconv" @@ -782,6 +783,8 @@ func SettingsPost(ctx *context.Context) { ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) } else if models.IsErrRepoTransferInProgress(err) { ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) + } else if errors.Is(err, user_model.ErrBlockedUser) { + ctx.RenderWithErr(ctx.Tr("repo.settings.transfer.blocked_user"), tplSettingsOptions, nil) } else { ctx.ServerError("TransferOwnership", err) } diff --git a/routers/web/shared/user/block.go b/routers/web/shared/user/block.go new file mode 100644 index 00000000000..8a2357623f1 --- /dev/null +++ b/routers/web/shared/user/block.go @@ -0,0 +1,76 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + user_service "code.gitea.io/gitea/services/user" +) + +func BlockedUsers(ctx *context.Context, blocker *user_model.User) { + blocks, _, err := user_model.FindBlockings(ctx, &user_model.FindBlockingOptions{ + BlockerID: blocker.ID, + }) + if err != nil { + ctx.ServerError("FindBlockings", err) + return + } + if err := user_model.BlockingList(blocks).LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + ctx.Data["UserBlocks"] = blocks +} + +func BlockedUsersPost(ctx *context.Context, blocker *user_model.User) { + form := web.GetForm(ctx).(*forms.BlockUserForm) + if ctx.HasError() { + ctx.ServerError("FormValidation", nil) + return + } + + blockee, err := user_model.GetUserByName(ctx, form.Blockee) + if err != nil { + ctx.ServerError("GetUserByName", nil) + return + } + + switch form.Action { + case "block": + if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, form.Note); err != nil { + if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Flash.Error(ctx.Tr("user.block.block.failure", err.Error())) + } else { + ctx.ServerError("BlockUser", err) + return + } + } + case "unblock": + if err := user_service.UnblockUser(ctx, ctx.Doer, blocker, blockee); err != nil { + if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) { + ctx.Flash.Error(ctx.Tr("user.block.unblock.failure", err.Error())) + } else { + ctx.ServerError("UnblockUser", err) + return + } + } + case "note": + block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) + if err != nil { + ctx.ServerError("GetBlocking", err) + return + } + if block != nil { + if err := user_model.UpdateBlockingNote(ctx, block.ID, form.Note); err != nil { + ctx.ServerError("UpdateBlockingNote", err) + return + } + } + } +} diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 3bc1adae99e..2d6d9ad98d7 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -72,6 +72,14 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { if _, ok := ctx.Data["NumFollowing"]; !ok { _, ctx.Data["NumFollowing"], _ = user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, db.ListOptions{PageSize: 1, Page: 1}) } + + if ctx.Doer != nil { + if block, err := user_model.GetBlocking(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + ctx.ServerError("GetBlocking", err) + } else { + ctx.Data["UserBlocking"] = block + } + } } func FindUserProfileReadme(ctx *context.Context, doer *user_model.User) (profileDbRepo *repo_model.Repository, profileGitRepo *git.Repository, profileReadmeBlob *git.Blob, profileClose func()) { diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 833312c5010..9851ea90a66 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -339,7 +339,7 @@ func Action(ctx *context.Context) { var err error switch ctx.FormString("action") { case "follow": - err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + err = user_model.FollowUser(ctx, ctx.Doer, ctx.ContextUser) case "unfollow": err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) } diff --git a/routers/web/user/setting/block.go b/routers/web/user/setting/block.go new file mode 100644 index 00000000000..94fc380cee8 --- /dev/null +++ b/routers/web/user/setting/block.go @@ -0,0 +1,38 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" +) + +const ( + tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users" +) + +func BlockedUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("user.block.list") + ctx.Data["PageIsSettingsBlockedUsers"] = true + + shared_user.BlockedUsers(ctx, ctx.Doer) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplSettingsBlockedUsers) +} + +func BlockedUsersPost(ctx *context.Context) { + shared_user.BlockedUsersPost(ctx, ctx.Doer) + if ctx.Written() { + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") +} diff --git a/routers/web/web.go b/routers/web/web.go index 14d31b3a908..8710f6e3e5e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -647,6 +647,11 @@ func registerRoutes(m *web.Route) { }) addWebhookEditRoutes() }, webhooksEnabled) + + m.Group("/blocked_users", func() { + m.Get("", user_setting.BlockedUsers) + m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost) + }) }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled)) m.Group("/user", func() { @@ -945,6 +950,11 @@ func registerRoutes(m *web.Route) { m.Post("/rebuild", org.RebuildCargoIndex) }) }, packagesEnabled) + + m.Group("/blocked_users", func() { + m.Get("", org.BlockedUsers) + m.Post("", web.Bind(forms.BlockUserForm{}), org.BlockedUsersPost) + }) }, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "PageIsOrgSettings", true)) }, context.OrgAssignment(true, true)) }, reqSignIn) diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go index 3a2411ec55f..05293f202f5 100644 --- a/services/auth/source/source_group_sync.go +++ b/services/auth/source/source_group_sync.go @@ -100,12 +100,12 @@ func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeam } if action == syncAdd && !isMember { - if err := models.AddTeamMember(ctx, team, user.ID); err != nil { + if err := models.AddTeamMember(ctx, team, user); err != nil { log.Error("group sync: Could not add user to team: %v", err) return err } } else if action == syncRemove && isMember { - if err := models.RemoveTeamMember(ctx, team, user.ID); err != nil { + if err := models.RemoveTeamMember(ctx, team, user); err != nil { log.Error("group sync: Could not remove user from team: %v", err) return err } diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 186aa4a8782..416592bfda0 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -449,3 +449,14 @@ func (f *PackageSettingForm) Validate(req *http.Request, errs binding.Errors) bi ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +type BlockUserForm struct { + Action string `binding:"Required;In(block,unblock,note)"` + Blockee string `binding:"Required"` + Note string +} + +func (f *BlockUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/services/issue/comments.go b/services/issue/comments.go index 8d8c575c140..d68623aff68 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" @@ -21,6 +22,12 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod return fmt.Errorf("cannot create reference with empty commit SHA") } + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + // Check if same reference from same commit has already existed. has, err := db.GetEngine(ctx).Get(&issues_model.Comment{ Type: issues_model.CommentTypeCommitRef, @@ -46,6 +53,12 @@ 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) { + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin { + return nil, user_model.ErrBlockedUser + } + } + comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ Type: issues_model.CommentTypeComment, Doer: doer, @@ -70,6 +83,19 @@ func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_m // UpdateComment updates information of comment. func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_model.User, oldContent string) error { + if err := c.LoadIssue(ctx); err != nil { + return err + } + if err := c.Issue.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, doer, c.Issue.PosterID, c.Issue.Repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, c.Issue.Repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + needsContentHistory := c.Content != oldContent && c.Type.HasContentSupport() if needsContentHistory { hasContentHistory, err := issues_model.HasIssueContentHistory(ctx, c.IssueID, c.ID) diff --git a/services/issue/commit.go b/services/issue/commit.go index e493a032114..0a59088d127 100644 --- a/services/issue/commit.go +++ b/services/issue/commit.go @@ -5,6 +5,7 @@ package issue import ( "context" + "errors" "fmt" "html" "net/url" @@ -160,6 +161,9 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m message := fmt.Sprintf(`%s`, html.EscapeString(repo.Link()), html.EscapeString(url.PathEscape(c.Sha1)), html.EscapeString(strings.SplitN(c.Message, "\n", 2)[0])) if err = CreateRefComment(ctx, doer, refRepo, refIssue, message, c.Sha1); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + continue + } return err } diff --git a/services/issue/content.go b/services/issue/content.go index 6e56714ddfa..2f9bee806a9 100644 --- a/services/issue/content.go +++ b/services/issue/content.go @@ -7,12 +7,23 @@ import ( "context" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" user_model "code.gitea.io/gitea/models/user" notify_service "code.gitea.io/gitea/services/notify" ) // ChangeContent changes issue content, as the given user. -func ChangeContent(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string) (err error) { +func ChangeContent(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, content string) error { + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, issue.Repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + oldContent := issue.Content if err := issues_model.ChangeIssueContent(ctx, issue, doer, content); err != nil { diff --git a/services/issue/issue.go b/services/issue/issue.go index b1f418c32e5..27a106009c8 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -15,6 +15,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/storage" notify_service "code.gitea.io/gitea/services/notify" @@ -22,6 +23,14 @@ 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 { + if err := issue.LoadPoster(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) { + return user_model.ErrBlockedUser + } + if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil { return err } @@ -57,6 +66,16 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode return nil } + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, issue.Repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + if err := issues_model.ChangeIssueTitle(ctx, issue, doer, oldTitle); err != nil { return err } @@ -93,31 +112,25 @@ func ChangeIssueRef(ctx context.Context, issue *issues_model.Issue, doer *user_m // Pass one or more user logins to replace the set of assignees on this Issue. // Send an empty array ([]) to clear all assignees from the Issue. func UpdateAssignees(ctx context.Context, issue *issues_model.Issue, oneAssignee string, multipleAssignees []string, doer *user_model.User) (err error) { - var allNewAssignees []*user_model.User + uniqueAssignees := container.SetOf(multipleAssignees...) // Keep the old assignee thingy for compatibility reasons if oneAssignee != "" { - // Prevent double adding assignees - var isDouble bool - for _, assignee := range multipleAssignees { - if assignee == oneAssignee { - isDouble = true - break - } - } - - if !isDouble { - multipleAssignees = append(multipleAssignees, oneAssignee) - } + uniqueAssignees.Add(oneAssignee) } // Loop through all assignees to add them - for _, assigneeName := range multipleAssignees { + allNewAssignees := make([]*user_model.User, 0, len(uniqueAssignees)) + for _, assigneeName := range uniqueAssignees.Values() { assignee, err := user_model.GetUserByName(ctx, assigneeName) if err != nil { return err } + if user_model.IsUserBlockedBy(ctx, doer, assignee.ID) { + return user_model.ErrBlockedUser + } + allNewAssignees = append(allNewAssignees, assignee) } diff --git a/services/issue/reaction.go b/services/issue/reaction.go new file mode 100644 index 00000000000..deb99169e1b --- /dev/null +++ b/services/issue/reaction.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Gitea 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 an 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 + } + + if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, issue.Repo.OwnerID) { + return nil, user_model.ErrBlockedUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: issue.ID, + }) +} + +// CreateCommentReaction creates a reaction on a comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) { + if err := comment.LoadIssue(ctx); err != nil { + return nil, err + } + + if err := comment.Issue.LoadRepo(ctx); err != nil { + return nil, err + } + + if user_model.IsUserBlockedBy(ctx, doer, comment.Issue.PosterID, comment.Issue.Repo.OwnerID, comment.PosterID) { + return nil, user_model.ErrBlockedUser + } + + return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + IssueID: comment.Issue.ID, + CommentID: comment.ID, + }) +} diff --git a/models/issues/reaction_test.go b/services/issue/reaction_test.go similarity index 65% rename from models/issues/reaction_test.go rename to services/issue/reaction_test.go index 5dc8e1a5f3e..7734860fc0d 100644 --- a/models/issues/reaction_test.go +++ b/services/issue/reaction_test.go @@ -1,7 +1,7 @@ // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package issues_test +package issue import ( "testing" @@ -16,13 +16,13 @@ import ( "github.com/stretchr/testify/assert" ) -func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) { +func addReaction(t *testing.T, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) { var reaction *issues_model.Reaction var err error - if commentID == 0 { - reaction, err = issues_model.CreateIssueReaction(db.DefaultContext, doerID, issueID, content) + if comment == nil { + reaction, err = CreateIssueReaction(db.DefaultContext, doer, issue, content) } else { - reaction, err = issues_model.CreateCommentReaction(db.DefaultContext, doerID, issueID, commentID, content) + reaction, err = CreateCommentReaction(db.DefaultContext, doer, comment, content) } assert.NoError(t, err) assert.NotNil(t, reaction) @@ -32,32 +32,26 @@ func TestIssueAddReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - var issue1ID int64 = 1 + addReaction(t, user1, issue, nil, "heart") - addReaction(t, user1.ID, issue1ID, 0, "heart") - - unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) + unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID}) } func TestIssueAddDuplicateReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - var issue1ID int64 = 1 - - addReaction(t, user1.ID, issue1ID, 0, "heart") + addReaction(t, user1, issue, nil, "heart") - reaction, err := issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{ - DoerID: user1.ID, - IssueID: issue1ID, - Type: "heart", - }) + reaction, err := CreateIssueReaction(db.DefaultContext, user1, issue, "heart") assert.Error(t, err) assert.Equal(t, issues_model.ErrReactionAlreadyExist{Reaction: "heart"}, err) - existingR := unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) + existingR := unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID}) assert.Equal(t, existingR.ID, reaction.ID) } @@ -65,15 +59,14 @@ func TestIssueDeleteReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - var issue1ID int64 = 1 + addReaction(t, user1, issue, nil, "heart") - addReaction(t, user1.ID, issue1ID, 0, "heart") - - err := issues_model.DeleteIssueReaction(db.DefaultContext, user1.ID, issue1ID, "heart") + err := issues_model.DeleteIssueReaction(db.DefaultContext, user1.ID, issue.ID, "heart") assert.NoError(t, err) - unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) + unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue.ID}) } func TestIssueReactionCount(t *testing.T) { @@ -87,19 +80,19 @@ func TestIssueReactionCount(t *testing.T) { user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) ghost := user_model.NewGhostUser() - var issueID int64 = 2 + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - addReaction(t, user1.ID, issueID, 0, "heart") - addReaction(t, user2.ID, issueID, 0, "heart") - addReaction(t, org3.ID, issueID, 0, "heart") - addReaction(t, org3.ID, issueID, 0, "+1") - addReaction(t, user4.ID, issueID, 0, "+1") - addReaction(t, user4.ID, issueID, 0, "heart") - addReaction(t, ghost.ID, issueID, 0, "-1") + addReaction(t, user1, issue, nil, "heart") + addReaction(t, user2, issue, nil, "heart") + addReaction(t, org3, issue, nil, "heart") + addReaction(t, org3, issue, nil, "+1") + addReaction(t, user4, issue, nil, "+1") + addReaction(t, user4, issue, nil, "heart") + addReaction(t, ghost, issue, nil, "-1") reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{ - IssueID: issueID, + IssueID: issue.ID, }) assert.NoError(t, err) assert.Len(t, reactionsList, 7) @@ -122,13 +115,11 @@ func TestIssueCommentAddReaction(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) - var issue1ID int64 = 1 - var comment1ID int64 = 1 - - addReaction(t, user1.ID, issue1ID, comment1ID, "heart") + addReaction(t, user1, nil, comment, "heart") - unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID}) + unittest.AssertExistsAndLoadBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: comment.IssueID, CommentID: comment.ID}) } func TestIssueCommentDeleteReaction(t *testing.T) { @@ -139,17 +130,16 @@ func TestIssueCommentDeleteReaction(t *testing.T) { org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) - var issue1ID int64 = 1 - var comment1ID int64 = 1 + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) - addReaction(t, user1.ID, issue1ID, comment1ID, "heart") - addReaction(t, user2.ID, issue1ID, comment1ID, "heart") - addReaction(t, org3.ID, issue1ID, comment1ID, "heart") - addReaction(t, user4.ID, issue1ID, comment1ID, "+1") + addReaction(t, user1, nil, comment, "heart") + addReaction(t, user2, nil, comment, "heart") + addReaction(t, org3, nil, comment, "heart") + addReaction(t, user4, nil, comment, "+1") reactionsList, _, err := issues_model.FindReactions(db.DefaultContext, issues_model.FindReactionsOptions{ - IssueID: issue1ID, - CommentID: comment1ID, + IssueID: comment.IssueID, + CommentID: comment.ID, }) assert.NoError(t, err) assert.Len(t, reactionsList, 4) @@ -163,12 +153,10 @@ func TestIssueCommentReactionCount(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) - var issue1ID int64 = 1 - var comment1ID int64 = 1 - - addReaction(t, user1.ID, issue1ID, comment1ID, "heart") - assert.NoError(t, issues_model.DeleteCommentReaction(db.DefaultContext, user1.ID, issue1ID, comment1ID, "heart")) + addReaction(t, user1, nil, comment, "heart") + assert.NoError(t, issues_model.DeleteCommentReaction(db.DefaultContext, user1.ID, comment.IssueID, comment.ID, "heart")) - unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID}) + unittest.AssertNotExistsBean(t, &issues_model.Reaction{Type: "heart", UserID: user1.ID, IssueID: comment.IssueID, CommentID: comment.ID}) } diff --git a/services/pull/pull.go b/services/pull/pull.go index 42363f886d7..be3d25d20a0 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -40,6 +40,14 @@ var pullWorkingPool = sync.NewExclusivePool() // NewPullRequest creates new pull request with labels for repository. func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error { + if err := issue.LoadPoster(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) { + return user_model.ErrBlockedUser + } + prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) if err != nil { if !git_model.IsErrBranchNotExist(err) { diff --git a/services/repository/collaboration.go b/services/repository/collaboration.go index dccc1247481..4a43ae2a28f 100644 --- a/services/repository/collaboration.go +++ b/services/repository/collaboration.go @@ -11,13 +11,14 @@ import ( "code.gitea.io/gitea/models/db" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" ) // DeleteCollaboration removes collaboration relation between the user and repository. -func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, uid int64) (err error) { +func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) { collaboration := &repo_model.Collaboration{ RepoID: repo.ID, - UserID: uid, + UserID: collaborator.ID, } ctx, committer, err := db.TxContext(ctx) @@ -31,20 +32,25 @@ func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, uid i } else if has == 0 { return committer.Commit() } + + if err := repo.LoadOwner(ctx); err != nil { + return err + } + if err = access_model.RecalculateAccesses(ctx, repo); err != nil { return err } - if err = repo_model.WatchRepo(ctx, uid, repo.ID, false); err != nil { + if err = repo_model.WatchRepo(ctx, collaborator, repo, false); err != nil { return err } - if err = models.ReconsiderWatches(ctx, repo, uid); err != nil { + if err = models.ReconsiderWatches(ctx, repo, collaborator); err != nil { return err } // Unassign a user from any issue (s)he has been assigned to in the repository - if err := models.ReconsiderRepoIssuesAssignee(ctx, repo, uid); err != nil { + if err := models.ReconsiderRepoIssuesAssignee(ctx, repo, collaborator); err != nil { return err } diff --git a/services/repository/collaboration_test.go b/services/repository/collaboration_test.go index c3d006bfd8c..a2eb06b81a2 100644 --- a/services/repository/collaboration_test.go +++ b/services/repository/collaboration_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" ) @@ -16,13 +17,15 @@ import ( func TestRepository_DeleteCollaboration(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + assert.NoError(t, repo.LoadOwner(db.DefaultContext)) - assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, 4)) - unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4}) + assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, user)) + unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID}) - assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, 4)) - unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4}) + assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, user)) + unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID}) unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) } diff --git a/services/repository/delete.go b/services/repository/delete.go index 08d6800ee76..1eeec276600 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -365,24 +365,26 @@ func removeRepositoryFromTeam(ctx context.Context, t *organization.Team, repo *r } } - teamUsers, err := organization.GetTeamUsersByTeamID(ctx, t.ID) + teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ + TeamID: t.ID, + }) if err != nil { - return fmt.Errorf("getTeamUsersByTeamID: %w", err) + return fmt.Errorf("GetTeamMembers: %w", err) } - for _, teamUser := range teamUsers { - has, err := access_model.HasAccess(ctx, teamUser.UID, repo) + for _, member := range teamMembers { + has, err := access_model.HasAccess(ctx, member.ID, repo) if err != nil { return err } else if has { continue } - if err = repo_model.WatchRepo(ctx, teamUser.UID, repo.ID, false); err != nil { + if err = repo_model.WatchRepo(ctx, member, repo, false); err != nil { return err } // Remove all IssueWatches a user has subscribed to in the repositories - if err := issues_model.RemoveIssueWatchersByRepoID(ctx, teamUser.UID, repo.ID); err != nil { + if err := issues_model.RemoveIssueWatchersByRepoID(ctx, member.ID, repo.ID); err != nil { return err } } diff --git a/services/repository/fork.go b/services/repository/fork.go index f9c13a109eb..f074fd10821 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -53,6 +53,14 @@ type ForkRepoOptions struct { // ForkRepository forks a repository func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts ForkRepoOptions) (*repo_model.Repository, error) { + if err := opts.BaseRepo.LoadOwner(ctx); err != nil { + return nil, err + } + + if user_model.IsUserBlockedBy(ctx, doer, opts.BaseRepo.Owner.ID) { + return nil, user_model.ErrBlockedUser + } + // Fork is prohibited, if user has reached maximum limit of repositories if !owner.CanForkRepo() { return nil, repo_model.ErrReachLimitOfRepo{ diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 59a4eb260e2..83d30321882 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -139,9 +139,9 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName } // Remove redundant collaborators. - collaborators, err := repo_model.GetCollaborators(ctx, repo.ID, db.ListOptions{}) + collaborators, _, err := repo_model.GetCollaborators(ctx, &repo_model.FindCollaborationOptions{RepoID: repo.ID}) if err != nil { - return fmt.Errorf("getCollaborators: %w", err) + return fmt.Errorf("GetCollaborators: %w", err) } // Dummy object. @@ -201,13 +201,13 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName return fmt.Errorf("decrease old owner repository count: %w", err) } - if err := repo_model.WatchRepo(ctx, doer.ID, repo.ID, true); err != nil { + if err := repo_model.WatchRepo(ctx, doer, repo, true); err != nil { return fmt.Errorf("watchRepo: %w", err) } // Remove watch for organization. if oldOwner.IsOrganization() { - if err := repo_model.WatchRepo(ctx, oldOwner.ID, repo.ID, false); err != nil { + if err := repo_model.WatchRepo(ctx, oldOwner, repo, false); err != nil { return fmt.Errorf("watchRepo [false]: %w", err) } } @@ -371,6 +371,10 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use return TransferOwnership(ctx, doer, newOwner, repo, teams) } + if user_model.IsUserBlockedBy(ctx, doer, newOwner.ID) { + return user_model.ErrBlockedUser + } + // If new owner is an org and user can create repos he can transfer directly too if newOwner.IsOrganization() { allowed, err := organization.CanCreateOrgRepo(ctx, newOwner.ID, doer.ID) diff --git a/services/user/block.go b/services/user/block.go new file mode 100644 index 00000000000..0b3b618aae6 --- /dev/null +++ b/services/user/block.go @@ -0,0 +1,308 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + org_model "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + repo_service "code.gitea.io/gitea/services/repository" +) + +func CanBlockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool { + if blocker.ID == blockee.ID { + return false + } + if doer.ID == blockee.ID { + return false + } + + if blockee.IsOrganization() { + return false + } + + if user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) { + return false + } + + if blocker.IsOrganization() { + org := org_model.OrgFromUser(blocker) + if isMember, _ := org.IsOrgMember(ctx, blockee.ID); isMember { + return false + } + if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin { + return false + } + } else if !doer.IsAdmin && doer.ID != blocker.ID { + return false + } + + return true +} + +func CanUnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) bool { + if doer.ID == blockee.ID { + return false + } + + if !user_model.IsUserBlockedBy(ctx, blockee, blocker.ID) { + return false + } + + if blocker.IsOrganization() { + org := org_model.OrgFromUser(blocker) + if isAdmin, _ := org.IsOwnedBy(ctx, doer.ID); !isAdmin && !doer.IsAdmin { + return false + } + } else if !doer.IsAdmin && doer.ID != blocker.ID { + return false + } + + return true +} + +func BlockUser(ctx context.Context, doer, blocker, blockee *user_model.User, note string) error { + if blockee.IsOrganization() { + return user_model.ErrBlockOrganization + } + + if !CanBlockUser(ctx, doer, blocker, blockee) { + return user_model.ErrCanNotBlock + } + + return db.WithTx(ctx, func(ctx context.Context) error { + // unfollow each other + if err := user_model.UnfollowUser(ctx, blocker.ID, blockee.ID); err != nil { + return err + } + if err := user_model.UnfollowUser(ctx, blockee.ID, blocker.ID); err != nil { + return err + } + + // unstar each other + if err := unstarRepos(ctx, blocker, blockee); err != nil { + return err + } + if err := unstarRepos(ctx, blockee, blocker); err != nil { + return err + } + + // unwatch each others repositories + if err := unwatchRepos(ctx, blocker, blockee); err != nil { + return err + } + if err := unwatchRepos(ctx, blockee, blocker); err != nil { + return err + } + + // unassign each other from issues + if err := unassignIssues(ctx, blocker, blockee); err != nil { + return err + } + if err := unassignIssues(ctx, blockee, blocker); err != nil { + return err + } + + // remove each other from repository collaborations + if err := removeCollaborations(ctx, blocker, blockee); err != nil { + return err + } + if err := removeCollaborations(ctx, blockee, blocker); err != nil { + return err + } + + // cancel each other repository transfers + if err := cancelRepositoryTransfers(ctx, blocker, blockee); err != nil { + return err + } + if err := cancelRepositoryTransfers(ctx, blockee, blocker); err != nil { + return err + } + + return db.Insert(ctx, &user_model.Blocking{ + BlockerID: blocker.ID, + BlockeeID: blockee.ID, + Note: note, + }) + }) +} + +func unstarRepos(ctx context.Context, starrer, repoOwner *user_model.User) error { + opts := &repo_model.StarredReposOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + StarrerID: starrer.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + repos, err := repo_model.GetStarredRepos(ctx, opts) + if err != nil { + return err + } + + if len(repos) == 0 { + return nil + } + + for _, repo := range repos { + if err := repo_model.StarRepo(ctx, starrer, repo, false); err != nil { + return err + } + } + + opts.Page++ + } +} + +func unwatchRepos(ctx context.Context, watcher, repoOwner *user_model.User) error { + opts := &repo_model.WatchedReposOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + WatcherID: watcher.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + repos, _, err := repo_model.GetWatchedRepos(ctx, opts) + if err != nil { + return err + } + + if len(repos) == 0 { + return nil + } + + for _, repo := range repos { + if err := repo_model.WatchRepo(ctx, watcher, repo, false); err != nil { + return err + } + } + + opts.Page++ + } +} + +func cancelRepositoryTransfers(ctx context.Context, sender, recipient *user_model.User) error { + transfers, err := models.GetPendingRepositoryTransfers(ctx, &models.PendingRepositoryTransferOptions{ + SenderID: sender.ID, + RecipientID: recipient.ID, + }) + if err != nil { + return err + } + + for _, transfer := range transfers { + repo, err := repo_model.GetRepositoryByID(ctx, transfer.RepoID) + if err != nil { + return err + } + + if err := repo_service.CancelRepositoryTransfer(ctx, repo); err != nil { + return err + } + } + + return nil +} + +func unassignIssues(ctx context.Context, assignee, repoOwner *user_model.User) error { + opts := &issues_model.AssignedIssuesOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + AssigneeID: assignee.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + issues, _, err := issues_model.GetAssignedIssues(ctx, opts) + if err != nil { + return err + } + + if len(issues) == 0 { + return nil + } + + for _, issue := range issues { + if err := issue.LoadAssignees(ctx); err != nil { + return err + } + + if _, _, err := issues_model.ToggleIssueAssignee(ctx, issue, assignee, assignee.ID); err != nil { + return err + } + } + + opts.Page++ + } +} + +func removeCollaborations(ctx context.Context, repoOwner, collaborator *user_model.User) error { + opts := &repo_model.FindCollaborationOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 25, + }, + CollaboratorID: collaborator.ID, + RepoOwnerID: repoOwner.ID, + } + + for { + collaborations, _, err := repo_model.GetCollaborators(ctx, opts) + if err != nil { + return err + } + + if len(collaborations) == 0 { + return nil + } + + for _, collaboration := range collaborations { + repo, err := repo_model.GetRepositoryByID(ctx, collaboration.Collaboration.RepoID) + if err != nil { + return err + } + + if err := repo_service.DeleteCollaboration(ctx, repo, collaborator); err != nil { + return err + } + } + + opts.Page++ + } +} + +func UnblockUser(ctx context.Context, doer, blocker, blockee *user_model.User) error { + if blockee.IsOrganization() { + return user_model.ErrBlockOrganization + } + + if !CanUnblockUser(ctx, doer, blocker, blockee) { + return user_model.ErrCanNotUnblock + } + + return db.WithTx(ctx, func(ctx context.Context) error { + block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) + if err != nil { + return err + } + if block != nil { + _, err = db.DeleteByID[user_model.Blocking](ctx, block.ID) + return err + } + return nil + }) +} diff --git a/services/user/block_test.go b/services/user/block_test.go new file mode 100644 index 00000000000..aec3e03cf37 --- /dev/null +++ b/services/user/block_test.go @@ -0,0 +1,66 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +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 TestCanBlockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + + // Doer can't self block + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user1)) + // Blocker can't be blockee + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user2)) + // Can't block already blocked user + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, user29)) + // Blockee can't be an organization + assert.False(t, CanBlockUser(db.DefaultContext, user1, user2, org3)) + // Doer must be blocker or admin + assert.False(t, CanBlockUser(db.DefaultContext, user2, user4, user29)) + // Organization can't block a member + assert.False(t, CanBlockUser(db.DefaultContext, user1, org3, user4)) + // Doer must be organization owner or admin if blocker is an organization + assert.False(t, CanBlockUser(db.DefaultContext, user4, org3, user2)) + + assert.True(t, CanBlockUser(db.DefaultContext, user1, user2, user4)) + assert.True(t, CanBlockUser(db.DefaultContext, user2, user2, user4)) + assert.True(t, CanBlockUser(db.DefaultContext, user2, org3, user29)) +} + +func TestCanUnblockUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + user28 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) + user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) + org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + + // Doer can't self unblock + assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user1)) + // Can't unblock not blocked user + assert.False(t, CanUnblockUser(db.DefaultContext, user1, user2, user28)) + // Doer must be blocker or admin + assert.False(t, CanUnblockUser(db.DefaultContext, user28, user2, user29)) + // Doer must be organization owner or admin if blocker is an organization + assert.False(t, CanUnblockUser(db.DefaultContext, user2, org17, user28)) + + assert.True(t, CanUnblockUser(db.DefaultContext, user1, user2, user29)) + assert.True(t, CanUnblockUser(db.DefaultContext, user2, user2, user29)) + assert.True(t, CanUnblockUser(db.DefaultContext, user1, org17, user28)) +} diff --git a/services/user/delete.go b/services/user/delete.go index 000910319a6..212cb83e031 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -92,6 +92,8 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &pull_model.ReviewState{UserID: u.ID}, &user_model.Redirect{RedirectUserID: u.ID}, &actions_model.ActionRunner{OwnerID: u.ID}, + &user_model.Blocking{BlockerID: u.ID}, + &user_model.Blocking{BlockeeID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/services/user/user.go b/services/user/user.go index f2648db409f..6604dba4d62 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -188,7 +188,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { break } for _, org := range orgs { - if err := models.RemoveOrgUser(ctx, org.ID, u.ID); err != nil { + if err := models.RemoveOrgUser(ctx, org, u); err != nil { if organization.IsErrLastOrgOwner(err) { err = org_service.DeleteOrganization(ctx, org, true) if err != nil { diff --git a/services/user/user_test.go b/services/user/user_test.go index 2ebcded9252..f110bd26d08 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -41,7 +41,8 @@ func TestDeleteUser(t *testing.T) { orgUsers := make([]*organization.OrgUser, 0, 10) assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&orgUsers, &organization.OrgUser{UID: userID})) for _, orgUser := range orgUsers { - if err := models.RemoveOrgUser(db.DefaultContext, orgUser.OrgID, orgUser.UID); err != nil { + org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: orgUser.OrgID}) + if err := models.RemoveOrgUser(db.DefaultContext, org, user); err != nil { assert.True(t, organization.IsErrLastOrgOwner(err)) return } diff --git a/templates/org/settings/blocked_users.tmpl b/templates/org/settings/blocked_users.tmpl new file mode 100644 index 00000000000..eab5ec0007c --- /dev/null +++ b/templates/org/settings/blocked_users.tmpl @@ -0,0 +1,5 @@ +{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked_users")}} +
+ {{template "shared/user/blocked_users" .}} +
+{{template "org/settings/layout_footer" .}} diff --git a/templates/org/settings/navbar.tmpl b/templates/org/settings/navbar.tmpl index 64ae20f0a3b..ce792f667c4 100644 --- a/templates/org/settings/navbar.tmpl +++ b/templates/org/settings/navbar.tmpl @@ -17,6 +17,9 @@ {{ctx.Locale.Tr "settings.applications"}} {{end}} + + {{ctx.Locale.Tr "user.block.list"}} + {{if .EnablePackages}} {{ctx.Locale.Tr "packages.title"}} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 39cf8755f28..1cb3aaaa212 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -251,5 +251,6 @@ {{end}} {{if (not .DiffNotAvailable)}} {{template "repo/issue/view_content/reference_issue_dialog" .}} + {{template "shared/user/block_user_dialog" .}} {{end}} diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 747132931ef..edfa9c0bc5e 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -170,6 +170,7 @@ {{template "repo/issue/view_content/reference_issue_dialog" .}} +{{template "shared/user/block_user_dialog" .}}
{{ctx.Locale.Tr "repo.issues.no_content"}} diff --git a/templates/repo/issue/view_content/context_menu.tmpl b/templates/repo/issue/view_content/context_menu.tmpl index 4afd73c3717..17556d4e489 100644 --- a/templates/repo/issue/view_content/context_menu.tmpl +++ b/templates/repo/issue/view_content/context_menu.tmpl @@ -10,16 +10,33 @@ {{$referenceUrl = printf "%s/files#%s" .ctxData.Issue.Link .item.HashTag}} {{end}}
{{ctx.Locale.Tr "repo.issues.context.copy_link"}}
- {{if and .ctxData.IsSigned (not .ctxData.Repository.IsArchived)}} -
{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}
- {{if not .ctxData.UnitIssuesGlobalDisabled}} -
{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}
+ {{if .ctxData.IsSigned}} + {{$needDivider := false}} + {{if not .ctxData.Repository.IsArchived}} + {{$needDivider = true}} +
{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}
+ {{if not .ctxData.UnitIssuesGlobalDisabled}} +
{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}
+ {{end}} + {{if or .ctxData.Permission.IsAdmin .IsCommentPoster .ctxData.HasIssuesOrPullsWritePermission}} +
+
{{ctx.Locale.Tr "repo.issues.context.edit"}}
+ {{if .delete}} +
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+ {{end}} + {{end}} {{end}} - {{if or .ctxData.Permission.IsAdmin .IsCommentPoster .ctxData.HasIssuesOrPullsWritePermission}} -
-
{{ctx.Locale.Tr "repo.issues.context.edit"}}
- {{if .delete}} -
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+ {{$canUserBlock := call .ctxData.CanBlockUser .ctxData.SignedUser .item.Poster}} + {{$canOrgBlock := and .ctxData.Repository.Owner.IsOrganization (call .ctxData.CanBlockUser .ctxData.Repository.Owner .item.Poster)}} + {{if or $canOrgBlock $canUserBlock}} + {{if $needDivider}} +
+ {{end}} + {{if $canUserBlock}} +
{{ctx.Locale.Tr "user.block.block.user"}}
+ {{end}} + {{if $canOrgBlock}} +
{{ctx.Locale.Tr "user.block.block.org"}}
{{end}} {{end}} {{end}} diff --git a/templates/shared/user/block_user_dialog.tmpl b/templates/shared/user/block_user_dialog.tmpl new file mode 100644 index 00000000000..c6db4ca1e46 --- /dev/null +++ b/templates/shared/user/block_user_dialog.tmpl @@ -0,0 +1,23 @@ + diff --git a/templates/shared/user/blocked_users.tmpl b/templates/shared/user/blocked_users.tmpl new file mode 100644 index 00000000000..b2f09576918 --- /dev/null +++ b/templates/shared/user/blocked_users.tmpl @@ -0,0 +1,83 @@ +

+ {{ctx.Locale.Tr "user.block.title"}} +

+
+

{{ctx.Locale.Tr "user.block.info_1"}}

+
    +
  • {{ctx.Locale.Tr "user.block.info_2"}}
  • +
  • {{ctx.Locale.Tr "user.block.info_3"}}
  • +
  • {{ctx.Locale.Tr "user.block.info_4"}}
  • +
  • {{ctx.Locale.Tr "user.block.info_5"}}
  • +
  • {{ctx.Locale.Tr "user.block.info_6"}}
  • +
  • {{ctx.Locale.Tr "user.block.info_7"}}
  • +
+
+
+
+ {{.CsrfTokenHtml}} + + +
+ + +

{{ctx.Locale.Tr "user.block.note.info"}}

+
+
+
+

+ {{ctx.Locale.Tr "user.block.list"}} +

+
+
+ {{range .UserBlocks}} +
+
+ {{ctx.AvatarUtils.Avatar .Blockee}} +
+
+ + {{if .Note}} +
+ {{ctx.Locale.Tr "user.block.note"}}: {{.Note}} +
+ {{end}} +
+
+ +
+ {{$.CsrfTokenHtml}} + + + +
+
+
+ {{else}} +
{{ctx.Locale.Tr "user.block.list.none"}}
+ {{end}} +
+
+ diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl index 88d3b9a6e56..a168e6903e4 100644 --- a/templates/shared/user/profile_big_avatar.tmpl +++ b/templates/shared/user/profile_big_avatar.tmpl @@ -27,6 +27,12 @@
    + {{if .UserBlocking}} +
  • {{svg "octicon-circle-slash"}} {{ctx.Locale.Tr "user.block.blocked"}}
  • + {{if .UserBlocking.Note}} +
  • {{ctx.Locale.Tr "user.block.note"}}: {{.UserBlocking.Note}}
  • + {{end}} + {{end}} {{if .ContextUser.Location}}
  • {{svg "octicon-location"}} @@ -109,18 +115,29 @@
  • {{end}} {{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}} - {{end}} - +
  • + {{if not .UserBlocking}} + {{ctx.Locale.Tr "user.block.block.user"}} + {{else}} + {{ctx.Locale.Tr "user.block.unblock"}} + {{end}} +
  • {{end}}
+ +{{template "shared/user/block_user_dialog" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 9aba84a0232..98198696bc4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1955,6 +1955,151 @@ } } }, + "/orgs/{org}/blocks": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "List users blocked by the organization", + "operationId": "organizationListBlocks", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "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/UserList" + } + } + } + }, + "/orgs/{org}/blocks/{username}": { + "get": { + "tags": [ + "organization" + ], + "summary": "Check if a user is blocked by the organization", + "operationId": "organizationCheckUserBlock", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "user to check", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "tags": [ + "organization" + ], + "summary": "Block a user", + "operationId": "organizationBlockUser", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "user to block", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "optional note for the block", + "name": "note", + "in": "query" + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "tags": [ + "organization" + ], + "summary": "Unblock a user", + "operationId": "organizationUnblockUser", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "user to unblock", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/orgs/{org}/hooks": { "get": { "produces": [ @@ -4340,6 +4485,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -6692,6 +6840,9 @@ "400": { "$ref": "#/responses/error" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/error" }, @@ -10461,6 +10612,9 @@ "201": { "$ref": "#/responses/PullRequest" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" }, @@ -12959,6 +13113,9 @@ "200": { "$ref": "#/responses/WatchInfo" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } @@ -14513,6 +14670,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } @@ -15081,6 +15241,123 @@ } } }, + "/user/blocks": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "List users blocked by the authenticated user", + "operationId": "userListBlocks", + "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/UserList" + } + } + } + }, + "/user/blocks/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Check if a user is blocked by the authenticated user", + "operationId": "userCheckUserBlock", + "parameters": [ + { + "type": "string", + "description": "user to check", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Block a user", + "operationId": "userBlockUser", + "parameters": [ + { + "type": "string", + "description": "user to block", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "optional note for the block", + "name": "note", + "in": "query" + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Unblock a user", + "operationId": "userUnblockUser", + "parameters": [ + { + "type": "string", + "description": "user to unblock", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/user/emails": { "get": { "produces": [ @@ -15258,6 +15535,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } @@ -15965,6 +16245,9 @@ "204": { "$ref": "#/responses/empty" }, + "403": { + "$ref": "#/responses/forbidden" + }, "404": { "$ref": "#/responses/notFound" } diff --git a/templates/user/settings/blocked_users.tmpl b/templates/user/settings/blocked_users.tmpl new file mode 100644 index 00000000000..e495b85f581 --- /dev/null +++ b/templates/user/settings/blocked_users.tmpl @@ -0,0 +1,5 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings blocked_users")}} +
+ {{template "shared/user/blocked_users" .}} +
+{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index a690d003524..c360944814a 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -13,6 +13,9 @@ {{ctx.Locale.Tr "settings.security"}} + + {{ctx.Locale.Tr "user.block.list"}} + {{ctx.Locale.Tr "settings.applications"}} diff --git a/tests/integration/api_comment_test.go b/tests/integration/api_comment_test.go index a9c5228a16c..255b8332b23 100644 --- a/tests/integration/api_comment_test.go +++ b/tests/integration/api_comment_test.go @@ -108,6 +108,32 @@ func TestAPICreateComment(t *testing.T) { DecodeJSON(t, resp, &updatedComment) assert.EqualValues(t, commentBody, updatedComment.Body) unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + + t.Run("BlockedByRepoOwner", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", repo.OwnerName, repo.Name, issue.Index), map[string]string{ + "body": commentBody, + }).AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteRepository)) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("BlockedByIssuePoster", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 13}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", repo.OwnerName, repo.Name, issue.Index), map[string]string{ + "body": commentBody, + }).AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteRepository)) + MakeRequest(t, req, http.StatusForbidden) + }) } func TestAPIGetComment(t *testing.T) { diff --git a/tests/integration/api_issue_reaction_test.go b/tests/integration/api_issue_reaction_test.go index 4ca909f2812..17e9f7aed5a 100644 --- a/tests/integration/api_issue_reaction_test.go +++ b/tests/integration/api_issue_reaction_test.go @@ -58,6 +58,13 @@ func TestAPIIssuesReactions(t *testing.T) { // Add existing reaction MakeRequest(t, req, http.StatusForbidden) + // Blocked user can't react to comment + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "rocket", + }).AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteIssue)) + MakeRequest(t, req, http.StatusForbidden) + // Get end result of reaction list of issue #1 req = NewRequest(t, "GET", urlStr). AddTokenAuth(token) diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 650bac2e323..17b4e5bd71b 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -84,7 +84,7 @@ func TestAPICreateIssue(t *testing.T) { session := loginUser(t, owner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repoBefore.Name) req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ Body: body, Title: title, @@ -106,6 +106,12 @@ func TestAPICreateIssue(t *testing.T) { repoAfter := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) assert.Equal(t, repoBefore.NumIssues+1, repoAfter.NumIssues) assert.Equal(t, repoBefore.NumClosedIssues, repoAfter.NumClosedIssues) + + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{ + Title: title, + }).AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteIssue)) + MakeRequest(t, req, http.StatusForbidden) } func TestAPICreateIssueParallel(t *testing.T) { @@ -117,7 +123,7 @@ func TestAPICreateIssueParallel(t *testing.T) { session := loginUser(t, owner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repoBefore.Name) var wg sync.WaitGroup for i := 0; i < 10; i++ { diff --git a/tests/integration/api_repo_collaborator_test.go b/tests/integration/api_repo_collaborator_test.go index 59cf85fef33..463db1dfb13 100644 --- a/tests/integration/api_repo_collaborator_test.go +++ b/tests/integration/api_repo_collaborator_test.go @@ -27,6 +27,7 @@ func TestAPIRepoCollaboratorPermission(t *testing.T) { user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10}) user11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 11}) + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) testCtx := NewAPITestContext(t, repo2Owner.Name, repo2.Name, auth_model.AccessTokenScopeWriteRepository) @@ -86,6 +87,12 @@ func TestAPIRepoCollaboratorPermission(t *testing.T) { MakeRequest(t, req, http.StatusNotFound) }) + t.Run("CollaboratorBlocked", func(t *testing.T) { + ctx := NewAPITestContext(t, repo2Owner.Name, repo2.Name, auth_model.AccessTokenScopeWriteRepository) + ctx.ExpectedCode = http.StatusForbidden + doAPIAddCollaborator(ctx, user34.Name, perm.AccessModeAdmin)(t) + }) + t.Run("CollaboratorCanQueryItsPermissions", func(t *testing.T) { t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user5.Name, perm.AccessModeRead)) diff --git a/tests/integration/api_user_block_test.go b/tests/integration/api_user_block_test.go new file mode 100644 index 00000000000..2cc3895a71d --- /dev/null +++ b/tests/integration/api_user_block_test.go @@ -0,0 +1,243 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestBlockUser(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + countStars := func(t *testing.T, repoOwnerID, starrerID int64) int64 { + count, err := db.Count[repo_model.Repository](db.DefaultContext, &repo_model.StarredReposOptions{ + StarrerID: starrerID, + RepoOwnerID: repoOwnerID, + IncludePrivate: true, + }) + assert.NoError(t, err) + return count + } + + countWatches := func(t *testing.T, repoOwnerID, watcherID int64) int64 { + count, err := db.Count[repo_model.Repository](db.DefaultContext, &repo_model.WatchedReposOptions{ + WatcherID: watcherID, + RepoOwnerID: repoOwnerID, + }) + assert.NoError(t, err) + return count + } + + countRepositoryTransfers := func(t *testing.T, senderID, recipientID int64) int64 { + transfers, err := models.GetPendingRepositoryTransfers(db.DefaultContext, &models.PendingRepositoryTransferOptions{ + SenderID: senderID, + RecipientID: recipientID, + }) + assert.NoError(t, err) + return int64(len(transfers)) + } + + countAssignedIssues := func(t *testing.T, repoOwnerID, assigneeID int64) int64 { + _, count, err := issues_model.GetAssignedIssues(db.DefaultContext, &issues_model.AssignedIssuesOptions{ + AssigneeID: assigneeID, + RepoOwnerID: repoOwnerID, + }) + assert.NoError(t, err) + return count + } + + countCollaborations := func(t *testing.T, repoOwnerID, collaboratorID int64) int64 { + count, err := db.Count[repo_model.Collaboration](db.DefaultContext, &repo_model.FindCollaborationOptions{ + CollaboratorID: collaboratorID, + RepoOwnerID: repoOwnerID, + }) + assert.NoError(t, err) + return count + } + + t.Run("User", func(t *testing.T) { + var blockerID int64 = 16 + blockerName := "user16" + blockerToken := getUserToken(t, blockerName, auth_model.AccessTokenScopeWriteUser) + + var blockeeID int64 = 10 + blockeeName := "user10" + + t.Run("Block", func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/blocks/%s", blockeeName)) + MakeRequest(t, req, http.StatusUnauthorized) + + assert.EqualValues(t, 1, countStars(t, blockerID, blockeeID)) + assert.EqualValues(t, 1, countWatches(t, blockerID, blockeeID)) + assert.EqualValues(t, 1, countRepositoryTransfers(t, blockerID, blockeeID)) + assert.EqualValues(t, 1, countCollaborations(t, blockerID, blockeeID)) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/blocks/%s", blockeeName)). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/blocks/%s?reason=test", blockeeName)). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusNoContent) + + assert.EqualValues(t, 0, countStars(t, blockerID, blockeeID)) + assert.EqualValues(t, 0, countWatches(t, blockerID, blockeeID)) + assert.EqualValues(t, 0, countRepositoryTransfers(t, blockerID, blockeeID)) + assert.EqualValues(t, 0, countCollaborations(t, blockerID, blockeeID)) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/blocks/%s", blockeeName)). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/blocks/%s", blockeeName)). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusBadRequest) // can't block blocked user + + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/blocks/%s", "org3")). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusBadRequest) // can't block organization + + req = NewRequest(t, "GET", "/api/v1/user/blocks") + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", "/api/v1/user/blocks"). + AddTokenAuth(blockerToken) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + + assert.Len(t, users, 1) + assert.Equal(t, blockeeName, users[0].UserName) + }) + + t.Run("Unblock", func(t *testing.T) { + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/blocks/%s", blockeeName)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/blocks/%s", blockeeName)). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/blocks/%s", blockeeName)). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/blocks/%s", "org3")). + AddTokenAuth(blockerToken) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequest(t, "GET", "/api/v1/user/blocks"). + AddTokenAuth(blockerToken) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + + assert.Empty(t, users) + }) + }) + + t.Run("Organization", func(t *testing.T) { + var blockerID int64 = 3 + blockerName := "org3" + + doerToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteOrganization) + + var blockeeID int64 = 10 + blockeeName := "user10" + + t.Run("Block", func(t *testing.T) { + req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, blockeeName)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, "user4")). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusBadRequest) // can't block member + + assert.EqualValues(t, 1, countStars(t, blockerID, blockeeID)) + assert.EqualValues(t, 1, countWatches(t, blockerID, blockeeID)) + assert.EqualValues(t, 1, countRepositoryTransfers(t, blockerID, blockeeID)) + assert.EqualValues(t, 1, countAssignedIssues(t, blockerID, blockeeID)) + assert.EqualValues(t, 1, countCollaborations(t, blockerID, blockeeID)) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, blockeeName)). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusNotFound) + + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s?reason=test", blockerName, blockeeName)). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusNoContent) + + assert.EqualValues(t, 0, countStars(t, blockerID, blockeeID)) + assert.EqualValues(t, 0, countWatches(t, blockerID, blockeeID)) + assert.EqualValues(t, 0, countRepositoryTransfers(t, blockerID, blockeeID)) + assert.EqualValues(t, 0, countAssignedIssues(t, blockerID, blockeeID)) + assert.EqualValues(t, 0, countCollaborations(t, blockerID, blockeeID)) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, blockeeName)). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, blockeeName)). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusBadRequest) // can't block blocked user + + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, "org3")). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusBadRequest) // can't block organization + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/blocks", blockerName)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/blocks", blockerName)). + AddTokenAuth(doerToken) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + + assert.Len(t, users, 1) + assert.Equal(t, blockeeName, users[0].UserName) + }) + + t.Run("Unblock", func(t *testing.T) { + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, blockeeName)) + MakeRequest(t, req, http.StatusUnauthorized) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, blockeeName)). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusNoContent) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, blockeeName)). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/%s/blocks/%s", blockerName, "org3")). + AddTokenAuth(doerToken) + MakeRequest(t, req, http.StatusBadRequest) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/blocks", blockerName)). + AddTokenAuth(doerToken) + resp := MakeRequest(t, req, http.StatusOK) + + var users []api.User + DecodeJSON(t, resp, &users) + + assert.Empty(t, users) + }) + }) +} diff --git a/tests/integration/api_user_follow_test.go b/tests/integration/api_user_follow_test.go index 1762732c10e..fe20af67698 100644 --- a/tests/integration/api_user_follow_test.go +++ b/tests/integration/api_user_follow_test.go @@ -9,6 +9,8 @@ import ( "testing" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/tests" @@ -33,6 +35,12 @@ func TestAPIFollow(t *testing.T) { req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/following/%s", user1)). AddTokenAuth(token2) MakeRequest(t, req, http.StatusNoContent) + + // blocked user can't follow blocker + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + req = NewRequest(t, "PUT", "/api/v1/user/following/user2"). + AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteUser)) + MakeRequest(t, req, http.StatusForbidden) }) t.Run("ListFollowing", func(t *testing.T) { diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go index 50423c80e7a..0062889a92d 100644 --- a/tests/integration/api_user_star_test.go +++ b/tests/integration/api_user_star_test.go @@ -9,6 +9,8 @@ import ( "testing" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/tests" @@ -31,6 +33,12 @@ func TestAPIStar(t *testing.T) { req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/starred/%s", repo)). AddTokenAuth(tokenWithUserScope) MakeRequest(t, req, http.StatusNoContent) + + // blocked user can't star a repo + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/starred/%s", repo)). + AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteRepository)) + MakeRequest(t, req, http.StatusForbidden) }) t.Run("GetStarredRepos", func(t *testing.T) { diff --git a/tests/integration/api_user_watch_test.go b/tests/integration/api_user_watch_test.go index 953e00551d1..71dc57453e9 100644 --- a/tests/integration/api_user_watch_test.go +++ b/tests/integration/api_user_watch_test.go @@ -9,6 +9,8 @@ import ( "testing" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/tests" @@ -31,6 +33,12 @@ func TestAPIWatch(t *testing.T) { req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)). AddTokenAuth(tokenWithRepoScope) MakeRequest(t, req, http.StatusOK) + + // blocked user can't watch a repo + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)). + AddTokenAuth(getUserToken(t, user34.Name, auth_model.AccessTokenScopeWriteRepository)) + MakeRequest(t, req, http.StatusForbidden) }) t.Run("GetWatchedRepos", func(t *testing.T) { diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go index 3a5fdb97a6b..0d733f663a7 100644 --- a/tests/integration/auth_ldap_test.go +++ b/tests/integration/auth_ldap_test.go @@ -428,9 +428,9 @@ func TestLDAPGroupTeamSyncAddMember(t *testing.T) { isMember, err := organization.IsTeamMember(db.DefaultContext, usersOrgs[0].ID, team.ID, user.ID) assert.NoError(t, err) assert.True(t, isMember, "Membership should be added to the right team") - err = models.RemoveTeamMember(db.DefaultContext, team, user.ID) + err = models.RemoveTeamMember(db.DefaultContext, team, user) assert.NoError(t, err) - err = models.RemoveOrgUser(db.DefaultContext, usersOrgs[0].ID, user.ID) + err = models.RemoveOrgUser(db.DefaultContext, usersOrgs[0], user) assert.NoError(t, err) } else { // assert members of LDAP group "cn=admin_staff" keep initial team membership since mapped team does not exist @@ -460,7 +460,7 @@ func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) { }) err = organization.AddOrgUser(db.DefaultContext, org.ID, user.ID) assert.NoError(t, err) - err = models.AddTeamMember(db.DefaultContext, team, user.ID) + err = models.AddTeamMember(db.DefaultContext, team, user) assert.NoError(t, err) isMember, err := organization.IsOrganizationMember(db.DefaultContext, org.ID, user.ID) assert.NoError(t, err)