mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-23 19:11:58 +01:00
[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:6bbccdd177
Improve AJAX link and modal confirm dialog (#25210) Refs: https://codeberg.org/forgejo/forgejo/pulls/882/files#issuecomment-945962 Refs: https://codeberg.org/forgejo/forgejo/pulls/882#issue-330561 (cherry picked from commit523635f83c
) (cherry picked from commit4743eaa6a0
) (cherry picked from commiteff5b43d2e
) Conflicts: https://codeberg.org/forgejo/forgejo/pulls/1014 routers/web/user/profile.go (cherry picked from commit9d359be5ed
)
This commit is contained in:
parent
e2f1b73752
commit
b1f3069a22
26 changed files with 372 additions and 17 deletions
|
@ -37,7 +37,7 @@
|
||||||
lower_name: repo2
|
lower_name: repo2
|
||||||
name: repo2
|
name: repo2
|
||||||
default_branch: master
|
default_branch: master
|
||||||
num_watches: 0
|
num_watches: 1
|
||||||
num_stars: 1
|
num_stars: 1
|
||||||
num_forks: 0
|
num_forks: 0
|
||||||
num_issues: 2
|
num_issues: 2
|
||||||
|
|
|
@ -27,3 +27,9 @@
|
||||||
user_id: 11
|
user_id: 11
|
||||||
repo_id: 1
|
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).
|
Limit(30).
|
||||||
Find(&users)
|
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"
|
"code.gitea.io/gitea/models/db"
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
@ -71,3 +72,15 @@ func TestRepoGetReviewers(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, reviewers, 1)
|
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)
|
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))
|
assert.NoError(t, repo_model.WatchRepoMode(12, 1, repo_model.WatchModeNone))
|
||||||
unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0)
|
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.
|
// 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) {
|
func ListBlockedUsers(ctx context.Context, userID int64) ([]*User, error) {
|
||||||
users := make([]*User, 0, 8)
|
users := make([]*User, 0, 8)
|
||||||
err := db.GetEngine(ctx).
|
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").
|
Join("INNER", "forgejo_blocked_user", "`user`.id=`forgejo_blocked_user`.block_id").
|
||||||
Where("`forgejo_blocked_user`.user_id=?", userID).
|
Where("`forgejo_blocked_user`.user_id=?", userID).
|
||||||
Find(&users)
|
Find(&users)
|
||||||
|
|
|
@ -24,16 +24,25 @@ func init() {
|
||||||
|
|
||||||
// IsFollowing returns true if user is following followID.
|
// IsFollowing returns true if user is following followID.
|
||||||
func IsFollowing(userID, followID int64) bool {
|
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
|
return has
|
||||||
}
|
}
|
||||||
|
|
||||||
// FollowUser marks someone be another's follower.
|
// FollowUser marks someone be another's follower.
|
||||||
func FollowUser(ctx context.Context, userID, followID int64) (err error) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if IsBlocked(ctx, userID, followID) || IsBlocked(ctx, followID, userID) {
|
||||||
|
return ErrBlockedByUser
|
||||||
|
}
|
||||||
|
|
||||||
ctx, committer, err := db.TxContext(ctx)
|
ctx, committer, err := db.TxContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -56,7 +65,7 @@ func FollowUser(ctx context.Context, userID, followID int64) (err error) {
|
||||||
|
|
||||||
// UnfollowUser unmarks someone as another's follower.
|
// UnfollowUser unmarks someone as another's follower.
|
||||||
func UnfollowUser(ctx context.Context, userID, followID int64) (err error) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -457,6 +457,12 @@ func TestFollowUser(t *testing.T) {
|
||||||
|
|
||||||
assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
|
assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
|
||||||
|
|
||||||
|
// Blocked user.
|
||||||
|
assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 1, 4))
|
||||||
|
assert.ErrorIs(t, user_model.ErrBlockedByUser, user_model.FollowUser(db.DefaultContext, 4, 1))
|
||||||
|
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 1, FollowID: 4})
|
||||||
|
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: 4, FollowID: 1})
|
||||||
|
|
||||||
unittest.CheckConsistencyFor(t, &user_model.User{})
|
unittest.CheckConsistencyFor(t, &user_model.User{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -607,6 +607,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 = 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_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_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_reserved = The username "%s" is reserved.
|
||||||
form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username.
|
form.name_pattern_not_allowed = The pattern "%s" is not allowed in a username.
|
||||||
|
@ -898,6 +899,7 @@ hooks.desc = Add webhooks which will be triggered for <strong>all repositories</
|
||||||
|
|
||||||
orgs_none = You are not a member of any organizations.
|
orgs_none = You are not a member of any organizations.
|
||||||
repos_none = You do not own any repositories
|
repos_none = You do not own any repositories
|
||||||
|
blocked_users_none = You haven't blocked any users.
|
||||||
|
|
||||||
delete_account = Delete Your Account
|
delete_account = Delete Your Account
|
||||||
delete_prompt = This operation will permanently delete your user account. It <strong>CANNOT</strong> be undone.
|
delete_prompt = This operation will permanently delete your user account. It <strong>CANNOT</strong> be undone.
|
||||||
|
@ -920,6 +922,10 @@ visibility.limited_tooltip = Visible to authenticated users only
|
||||||
visibility.private = Private
|
visibility.private = Private
|
||||||
visibility.private_tooltip = Visible only to organization members
|
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]
|
[repo]
|
||||||
new_repo_helper = A repository contains all project files, including revision history. Already have it elsewhere? <a href="%s">Migrate repository.</a>
|
new_repo_helper = A repository contains all project files, including revision history. Already have it elsewhere? <a href="%s">Migrate repository.</a>
|
||||||
owner = Owner
|
owner = Owner
|
||||||
|
@ -2533,6 +2539,7 @@ team_access_desc = Repository access
|
||||||
team_permission_desc = Permission
|
team_permission_desc = Permission
|
||||||
team_unit_desc = Allow Access to Repository Sections
|
team_unit_desc = Allow Access to Repository Sections
|
||||||
team_unit_disabled = (Disabled)
|
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_reserved = The organization name "%s" is reserved.
|
||||||
form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name.
|
form.name_pattern_not_allowed = The pattern "%s" is not allowed in an organization name.
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
@ -217,8 +218,14 @@ func Follow(ctx *context.APIContext) {
|
||||||
// responses:
|
// responses:
|
||||||
// "204":
|
// "204":
|
||||||
// "$ref": "#/responses/empty"
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
|
||||||
if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
|
if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
|
||||||
|
if errors.Is(err, user_model.ErrBlockedByUser) {
|
||||||
|
ctx.Error(http.StatusForbidden, "BlockedByUser", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.Error(http.StatusInternalServerError, "FollowUser", err)
|
ctx.Error(http.StatusInternalServerError, "FollowUser", err)
|
||||||
return
|
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
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -294,9 +295,17 @@ func Action(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
|
if !errors.Is(err, user_model.ErrBlockedByUser) {
|
||||||
ctx.JSONError(fmt.Sprintf("Action %q failed", ctx.FormString("action")))
|
log.Error("Failed to apply action %q: %v", ctx.FormString("action"), err)
|
||||||
return
|
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 {
|
if redirectViaJSON {
|
||||||
|
|
|
@ -32,3 +32,14 @@ func BlockedUsers(ctx *context.Context) {
|
||||||
ctx.Data["BlockedUsers"] = blockedUsers
|
ctx.Data["BlockedUsers"] = blockedUsers
|
||||||
ctx.HTML(http.StatusOK, tplSettingsBlockedUsers)
|
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()
|
addWebhookEditRoutes()
|
||||||
}, webhooksEnabled)
|
}, 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))
|
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "AllThemes", setting.UI.Themes, "EnablePackages", setting.Packages.Enabled))
|
||||||
|
|
||||||
m.Group("/user", func() {
|
m.Group("/user", func() {
|
||||||
|
@ -777,6 +780,12 @@ func registerRoutes(m *web.Route) {
|
||||||
addSettingVariablesRoutes()
|
addSettingVariablesRoutes()
|
||||||
}, actions.MustEnableActions)
|
}, 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.RouteMethods("/delete", "GET,POST", org.SettingsDelete)
|
||||||
|
|
||||||
m.Group("/packages", func() {
|
m.Group("/packages", func() {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -30,11 +31,28 @@ func BlockUser(ctx context.Context, userID, blockID int64) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unfollow the user from block's perspective.
|
// Unfollow the user from the block's perspective.
|
||||||
err = user_model.UnfollowUser(ctx, blockID, userID)
|
err = user_model.UnfollowUser(ctx, blockID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()
|
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" .}}
|
{{template "base/head" .}}
|
||||||
<div role="main" aria-label="{{.Title}}" class="page-content organization profile">
|
<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">
|
<div class="ui container gt-df">
|
||||||
{{avatar $.Context .Org 140 "org-avatar"}}
|
{{avatar $.Context .Org 140 "org-avatar"}}
|
||||||
<div id="org-info">
|
<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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{{end}}
|
{{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">
|
<a class="{{if .PageIsSettingsDelete}}active {{end}}item" href="{{.OrgLink}}/settings/delete">
|
||||||
{{.locale.Tr "org.settings.delete"}}
|
{{.locale.Tr "org.settings.delete"}}
|
||||||
</a>
|
</a>
|
||||||
|
|
3
templates/swagger/v1_json.tmpl
generated
3
templates/swagger/v1_json.tmpl
generated
|
@ -14135,6 +14135,9 @@
|
||||||
"responses": {
|
"responses": {
|
||||||
"204": {
|
"204": {
|
||||||
"$ref": "#/responses/empty"
|
"$ref": "#/responses/empty"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{{template "base/head" .}}
|
{{template "base/head" .}}
|
||||||
<div role="main" aria-label="{{.Title}}" class="page-content user profile">
|
<div role="main" aria-label="{{.Title}}" class="page-content user profile">
|
||||||
<div class="ui container">
|
<div class="ui container">
|
||||||
|
{{template "base/alert" .}}
|
||||||
<div class="ui stackable grid">
|
<div class="ui stackable grid">
|
||||||
<div class="ui four wide column">
|
<div class="ui four wide column">
|
||||||
{{template "shared/user/profile_big_avatar" .}}
|
{{template "shared/user/profile_big_avatar" .}}
|
||||||
|
|
|
@ -6,8 +6,23 @@
|
||||||
<div class="ui attached segment">
|
<div class="ui attached segment">
|
||||||
<div class="ui blocked-user list gt-mt-0">
|
<div class="ui blocked-user list gt-mt-0">
|
||||||
{{range .BlockedUsers}}
|
{{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">
|
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,7 +19,7 @@ func TestAPIFollow(t *testing.T) {
|
||||||
defer tests.PrepareTestEnv(t)()
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
user1 := "user4"
|
user1 := "user4"
|
||||||
user2 := "user1"
|
user2 := "user10"
|
||||||
|
|
||||||
session1 := loginUser(t, user1)
|
session1 := loginUser(t, user1)
|
||||||
token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser)
|
token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser)
|
||||||
|
|
|
@ -41,7 +41,7 @@ func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
|
||||||
var respBody redirect
|
var respBody redirect
|
||||||
DecodeJSON(t, resp, &respBody)
|
DecodeJSON(t, resp, &respBody)
|
||||||
assert.EqualValues(t, "/"+blockedUser.Name, respBody.Redirect)
|
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) {
|
func TestBlockUser(t *testing.T) {
|
||||||
|
@ -156,3 +156,57 @@ func TestBlockCommentReaction(t *testing.T) {
|
||||||
|
|
||||||
assert.EqualValues(t, true, respBody.Empty)
|
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})
|
||||||
|
}
|
||||||
|
|
|
@ -191,17 +191,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.organization.teams .repositories .item,
|
.organization.teams .repositories .item,
|
||||||
.organization.teams .members .item {
|
.organization.teams .members .item,
|
||||||
|
.organization.settings .blocked-users .item {
|
||||||
padding: 10px 19px;
|
padding: 10px 19px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.organization.teams .repositories .item:not(:last-child),
|
.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);
|
border-bottom: 1px solid var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.organization.teams .repositories .item .button,
|
.organization.teams .repositories .item .button,
|
||||||
.organization.teams .members .item .button {
|
.organization.teams .members .item .button,
|
||||||
|
.organization.settings .blocked-users .item button {
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue