Merge branch 'rebase-v1.21/forgejo-moderation' into wip-v1.21-forgejo

This commit is contained in:
Earl Warren 2023-11-13 16:47:07 +01:00
commit 2f84786d0c
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
79 changed files with 2355 additions and 59 deletions

View file

@ -588,7 +588,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
if repoChanged {
// Add feeds for user self and all watchers.
watchers, err = repo_model.GetWatchers(ctx, act.RepoID)
watchers, err = repo_model.GetWatchersExcludeBlocked(ctx, act.RepoID, act.ActUserID)
if err != nil {
return fmt.Errorf("get watchers: %w", err)
}

View file

@ -235,6 +235,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
for _, id := range issueUnWatches {
toNotify.Remove(id)
}
// Remove users who have the notification author blocked.
blockedAuthorIDs, err := user_model.ListBlockedByUsersID(ctx, notificationAuthorID)
if err != nil {
return err
}
for _, id := range blockedAuthorIDs {
toNotify.Remove(id)
}
}
err = issue.LoadRepo(ctx)

View file

@ -66,3 +66,12 @@
tree_path: "README.md"
created_unix: 946684812
invalidated: true
-
id: 8
type: 0 # comment
poster_id: 2
issue_id: 4 # in repo_id 2
content: "I just wanted to add.."
created_unix: 946684812
updated_unix: 946684812

View file

@ -0,0 +1,5 @@
-
id: 1
user_id: 4
block_id: 1
created_unix: 1671607299

View file

@ -61,7 +61,7 @@
priority: 0
is_closed: true
is_pull: false
num_comments: 0
num_comments: 1
created_unix: 946684830
updated_unix: 978307200
is_locked: false

View file

@ -37,7 +37,7 @@
lower_name: repo2
name: repo2
default_branch: master
num_watches: 0
num_watches: 1
num_stars: 1
num_forks: 0
num_issues: 2
@ -83,7 +83,7 @@
is_empty: false
is_archived: false
is_mirror: false
status: 0
status: 2
is_fork: false
fork_id: 0
is_template: false

View file

@ -27,3 +27,9 @@
user_id: 11
repo_id: 1
mode: 3 # auto
-
id: 6
user_id: 4
repo_id: 2
mode: 1 # normal

View file

@ -305,6 +305,8 @@ func TestIssue_ResolveMentions(t *testing.T) {
testSuccess("user2", "repo1", "user1", []string{"nonexisting"}, []int64{})
// Public repo, doer
testSuccess("user2", "repo1", "user1", []string{"user1"}, []int64{})
// Public repo, blocked user
testSuccess("user2", "repo1", "user1", []string{"user4"}, []int64{})
// Private repo, team member
testSuccess("org17", "big_test_private_4", "user20", []string{"user2"}, []int64{2})
// Private repo, not a team member

View file

@ -630,9 +630,11 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
teamusers := make([]*user_model.User, 0, 20)
if err := db.GetEngine(ctx).
Join("INNER", "team_user", "team_user.uid = `user`.id").
Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
In("`team_user`.team_id", checked).
And("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
Find(&teamusers); err != nil {
return nil, fmt.Errorf("get teams users: %w", err)
}
@ -666,8 +668,10 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
unchecked := make([]*user_model.User, 0, len(mentionUsers))
if err := db.GetEngine(ctx).
Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `user`.id").
Where("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doer.ID})).
In("`user`.lower_name", mentionUsers).
Find(&unchecked); err != nil {
return nil, fmt.Errorf("find mentioned users: %w", err)

View file

@ -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{

View file

@ -19,11 +19,14 @@ import (
func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) {
var reaction *issues_model.Reaction
var err error
if commentID == 0 {
reaction, err = issues_model.CreateIssueReaction(db.DefaultContext, doerID, issueID, content)
} else {
reaction, err = issues_model.CreateCommentReaction(db.DefaultContext, doerID, issueID, commentID, content)
}
// NOTE: This doesn't do user blocking checking.
reaction, err = issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{
DoerID: doerID,
IssueID: issueID,
CommentID: commentID,
Type: content,
})
assert.NoError(t, err)
assert.NotNil(t, reaction)
}

View file

@ -137,6 +137,19 @@ func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid in
})
}
// GetCollaboratorWithUser returns all collaborator IDs of collabUserID on
// repositories of ownerID.
func GetCollaboratorWithUser(ctx context.Context, ownerID, collabUserID int64) ([]int64, error) {
collabsID := make([]int64, 0, 8)
err := db.GetEngine(ctx).Table("collaboration").Select("collaboration.`id`").
Join("INNER", "repository", "repository.id = collaboration.repo_id").
Where("repository.`owner_id` = ?", ownerID).
And("collaboration.`user_id` = ?", collabUserID).
Find(&collabsID)
return collabsID, err
}
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
if repo.OwnerID == userID {

View file

@ -11,6 +11,7 @@ import (
access_model "code.gitea.io/gitea/models/perm/access"
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"
)
@ -156,3 +157,23 @@ func TestRepo_GetCollaboration(t *testing.T) {
assert.NoError(t, err)
assert.Nil(t, collab)
}
func TestGetCollaboratorWithUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user16 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
user15 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
user18 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18})
collabs, err := repo_model.GetCollaboratorWithUser(db.DefaultContext, user16.ID, user15.ID)
assert.NoError(t, err)
assert.Len(t, collabs, 2)
assert.EqualValues(t, 5, collabs[0])
assert.EqualValues(t, 7, collabs[1])
collabs, err = repo_model.GetCollaboratorWithUser(db.DefaultContext, user16.ID, user18.ID)
assert.NoError(t, err)
assert.Len(t, collabs, 2)
assert.EqualValues(t, 6, collabs[0])
assert.EqualValues(t, 8, collabs[1])
}

View file

@ -177,3 +177,16 @@ func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull boo
Limit(30).
Find(&users)
}
// GetWatchedRepoIDsOwnedBy returns the repos owned by a particular user watched by a particular user
func GetWatchedRepoIDsOwnedBy(ctx context.Context, userID, ownedByUserID int64) ([]int64, error) {
repoIDs := make([]int64, 0, 10)
err := db.GetEngine(ctx).
Table("repository").
Select("`repository`.id").
Join("LEFT", "watch", "`repository`.id=`watch`.repo_id").
Where("`watch`.user_id=?", userID).
And("`watch`.mode<>?", WatchModeDont).
And("`repository`.owner_id=?", ownedByUserID).Find(&repoIDs)
return repoIDs, err
}

View file

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
@ -71,3 +72,15 @@ func TestRepoGetReviewers(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, reviewers, 1)
}
func GetWatchedRepoIDsOwnedBy(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(db.DefaultContext, user1.ID, user2.ID)
assert.NoError(t, err)
assert.Len(t, repoIDs, 1)
assert.EqualValues(t, 1, repoIDs[0])
}

View file

@ -10,6 +10,8 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"xorm.io/builder"
)
// WatchMode specifies what kind of watch the user has on a repository
@ -142,6 +144,21 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
Find(&watches)
}
// GetWatchersExcludeBlocked returns all watchers of given repository, whereby
// the doer isn't blocked by one of the watchers.
func GetWatchersExcludeBlocked(ctx context.Context, repoID, doerID int64) ([]*Watch, error) {
watches := make([]*Watch, 0, 10)
return watches, db.GetEngine(ctx).
Join("INNER", "`user`", "`user`.id = `watch`.user_id").
Join("LEFT", "forgejo_blocked_user", "forgejo_blocked_user.user_id = `watch`.user_id").
Where("`watch`.repo_id=?", repoID).
And("`watch`.mode<>?", WatchModeDont).
And("`user`.is_active=?", true).
And("`user`.prohibit_login=?", false).
And(builder.Or(builder.IsNull{"`forgejo_blocked_user`.block_id"}, builder.Neq{"`forgejo_blocked_user`.block_id": doerID})).
Find(&watches)
}
// GetRepoWatchersIDs returns IDs of watchers for a given repo ID
// but avoids joining with `user` for performance reasons
// User permissions must be verified elsewhere if required
@ -184,3 +201,9 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error
}
return watchRepoMode(ctx, watch, WatchModeAuto)
}
// UnwatchRepos will unwatch the user from all given repositories.
func UnwatchRepos(ctx context.Context, userID int64, repoIDs []int64) error {
_, err := db.GetEngine(ctx).Where("user_id=?", userID).In("repo_id", repoIDs).Delete(&Watch{})
return err
}

View file

@ -43,6 +43,24 @@ func TestGetWatchers(t *testing.T) {
assert.Len(t, watches, 0)
}
func TestGetWatchersExcludeBlocked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watches, err := repo_model.GetWatchersExcludeBlocked(db.DefaultContext, repo.ID, 1)
assert.NoError(t, err)
// One watchers are inactive and one watcher is blocked, thus minus 2
assert.Len(t, watches, repo.NumWatches-2)
for _, watch := range watches {
assert.EqualValues(t, repo.ID, watch.RepoID)
}
watches, err = repo_model.GetWatchersExcludeBlocked(db.DefaultContext, unittest.NonexistentID, 1)
assert.NoError(t, err)
assert.Len(t, watches, 0)
}
func TestRepository_GetWatchers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
@ -137,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) {
assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone))
unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0)
}
func TestUnwatchRepos(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2})
assert.NoError(t, err)
unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
}

View file

@ -417,3 +417,13 @@ func TransferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
return committer.Commit()
}
// GetPendingTransfers returns the pending transfers of recipient which were sent by by doer.
func GetPendingTransferIDs(ctx context.Context, reciepientID, doerID int64) ([]int64, error) {
pendingTransferIDs := make([]int64, 0, 8)
return pendingTransferIDs, db.GetEngine(ctx).Table("repo_transfer").
Where("doer_id = ?", doerID).
And("recipient_id = ?", reciepientID).
Cols("id").
Find(&pendingTransferIDs)
}

View file

@ -55,3 +55,16 @@ func TestRepositoryTransfer(t *testing.T) {
// Cancel transfer
assert.NoError(t, CancelRepositoryTransfer(db.DefaultContext, repo))
}
func TestGetPendingTransferIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
reciepient := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
pendingTransfer := unittest.AssertExistsAndLoadBean(t, &RepoTransfer{RecipientID: reciepient.ID, DoerID: doer.ID})
pendingTransferIDs, err := GetPendingTransferIDs(db.DefaultContext, reciepient.ID, doer.ID)
assert.NoError(t, err)
if assert.Len(t, pendingTransferIDs, 1) {
assert.EqualValues(t, pendingTransfer.ID, pendingTransferIDs[0])
}
}

91
models/user/block.go Normal file
View file

@ -0,0 +1,91 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"errors"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
)
// ErrBlockedByUser defines an error stating that the user is not allowed to perform the action because they are blocked.
var ErrBlockedByUser = errors.New("user is blocked by the poster or repository owner")
// BlockedUser represents a blocked user entry.
type BlockedUser struct {
ID int64 `xorm:"pk autoincr"`
// UID of the one who got blocked.
BlockID int64 `xorm:"index"`
// UID of the one who did the block action.
UserID int64 `xorm:"index"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// TableName provides the real table name
func (*BlockedUser) TableName() string {
return "forgejo_blocked_user"
}
func init() {
db.RegisterModel(new(BlockedUser))
}
// IsBlocked returns if userID has blocked blockID.
func IsBlocked(ctx context.Context, userID, blockID int64) bool {
has, _ := db.GetEngine(ctx).Exist(&BlockedUser{UserID: userID, BlockID: blockID})
return has
}
// IsBlockedMultiple returns if one of the userIDs has blocked blockID.
func IsBlockedMultiple(ctx context.Context, userIDs []int64, blockID int64) bool {
has, _ := db.GetEngine(ctx).In("user_id", userIDs).Exist(&BlockedUser{BlockID: blockID})
return has
}
// UnblockUser removes the blocked user entry.
func UnblockUser(ctx context.Context, userID, blockID int64) error {
_, err := db.GetEngine(ctx).Delete(&BlockedUser{UserID: userID, BlockID: blockID})
return err
}
// CountBlockedUsers returns the number of users the user has blocked.
func CountBlockedUsers(ctx context.Context, userID int64) (int64, error) {
return db.GetEngine(ctx).Where("user_id=?", userID).Count(&BlockedUser{})
}
// ListBlockedUsers returns the users that the user has blocked.
// The created_unix field of the user struct is overridden by the creation_unix
// field of blockeduser.
func ListBlockedUsers(ctx context.Context, userID int64, opts db.ListOptions) ([]*User, error) {
sess := db.GetEngine(ctx).
Select("`forgejo_blocked_user`.created_unix, `user`.*").
Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id").
Where("`forgejo_blocked_user`.user_id=?", userID)
if opts.Page > 0 {
sess = db.SetSessionPagination(sess, &opts)
users := make([]*User, 0, opts.PageSize)
return users, sess.Find(&users)
}
users := make([]*User, 0, 8)
return users, sess.Find(&users)
}
// ListBlockedByUsersID returns the ids of the users that blocked the user.
func ListBlockedByUsersID(ctx context.Context, userID int64) ([]int64, error) {
users := make([]int64, 0, 8)
err := db.GetEngine(ctx).
Table("user").
Select("`user`.id").
Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.user_id").
Where("`forgejo_blocked_user`.block_id=?", userID).
Find(&users)
return users, err
}

77
models/user/block_test.go Normal file
View file

@ -0,0 +1,77 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user_test
import (
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
func TestIsBlocked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
// Simple test cases to ensure the function can also respond with false.
assert.False(t, user_model.IsBlocked(db.DefaultContext, 1, 1))
assert.False(t, user_model.IsBlocked(db.DefaultContext, 3, 2))
}
func TestIsBlockedMultiple(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4}, 1))
assert.True(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{4, 3, 4, 5}, 1))
// Simple test cases to ensure the function can also respond with false.
assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{1}, 1))
assert.False(t, user_model.IsBlockedMultiple(db.DefaultContext, []int64{3, 4, 1}, 2))
}
func TestUnblockUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.True(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
assert.NoError(t, user_model.UnblockUser(db.DefaultContext, 4, 1))
// Simple test cases to ensure the function can also respond with false.
assert.False(t, user_model.IsBlocked(db.DefaultContext, 4, 1))
}
func TestListBlockedUsers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
blockedUsers, err := user_model.ListBlockedUsers(db.DefaultContext, 4, db.ListOptions{})
assert.NoError(t, err)
if assert.Len(t, blockedUsers, 1) {
assert.EqualValues(t, 1, blockedUsers[0].ID)
// The function returns the created Unix of the block, not that of the user.
assert.EqualValues(t, 1671607299, blockedUsers[0].CreatedUnix)
}
}
func TestListBlockedByUsersID(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
blockedByUserIDs, err := user_model.ListBlockedByUsersID(db.DefaultContext, 1)
assert.NoError(t, err)
if assert.Len(t, blockedByUserIDs, 1) {
assert.EqualValues(t, 4, blockedByUserIDs[0])
}
}
func TestCountBlockedUsers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
count, err := user_model.CountBlockedUsers(db.DefaultContext, 4)
assert.NoError(t, err)
assert.EqualValues(t, 1, count)
count, err = user_model.CountBlockedUsers(db.DefaultContext, 1)
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
}

View file

@ -34,6 +34,10 @@ func FollowUser(ctx context.Context, userID, followID int64) (err error) {
return nil
}
if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) {
return ErrBlockedByUser
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err

View file

@ -457,6 +457,12 @@ func TestFollowUser(t *testing.T) {
assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
// Blocked user.
assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 1, 4))
assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 4, 1))
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 1, FollowID: 4})
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 4, FollowID: 1})
unittest.CheckConsistencyFor(t, &user_model.User{})
}

View file

@ -14,6 +14,10 @@ import (
)
func AddCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User) error {
if user_model.IsBlocked(ctx, repo.OwnerID, u.ID) || user_model.IsBlocked(ctx, u.ID, repo.OwnerID) {
return user_model.ErrBlockedByUser
}
return db.WithTx(ctx, func(ctx context.Context) error {
collaboration := &repo_model.Collaboration{
RepoID: repo.ID,

View file

@ -33,6 +33,33 @@ func TestRepository_AddCollaborator(t *testing.T) {
testSuccess(3, 4)
}
func TestRepository_AddCollaborator_IsBlocked(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(repoID, userID int64) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
assert.NoError(t, repo.LoadOwner(db.DefaultContext))
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
// Owner blocked user.
unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
_, err := db.DeleteByBean(db.DefaultContext, &user_model.BlockedUser{UserID: repo.OwnerID, BlockID: userID})
assert.NoError(t, err)
// User has owner blocked.
unittest.AssertSuccessfulInsert(t, &user_model.BlockedUser{UserID: userID, BlockID: repo.OwnerID})
assert.ErrorIs(t, AddCollaborator(db.DefaultContext, repo, user), user_model.ErrBlockedByUser)
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID})
}
// Ensure idempotency (public repository).
testSuccess(1, 4)
testSuccess(1, 4)
// Add collaborator to private repository.
testSuccess(3, 4)
}
func TestRepoPermissionPublicNonOrgRepo(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

View file

@ -0,0 +1,13 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package structs
import "time"
// BlockedUser represents a blocked user.
type BlockedUser struct {
BlockID int64 `json:"block_id"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
}

View file

@ -594,6 +594,12 @@ joined_on = Joined on %s
repositories = Repositories
activity = Public Activity
followers = Followers
block_user = Block User
block_user.detail = Please understand that if you block this user, other actions will be taken. Such as:
block_user.detail_1 = You are being unfollowed from this user.
block_user.detail_2 = This user cannot interact with your repositories, created issues and comments.
block_user.detail_3 = This user cannot add you as a collaborator, nor can you add them as a collaborator.
follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you.
starred = Starred Repositories
watched = Watched Repositories
code = Code
@ -602,6 +608,8 @@ overview = Overview
following = Following
follow = Follow
unfollow = Unfollow
block = Block
unblock = Unblock
user_bio = Biography
disabled_public_activity = This user has disabled the public visibility of the activity.
email_visibility.limited = Your email address is visible to all authenticated users
@ -631,6 +639,7 @@ account_link = Linked Accounts
organization = Organizations
uid = UID
webauthn = Security Keys
blocked_users = Blocked Users
public_profile = Public Profile
biography_placeholder = Tell us a little bit about yourself! (You can use Markdown)
@ -900,6 +909,7 @@ hooks.desc = Add webhooks which will be triggered for <strong>all repositories</
orgs_none = You are not a member of any organizations.
repos_none = You do not own any repositories.
blocked_users_none = You haven't blocked any users.
delete_account = Delete Your Account
delete_prompt = This operation will permanently delete your user account. It <strong>CANNOT</strong> be undone.
@ -922,6 +932,10 @@ visibility.limited_tooltip = Visible only to authenticated users
visibility.private = Private
visibility.private_tooltip = Visible only to members of organizations you have joined
blocked_since = Blocked since %s
user_unblock_success = The user has been unblocked successfully.
user_block_success = The user has been blocked successfully.
[repo]
new_repo_helper = A repository contains all project files, including revision history. Already hosting one elsewhere? <a href="%s">Migrate repository.</a>
owner = Owner
@ -1671,6 +1685,8 @@ issues.content_history.delete_from_history = Delete from history
issues.content_history.delete_from_history_confirm = Delete from history?
issues.content_history.options = Options
issues.reference_link = Reference: %s
issues.blocked_by_user = You cannot create a issue on this repository because you are blocked by the repository owner.
issues.comment.blocked_by_user = You cannot create a comment on this issue because you are blocked by the repository owner or the poster of the issue.
compare.compare_base = base
compare.compare_head = compare
@ -1750,6 +1766,7 @@ pulls.reject_count_n = "%d change requests"
pulls.waiting_count_1 = "%d waiting review"
pulls.waiting_count_n = "%d waiting reviews"
pulls.wrong_commit_id = "commit id must be a commit id on the target branch"
pulls.blocked_by_user = You cannot create a pull request on this repository because you are blocked by the repository owner.
pulls.no_merge_desc = This pull request cannot be merged because all repository merge options are disabled.
pulls.no_merge_helper = Enable merge options in the repository settings or merge the pull request manually.
@ -2056,6 +2073,7 @@ settings.reindex_requested=Reindex Requested
settings.admin_enable_close_issues_via_commit_in_any_branch = Close an issue via a commit made in a non default branch
settings.danger_zone = Danger Zone
settings.new_owner_has_same_repo = The new owner already has a repository with same name. Please choose another name.
settings.new_owner_blocked_doer = The new owner has blocked you.
settings.convert = Convert to Regular Repository
settings.convert_desc = You can convert this mirror into a regular repository. This cannot be undone.
settings.convert_notices_1 = This operation will convert the mirror into a regular repository and cannot be undone.
@ -2114,6 +2132,8 @@ settings.add_collaborator_success = The collaborator has been added.
settings.add_collaborator_inactive_user = Cannot add an inactive user as a collaborator.
settings.add_collaborator_owner = Cannot add an owner as a collaborator.
settings.add_collaborator_duplicate = The collaborator is already added to this repository.
settings.add_collaborator_blocked_our = Cannot add the collaborator, because the repository owner has blocked them.
settings.add_collaborator_blocked_them = Cannot add the collaborator, because they have blocked the repository owner.
settings.delete_collaborator = Remove
settings.collaborator_deletion = Remove Collaborator
settings.collaborator_deletion_desc = Removing a collaborator will revoke their access to this repository. Continue?
@ -2577,6 +2597,7 @@ team_access_desc = Repository access
team_permission_desc = Permission
team_unit_desc = Allow Access to Repository Sections
team_unit_disabled = (Disabled)
follow_blocked_user = You cannot follow this organisation because this organisation has blocked you.
form.name_reserved = The organization name "%s" is reserved.
form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name.
@ -2824,7 +2845,7 @@ users.cannot_delete_self = "You cannot delete yourself"
users.still_own_repo = This user still owns one or more repositories. Delete or transfer these repositories first.
users.still_has_org = This user is a member of an organization. Remove the user from any organizations first.
users.purge = Purge User
users.purge_help = Forcibly delete user and any repositories, organizations, and packages owned by the user. All comments will be deleted too.
users.purge_help = Forcibly delete user and any repositories, organizations, and packages owned by the user. All comments and issues posted by this user will also be deleted.
users.still_own_packages = This user still owns one or more packages, delete these packages first.
users.deletion_success = The user account has been deleted.
users.reset_2fa = Reset 2FA

View file

@ -991,6 +991,14 @@ func Routes() *web.Route {
Delete(user.DeleteHook)
}, reqWebhooksEnabled())
m.Group("", func() {
m.Get("/list_blocked", user.ListBlockedUsers)
m.Group("", func() {
m.Put("/block/{username}", user.BlockUser)
m.Put("/unblock/{username}", user.UnblockUser)
}, context_service.UserAssignmentAPI())
})
m.Group("/avatar", func() {
m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar)
m.Delete("", user.DeleteAvatar)
@ -1430,6 +1438,14 @@ func Routes() *web.Route {
m.Delete("", org.DeleteAvatar)
}, reqToken(), reqOrgOwnership())
m.Get("/activities/feeds", org.ListOrgActivityFeeds)
m.Group("", func() {
m.Get("/list_blocked", org.ListBlockedUsers)
m.Group("", func() {
m.Put("/block/{username}", org.BlockUser)
m.Put("/unblock/{username}", org.UnblockUser)
}, context_service.UserAssignmentAPI())
}, reqToken(), reqOrgOwnership())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true))
m.Group("/teams/{teamid}", func() {
m.Combo("").Get(reqToken(), org.GetTeam).

View file

@ -5,6 +5,7 @@
package org
import (
"fmt"
"net/http"
activities_model "code.gitea.io/gitea/models/activities"
@ -457,3 +458,99 @@ func ListOrgActivityFeeds(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
}
// ListBlockedUsers list the organization's blocked users.
func ListBlockedUsers(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/list_blocked organization orgListBlockedUsers
// ---
// summary: List the organization's blocked users
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the org
// type: string
// required: true
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/BlockedUserList"
utils.ListUserBlockedUsers(ctx, ctx.ContextUser)
}
// BlockUser blocks a user from the organization.
func BlockUser(ctx *context.APIContext) {
// swagger:operation PUT /orgs/{org}/block/{username} organization orgBlockUser
// ---
// summary: Blocks a user from the organization
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the org
// type: string
// required: true
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if ctx.ContextUser.IsOrganization() {
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
return
}
utils.BlockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser)
}
// UnblockUser unblocks a user from the organization.
func UnblockUser(ctx *context.APIContext) {
// swagger:operation PUT /orgs/{org}/unblock/{username} organization orgUnblockUser
// ---
// summary: Unblock a user from the organization
// produces:
// - application/json
// parameters:
// - name: org
// in: path
// description: name of the org
// type: string
// required: true
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if ctx.ContextUser.IsOrganization() {
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
return
}
utils.UnblockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser)
}

View file

@ -160,6 +160,8 @@ func AddCollaborator(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
// "403":
// "$ref": "#/responses/forbidden"
form := web.GetForm(ctx).(*api.AddCollaboratorOption)
@ -179,7 +181,11 @@ func AddCollaborator(ctx *context.APIContext) {
}
if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil {
ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "AddCollaborator", err)
} else {
ctx.Error(http.StatusInternalServerError, "AddCollaborator", err)
}
return
}

View file

@ -5,6 +5,7 @@
package repo
import (
"errors"
"fmt"
"net/http"
"strconv"
@ -686,7 +687,10 @@ func CreateIssue(ctx *context.APIContext) {
}
if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "BlockedByUser", err)
return
} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
return
}

View file

@ -378,7 +378,11 @@ func CreateIssueComment(ctx *context.APIContext) {
comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil)
if err != nil {
ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "CreateIssueComment", err)
} else {
ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
}
return
}

View file

@ -8,11 +8,13 @@ import (
"net/http"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/v1/utils"
"code.gitea.io/gitea/services/convert"
issue_service "code.gitea.io/gitea/services/issue"
)
// GetIssueCommentReactions list reactions of a comment from an issue
@ -202,9 +204,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.Issue, comment, form.Reaction)
if err != nil {
if issues_model.IsErrForbiddenIssueReaction(err) {
if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, err.Error(), err)
} else if issues_model.IsErrReactionAlreadyExist(err) {
ctx.JSON(http.StatusOK, api.Reaction{
@ -418,9 +420,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.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, err.Error(), err)
} else if issues_model.IsErrReactionAlreadyExist(err) {
ctx.JSON(http.StatusOK, api.Reaction{

View file

@ -420,7 +420,10 @@ func CreatePullRequest(ctx *context.APIContext) {
}
if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "BlockedByUser", err)
return
} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
return
}

View file

@ -4,6 +4,7 @@
package repo
import (
"errors"
"fmt"
"net/http"
@ -107,6 +108,11 @@ func Transfer(ctx *context.APIContext) {
oldFullname := ctx.Repo.Repository.FullName()
if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, ctx.Repo.Repository, teams); err != nil {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "StartRepositoryTransfer", err)
return
}
if models.IsErrRepoTransferInProgress(err) {
ctx.Error(http.StatusConflict, "StartRepositoryTransfer", err)
return

View file

@ -414,3 +414,10 @@ type swaggerRepoNewIssuePinsAllowed struct {
// in:body
Body api.NewIssuePinsAllowed `json:"body"`
}
// BlockedUserList
// swagger:response BlockedUserList
type swaggerBlockedUserList struct {
// in:body
Body []api.BlockedUser `json:"body"`
}

View file

@ -5,6 +5,7 @@
package user
import (
"errors"
"net/http"
user_model "code.gitea.io/gitea/models/user"
@ -223,8 +224,14 @@ func Follow(ctx *context.APIContext) {
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "403":
// "$ref": "#/responses/forbidden"
if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "BlockedByUser", err)
return
}
ctx.Error(http.StatusInternalServerError, "FollowUser", err)
return
}

View file

@ -5,6 +5,7 @@
package user
import (
"fmt"
"net/http"
activities_model "code.gitea.io/gitea/models/activities"
@ -202,3 +203,84 @@ func ListUserActivityFeeds(ctx *context.APIContext) {
ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer))
}
// ListBlockedUsers list the authenticated user's blocked users.
func ListBlockedUsers(ctx *context.APIContext) {
// swagger:operation GET /user/list_blocked user userListBlockedUsers
// ---
// summary: List the authenticated user's blocked users
// produces:
// - application/json
// parameters:
// - name: page
// in: query
// description: page number of results to return (1-based)
// type: integer
// - name: limit
// in: query
// description: page size of results
// type: integer
// responses:
// "200":
// "$ref": "#/responses/BlockedUserList"
utils.ListUserBlockedUsers(ctx, ctx.Doer)
}
// BlockUser blocks a user from the doer.
func BlockUser(ctx *context.APIContext) {
// swagger:operation PUT /user/block/{username} user userBlockUser
// ---
// summary: Blocks a user from the doer.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if ctx.ContextUser.IsOrganization() {
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
return
}
utils.BlockUser(ctx, ctx.Doer, ctx.ContextUser)
}
// UnblockUser unblocks a user from the doer.
func UnblockUser(ctx *context.APIContext) {
// swagger:operation PUT /user/unblock/{username} user userUnblockUser
// ---
// summary: Unblocks a user from the doer.
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
// "404":
// "$ref": "#/responses/notFound"
// "422":
// "$ref": "#/responses/validationError"
if ctx.ContextUser.IsOrganization() {
ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name))
return
}
utils.UnblockUser(ctx, ctx.Doer, ctx.ContextUser)
}

View file

@ -0,0 +1,65 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package utils
import (
"net/http"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
api "code.gitea.io/gitea/modules/structs"
user_service "code.gitea.io/gitea/services/user"
)
// ListUserBlockedUsers lists the blocked users of the provided doer.
func ListUserBlockedUsers(ctx *context.APIContext, doer *user_model.User) {
count, err := user_model.CountBlockedUsers(ctx, doer.ID)
if err != nil {
ctx.InternalServerError(err)
return
}
blockedUsers, err := user_model.ListBlockedUsers(ctx, doer.ID, GetListOptions(ctx))
if err != nil {
ctx.InternalServerError(err)
return
}
apiBlockedUsers := make([]*api.BlockedUser, len(blockedUsers))
for i, blockedUser := range blockedUsers {
apiBlockedUsers[i] = &api.BlockedUser{
BlockID: blockedUser.ID,
Created: blockedUser.CreatedUnix.AsTime(),
}
if err != nil {
ctx.InternalServerError(err)
return
}
}
ctx.SetTotalCountHeader(count)
ctx.JSON(http.StatusOK, apiBlockedUsers)
}
// BlockUser blocks the blockUser from the doer.
func BlockUser(ctx *context.APIContext, doer, blockUser *user_model.User) {
err := user_service.BlockUser(ctx, doer.ID, blockUser.ID)
if err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}
// UnblockUser unblocks the blockUser from the doer.
func UnblockUser(ctx *context.APIContext, doer, blockUser *user_model.User) {
err := user_model.UnblockUser(ctx, doer.ID, blockUser.ID)
if err != nil {
ctx.InternalServerError(err)
return
}
ctx.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,79 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"fmt"
"net/http"
"strings"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/routers/utils"
user_service "code.gitea.io/gitea/services/user"
)
const tplBlockedUsers = "org/settings/blocked_users"
// BlockedUsers renders the blocked users page.
func BlockedUsers(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
ctx.Data["PageIsSettingsBlockedUsers"] = true
blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID, db.ListOptions{})
if err != nil {
ctx.ServerError("ListBlockedUsers", err)
return
}
ctx.Data["BlockedUsers"] = blockedUsers
ctx.HTML(http.StatusOK, tplBlockedUsers)
}
// BlockedUsersBlock blocks a particular user from the organization.
func BlockedUsersBlock(ctx *context.Context) {
uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname")))
u, err := user_model.GetUserByName(ctx, uname)
if err != nil {
ctx.ServerError("GetUserByName", err)
return
}
if u.IsOrganization() {
ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name))
return
}
if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil {
ctx.ServerError("BlockUser", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.user_block_success"))
ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
}
// BlockedUsersUnblock unblocks a particular user from the organization.
func BlockedUsersUnblock(ctx *context.Context) {
u, err := user_model.GetUserByID(ctx, ctx.FormInt64("user_id"))
if err != nil {
ctx.ServerError("GetUserByID", err)
return
}
if u.IsOrganization() {
ctx.ServerError("IsOrganization", fmt.Errorf("%s is an organization not a user", u.Name))
return
}
if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil {
ctx.ServerError("UnblockUser", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
}

View file

@ -1217,7 +1217,10 @@ func NewIssuePost(ctx *context.Context) {
}
if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.RenderWithErr(ctx.Tr("repo.issues.blocked_by_user"), tplIssueNew, form)
return
} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
return
}
@ -3071,7 +3074,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.ErrBlockedByUser) {
ctx.Flash.Error(ctx.Tr("repo.issues.comment.blocked_by_user"))
} else {
ctx.ServerError("CreateIssueComment", err)
}
return
}
@ -3211,7 +3218,7 @@ 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) {
ctx.ServerError("ChangeIssueReaction", err)
@ -3313,7 +3320,7 @@ 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.Issue, comment, form.Content)
if err != nil {
if issues_model.IsErrForbiddenIssueReaction(err) {
ctx.ServerError("ChangeIssueReaction", err)

View file

@ -1414,7 +1414,11 @@ func CompareAndPullRequestPost(ctx *context.Context) {
// instead of 500.
if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.Flash.Error(ctx.Tr("repo.pulls.blocked_by_user"))
ctx.Redirect(ctx.Link)
return
} else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
return
} else if git.IsErrPushRejected(err) {

View file

@ -4,6 +4,7 @@
package setting
import (
"errors"
"net/http"
"strings"
@ -102,7 +103,18 @@ func CollaborationPost(ctx *context.Context) {
}
if err = repo_module.AddCollaborator(ctx, ctx.Repo.Repository, u); err != nil {
ctx.ServerError("AddCollaborator", err)
if !errors.Is(err, user_model.ErrBlockedByUser) {
ctx.ServerError("AddCollaborator", err)
return
}
// To give an good error message, be precise on who has blocked who.
if blockedOurs := user_model.IsBlocked(ctx, ctx.Repo.Repository.OwnerID, u.ID); blockedOurs {
ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_our"))
} else {
ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator_blocked_them"))
}
ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration")
return
}

View file

@ -5,6 +5,7 @@
package setting
import (
"errors"
"fmt"
"net/http"
"strconv"
@ -775,7 +776,9 @@ func SettingsPost(ctx *context.Context) {
}
if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil {
if repo_model.IsErrRepoAlreadyExist(err) {
if errors.Is(err, user_model.ErrBlockedByUser) {
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_blocked_doer"), tplSettingsOptions, nil)
} else if repo_model.IsErrRepoAlreadyExist(err) {
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)

View file

@ -103,13 +103,15 @@ func TestCollaborationPost(t *testing.T) {
ctx.Req.Form.Set("collaborator", "user4")
u := &user_model.User{
ID: 2,
LowerName: "user2",
Type: user_model.UserTypeIndividual,
}
re := &repo_model.Repository{
ID: 2,
Owner: u,
ID: 2,
Owner: u,
OwnerID: u.ID,
}
repo := &context.Repository{
@ -161,13 +163,15 @@ func TestCollaborationPost_AddCollaboratorTwice(t *testing.T) {
ctx.Req.Form.Set("collaborator", "user4")
u := &user_model.User{
ID: 2,
LowerName: "user2",
Type: user_model.UserTypeIndividual,
}
re := &repo_model.Repository{
ID: 2,
Owner: u,
ID: 2,
Owner: u,
OwnerID: u.ID,
}
repo := &context.Repository{

View file

@ -31,6 +31,7 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) {
prepareContextForCommonProfile(ctx)
ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate
ctx.Data["UserLocationMapURL"] = setting.Service.UserLocationMapURL

View file

@ -5,6 +5,7 @@
package user
import (
"errors"
"fmt"
"net/http"
"strings"
@ -23,6 +24,7 @@ import (
"code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/routers/web/org"
shared_user "code.gitea.io/gitea/routers/web/shared/user"
user_service "code.gitea.io/gitea/services/user"
)
// OwnerProfile render profile page for a user or a organization (aka, repo owner)
@ -290,16 +292,45 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileGi
// Action response for follow/unfollow user request
func Action(ctx *context.Context) {
var err error
switch ctx.FormString("action") {
var redirectViaJSON bool
action := ctx.FormString("action")
if ctx.ContextUser.IsOrganization() && (action == "block" || action == "unblock") {
log.Error("Cannot perform this action on an organization %q", ctx.FormString("action"))
ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
return
}
switch action {
case "follow":
err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
case "unfollow":
err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
case "block":
err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
redirectViaJSON = true
case "unblock":
err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
}
if err != nil {
log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
if !errors.Is(err, user_model.ErrBlockedByUser) {
log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
return
}
if ctx.ContextUser.IsOrganization() {
ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"))
} else {
ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"))
}
}
if redirectViaJSON {
ctx.JSON(http.StatusOK, map[string]interface{}{
"redirect": ctx.ContextUser.HomeLink(),
})
return
}
ctx.JSONOK()

View file

@ -0,0 +1,46 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"net/http"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/setting"
)
const (
tplSettingsBlockedUsers base.TplName = "user/settings/blocked_users"
)
// BlockedUsers render the blocked users list page.
func BlockedUsers(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
ctx.Data["PageIsBlockedUsers"] = true
ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/blocked_users"
ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/blocked_users"
blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Doer.ID, db.ListOptions{})
if err != nil {
ctx.ServerError("ListBlockedUsers", err)
return
}
ctx.Data["BlockedUsers"] = blockedUsers
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
}
// UnblockUser unblocks a particular user for the doer.
func UnblockUser(ctx *context.Context) {
if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil {
ctx.ServerError("UnblockUser", err)
return
}
ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users")
}

View file

@ -631,6 +631,11 @@ func registerRoutes(m *web.Route) {
})
addWebhookEditRoutes()
}, webhooksEnabled)
m.Group("/blocked_users", func() {
m.Get("", user_setting.BlockedUsers)
m.Post("/unblock", user_setting.UnblockUser)
})
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
m.Group("/user", func() {
@ -888,6 +893,12 @@ func registerRoutes(m *web.Route) {
m.Methods("GET,POST", "/delete", org.SettingsDelete)
m.Group("/blocked_users", func() {
m.Get("", org_setting.BlockedUsers)
m.Post("/block", org_setting.BlockedUsersBlock)
m.Post("/unblock", org_setting.BlockedUsersUnblock)
})
m.Group("/packages", func() {
m.Get("", org.Packages)
m.Group("/rules", func() {

View file

@ -46,6 +46,11 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod
// CreateIssueComment creates a plain issue comment.
func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) {
// Check if doer is blocked by the poster of the issue.
if user_model.IsBlocked(ctx, issue.PosterID, doer.ID) {
return nil, user_model.ErrBlockedByUser
}
comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeComment,
Doer: doer,

View file

@ -24,6 +24,11 @@ import (
// NewIssue creates new issue with labels for repository.
func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error {
// Check if the user is not blocked by the repo's owner.
if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) {
return user_model.ErrBlockedByUser
}
if err := issues_model.NewIssue(ctx, repo, issue, labelIDs, uuids); err != nil {
return err
}

View file

@ -0,0 +1,47 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issue
import (
"context"
issues_model "code.gitea.io/gitea/models/issues"
user_model "code.gitea.io/gitea/models/user"
)
// CreateIssueReaction creates a reaction on issue.
func CreateIssueReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, content string) (*issues_model.Reaction, error) {
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
// Check if the doer is blocked by the issue's poster or repository owner.
if user_model.IsBlockedMultiple(ctx, []int64{issue.PosterID, issue.Repo.OwnerID}, doer.ID) {
return nil, user_model.ErrBlockedByUser
}
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
Type: content,
DoerID: doer.ID,
IssueID: issue.ID,
})
}
// CreateCommentReaction creates a reaction on comment.
func CreateCommentReaction(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, comment *issues_model.Comment, content string) (*issues_model.Reaction, error) {
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
// Check if the doer is blocked by the issue's poster, the comment's poster or repository owner.
if user_model.IsBlockedMultiple(ctx, []int64{comment.PosterID, issue.PosterID, issue.Repo.OwnerID}, doer.ID) {
return nil, user_model.ErrBlockedByUser
}
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
Type: content,
DoerID: doer.ID,
IssueID: issue.ID,
CommentID: comment.ID,
})
}

View file

@ -38,6 +38,11 @@ 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 {
// Check if the doer is not blocked by the repository's owner.
if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) {
return user_model.ErrBlockedByUser
}
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
if err != nil {
if !git_model.IsErrBranchNotExist(err) {

View file

@ -85,6 +85,10 @@ func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo
// StartRepositoryTransfer transfer a repo from one owner to a new one.
// it make repository into pending transfer state, if doer can not create repo for new owner.
func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository, teams []*organization.Team) error {
if user_model.IsBlocked(ctx, newOwner.ID, doer.ID) {
return user_model.ErrBlockedByUser
}
if err := models.TestRepositoryReadyForTransfer(repo.Status); err != nil {
return err
}

View file

@ -63,7 +63,7 @@ func TestStartRepositoryTransferSetPermission(t *testing.T) {
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
recipient := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
repo.Owner = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
hasAccess, err := access_model.HasAccess(db.DefaultContext, recipient.ID, repo)

95
services/user/block.go Normal file
View file

@ -0,0 +1,95 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
model "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"xorm.io/builder"
)
// BlockUser adds a blocked user entry for userID to block blockID.
// TODO: Figure out if instance admins should be immune to blocking.
// TODO: Add more mechanism like removing blocked user as collaborator on
// repositories where the user is an owner.
func BlockUser(ctx context.Context, userID, blockID int64) error {
if userID == blockID || user_model.IsBlocked(ctx, userID, blockID) {
return nil
}
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
// Add the blocked user entry.
_, err = db.GetEngine(ctx).Insert(&user_model.BlockedUser{UserID: userID, BlockID: blockID})
if err != nil {
return err
}
// Unfollow the user from the block's perspective.
err = user_model.UnfollowUser(ctx, blockID, userID)
if err != nil {
return err
}
// Unfollow the user from the doer's perspective.
err = user_model.UnfollowUser(ctx, userID, blockID)
if err != nil {
return err
}
// Blocked user unwatch all repository owned by the doer.
repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID)
if err != nil {
return err
}
err = repo_model.UnwatchRepos(ctx, blockID, repoIDs)
if err != nil {
return err
}
// Remove blocked user as collaborator from repositories the user owns as an
// individual.
collabsID, err := repo_model.GetCollaboratorWithUser(ctx, userID, blockID)
if err != nil {
return err
}
_, err = db.GetEngine(ctx).In("id", collabsID).Delete(&repo_model.Collaboration{})
if err != nil {
return err
}
// Remove pending repository transfers, and set the status on those repository
// back to ready.
pendingTransfersIDs, err := model.GetPendingTransferIDs(ctx, userID, blockID)
if err != nil {
return err
}
// Use a subquery instead of a JOIN, because not every database supports JOIN
// on a UPDATE query.
_, err = db.GetEngine(ctx).Table("repository").
In("id", builder.Select("repo_id").From("repo_transfer").Where(builder.In("id", pendingTransfersIDs))).
Cols("status").
Update(&repo_model.Repository{Status: repo_model.RepositoryReady})
if err != nil {
return err
}
_, err = db.GetEngine(ctx).In("id", pendingTransfersIDs).Delete(&model.RepoTransfer{})
if err != nil {
return err
}
return committer.Commit()
}

View file

@ -0,0 +1,91 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
model "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
// TestBlockUser will ensure that when you block a user, certain actions have
// been taken, like unfollowing each other etc.
func TestBlockUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
t.Run("Follow", func(t *testing.T) {
defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
// Follow each other.
assert.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID))
assert.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID))
assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
// Ensure they aren't following each other anymore.
assert.False(t, user_model.IsFollowing(db.DefaultContext, doer.ID, blockedUser.ID))
assert.False(t, user_model.IsFollowing(db.DefaultContext, blockedUser.ID, doer.ID))
})
t.Run("Watch", func(t *testing.T) {
defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
// Blocked user watch repository of doer.
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID})
assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true))
assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
// Ensure blocked user isn't following doer's repository.
assert.False(t, repo_model.IsWatching(db.DefaultContext, blockedUser.ID, repo.ID))
})
t.Run("Collaboration", func(t *testing.T) {
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 22, OwnerID: doer.ID})
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21, OwnerID: doer.ID})
defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
isBlockedUserCollab := func(repo *repo_model.Repository) bool {
isCollaborator, err := repo_model.IsCollaborator(db.DefaultContext, repo.ID, blockedUser.ID)
assert.NoError(t, err)
return isCollaborator
}
assert.True(t, isBlockedUserCollab(repo1))
assert.True(t, isBlockedUserCollab(repo2))
assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
assert.False(t, isBlockedUserCollab(repo1))
assert.False(t, isBlockedUserCollab(repo2))
})
t.Run("Pending transfers", func(t *testing.T) {
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
defer user_model.UnblockUser(db.DefaultContext, doer.ID, blockedUser.ID)
unittest.AssertExistsIf(t, true, &repo_model.Repository{ID: 3, OwnerID: blockedUser.ID, Status: repo_model.RepositoryPendingTransfer})
unittest.AssertExistsIf(t, true, &model.RepoTransfer{ID: 1, RecipientID: doer.ID, DoerID: blockedUser.ID})
assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
unittest.AssertExistsIf(t, false, &model.RepoTransfer{ID: 1, RecipientID: doer.ID, DoerID: blockedUser.ID})
// Don't use AssertExistsIf, as it doesn't include the zero values in the condition such as `repo_model.RepositoryReady`.
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3, OwnerID: blockedUser.ID})
assert.Equal(t, repo_model.RepositoryReady, repo.Status)
})
}

View file

@ -23,6 +23,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
issue_service "code.gitea.io/gitea/services/issue"
"xorm.io/builder"
)
@ -92,6 +93,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.BlockedUser{BlockID: u.ID},
&user_model.BlockedUser{UserID: u.ID},
); err != nil {
return fmt.Errorf("deleteBeans: %w", err)
}
@ -127,6 +130,31 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
}
}
// ***** START: Issues *****
if purge {
const batchSize = 50
for {
issues := make([]*issues_model.Issue, 0, batchSize)
if err = e.Where("poster_id=?", u.ID).Limit(batchSize, 0).Find(&issues); err != nil {
return err
}
if len(issues) == 0 {
break
}
for _, issue := range issues {
// NOTE: Don't open git repositories just to remove the reference data,
// `git gc` is able to remove that reference which is run as a cron job
// by default. Also use the deleted user as doer to delete the issue.
if err = issue_service.DeleteIssue(ctx, u, nil, issue); err != nil {
return err
}
}
}
}
// ***** END: Issues *****
// ***** START: Branch Protections *****
{
const batchSize = 50

View file

@ -1,5 +1,10 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content organization profile">
{{if .Flash}}
<div class="ui container gt-mb-5">
{{template "base/alert" .}}
</div>
{{end}}
<div class="ui container gt-df">
{{ctx.AvatarUtils.Avatar .Org 140 "org-avatar"}}
<div id="org-info">

View file

@ -0,0 +1,21 @@
{{template "org/settings/layout_head" (dict "ctxData" . "pageClass" "organization settings blocked-users")}}
<div class="org-setting-content">
<div class="ui attached segment">
<form class="ui form ignore-dirty gt-df gt-fw gt-gap-3" action="{{$.Link}}/block" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="uid" value="">
<div class="ui left">
<div id="search-user-box" class="ui search">
<div class="ui input">
<input class="prompt" name="uname" placeholder="{{ctx.Locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required>
</div>
</div>
</div>
<button type="submit" class="ui red button">{{ctx.Locale.Tr "user.block"}}</button>
</form>
</div>
<div class="ui attached segment">
{{template "shared/blocked_users_list" .}}
</div>
</div>
{{template "org/settings/layout_footer" .}}

View file

@ -38,6 +38,9 @@
</div>
</details>
{{end}}
<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
{{ctx.Locale.Tr "settings.blocked_users"}}
</a>
<a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete">
{{ctx.Locale.Tr "org.settings.delete"}}
</a>

View file

@ -0,0 +1,28 @@
<div class="flex-list">
{{range .BlockedUsers}}
<div class="flex-item flex-item-center">
<div class="flex-item-leading">
{{ctx.AvatarUtils.Avatar . 48}}
</div>
<div class="flex-item-main">
<div class="flex-item-title">
{{template "shared/user/name" .}}
</div>
<div class="flex-item-body">
<span>{{ctx.Locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</span>
</div>
</div>
<div class="flex-item-trailing">
<form action="{{$.Link}}/unblock" method="post">
{{$.CsrfTokenHtml}}
<input type="hidden" name="user_id" value="{{.ID}}">
<button class="ui red button">{{ctx.Locale.Tr "user.unblock"}}</button>
</form>
</div>
</div>
{{else}}
<div class="flex-item">
<span class="text grey italic">{{ctx.Locale.Tr "settings.blocked_users_none"}}</span>
</div>
{{end}}
</div>

View file

@ -117,6 +117,18 @@
</button>
{{end}}
</li>
<li class="block">
{{if $.IsBlocked}}
<button class="ui basic red button link-action" data-url="{{.ContextUser.HomeLink}}?action=unblock&redirect_to={{$.Link}}">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.unblock"}}
</button>
{{else}}
<button type="submit" class="ui basic orange button delete-button"
data-modal-id="block-user" data-url="{{.ContextUser.HomeLink}}?action=block">
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block"}}
</button>
{{end}}
</li>
{{end}}
</ul>
</div>

View file

@ -1806,6 +1806,45 @@
}
}
},
"/orgs/{org}/block/{username}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Blocks a user from the organization",
"operationId": "orgBlockUser",
"parameters": [
{
"type": "string",
"description": "name of the org",
"name": "org",
"in": "path",
"required": true
},
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/orgs/{org}/hooks": {
"get": {
"produces": [
@ -2200,6 +2239,44 @@
}
}
},
"/orgs/{org}/list_blocked": {
"get": {
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "List the organization's blocked users",
"operationId": "orgListBlockedUsers",
"parameters": [
{
"type": "string",
"description": "name of the org",
"name": "org",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/BlockedUserList"
}
}
}
},
"/orgs/{org}/members": {
"get": {
"produces": [
@ -2691,6 +2768,45 @@
}
}
},
"/orgs/{org}/unblock/{username}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"organization"
],
"summary": "Unblock a user from the organization",
"operationId": "orgUnblockUser",
"parameters": [
{
"type": "string",
"description": "name of the org",
"name": "org",
"in": "path",
"required": true
},
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/packages/{owner}": {
"get": {
"produces": [
@ -4179,6 +4295,9 @@
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
},
@ -14739,6 +14858,38 @@
}
}
},
"/user/block/{username}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Blocks a user from the doer.",
"operationId": "userBlockUser",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/user/emails": {
"get": {
"produces": [
@ -14916,6 +15067,9 @@
"204": {
"$ref": "#/responses/empty"
},
"403": {
"$ref": "#/responses/forbidden"
},
"404": {
"$ref": "#/responses/notFound"
}
@ -15391,6 +15545,37 @@
}
}
},
"/user/list_blocked": {
"get": {
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "List the authenticated user's blocked users",
"operationId": "userListBlockedUsers",
"parameters": [
{
"type": "integer",
"description": "page number of results to return (1-based)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "page size of results",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"$ref": "#/responses/BlockedUserList"
}
}
}
},
"/user/orgs": {
"get": {
"produces": [
@ -15801,6 +15986,38 @@
}
}
},
"/user/unblock/{username}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Unblocks a user from the doer.",
"operationId": "userUnblockUser",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
},
"404": {
"$ref": "#/responses/notFound"
},
"422": {
"$ref": "#/responses/validationError"
}
}
}
},
"/users/search": {
"get": {
"produces": [
@ -16764,6 +16981,23 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"BlockedUser": {
"type": "object",
"title": "BlockedUser represents a blocked user.",
"properties": {
"block_id": {
"type": "integer",
"format": "int64",
"x-go-name": "BlockID"
},
"created_at": {
"type": "string",
"format": "date-time",
"x-go-name": "Created"
}
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
"Branch": {
"description": "Branch represents a repository branch",
"type": "object",
@ -23084,6 +23318,15 @@
}
}
},
"BlockedUserList": {
"description": "BlockedUserList",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/BlockedUser"
}
}
},
"Branch": {
"description": "Branch",
"schema": {

View file

@ -1,6 +1,7 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content user profile">
<div class="ui container">
{{template "base/alert" .}}
<div class="ui stackable grid">
<div class="ui four wide column">
{{template "shared/user/profile_big_avatar" .}}
@ -39,4 +40,20 @@
</div>
</div>
</div>
<div class="ui g-modal-confirm delete modal" id="block-user">
<div class="header">
{{ctx.Locale.Tr "user.block_user"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "user.block_user.detail"}}</p>
<ul>
<li>{{ctx.Locale.Tr "user.block_user.detail_1"}}</li>
<li>{{ctx.Locale.Tr "user.block_user.detail_2"}}</li>
<li>{{ctx.Locale.Tr "user.block_user.detail_3"}}</li>
</ul>
</div>
{{template "base/modal_actions_confirm" .}}
</div>
{{template "base/footer" .}}

View file

@ -0,0 +1,10 @@
{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings blocked-users")}}
<div class="user-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.blocked_users"}}
</h4>
<div class="ui attached segment">
{{template "shared/blocked_users_list" .}}
</div>
</div>
{{template "user/settings/layout_footer" .}}

View file

@ -51,5 +51,8 @@
<a class="{{if .PageIsSettingsRepos}}active {{end}}item" href="{{AppSubUrl}}/user/settings/repos">
{{ctx.Locale.Tr "settings.repos"}}
</a>
<a class="{{if .PageIsBlockedUsers}}active {{end}}item" href="{{AppSubUrl}}/user/settings/blocked_users">
{{ctx.Locale.Tr "settings.blocked_users"}}
</a>
</div>
</div>

View file

@ -4,10 +4,12 @@
package integration
import (
"fmt"
"net/http"
"strconv"
"testing"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/tests"
@ -68,16 +70,22 @@ func makeRequest(t *testing.T, formData user_model.User, headerCode int) {
}
func TestAdminDeleteUser(t *testing.T) {
defer tests.AddFixtures("tests/integration/fixtures/TestAdminDeleteUser/")()
defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1")
csrf := GetCSRF(t, session, "/admin/users/8/edit")
req := NewRequestWithValues(t, "POST", "/admin/users/8/delete", map[string]string{
userID := int64(1000)
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{PosterID: userID})
csrf := GetCSRF(t, session, fmt.Sprintf("/admin/users/%d/edit", userID))
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/admin/users/%d/delete", userID), map[string]string{
"_csrf": csrf,
"purge": "true",
})
session.MakeRequest(t, req, http.StatusSeeOther)
assertUserDeleted(t, 8)
assertUserDeleted(t, userID, true)
unittest.CheckConsistencyFor(t, &user_model.User{})
}

View file

@ -0,0 +1,228 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIUserBlock(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := "user4"
session := loginUser(t, user)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
t.Run("BlockUser", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/user2?token=%s", token))
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
})
t.Run("ListBlocked", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/list_blocked?token=%s", token))
resp := MakeRequest(t, req, http.StatusOK)
// One user just got blocked and the other one is defined in the fixtures.
assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
var blockedUsers []api.BlockedUser
DecodeJSON(t, resp, &blockedUsers)
assert.Len(t, blockedUsers, 2)
assert.EqualValues(t, 1, blockedUsers[0].BlockID)
assert.EqualValues(t, 2, blockedUsers[1].BlockID)
})
t.Run("UnblockUser", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/user2?token=%s", token))
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
})
t.Run("Organization as target", func(t *testing.T) {
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization})
t.Run("Block", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", org.Name, token))
MakeRequest(t, req, http.StatusUnprocessableEntity)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: org.ID})
})
t.Run("Unblock", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/%s?token=%s", org.Name, token))
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
})
}
func TestAPIOrgBlock(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := "user5"
org := "org6"
session := loginUser(t, user)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
t.Run("BlockUser", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token))
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
})
t.Run("ListBlocked", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token))
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
var blockedUsers []api.BlockedUser
DecodeJSON(t, resp, &blockedUsers)
assert.Len(t, blockedUsers, 1)
assert.EqualValues(t, 2, blockedUsers[0].BlockID)
})
t.Run("UnblockUser", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2?token=%s", org, token))
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
})
t.Run("Organization as target", func(t *testing.T) {
targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization})
t.Run("Block", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/%s?token=%s", org, targetOrg.Name, token))
MakeRequest(t, req, http.StatusUnprocessableEntity)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: targetOrg.ID})
})
t.Run("Unblock", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/%s?token=%s", org, targetOrg.Name, token))
MakeRequest(t, req, http.StatusUnprocessableEntity)
})
})
t.Run("Read scope token", func(t *testing.T) {
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
t.Run("Write action", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token))
MakeRequest(t, req, http.StatusForbidden)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
})
t.Run("Read action", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token))
MakeRequest(t, req, http.StatusOK)
})
})
t.Run("Not as owner", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
org := "org3"
user := "user4" // Part of org team with write perms.
session := loginUser(t, user)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
t.Run("Block user", func(t *testing.T) {
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2?token=%s", org, token))
MakeRequest(t, req, http.StatusForbidden)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 3, BlockID: 2})
})
t.Run("Unblock user", func(t *testing.T) {
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2?token=%s", org, token))
MakeRequest(t, req, http.StatusForbidden)
})
t.Run("List blocked users", func(t *testing.T) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked?token=%s", org, token))
MakeRequest(t, req, http.StatusForbidden)
})
})
}
// TestAPIBlock_AddCollaborator ensures that the doer and blocked user cannot
// add each others as collaborators via the API.
func TestAPIBlock_AddCollaborator(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user1 := "user10"
user2 := "user2"
perm := "write"
collabOption := &api.AddCollaboratorOption{Permission: &perm}
// User1 blocks User2.
session := loginUser(t, user1)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s?token=%s", user2, token))
MakeRequest(t, req, http.StatusNoContent)
unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 10, BlockID: 2})
t.Run("BlockedUser Add Doer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2})
session := loginUser(t, user2)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user2, repo.Name, user1, token), collabOption)
session.MakeRequest(t, req, http.StatusForbidden)
})
t.Run("Doer Add BlockedUser", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: 10})
session := loginUser(t, user1)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s?token=%s", user1, repo.Name, user2, token), collabOption)
session.MakeRequest(t, req, http.StatusForbidden)
})
}

View file

@ -34,6 +34,6 @@ func TestNodeinfo(t *testing.T) {
assert.Equal(t, "gitea", nodeinfo.Software.Name)
assert.Equal(t, 25, nodeinfo.Usage.Users.Total)
assert.Equal(t, 20, nodeinfo.Usage.LocalPosts)
assert.Equal(t, 2, nodeinfo.Usage.LocalComments)
assert.Equal(t, 3, nodeinfo.Usage.LocalComments)
})
}

View file

@ -19,7 +19,7 @@ func TestAPIFollow(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user1 := "user4"
user2 := "user1"
user2 := "user10"
session1 := loginUser(t, user1)
token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser)

View file

@ -0,0 +1,388 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"path"
"strconv"
"testing"
issue_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
forgejo_context "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
t.Helper()
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
session := loginUser(t, doer.Name)
req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "block",
})
resp := session.MakeRequest(t, req, http.StatusOK)
type redirect struct {
Redirect string `json:"redirect"`
}
var respBody redirect
DecodeJSON(t, resp, &respBody)
assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect)
assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
}
// TestBlockUser ensures that users can execute blocking related actions can
// happen under the correct conditions.
func TestBlockUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, doer.Name)
t.Run("Block", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
BlockUser(t, doer, blockedUser)
})
// Unblock user.
t.Run("Unblock", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "unblock",
})
session.MakeRequest(t, req, http.StatusOK)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
})
t.Run("Organization as target", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
t.Run("Block", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+targetOrg.Name),
"action": "block",
})
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "Action \\\"block\\\" failed")
})
t.Run("Unblock", func(t *testing.T) {
req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+targetOrg.Name),
"action": "unblock",
})
resp := session.MakeRequest(t, req, http.StatusBadRequest)
assert.Contains(t, resp.Body.String(), "Action \\\"unblock\\\" failed")
})
})
}
// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user.
func TestBlockUserFromOrganization(t *testing.T) {
defer tests.PrepareTestEnv(t)()
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization})
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
session := loginUser(t, doer.Name)
t.Run("Block user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
"uname": blockedUser.Name,
})
session.MakeRequest(t, req, http.StatusSeeOther)
assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}))
})
t.Run("Unblock user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
"user_id": strconv.FormatInt(blockedUser.ID, 10),
})
session.MakeRequest(t, req, http.StatusSeeOther)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
})
t.Run("Organization as target", func(t *testing.T) {
targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
t.Run("Block", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
"uname": targetOrg.Name,
})
session.MakeRequest(t, req, http.StatusInternalServerError)
unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: targetOrg.ID})
})
t.Run("Unblock", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
"user_id": strconv.FormatInt(targetOrg.ID, 10),
})
session.MakeRequest(t, req, http.StatusInternalServerError)
})
})
}
// TestBlockActions ensures that certain actions cannot be performed as a doer
// and as a blocked user and are handled cleanly after the blocking has taken
// place.
func TestBlockActions(t *testing.T) {
defer tests.PrepareTestEnv(t)()
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
blockedUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID})
repo7 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: blockedUser2.ID})
issue4 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, RepoID: repo2.ID})
issue4URL := fmt.Sprintf("/%s/issues/%d", repo2.FullName(), issue4.Index)
// NOTE: Sessions shouldn't be shared, because in some situations flash
// messages are persistent and that would interfere with accurate test
// results.
BlockUser(t, doer, blockedUser)
BlockUser(t, doer, blockedUser2)
// Ensures that issue creation on doer's ownen repositories are blocked.
t.Run("Issue creation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, blockedUser.Name)
link := fmt.Sprintf("%s/issues/new", repo2.FullName())
req := NewRequestWithValues(t, "POST", link, map[string]string{
"_csrf": GetCSRF(t, session, link),
"title": "Title",
"content": "Hello!",
})
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t,
htmlDoc.doc.Find(".ui.negative.message").Text(),
translation.NewLocale("en-US").Tr("repo.issues.blocked_by_user"),
)
})
// Ensures that comment creation on doer's owned repositories and doer's
// posted issues are blocked.
t.Run("Comment creation", func(t *testing.T) {
expectedFlash := "error%3DYou%2Bcannot%2Bcreate%2Ba%2Bcomment%2Bon%2Bthis%2Bissue%2Bbecause%2Byou%2Bare%2Bblocked%2Bby%2Bthe%2Brepository%2Bowner%2Bor%2Bthe%2Bposter%2Bof%2Bthe%2Bissue."
t.Run("Blocked by repository owner", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, blockedUser.Name)
req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/comments"), map[string]string{
"_csrf": GetCSRF(t, session, issue4URL),
"content": "Not a kind comment",
})
session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, expectedFlash, flashCookie.Value)
})
t.Run("Blocked by issue poster", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
issue15 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 15, RepoID: repo5.ID, PosterID: doer.ID})
session := loginUser(t, blockedUser.Name)
issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo5.OwnerName), url.PathEscape(repo5.Name), issue15.Index)
req := NewRequestWithValues(t, "POST", path.Join(issueURL, "/comments"), map[string]string{
"_csrf": GetCSRF(t, session, issueURL),
"content": "Not a kind comment",
})
session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, expectedFlash, flashCookie.Value)
})
})
// Ensures that reactions on doer's owned issues and doer's owned comments are
// blocked.
t.Run("Add a reaction", func(t *testing.T) {
type reactionResponse struct {
Empty bool `json:"empty"`
}
t.Run("On a issue", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, blockedUser.Name)
req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/reactions/react"), map[string]string{
"_csrf": GetCSRF(t, session, issue4URL),
"content": "eyes",
})
resp := session.MakeRequest(t, req, http.StatusOK)
var respBody reactionResponse
DecodeJSON(t, resp, &respBody)
assert.EqualValues(t, true, respBody.Empty)
})
t.Run("On a comment", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 8, PosterID: doer.ID, IssueID: issue4.ID})
session := loginUser(t, blockedUser.Name)
req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/comments/%d/reactions/react", repo2.FullName(), comment.ID), map[string]string{
"_csrf": GetCSRF(t, session, issue4URL),
"content": "eyes",
})
resp := session.MakeRequest(t, req, http.StatusOK)
var respBody reactionResponse
DecodeJSON(t, resp, &respBody)
assert.EqualValues(t, true, respBody.Empty)
})
})
// Ensures that the doer and blocked user cannot follow each other.
t.Run("Follow", func(t *testing.T) {
// Sanity checks to make sure doing these tests are valid.
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
// Doer cannot follow blocked user.
t.Run("Doer follow blocked user", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, doer.Name)
req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
"action": "follow",
})
session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
// Assert it still doesn't exist.
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
})
// Blocked user cannot follow doer.
t.Run("Blocked user follow doer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, blockedUser.Name)
req := NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{
"_csrf": GetCSRF(t, session, "/"+doer.Name),
"action": "follow",
})
session.MakeRequest(t, req, http.StatusOK)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, "error%3DYou%2Bcannot%2Bfollow%2Bthis%2Buser%2Bbecause%2Byou%2Bhave%2Bblocked%2Bthis%2Buser%2Bor%2Bthis%2Buser%2Bhas%2Bblocked%2Byou.", flashCookie.Value)
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
})
})
// Ensures that the doer and blocked user cannot add each each other as collaborators.
t.Run("Add collaborator", func(t *testing.T) {
t.Run("Doer Add BlockedUser", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, doer.Name)
link := fmt.Sprintf("/%s/settings/collaboration", repo2.FullName())
req := NewRequestWithValues(t, "POST", link, map[string]string{
"_csrf": GetCSRF(t, session, link),
"collaborator": blockedUser2.Name,
})
session.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthe%2Brepository%2Bowner%2Bhas%2Bblocked%2Bthem.", flashCookie.Value)
})
t.Run("BlockedUser Add doer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, blockedUser2.Name)
link := fmt.Sprintf("/%s/settings/collaboration", repo7.FullName())
req := NewRequestWithValues(t, "POST", link, map[string]string{
"_csrf": GetCSRF(t, session, link),
"collaborator": doer.Name,
})
session.MakeRequest(t, req, http.StatusSeeOther)
flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
assert.NotNil(t, flashCookie)
assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthey%2Bhave%2Bblocked%2Bthe%2Brepository%2Bowner.", flashCookie.Value)
})
})
// Ensures that the blocked user cannot transfer a repository to the doer.
t.Run("Repository transfer", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
session := loginUser(t, blockedUser2.Name)
link := fmt.Sprintf("%s/settings", repo7.FullName())
req := NewRequestWithValues(t, "POST", link, map[string]string{
"_csrf": GetCSRF(t, session, link),
"action": "transfer",
"repo_name": repo7.FullName(),
"new_owner_name": doer.Name,
})
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Contains(t,
htmlDoc.doc.Find(".ui.negative.message").Text(),
translation.NewLocale("en-US").Tr("repo.settings.new_owner_blocked_doer"),
)
})
}

View file

@ -17,7 +17,7 @@ import (
"code.gitea.io/gitea/tests"
)
func assertUserDeleted(t *testing.T, userID int64) {
func assertUserDeleted(t *testing.T, userID int64, purged bool) {
unittest.AssertNotExistsBean(t, &user_model.User{ID: userID})
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: userID})
unittest.AssertNotExistsBean(t, &user_model.Follow{FollowID: userID})
@ -27,6 +27,9 @@ func assertUserDeleted(t *testing.T, userID int64) {
unittest.AssertNotExistsBean(t, &issues_model.IssueUser{UID: userID})
unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: userID})
unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID})
if purged {
unittest.AssertNotExistsBean(t, &issues_model.Issue{PosterID: userID})
}
}
func TestUserDeleteAccount(t *testing.T) {
@ -40,7 +43,7 @@ func TestUserDeleteAccount(t *testing.T) {
})
session.MakeRequest(t, req, http.StatusSeeOther)
assertUserDeleted(t, 8)
assertUserDeleted(t, 8, false)
unittest.CheckConsistencyFor(t, &user_model.User{})
}

View file

@ -0,0 +1,16 @@
-
id: 1000
repo_id: 1000
index: 2
poster_id: 1000
original_author_id: 0
name: NAME
content: content
milestone_id: 0
priority: 0
is_closed: false
is_pull: false
num_comments: 0
created_unix: 946684830
updated_unix: 978307200
is_locked: false

View file

@ -0,0 +1,3 @@
-
group_id: 1000
max_index: 2

View file

@ -0,0 +1,30 @@
-
id: 1000
owner_id: 1001
owner_name: user1001
lower_name: repo1000
name: repo1000
default_branch: master
num_watches: 0
num_stars: 0
num_forks: 0
num_issues: 1
num_closed_issues: 0
num_pulls: 0
num_closed_pulls: 0
num_milestones: 0
num_closed_milestones: 0
num_projects: 0
num_closed_projects: 0
is_private: false
is_empty: false
is_archived: false
is_mirror: false
status: 0
is_fork: false
fork_id: 0
is_template: false
template_id: 0
size: 0
is_fsck_enabled: true
close_issues_via_commit_in_any_branch: false

View file

@ -0,0 +1,73 @@
-
id: 1000
lower_name: user1000
name: user1000
full_name: User Thousand
email: user1000@example.com
keep_email_private: false
email_notifications_preference: enabled
passwd: ZogKvWdyEx:password
passwd_hash_algo: dummy
must_change_password: false
login_source: 0
login_name: user1000
type: 0
salt: ZogKvWdyEx
max_repo_creation: -1
is_active: true
is_admin: false
is_restricted: false
allow_git_hook: false
allow_import_local: false
allow_create_organization: true
prohibit_login: false
avatar: avatar1000
avatar_email: user1000@example.com
use_custom_avatar: false
num_followers: 1
num_following: 1
num_stars: 0
num_repos: 0
num_teams: 0
num_members: 0
visibility: 0
repo_admin_change_team_access: false
theme: ""
keep_activity_private: false
-
id: 1001
lower_name: user1001
name: user1001
full_name: User 1001
email: user1001@example.com
keep_email_private: false
email_notifications_preference: enabled
passwd: ZogKvWdyEx:password
passwd_hash_algo: dummy
must_change_password: false
login_source: 0
login_name: user1001
type: 0
salt: ZogKvWdyEx
max_repo_creation: -1
is_active: true
is_admin: false
is_restricted: false
allow_git_hook: false
allow_import_local: false
allow_create_organization: true
prohibit_login: false
avatar: avatar1001
avatar_email: user1001@example.com
use_custom_avatar: false
num_followers: 0
num_following: 0
num_stars: 0
num_repos: 1
num_teams: 0
num_members: 0
visibility: 0
repo_admin_change_team_access: false
theme: ""
keep_activity_private: false

View file

@ -166,6 +166,22 @@
border-bottom: 1px solid var(--color-secondary);
}
.organization.teams .repositories .item,
.organization.teams .members .item {
padding: 10px 19px;
}
.organization.teams .repositories .item:not(:last-child),
.organization.teams .members .item:not(:last-child) {
border-bottom: 1px solid var(--color-secondary);
}
.organization.teams .repositories .item .button,
.organization.teams .members .item .button {
padding: 9px 10px;
margin: 0;
}
.org-team-navbar .active.item {
background: var(--color-box-body) !important;
}

View file

@ -36,6 +36,19 @@
width: 100%;
}
.user.profile .ui.card .extra.content > ul > li .svg {
margin-left: 1px;
margin-right: 5px;
}
.user.profile .ui.card .extra.content > ul > li.follow .ui.button,
.user.profile .ui.card .extra.content > ul > li.block .ui.button {
align-items: center;
display: flex;
justify-content: center;
width: 100%;
}
.user.profile .ui.card #profile-avatar {
padding: 1rem 1rem 0.25rem;
justify-content: center;