From c337ff0ec70618ef2ead7850f90ab2a8458db192 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Mon, 4 Mar 2024 09:16:03 +0100 Subject: [PATCH 1/8] 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 0000000000000..b59bbe4d624b6 --- /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 641c453eb7760..4171e31fef777 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 7603bdad32c7d..4c3ac367f6b59 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 e5d36f921a7e0..c40ecad6764ce 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 b841b5e983a1b..db92c952482c8 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 d094fe82d8d73..e5c6224c96ff3 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 860f26b8e2282..39b51b3736f2f 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 16b687ae04d1f..a3de535508b39 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 0000000000000..2ec9d99df5214 --- /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 1950ac99e7f9c..18bcd2ed2b478 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 60f32d9557874..30234be07a60d 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 b258dc882cb2c..ef96e1ee50eaf 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 cfc3c1683c615..e2e35859df149 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 bb47cf24cac44..d5448636fe828 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 516eb53f62c02..9d288ec2bdf17 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 0000000000000..7c93bfcc6632e --- /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 5f61f05b16ac7..69cc47137eb9d 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 1a452436c3e32..aecf0d80fd8ac 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 e4b7b917e8528..cf2c8be536d8e 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 d10a1dc218daf..247530406d6de 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 b4919defb43ae..a3082e9ac7458 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 ab767db200044..d6d0a5054dd86 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 3e2568b4b429c..b422a086149a0 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 7288082614738..272c6ac05bd54 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 21a99dd5573e6..639050f5fd0bd 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 1a870224bf5d1..c13b698abf148 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 60737149da85e..4c66855525fa6 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 62eac4e29a88d..aaac89d975d7c 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 30c9db7474d45..686224765785d 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 7816b0262a547..591dcea5b540f 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 80da4030cbcc3..a616544cae314 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 7aa899291c205..a95a267961638 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 676e2dbb63382..747ec2f2488d7 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 0000000000000..5f2b65a199099 --- /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 f4dd2891ff402..cf9672109a59c 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 a898e71a2d710..2e1d6af1763a8 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 f522f743d59c6..f4efd071eade1 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 ebe14e3a4c2e5..f71c58fbdf5bf 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 f009c0880d711..4f18b9b3fa647 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 c8c8f2dfebcfd..255fed28ad1e0 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 1587d413f5541..c65650c388407 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 0000000000000..69a5222a20aea --- /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 fb66d4c3f5b00..9db9ad964b6bf 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 b62a386fd77ec..015af774e34e9 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 7d48d7151690c..4ce14f7d01837 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 212cc7a93b54f..a1e3c9804ba39 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 1b2ecd474be1c..d43711e362c64 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 6209e960af450..21aabadf3ddf6 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 e7436db7982a7..4096cbf07b025 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 799c687812635..3ff3d19f13efc 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 8f9848f71d17c..4cb94b11a2951 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 4f05c0df518c7..776b336761ff8 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 0000000000000..a1e65625ed35e --- /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 0000000000000..7231e9add7a93 --- /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 398c6b25673dc..6abb70de194d4 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 e624884db361c..ad9ed9548d091 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 706f4cc66bc2e..2cc23ae4763c9 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 0000000000000..d40458e25066f --- /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 9a3d60e122baf..63ac57cf0dc4c 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 fd7486cacdbc4..144d9b1b43dd7 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 b8c7f70aa6d34..45fd01f4da530 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 bf52d76e95616..ed063715e50d3 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 49779efa37586..f0caf199a2979 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 6bfd4855667b4..31f9f76d0f996 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 3af0ddb5789d0..992a980d9e641 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 0000000000000..8a2357623f195 --- /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 3bc1adae99ec6..2d6d9ad98d77d 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 833312c50102a..9851ea90a66c0 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 0000000000000..94fc380cee848 --- /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 14d31b3a9088f..8710f6e3e5ea8 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 3a2411ec55f1b..05293f202f5d8 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 186aa4a8782ad..416592bfda055 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 8d8c575c14039..d68623aff6833 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 e493a032114af..0a59088d127b7 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 6e56714ddfacf..2f9bee806a9b1 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 b1f418c32e565..27a106009c899 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 0000000000000..deb99169e1b67 --- /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 5dc8e1a5f3e33..7734860fc0d71 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 42363f886d7d0..be3d25d20a03c 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 dccc124748136..4a43ae2a28fca 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 c3d006bfd8c27..a2eb06b81a220 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 08d6800ee7660..1eeec27660075 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 f9c13a109eba7..f074fd1082118 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 59a4eb260e290..83d303218825d 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 0000000000000..0b3b618aae670 --- /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 0000000000000..aec3e03cf3700 --- /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 000910319a6bd..212cb83e03114 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 f2648db409f5f..6604dba4d62c4 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 2ebcded925218..f110bd26d0886 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 0000000000000..eab5ec0007c21 --- /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 64ae20f0a3b72..ce792f667c4f9 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 39cf8755f2847..1cb3aaaa212d7 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 747132931efea..edfa9c0bc5eaf 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 4afd73c37177e..17556d4e4894a 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 0000000000000..c6db4ca1e46bb --- /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 0000000000000..b2f0957691898 --- /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 88d3b9a6e5616..a168e6903e426 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 9aba84a023272..98198696bc40c 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 0000000000000..e495b85f581a7 --- /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 a690d003524c7..c360944814a45 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 a9c5228a16c17..255b8332b23d6 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 4ca909f2812b9..17e9f7aed5a91 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 650bac2e323a7..17b4e5bd71b8c 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 59cf85fef33d8..463db1dfb1305 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 0000000000000..2cc3895a71dc7 --- /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 1762732c10e6d..fe20af67698ba 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 50423c80e7a2b..0062889a92db6 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 953e00551d196..71dc57453e9ca 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 3a5fdb97a6bb8..0d733f663a776 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) From e2277d07ca5112a797f8c86f825060027ff1c2da Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 4 Mar 2024 16:57:39 +0800 Subject: [PATCH 2/8] Move some asymkey functions to service layer (#28894) After the moving, all models will not depend on `util.Rename` so that I can do next step refactoring. --- cmd/admin_regenerate.go | 4 +- models/asymkey/ssh_key_authorized_keys.go | 66 ++-------------- models/asymkey/ssh_key_principals.go | 40 ---------- routers/init.go | 4 +- routers/web/user/setting/keys.go | 2 +- services/asymkey/deploy_key.go | 3 +- services/asymkey/ssh_key.go | 4 +- services/asymkey/ssh_key_authorized_keys.go | 79 +++++++++++++++++++ .../asymkey/ssh_key_authorized_principals.go | 30 +++---- services/asymkey/ssh_key_principals.go | 54 +++++++++++++ .../auth/source/ldap/source_authenticate.go | 5 +- services/auth/source/ldap/source_sync.go | 5 +- services/cron/tasks_extended.go | 6 +- services/doctor/authorizedkeys.go | 5 +- services/repository/delete.go | 3 +- services/user/user.go | 6 +- 16 files changed, 176 insertions(+), 140 deletions(-) create mode 100644 services/asymkey/ssh_key_authorized_keys.go rename {models => services}/asymkey/ssh_key_authorized_principals.go (73%) create mode 100644 services/asymkey/ssh_key_principals.go diff --git a/cmd/admin_regenerate.go b/cmd/admin_regenerate.go index 0db505ff9c7ef..ab769f6d0c6f3 100644 --- a/cmd/admin_regenerate.go +++ b/cmd/admin_regenerate.go @@ -4,8 +4,8 @@ package cmd import ( - asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/modules/graceful" + asymkey_service "code.gitea.io/gitea/services/asymkey" repo_service "code.gitea.io/gitea/services/repository" "github.com/urfave/cli/v2" @@ -42,5 +42,5 @@ func runRegenerateKeys(_ *cli.Context) error { if err := initDB(ctx); err != nil { return err } - return asymkey_model.RewriteAllPublicKeys(ctx) + return asymkey_service.RewriteAllPublicKeys(ctx) } diff --git a/models/asymkey/ssh_key_authorized_keys.go b/models/asymkey/ssh_key_authorized_keys.go index 267ab252c8dc2..9279db20208fb 100644 --- a/models/asymkey/ssh_key_authorized_keys.go +++ b/models/asymkey/ssh_key_authorized_keys.go @@ -12,7 +12,6 @@ import ( "path/filepath" "strings" "sync" - "time" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" @@ -44,6 +43,12 @@ const ( var sshOpLocker sync.Mutex +func WithSSHOpLocker(f func() error) error { + sshOpLocker.Lock() + defer sshOpLocker.Unlock() + return f() +} + // AuthorizedStringForKey creates the authorized keys string appropriate for the provided key func AuthorizedStringForKey(key *PublicKey) string { sb := &strings.Builder{} @@ -114,65 +119,6 @@ func appendAuthorizedKeysToFile(keys ...*PublicKey) error { return nil } -// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again. -// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function -// outside any session scope independently. -func RewriteAllPublicKeys(ctx context.Context) error { - // Don't rewrite key if internal server - if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { - return nil - } - - sshOpLocker.Lock() - defer sshOpLocker.Unlock() - - if setting.SSH.RootPath != "" { - // First of ensure that the RootPath is present, and if not make it with 0700 permissions - // This of course doesn't guarantee that this is the right directory for authorized_keys - // but at least if it's supposed to be this directory and it doesn't exist and we're the - // right user it will at least be created properly. - err := os.MkdirAll(setting.SSH.RootPath, 0o700) - if err != nil { - log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) - return err - } - } - - fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") - tmpPath := fPath + ".tmp" - t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) - if err != nil { - return err - } - defer func() { - t.Close() - if err := util.Remove(tmpPath); err != nil { - log.Warn("Unable to remove temporary authorized keys file: %s: Error: %v", tmpPath, err) - } - }() - - if setting.SSH.AuthorizedKeysBackup { - isExist, err := util.IsExist(fPath) - if err != nil { - log.Error("Unable to check if %s exists. Error: %v", fPath, err) - return err - } - if isExist { - bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix()) - if err = util.CopyFile(fPath, bakPath); err != nil { - return err - } - } - } - - if err := RegeneratePublicKeys(ctx, t); err != nil { - return err - } - - t.Close() - return util.Rename(tmpPath, fPath) -} - // RegeneratePublicKeys regenerates the authorized_keys file func RegeneratePublicKeys(ctx context.Context, t io.StringWriter) error { if err := db.GetEngine(ctx).Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { diff --git a/models/asymkey/ssh_key_principals.go b/models/asymkey/ssh_key_principals.go index 4e7dee2c91d06..e8b97d306e6b8 100644 --- a/models/asymkey/ssh_key_principals.go +++ b/models/asymkey/ssh_key_principals.go @@ -9,51 +9,11 @@ import ( "strings" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/perm" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" ) -// AddPrincipalKey adds new principal to database and authorized_principals file. -func AddPrincipalKey(ctx context.Context, ownerID int64, content string, authSourceID int64) (*PublicKey, error) { - dbCtx, committer, err := db.TxContext(ctx) - if err != nil { - return nil, err - } - defer committer.Close() - - // Principals cannot be duplicated. - has, err := db.GetEngine(dbCtx). - Where("content = ? AND type = ?", content, KeyTypePrincipal). - Get(new(PublicKey)) - if err != nil { - return nil, err - } else if has { - return nil, ErrKeyAlreadyExist{0, "", content} - } - - key := &PublicKey{ - OwnerID: ownerID, - Name: content, - Content: content, - Mode: perm.AccessModeWrite, - Type: KeyTypePrincipal, - LoginSourceID: authSourceID, - } - if err = db.Insert(dbCtx, key); err != nil { - return nil, fmt.Errorf("addKey: %w", err) - } - - if err = committer.Commit(); err != nil { - return nil, err - } - - committer.Close() - - return key, RewriteAllPrincipalKeys(ctx) -} - // CheckPrincipalKeyString strips spaces and returns an error if the given principal contains newlines func CheckPrincipalKeyString(ctx context.Context, user *user_model.User, content string) (_ string, err error) { if setting.SSH.Disabled { diff --git a/routers/init.go b/routers/init.go index 1dedbebeb5c47..aaf95920c2e6f 100644 --- a/routers/init.go +++ b/routers/init.go @@ -9,7 +9,6 @@ import ( "runtime" "code.gitea.io/gitea/models" - asymkey_model "code.gitea.io/gitea/models/asymkey" authmodel "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/eventsource" @@ -33,6 +32,7 @@ import ( "code.gitea.io/gitea/routers/private" web_routers "code.gitea.io/gitea/routers/web" actions_service "code.gitea.io/gitea/services/actions" + asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/automerge" @@ -94,7 +94,7 @@ func syncAppConfForGit(ctx context.Context) error { mustInitCtx(ctx, repo_service.SyncRepositoryHooks) log.Info("re-write ssh public keys ...") - mustInitCtx(ctx, asymkey_model.RewriteAllPublicKeys) + mustInitCtx(ctx, asymkey_service.RewriteAllPublicKeys) return system.AppState.Set(ctx, runtimeState) } diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index d2b60fc809e31..056fcc0ace2f3 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -62,7 +62,7 @@ func KeysPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/keys") return } - if _, err = asymkey_model.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil { + if _, err = asymkey_service.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil { ctx.Data["HasPrincipalError"] = true switch { case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.IsErrKeyNameAlreadyUsed(err): diff --git a/services/asymkey/deploy_key.go b/services/asymkey/deploy_key.go index e127cbfc6e01e..324688c53408e 100644 --- a/services/asymkey/deploy_key.go +++ b/services/asymkey/deploy_key.go @@ -7,7 +7,6 @@ import ( "context" "code.gitea.io/gitea/models" - asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" ) @@ -27,5 +26,5 @@ func DeleteDeployKey(ctx context.Context, doer *user_model.User, id int64) error return err } - return asymkey_model.RewriteAllPublicKeys(ctx) + return RewriteAllPublicKeys(ctx) } diff --git a/services/asymkey/ssh_key.go b/services/asymkey/ssh_key.go index 83d7edafa3b4e..da57059d4b40f 100644 --- a/services/asymkey/ssh_key.go +++ b/services/asymkey/ssh_key.go @@ -43,8 +43,8 @@ func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err committer.Close() if key.Type == asymkey_model.KeyTypePrincipal { - return asymkey_model.RewriteAllPrincipalKeys(ctx) + return RewriteAllPrincipalKeys(ctx) } - return asymkey_model.RewriteAllPublicKeys(ctx) + return RewriteAllPublicKeys(ctx) } diff --git a/services/asymkey/ssh_key_authorized_keys.go b/services/asymkey/ssh_key_authorized_keys.go new file mode 100644 index 0000000000000..5caa5bbfb6189 --- /dev/null +++ b/services/asymkey/ssh_key_authorized_keys.go @@ -0,0 +1,79 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again. +// Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function +// outside any session scope independently. +func RewriteAllPublicKeys(ctx context.Context) error { + // Don't rewrite key if internal server + if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { + return nil + } + + return asymkey_model.WithSSHOpLocker(func() error { + return rewriteAllPublicKeys(ctx) + }) +} + +func rewriteAllPublicKeys(ctx context.Context) error { + if setting.SSH.RootPath != "" { + // First of ensure that the RootPath is present, and if not make it with 0700 permissions + // This of course doesn't guarantee that this is the right directory for authorized_keys + // but at least if it's supposed to be this directory and it doesn't exist and we're the + // right user it will at least be created properly. + err := os.MkdirAll(setting.SSH.RootPath, 0o700) + if err != nil { + log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) + return err + } + } + + fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") + tmpPath := fPath + ".tmp" + t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + t.Close() + if err := util.Remove(tmpPath); err != nil { + log.Warn("Unable to remove temporary authorized keys file: %s: Error: %v", tmpPath, err) + } + }() + + if setting.SSH.AuthorizedKeysBackup { + isExist, err := util.IsExist(fPath) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", fPath, err) + return err + } + if isExist { + bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix()) + if err = util.CopyFile(fPath, bakPath); err != nil { + return err + } + } + } + + if err := asymkey_model.RegeneratePublicKeys(ctx, t); err != nil { + return err + } + + t.Close() + return util.Rename(tmpPath, fPath) +} diff --git a/models/asymkey/ssh_key_authorized_principals.go b/services/asymkey/ssh_key_authorized_principals.go similarity index 73% rename from models/asymkey/ssh_key_authorized_principals.go rename to services/asymkey/ssh_key_authorized_principals.go index 107d70c766e8e..9154db7dbb58a 100644 --- a/models/asymkey/ssh_key_authorized_principals.go +++ b/services/asymkey/ssh_key_authorized_principals.go @@ -13,31 +13,22 @@ import ( "strings" "time" + asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" ) -// _____ __ .__ .__ .___ -// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ -// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | -// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | -// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | -// \/ \/ \/ \/ \/ -// __________ .__ .__ .__ -// \______ _______|__| ____ ____ |_____________ | | ______ -// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/ -// | | | | \| | | \ \___| | |_> / __ \| |__\___ \ -// |____| |__| |__|___| /\___ |__| __(____ |____/____ > -// \/ \/ |__| \/ \/ -// // This file contains functions for creating authorized_principals files // // There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys // The sshOpLocker is used from ssh_key_authorized_keys.go -const authorizedPrincipalsFile = "authorized_principals" +const ( + authorizedPrincipalsFile = "authorized_principals" + tplCommentPrefix = `# gitea public key` +) // RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again. // Note: db.GetEngine(ctx).Iterate does not get latest data after insert/delete, so we have to call this function @@ -48,9 +39,12 @@ func RewriteAllPrincipalKeys(ctx context.Context) error { return nil } - sshOpLocker.Lock() - defer sshOpLocker.Unlock() + return asymkey_model.WithSSHOpLocker(func() error { + return rewriteAllPrincipalKeys(ctx) + }) +} +func rewriteAllPrincipalKeys(ctx context.Context) error { if setting.SSH.RootPath != "" { // First of ensure that the RootPath is present, and if not make it with 0700 permissions // This of course doesn't guarantee that this is the right directory for authorized_keys @@ -97,8 +91,8 @@ func RewriteAllPrincipalKeys(ctx context.Context) error { } func regeneratePrincipalKeys(ctx context.Context, t io.StringWriter) error { - if err := db.GetEngine(ctx).Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean any) (err error) { - _, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) + if err := db.GetEngine(ctx).Where("type = ?", asymkey_model.KeyTypePrincipal).Iterate(new(asymkey_model.PublicKey), func(idx int, bean any) (err error) { + _, err = t.WriteString((bean.(*asymkey_model.PublicKey)).AuthorizedString()) return err }); err != nil { return err diff --git a/services/asymkey/ssh_key_principals.go b/services/asymkey/ssh_key_principals.go new file mode 100644 index 0000000000000..5ed5cfa78298a --- /dev/null +++ b/services/asymkey/ssh_key_principals.go @@ -0,0 +1,54 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package asymkey + +import ( + "context" + "fmt" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" +) + +// AddPrincipalKey adds new principal to database and authorized_principals file. +func AddPrincipalKey(ctx context.Context, ownerID int64, content string, authSourceID int64) (*asymkey_model.PublicKey, error) { + dbCtx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + // Principals cannot be duplicated. + has, err := db.GetEngine(dbCtx). + Where("content = ? AND type = ?", content, asymkey_model.KeyTypePrincipal). + Get(new(asymkey_model.PublicKey)) + if err != nil { + return nil, err + } else if has { + return nil, asymkey_model.ErrKeyAlreadyExist{ + Content: content, + } + } + + key := &asymkey_model.PublicKey{ + OwnerID: ownerID, + Name: content, + Content: content, + Mode: perm.AccessModeWrite, + Type: asymkey_model.KeyTypePrincipal, + LoginSourceID: authSourceID, + } + if err = db.Insert(dbCtx, key); err != nil { + return nil, fmt.Errorf("addKey: %w", err) + } + + if err = committer.Commit(); err != nil { + return nil, err + } + + committer.Close() + + return key, RewriteAllPrincipalKeys(ctx) +} diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index 68ecd16342302..6ebd3ea50ac8c 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -13,6 +13,7 @@ import ( user_model "code.gitea.io/gitea/models/user" auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/optional" + asymkey_service "code.gitea.io/gitea/services/asymkey" source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -68,7 +69,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u if user != nil { if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sr.SSHPublicKey) { - if err := asymkey_model.RewriteAllPublicKeys(ctx); err != nil { + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { return user, err } } @@ -94,7 +95,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u } if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.authSource, sr.SSHPublicKey) { - if err := asymkey_model.RewriteAllPublicKeys(ctx); err != nil { + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { return user, err } } diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index 62f052d68c3c9..0c9491cd09878 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" + asymkey_service "code.gitea.io/gitea/services/asymkey" source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -77,7 +78,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { log.Warn("SyncExternalUsers: Cancelled at update of %s before completed update of users", source.authSource.Name) // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed if sshKeysNeedUpdate { - err = asymkey_model.RewriteAllPublicKeys(ctx) + err = asymkey_service.RewriteAllPublicKeys(ctx) if err != nil { log.Error("RewriteAllPublicKeys: %v", err) } @@ -195,7 +196,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed if sshKeysNeedUpdate { - err = asymkey_model.RewriteAllPublicKeys(ctx) + err = asymkey_service.RewriteAllPublicKeys(ctx) if err != nil { log.Error("RewriteAllPublicKeys: %v", err) } diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 1dd5d70a38d56..0018c5facc5d7 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -8,13 +8,13 @@ import ( "time" activities_model "code.gitea.io/gitea/models/activities" - asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" issue_indexer "code.gitea.io/gitea/modules/indexer/issues" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/updatechecker" + asymkey_service "code.gitea.io/gitea/services/asymkey" repo_service "code.gitea.io/gitea/services/repository" archiver_service "code.gitea.io/gitea/services/repository/archiver" user_service "code.gitea.io/gitea/services/user" @@ -71,7 +71,7 @@ func registerRewriteAllPublicKeys() { RunAtStart: false, Schedule: "@every 72h", }, func(ctx context.Context, _ *user_model.User, _ Config) error { - return asymkey_model.RewriteAllPublicKeys(ctx) + return asymkey_service.RewriteAllPublicKeys(ctx) }) } @@ -81,7 +81,7 @@ func registerRewriteAllPrincipalKeys() { RunAtStart: false, Schedule: "@every 72h", }, func(ctx context.Context, _ *user_model.User, _ Config) error { - return asymkey_model.RewriteAllPrincipalKeys(ctx) + return asymkey_service.RewriteAllPrincipalKeys(ctx) }) } diff --git a/services/doctor/authorizedkeys.go b/services/doctor/authorizedkeys.go index 050a4e79742f0..d5a96605b9ca2 100644 --- a/services/doctor/authorizedkeys.go +++ b/services/doctor/authorizedkeys.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + asymkey_service "code.gitea.io/gitea/services/asymkey" ) const tplCommentPrefix = `# gitea public key` @@ -33,7 +34,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e return fmt.Errorf("Unable to open authorized_keys file. ERROR: %w", err) } logger.Warn("Unable to open authorized_keys. (ERROR: %v). Attempting to rewrite...", err) - if err = asymkey_model.RewriteAllPublicKeys(ctx); err != nil { + if err = asymkey_service.RewriteAllPublicKeys(ctx); err != nil { logger.Critical("Unable to rewrite authorized_keys file. ERROR: %v", err) return fmt.Errorf("Unable to rewrite authorized_keys file. ERROR: %w", err) } @@ -76,7 +77,7 @@ func checkAuthorizedKeys(ctx context.Context, logger log.Logger, autofix bool) e return fmt.Errorf(`authorized_keys is out of date and should be regenerated with "gitea admin regenerate keys" or "gitea doctor --run authorized-keys --fix"`) } logger.Warn("authorized_keys is out of date. Attempting rewrite...") - err = asymkey_model.RewriteAllPublicKeys(ctx) + err = asymkey_service.RewriteAllPublicKeys(ctx) if err != nil { logger.Critical("Unable to rewrite authorized_keys file. ERROR: %v", err) return fmt.Errorf("Unable to rewrite authorized_keys file. ERROR: %w", err) diff --git a/services/repository/delete.go b/services/repository/delete.go index 1eeec27660075..8d6729f31bc83 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/storage" + asymkey_service "code.gitea.io/gitea/services/asymkey" "xorm.io/builder" ) @@ -277,7 +278,7 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID committer.Close() if needRewriteKeysFile { - if err := asymkey_model.RewriteAllPublicKeys(ctx); err != nil { + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { log.Error("RewriteAllPublicKeys failed: %v", err) } } diff --git a/services/user/user.go b/services/user/user.go index 6604dba4d62c4..4fcb81581d0d8 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -11,7 +11,6 @@ import ( "time" "code.gitea.io/gitea/models" - asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" @@ -24,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/agit" + asymkey_service "code.gitea.io/gitea/services/asymkey" org_service "code.gitea.io/gitea/services/org" "code.gitea.io/gitea/services/packages" container_service "code.gitea.io/gitea/services/packages/container" @@ -252,10 +252,10 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { } committer.Close() - if err = asymkey_model.RewriteAllPublicKeys(ctx); err != nil { + if err = asymkey_service.RewriteAllPublicKeys(ctx); err != nil { return err } - if err = asymkey_model.RewriteAllPrincipalKeys(ctx); err != nil { + if err = asymkey_service.RewriteAllPrincipalKeys(ctx); err != nil { return err } From 7ec4c65ea5d5a3765d24ee68ae421f2f911b6b5d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 4 Mar 2024 18:57:30 +0800 Subject: [PATCH 3/8] Fix incorrect package link method calls in templates (#29580) Fix #29562 Follow #29531 --- templates/admin/packages/list.tmpl | 2 +- templates/package/settings.tmpl | 2 +- templates/package/shared/cleanup_rules/preview.tmpl | 2 +- templates/package/shared/list.tmpl | 2 +- templates/package/shared/versionlist.tmpl | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/templates/admin/packages/list.tmpl b/templates/admin/packages/list.tmpl index cf860dab2ab1a..1f86803d55def 100644 --- a/templates/admin/packages/list.tmpl +++ b/templates/admin/packages/list.tmpl @@ -63,7 +63,7 @@ {{.Package.Type.Name}} {{.Package.Name}} - {{.Version.Version}} + {{.Version.Version}} {{.Creator.Name}} {{if .Repository}} diff --git a/templates/package/settings.tmpl b/templates/package/settings.tmpl index 10e26c70103cc..9424baf4939ef 100644 --- a/templates/package/settings.tmpl +++ b/templates/package/settings.tmpl @@ -10,7 +10,7 @@ {{template "user/overview/header" .}} {{end}} {{template "base/alert" .}} -

{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}}) / {{ctx.Locale.Tr "repo.settings"}}

+

{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}}) / {{ctx.Locale.Tr "repo.settings"}}

{{ctx.Locale.Tr "packages.settings.link"}}

diff --git a/templates/package/shared/cleanup_rules/preview.tmpl b/templates/package/shared/cleanup_rules/preview.tmpl index 7a50d5ccca2e8..cff8e8249f46f 100644 --- a/templates/package/shared/cleanup_rules/preview.tmpl +++ b/templates/package/shared/cleanup_rules/preview.tmpl @@ -19,7 +19,7 @@ {{.Package.Type.Name}} {{.Package.Name}} - {{.Version.Version}} + {{.Version.Version}} {{.Creator.Name}} {{FileSize .CalculateBlobSize}} {{DateTime "short" .Version.CreatedUnix}} diff --git a/templates/package/shared/list.tmpl b/templates/package/shared/list.tmpl index 51e080f495167..09205b19a54d8 100644 --- a/templates/package/shared/list.tmpl +++ b/templates/package/shared/list.tmpl @@ -20,7 +20,7 @@
- {{.Package.Name}} + {{.Package.Name}} {{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}
diff --git a/templates/package/shared/versionlist.tmpl b/templates/package/shared/versionlist.tmpl index eee952c096e88..59d6d89b5333b 100644 --- a/templates/package/shared/versionlist.tmpl +++ b/templates/package/shared/versionlist.tmpl @@ -23,7 +23,7 @@
- {{.Version.LowerVersion}} + {{.Version.LowerVersion}}
{{ctx.Locale.Tr "packages.published_by" (TimeSinceUnix .Version.CreatedUnix ctx.Locale) .Creator.HomeLink .Creator.GetDisplayName}}
From e91733468ef726fc9365aa4820cdd5f2ddfdaa23 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 4 Mar 2024 19:24:02 +0800 Subject: [PATCH 4/8] Add missing database transaction for new issue (#29490) When creating an issue, inserting issue, assign users and set project should be in the same transaction. --- routers/api/v1/repo/issue.go | 2 +- routers/web/repo/issue.go | 22 +++++++++------------- services/issue/issue.go | 23 ++++++++++++++++------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index d43711e362c64..b63e7ab662c5c 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -709,7 +709,7 @@ func CreateIssue(ctx *context.APIContext) { form.Labels = make([]int64, 0) } - if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil { + if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs, 0); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) } else if errors.Is(err, user_model.ErrBlockedUser) { diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 45fd01f4da530..83a5b76bf15bc 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1224,6 +1224,14 @@ func NewIssuePost(ctx *context.Context) { return } + if projectID > 0 { + if !ctx.Repo.CanRead(unit.TypeProjects) { + // User must also be able to see the project. + ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") + return + } + } + if setting.Attachment.Enabled { attachments = form.Files } @@ -1256,7 +1264,7 @@ func NewIssuePost(ctx *context.Context) { Ref: form.Ref, } - if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil { + if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectID); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) } else if errors.Is(err, user_model.ErrBlockedUser) { @@ -1267,18 +1275,6 @@ func NewIssuePost(ctx *context.Context) { return } - if projectID > 0 { - if !ctx.Repo.CanRead(unit.TypeProjects) { - // User must also be able to see the project. - ctx.Error(http.StatusBadRequest, "user hasn't permissions to read projects") - return - } - if err := issues_model.ChangeProjectAssign(ctx, issue, ctx.Doer, projectID); err != nil { - ctx.ServerError("ChangeProjectAssign", err) - return - } - } - log.Trace("Issue created: %d/%d", repo.ID, issue.ID) if ctx.FormString("redirect_after_creation") == "project" && projectID > 0 { ctx.JSONRedirect(ctx.Repo.RepoLink + "/projects/" + strconv.FormatInt(projectID, 10)) diff --git a/services/issue/issue.go b/services/issue/issue.go index 27a106009c899..0753813b646f9 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -22,7 +22,7 @@ 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 { +func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64, projectID int64) error { if err := issue.LoadPoster(ctx); err != nil { return err } @@ -31,14 +31,23 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo return user_model.ErrBlockedUser } - if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil { - return err - } - - for _, assigneeID := range assigneeIDs { - if _, err := AddAssigneeIfNotAssigned(ctx, issue, issue.Poster, assigneeID, true); err != nil { + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil { return err } + for _, assigneeID := range assigneeIDs { + if _, err := AddAssigneeIfNotAssigned(ctx, issue, issue.Poster, assigneeID, true); err != nil { + return err + } + } + if projectID > 0 { + if err := issues_model.ChangeProjectAssign(ctx, issue, issue.Poster, projectID); err != nil { + return err + } + } + return nil + }); err != nil { + return err } mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, issue.Poster, issue.Content) From dae7f1ebdbe19620f40e110b285f7c0ecd0bb33b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 4 Mar 2024 20:02:45 +0800 Subject: [PATCH 5/8] Remove unnecessary SanitizeHTML from code (#29575) * "mail/issue/default.tmpl": the body is rendered by backend `markdown.RenderString() HTML`, it has been already sanitized * "repo/settings/webhook/base_list.tmpl": "Description" is prepared by backend `ctx.Tr`, it doesn't need to be sanitized --- docs/content/administration/mail-templates.en-us.md | 2 +- docs/content/administration/mail-templates.zh-cn.md | 2 +- modules/templates/helper.go | 10 ++-------- modules/templates/helper_test.go | 1 - templates/mail/issue/default.tmpl | 2 +- templates/repo/settings/webhook/base_list.tmpl | 2 +- templates/status/500.tmpl | 2 +- 7 files changed, 7 insertions(+), 14 deletions(-) diff --git a/docs/content/administration/mail-templates.en-us.md b/docs/content/administration/mail-templates.en-us.md index 0154fe55d0f7b..4026b89975b73 100644 --- a/docs/content/administration/mail-templates.en-us.md +++ b/docs/content/administration/mail-templates.en-us.md @@ -224,7 +224,7 @@ Please check [Gitea's logs](administration/logging-config.md) for error messages {{if not (eq .Body "")}}

Message content


- {{.Body | SanitizeHTML}} + {{.Body}} {{end}}


diff --git a/docs/content/administration/mail-templates.zh-cn.md b/docs/content/administration/mail-templates.zh-cn.md index e8c2817336c80..3c7c2a9397283 100644 --- a/docs/content/administration/mail-templates.zh-cn.md +++ b/docs/content/administration/mail-templates.zh-cn.md @@ -207,7 +207,7 @@ _主题_ 和 _邮件正文_ 由 [Golang的模板引擎](https://go.dev/pkg/text/ {{if not (eq .Body "")}}

消息内容:


- {{.Body | SanitizeHTML}} + {{.Body}} {{end}}


diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 1487fce69dc31..0997239a55add 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -208,14 +208,8 @@ func SafeHTML(s any) template.HTML { } // SanitizeHTML sanitizes the input by pre-defined markdown rules -func SanitizeHTML(s any) template.HTML { - switch v := s.(type) { - case string: - return template.HTML(markup.Sanitize(v)) - case template.HTML: - return template.HTML(markup.Sanitize(string(v))) - } - panic(fmt.Sprintf("unexpected type %T", s)) +func SanitizeHTML(s string) template.HTML { + return template.HTML(markup.Sanitize(s)) } func HTMLEscape(s any) template.HTML { diff --git a/modules/templates/helper_test.go b/modules/templates/helper_test.go index 3365278ac2910..64f29d033ec35 100644 --- a/modules/templates/helper_test.go +++ b/modules/templates/helper_test.go @@ -64,5 +64,4 @@ func TestHTMLFormat(t *testing.T) { func TestSanitizeHTML(t *testing.T) { assert.Equal(t, template.HTML(`link xss
inline
`), SanitizeHTML(`link xss
inline
`)) - assert.Equal(t, template.HTML(`link xss
inline
`), SanitizeHTML(template.HTML(`link xss
inline
`))) } diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl index 021ca3989dbb9..395b118d3ef22 100644 --- a/templates/mail/issue/default.tmpl +++ b/templates/mail/issue/default.tmpl @@ -58,7 +58,7 @@ {{.locale.Tr "mail.issue.action.new" .Doer.Name .Issue.Index}} {{end}} {{else}} - {{.Body | SanitizeHTML}} + {{.Body}} {{end -}} {{- range .ReviewComments}}
diff --git a/templates/repo/settings/webhook/base_list.tmpl b/templates/repo/settings/webhook/base_list.tmpl index 00f9a48ba7717..e56929b70f34c 100644 --- a/templates/repo/settings/webhook/base_list.tmpl +++ b/templates/repo/settings/webhook/base_list.tmpl @@ -10,7 +10,7 @@
- {{.Description | SanitizeHTML}} + {{.Description}}
{{range .Webhooks}}
diff --git a/templates/status/500.tmpl b/templates/status/500.tmpl index 58795e4bc04cd..03d0183280c21 100644 --- a/templates/status/500.tmpl +++ b/templates/status/500.tmpl @@ -1,5 +1,5 @@ {{/* This page should only depend the minimal template functions/variables, to avoid triggering new panics. -* base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl, ThemeName, SanitizeHTML +* base template functions: AppName, AssetUrlPrefix, AssetVersion, AppSubUrl, ThemeName * ctx.Locale * .Flash * .ErrorMsg From 62aa5e2cbd64c90546bbaf849070b42931a4e60b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 4 Mar 2024 20:56:34 +0800 Subject: [PATCH 6/8] Refactor star/watch button (#29576) 1. Use "star/unstart", but not `{{if}}un{{}}star{{}}` (the same to "watch/unwatch") 2. Use "not-mobile" for hiding the elements on mobile --- templates/repo/star_unstar.tmpl | 13 ++++++------- templates/repo/watch_unwatch.tmpl | 13 ++++++------- web_src/css/repo/header.css | 3 --- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/templates/repo/star_unstar.tmpl b/templates/repo/star_unstar.tmpl index 9c342f4065944..1cdb98bf2721d 100644 --- a/templates/repo/star_unstar.tmpl +++ b/templates/repo/star_unstar.tmpl @@ -1,11 +1,10 @@ -
+
- {{CountFmt .Repository.NumStars}} diff --git a/templates/repo/watch_unwatch.tmpl b/templates/repo/watch_unwatch.tmpl index c42bc5a9e77d9..2bf2c7bd21c6a 100644 --- a/templates/repo/watch_unwatch.tmpl +++ b/templates/repo/watch_unwatch.tmpl @@ -1,11 +1,10 @@ - +
- {{CountFmt .Repository.NumWatches}} diff --git a/web_src/css/repo/header.css b/web_src/css/repo/header.css index d5c7d212e8e75..0eb03136efbb2 100644 --- a/web_src/css/repo/header.css +++ b/web_src/css/repo/header.css @@ -89,9 +89,6 @@ .repo-header .flex-item { flex-grow: 1; } - .repo-buttons .ui.labeled.button .text { - display: none; - } .repo-header .flex-item-trailing .label { display: none; } From fad232054542ade88268304fea9b09f778d74a29 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 4 Mar 2024 21:48:59 +0800 Subject: [PATCH 7/8] Make admin pages wider because of left sidebar added and some tables become too narrow (#29581) Fix #25939 screenshots image --- templates/admin/layout_head.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/admin/layout_head.tmpl b/templates/admin/layout_head.tmpl index 0067f336e05c6..b326c82a6c6a2 100644 --- a/templates/admin/layout_head.tmpl +++ b/templates/admin/layout_head.tmpl @@ -3,7 +3,7 @@
{{template "base/alert" .ctxData}}
-
+
{{template "admin/navbar" .ctxData}}
{{/* block: admin-setting-content */}} From 76789bdbcd88c02b306842c91da18d8255dd582b Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 4 Mar 2024 21:51:12 +0800 Subject: [PATCH 8/8] Document that all unmerged feature PRs will be moved to next milestone when the feature freeze time comes (#29578) Some contributors may be surprised when moving the feature PRs to the next release when the feature freeze time comes. So this PR documents the habits we have done for feature freeze so people expect the maintainers' behaviors. We are sorry for disturbing you with the milestones changes. A feature freeze announcement should be published 2 or 3 weeks before the feature freeze by maintainers. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc90c6905b9d8..5d20bc258947c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -464,7 +464,7 @@ We assume in good faith that the information you provide is legally binding. We adopted a release schedule to streamline the process of working on, finishing, and issuing releases. \ The overall goal is to make a major release every three or four months, which breaks down into two or three months of general development followed by one month of testing and polishing known as the release freeze. \ All the feature pull requests should be -merged before feature freeze. And, during the frozen period, a corresponding +merged before feature freeze. All feature pull requests haven't been merged before this feature freeze will be moved to next milestone, please notice our feature freeze announcement on discord. And, during the frozen period, a corresponding release branch is open for fixes backported from main branch. Release candidates are made during this period for user testing to obtain a final version that is maintained in this branch.