[MODERATION] organization blocking a user (#802)
- Resolves #476 - Follow up for: #540 - Ensure that the doer and blocked person cannot follow each other. - Ensure that the block person cannot watch doer's repositories. - Add unblock button to the blocked user list. - Add blocked since information to the blocked user list. - Add extra testing to moderation code. - Blocked user will unwatch doer's owned repository upon blocking. - Add flash messages to let the user know the block/unblock action was successful. - Add "You haven't blocked any users" message. - Add organization blocking a user. Co-authored-by: Gusted <postmaster@gusted.xyz> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/802 (cherry picked from commit0505a10421) (cherry picked from commit37b4e6ef9b) (cherry picked from commitc17c121f2c) [MODERATION] organization blocking a user (#802) (squash) Changes to adapt to:6bbccdd177Improve AJAX link and modal confirm dialog (#25210) Refs: https://codeberg.org/forgejo/forgejo/pulls/882/files#issuecomment-945962 Refs: https://codeberg.org/forgejo/forgejo/pulls/882#issue-330561 (cherry picked from commit523635f83c)
This commit is contained in:
		
							parent
							
								
									a3e2bfd7e9
								
							
						
					
					
						commit
						4743eaa6a0
					
				
					 26 changed files with 371 additions and 16 deletions
				
			
		| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,4 +26,10 @@
 | 
			
		|||
  id: 5
 | 
			
		||||
  user_id: 11
 | 
			
		||||
  repo_id: 1
 | 
			
		||||
  mode: 3 # auto 
 | 
			
		||||
  mode: 3 # auto
 | 
			
		||||
 | 
			
		||||
-
 | 
			
		||||
  id: 6
 | 
			
		||||
  user_id: 4
 | 
			
		||||
  repo_id: 2
 | 
			
		||||
  mode: 1 # normal
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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])
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -201,3 +201,9 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error
 | 
			
		|||
	}
 | 
			
		||||
	return watchRepoMode(ctx, watch, WatchModeAuto)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UnwatchRepos will unwatch the user from all given repositories.
 | 
			
		||||
func UnwatchRepos(ctx context.Context, userID int64, repoIDs []int64) error {
 | 
			
		||||
	_, err := db.GetEngine(ctx).Where("user_id=?", userID).In("repo_id", repoIDs).Delete(&Watch{})
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -155,3 +155,16 @@ func TestWatchRepoMode(t *testing.T) {
 | 
			
		|||
	assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone))
 | 
			
		||||
	unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestUnwatchRepos(t *testing.T) {
 | 
			
		||||
	assert.NoError(t, unittest.PrepareTestDatabase())
 | 
			
		||||
 | 
			
		||||
	unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
 | 
			
		||||
	unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
 | 
			
		||||
 | 
			
		||||
	err := repo_model.UnwatchRepos(db.DefaultContext, 4, []int64{1, 2})
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 1})
 | 
			
		||||
	unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 4, RepoID: 2})
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -53,10 +53,12 @@ func UnblockUser(ctx context.Context, userID, blockID int64) error {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
// ListBlockedUsers returns the users that the user has blocked.
 | 
			
		||||
// The created_unix field of the user struct is overridden by the creation_unix
 | 
			
		||||
// field of blockeduser.
 | 
			
		||||
func ListBlockedUsers(ctx context.Context, userID int64) ([]*User, error) {
 | 
			
		||||
	users := make([]*User, 0, 8)
 | 
			
		||||
	err := db.GetEngine(ctx).
 | 
			
		||||
		Select("`user`.*").
 | 
			
		||||
		Select("`forgejo_blocked_user`.created_unix, `user`.*").
 | 
			
		||||
		Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id").
 | 
			
		||||
		Where("`forgejo_blocked_user`.user_id=?", userID).
 | 
			
		||||
		Find(&users)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,16 +24,25 @@ func init() {
 | 
			
		|||
 | 
			
		||||
// IsFollowing returns true if user is following followID.
 | 
			
		||||
func IsFollowing(userID, followID int64) bool {
 | 
			
		||||
	has, _ := db.GetEngine(db.DefaultContext).Get(&Follow{UserID: userID, FollowID: followID})
 | 
			
		||||
	return IsFollowingCtx(db.DefaultContext, userID, followID)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsFollowingCtx returns true if user is following followID.
 | 
			
		||||
func IsFollowingCtx(ctx context.Context, userID, followID int64) bool {
 | 
			
		||||
	has, _ := db.GetEngine(ctx).Get(&Follow{UserID: userID, FollowID: followID})
 | 
			
		||||
	return has
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// FollowUser marks someone be another's follower.
 | 
			
		||||
func FollowUser(ctx context.Context, userID, followID int64) (err error) {
 | 
			
		||||
	if userID == followID || IsFollowing(userID, followID) {
 | 
			
		||||
	if userID == followID || IsFollowingCtx(ctx, userID, followID) {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) {
 | 
			
		||||
		return ErrBlockedByUser
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx, committer, err := db.TxContext(ctx)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +65,7 @@ func FollowUser(ctx context.Context, userID, followID int64) (err error) {
 | 
			
		|||
 | 
			
		||||
// UnfollowUser unmarks someone as another's follower.
 | 
			
		||||
func UnfollowUser(ctx context.Context, userID, followID int64) (err error) {
 | 
			
		||||
	if userID == followID || !IsFollowing(userID, followID) {
 | 
			
		||||
	if userID == followID || !IsFollowingCtx(ctx, userID, followID) {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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{})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -604,6 +604,7 @@ block_user = Block User
 | 
			
		|||
block_user.detail = Please understand that if you block this user, other actions will be taken. Such as:
 | 
			
		||||
block_user.detail_1 = You are being unfollowed from this user.
 | 
			
		||||
block_user.detail_2 = This user cannot interact with your repositories, created issues and comments.
 | 
			
		||||
follow_blocked_user = You cannot follow this user because you have blocked this user or this user has blocked you.
 | 
			
		||||
 | 
			
		||||
form.name_reserved = The username "%s" is reserved.
 | 
			
		||||
form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username.
 | 
			
		||||
| 
						 | 
				
			
			@ -894,6 +895,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.
 | 
			
		||||
| 
						 | 
				
			
			@ -916,6 +918,10 @@ visibility.limited_tooltip = Visible to authenticated users only
 | 
			
		|||
visibility.private = Private
 | 
			
		||||
visibility.private_tooltip = Visible only to organization members
 | 
			
		||||
 | 
			
		||||
blocked_since = Blocked since %s
 | 
			
		||||
user_unblock_success = The user has been unblocked successfully.
 | 
			
		||||
user_block_success = The user has been blocked successfully.
 | 
			
		||||
 | 
			
		||||
[repo]
 | 
			
		||||
new_repo_helper = A repository contains all project files, including revision history.  Already have it elsewhere? <a href="%s">Migrate repository.</a>
 | 
			
		||||
owner = Owner
 | 
			
		||||
| 
						 | 
				
			
			@ -2524,6 +2530,7 @@ team_access_desc = Repository access
 | 
			
		|||
team_permission_desc = Permission
 | 
			
		||||
team_unit_desc = Allow Access to Repository Sections
 | 
			
		||||
team_unit_disabled = (Disabled)
 | 
			
		||||
follow_blocked_user = You cannot follow this organisation because this organisation has blocked you.
 | 
			
		||||
 | 
			
		||||
form.name_reserved = The organization name "%s" is reserved.
 | 
			
		||||
form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@
 | 
			
		|||
package user
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	user_model "code.gitea.io/gitea/models/user"
 | 
			
		||||
| 
						 | 
				
			
			@ -217,8 +218,14 @@ func Follow(ctx *context.APIContext) {
 | 
			
		|||
	// responses:
 | 
			
		||||
	//   "204":
 | 
			
		||||
	//     "$ref": "#/responses/empty"
 | 
			
		||||
	//   "403":
 | 
			
		||||
	//     "$ref": "#/responses/forbidden"
 | 
			
		||||
 | 
			
		||||
	if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
 | 
			
		||||
		if errors.Is(err, user_model.ErrBlockedByUser) {
 | 
			
		||||
			ctx.Error(http.StatusForbidden, "BlockedByUser", err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
		ctx.Error(http.StatusInternalServerError, "FollowUser", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										61
									
								
								routers/web/org/setting/blocked_users.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								routers/web/org/setting/blocked_users.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package setting
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	user_model "code.gitea.io/gitea/models/user"
 | 
			
		||||
	"code.gitea.io/gitea/modules/context"
 | 
			
		||||
	"code.gitea.io/gitea/routers/utils"
 | 
			
		||||
	user_service "code.gitea.io/gitea/services/user"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const tplBlockedUsers = "org/settings/blocked_users"
 | 
			
		||||
 | 
			
		||||
// BlockedUsers renders the blocked users page.
 | 
			
		||||
func BlockedUsers(ctx *context.Context) {
 | 
			
		||||
	ctx.Data["Title"] = ctx.Tr("settings.blocked_users")
 | 
			
		||||
	ctx.Data["PageIsSettingsBlockedUsers"] = true
 | 
			
		||||
 | 
			
		||||
	blockedUsers, err := user_model.ListBlockedUsers(ctx, ctx.Org.Organization.ID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ctx.ServerError("ListBlockedUsers", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx.Data["BlockedUsers"] = blockedUsers
 | 
			
		||||
 | 
			
		||||
	ctx.HTML(http.StatusOK, tplBlockedUsers)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// BlockedUsersBlock blocks a particular user from the organization.
 | 
			
		||||
func BlockedUsersBlock(ctx *context.Context) {
 | 
			
		||||
	uname := utils.RemoveUsernameParameterSuffix(strings.ToLower(ctx.FormString("uname")))
 | 
			
		||||
	u, err := user_model.GetUserByName(ctx, uname)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ctx.ServerError("GetUserByName", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := user_service.BlockUser(ctx, ctx.Org.Organization.ID, u.ID); err != nil {
 | 
			
		||||
		ctx.ServerError("BlockUser", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx.Flash.Success(ctx.Tr("settings.user_block_success"))
 | 
			
		||||
	ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// BlockedUsersUnblock unblocks a particular user from the organization.
 | 
			
		||||
func BlockedUsersUnblock(ctx *context.Context) {
 | 
			
		||||
	if err := user_model.UnblockUser(ctx, ctx.Org.Organization.ID, ctx.FormInt64("user_id")); err != nil {
 | 
			
		||||
		ctx.ServerError("BlockUser", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
 | 
			
		||||
	ctx.Redirect(ctx.Org.OrgLink + "/settings/blocked_users")
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,6 +5,7 @@
 | 
			
		|||
package user
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
| 
						 | 
				
			
			@ -369,8 +370,16 @@ func Action(ctx *context.Context) {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err)
 | 
			
		||||
		return
 | 
			
		||||
		if !errors.Is(err, user_model.ErrBlockedByUser) {
 | 
			
		||||
			ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.FormString("action")), err)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if ctx.ContextUser.IsOrganization() {
 | 
			
		||||
			ctx.Flash.Error(ctx.Tr("org.follow_blocked_user"))
 | 
			
		||||
		} else {
 | 
			
		||||
			ctx.Flash.Error(ctx.Tr("user.follow_blocked_user"))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if redirectViaJSON {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,3 +32,14 @@ func BlockedUsers(ctx *context.Context) {
 | 
			
		|||
	ctx.Data["BlockedUsers"] = blockedUsers
 | 
			
		||||
	ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UnblockUser unblocks a particular user for the doer.
 | 
			
		||||
func UnblockUser(ctx *context.Context) {
 | 
			
		||||
	if err := user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.FormInt64("user_id")); err != nil {
 | 
			
		||||
		ctx.ServerError("UnblockUser", err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	ctx.Flash.Success(ctx.Tr("settings.user_unblock_success"))
 | 
			
		||||
	ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users")
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -523,7 +523,10 @@ func registerRoutes(m *web.Route) {
 | 
			
		|||
			addWebhookEditRoutes()
 | 
			
		||||
		}, webhooksEnabled)
 | 
			
		||||
 | 
			
		||||
		m.Get("/blocked_users", user_setting.BlockedUsers)
 | 
			
		||||
		m.Group("/blocked_users", func() {
 | 
			
		||||
			m.Get("", user_setting.BlockedUsers)
 | 
			
		||||
			m.Post("/unblock", user_setting.UnblockUser)
 | 
			
		||||
		})
 | 
			
		||||
	}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
 | 
			
		||||
 | 
			
		||||
	m.Group("/user", func() {
 | 
			
		||||
| 
						 | 
				
			
			@ -777,6 +780,12 @@ func registerRoutes(m *web.Route) {
 | 
			
		|||
					addSettingVariablesRoutes()
 | 
			
		||||
				}, actions.MustEnableActions)
 | 
			
		||||
 | 
			
		||||
				m.Group("/blocked_users", func() {
 | 
			
		||||
					m.Get("", org_setting.BlockedUsers)
 | 
			
		||||
					m.Post("/block", org_setting.BlockedUsersBlock)
 | 
			
		||||
					m.Post("/unblock", org_setting.BlockedUsersUnblock)
 | 
			
		||||
				})
 | 
			
		||||
 | 
			
		||||
				m.RouteMethods("/delete", "GET,POST", org.SettingsDelete)
 | 
			
		||||
 | 
			
		||||
				m.Group("/packages", func() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@ import (
 | 
			
		|||
	"context"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/db"
 | 
			
		||||
	repo_model "code.gitea.io/gitea/models/repo"
 | 
			
		||||
	user_model "code.gitea.io/gitea/models/user"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -30,11 +31,28 @@ func BlockUser(ctx context.Context, userID, blockID int64) error {
 | 
			
		|||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Unfollow the user from block's perspective.
 | 
			
		||||
	// Unfollow the user from the block's perspective.
 | 
			
		||||
	err = user_model.UnfollowUser(ctx, blockID, userID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Unfollow the user from the doer's perspective.
 | 
			
		||||
	err = user_model.UnfollowUser(ctx, userID, blockID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Blocked user unwatch all repository owned by the doer.
 | 
			
		||||
	repoIDs, err := repo_model.GetWatchedRepoIDsOwnedBy(ctx, blockID, userID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = repo_model.UnwatchRepos(ctx, blockID, repoIDs)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return committer.Commit()
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										41
									
								
								services/user/block_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								services/user/block_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package user
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/db"
 | 
			
		||||
	repo_model "code.gitea.io/gitea/models/repo"
 | 
			
		||||
	"code.gitea.io/gitea/models/unittest"
 | 
			
		||||
	user_model "code.gitea.io/gitea/models/user"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// TestBlockUser will ensure that when you block a user, certain actions have
 | 
			
		||||
// been taken, like unfollowing each other etc.
 | 
			
		||||
func TestBlockUser(t *testing.T) {
 | 
			
		||||
	assert.NoError(t, unittest.PrepareTestDatabase())
 | 
			
		||||
 | 
			
		||||
	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
 | 
			
		||||
	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
 | 
			
		||||
 | 
			
		||||
	// Follow each other.
 | 
			
		||||
	assert.NoError(t, user_model.FollowUser(db.DefaultContext, doer.ID, blockedUser.ID))
 | 
			
		||||
	assert.NoError(t, user_model.FollowUser(db.DefaultContext, blockedUser.ID, doer.ID))
 | 
			
		||||
 | 
			
		||||
	// Blocked user watch repository of doer.
 | 
			
		||||
	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: doer.ID})
 | 
			
		||||
	assert.NoError(t, repo_model.WatchRepo(db.DefaultContext, blockedUser.ID, repo.ID, true))
 | 
			
		||||
 | 
			
		||||
	assert.NoError(t, BlockUser(db.DefaultContext, doer.ID, blockedUser.ID))
 | 
			
		||||
 | 
			
		||||
	// Ensure they aren't following each other anymore.
 | 
			
		||||
	assert.False(t, user_model.IsFollowing(doer.ID, blockedUser.ID))
 | 
			
		||||
	assert.False(t, user_model.IsFollowing(blockedUser.ID, doer.ID))
 | 
			
		||||
 | 
			
		||||
	// Ensure blocked user isn't following doer's repository.
 | 
			
		||||
	assert.False(t, repo_model.IsWatching(blockedUser.ID, repo.ID))
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
		{{avatar $.Context .Org 140 "org-avatar"}}
 | 
			
		||||
		<div id="org-info">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										40
									
								
								templates/org/settings/blocked_users.tmpl
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								templates/org/settings/blocked_users.tmpl
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
{{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="{{.locale.Tr "repo.settings.search_user_placeholder"}}" autocomplete="off" required>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<button type="submit" class="ui red button">{{.locale.Tr "user.block"}}</button>
 | 
			
		||||
		</form>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="ui bottom attached table segment blocked-users">
 | 
			
		||||
		{{range .BlockedUsers}}
 | 
			
		||||
			<div class="item gt-df gt-ac gt-fw">
 | 
			
		||||
				{{avatar $.Context . 48 "gt-mr-3 gt-mb-0"}}
 | 
			
		||||
					<div class="gt-df gt-fc">
 | 
			
		||||
						<a href="{{.HomeLink}}">{{.Name}}</a>
 | 
			
		||||
						<i class="gt-mt-2">{{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</i>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="gt-ml-auto content">
 | 
			
		||||
						<form action="{{$.Link}}/unblock" method="post">
 | 
			
		||||
							{{$.CsrfTokenHtml}}
 | 
			
		||||
							<input type="hidden" name="user_id" value="{{.ID}}">
 | 
			
		||||
							<button class="ui red button">{{$.locale.Tr "user.unblock"}}</button>
 | 
			
		||||
						</form>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			{{else}}
 | 
			
		||||
				<div class="item">
 | 
			
		||||
					<span class="text grey italic">{{$.locale.Tr "settings.blocked_users_none"}}</span>
 | 
			
		||||
				</div>
 | 
			
		||||
			{{end}}
 | 
			
		||||
		</div>
 | 
			
		||||
</div>
 | 
			
		||||
{{template "org/settings/layout_footer" .}}
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +38,9 @@
 | 
			
		|||
			</div>
 | 
			
		||||
		</details>
 | 
			
		||||
		{{end}}
 | 
			
		||||
			<a class="{{if .PageIsSettingsBlockedUsers}}active {{end}}item" href="{{.OrgLink}}/settings/blocked_users">
 | 
			
		||||
			{{.locale.Tr "settings.blocked_users"}}
 | 
			
		||||
		</a>
 | 
			
		||||
		<a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete">
 | 
			
		||||
			{{.locale.Tr "org.settings.delete"}}
 | 
			
		||||
		</a>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										3
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -13963,6 +13963,9 @@
 | 
			
		|||
        "responses": {
 | 
			
		||||
          "204": {
 | 
			
		||||
            "$ref": "#/responses/empty"
 | 
			
		||||
          },
 | 
			
		||||
          "403": {
 | 
			
		||||
            "$ref": "#/responses/forbidden"
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
				<div class="ui card">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,8 +6,23 @@
 | 
			
		|||
		<div class="ui attached segment">
 | 
			
		||||
			<div class="ui blocked-user list gt-mt-0">
 | 
			
		||||
				{{range .BlockedUsers}}
 | 
			
		||||
					<div class="item gt-df gt-ac">
 | 
			
		||||
						{{avatar $.Context . 28 "gt-mr-3"}}
 | 
			
		||||
						<div class="gt-df gt-fc">
 | 
			
		||||
							<a href="{{.HomeLink}}">{{.Name}}</a>
 | 
			
		||||
							<i class="gt-mt-2">{{$.locale.Tr "settings.blocked_since" (DateTime "short" .CreatedUnix) | Safe}}</i>
 | 
			
		||||
						</div>
 | 
			
		||||
						<div class="gt-ml-auto content">
 | 
			
		||||
							<form action="{{$.Link}}/unblock" method="post">
 | 
			
		||||
								{{$.CsrfTokenHtml}}
 | 
			
		||||
								<input type="hidden" name="user_id" value="{{.ID}}">
 | 
			
		||||
								<button class="ui red button">{{$.locale.Tr "user.unblock"}}</button>
 | 
			
		||||
							</form>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				{{else}}
 | 
			
		||||
					<div class="item">
 | 
			
		||||
						{{avatar $.Context . 28 "gt-mr-3"}}<a href="{{.HomeLink}}">{{.Name}}</a>
 | 
			
		||||
						<span class="text grey italic">{{$.locale.Tr "settings.blocked_users_none"}}</span>
 | 
			
		||||
					</div>
 | 
			
		||||
				{{end}}
 | 
			
		||||
			</div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,7 +41,7 @@ func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
 | 
			
		|||
	var respBody redirect
 | 
			
		||||
	DecodeJSON(t, resp, &respBody)
 | 
			
		||||
	assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect)
 | 
			
		||||
	assert.EqualValues(t, true, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
 | 
			
		||||
	assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestBlockUser(t *testing.T) {
 | 
			
		||||
| 
						 | 
				
			
			@ -156,3 +156,57 @@ func TestBlockCommentReaction(t *testing.T) {
 | 
			
		|||
 | 
			
		||||
	assert.EqualValues(t, true, respBody.Empty)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestBlockFollow ensures that the doer and blocked user cannot follow each other.
 | 
			
		||||
func TestBlockFollow(t *testing.T) {
 | 
			
		||||
	defer tests.PrepareTestEnv(t)()
 | 
			
		||||
	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
 | 
			
		||||
	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
 | 
			
		||||
 | 
			
		||||
	BlockUser(t, doer, blockedUser)
 | 
			
		||||
 | 
			
		||||
	// Doer cannot follow blocked user.
 | 
			
		||||
	session := loginUser(t, doer.Name)
 | 
			
		||||
	req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
 | 
			
		||||
		"_csrf":  GetCSRF(t, session, "/"+blockedUser.Name),
 | 
			
		||||
		"action": "follow",
 | 
			
		||||
	})
 | 
			
		||||
	session.MakeRequest(t, req, http.StatusSeeOther)
 | 
			
		||||
 | 
			
		||||
	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
 | 
			
		||||
 | 
			
		||||
	// Blocked user cannot follow doer.
 | 
			
		||||
	session = loginUser(t, blockedUser.Name)
 | 
			
		||||
	req = NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{
 | 
			
		||||
		"_csrf":  GetCSRF(t, session, "/"+doer.Name),
 | 
			
		||||
		"action": "follow",
 | 
			
		||||
	})
 | 
			
		||||
	session.MakeRequest(t, req, http.StatusSeeOther)
 | 
			
		||||
 | 
			
		||||
	unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user.
 | 
			
		||||
func TestBlockUserFromOrganization(t *testing.T) {
 | 
			
		||||
	defer tests.PrepareTestEnv(t)()
 | 
			
		||||
 | 
			
		||||
	doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
 | 
			
		||||
	blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
 | 
			
		||||
	org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization})
 | 
			
		||||
	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
 | 
			
		||||
 | 
			
		||||
	session := loginUser(t, doer.Name)
 | 
			
		||||
	req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
 | 
			
		||||
		"_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
 | 
			
		||||
		"uname": blockedUser.Name,
 | 
			
		||||
	})
 | 
			
		||||
	session.MakeRequest(t, req, http.StatusSeeOther)
 | 
			
		||||
	assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}))
 | 
			
		||||
 | 
			
		||||
	req = NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
 | 
			
		||||
		"_csrf":   GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
 | 
			
		||||
		"user_id": strconv.FormatInt(blockedUser.ID, 10),
 | 
			
		||||
	})
 | 
			
		||||
	session.MakeRequest(t, req, http.StatusSeeOther)
 | 
			
		||||
	unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -190,17 +190,20 @@
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
.organization.teams .repositories .item,
 | 
			
		||||
.organization.teams .members .item {
 | 
			
		||||
.organization.teams .members .item,
 | 
			
		||||
.organization.settings .blocked-users .item {
 | 
			
		||||
  padding: 10px 19px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.organization.teams .repositories .item:not(:last-child),
 | 
			
		||||
.organization.teams .members .item:not(:last-child) {
 | 
			
		||||
.organization.teams .members .item:not(:last-child),
 | 
			
		||||
.organization.settings .blocked-users .item:not(:last-child) {
 | 
			
		||||
  border-bottom: 1px solid var(--color-secondary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.organization.teams .repositories .item .button,
 | 
			
		||||
.organization.teams .members .item .button {
 | 
			
		||||
.organization.teams .members .item .button,
 | 
			
		||||
.organization.settings .blocked-users .item button {
 | 
			
		||||
  padding: 9px 10px;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue