WIP: Add user blocking

TODO:
Frontend:
- Disable reactions where needed. (The check is already in the code).
- Disable comment form where needed. (The check is already in the code).
- Add UI to list users who you have blocked.
- Add block confirmation screen and list what it implies.
Backend:
- Add check to not allow a user to mention someone they got blocked
by.
This commit is contained in:
Gusted 2023-03-12 13:28:18 +01:00
parent d3ebc9449c
commit 7cd8d1410f
No known key found for this signature in database
GPG Key ID: FD821B732837125F
17 changed files with 195 additions and 47 deletions

View File

@ -218,12 +218,12 @@ type ReactionOptions struct {
}
// CreateReaction creates reaction for issue or comment.
func CreateReaction(opts *ReactionOptions) (*Reaction, error) {
func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) {
if !setting.UI.ReactionsLookup.Contains(opts.Type) {
return nil, ErrForbiddenIssueReaction{opts.Type}
}
ctx, committer, err := db.TxContext(db.DefaultContext)
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
@ -240,25 +240,6 @@ func CreateReaction(opts *ReactionOptions) (*Reaction, error) {
return reaction, nil
}
// CreateIssueReaction creates a reaction on issue.
func CreateIssueReaction(doerID, issueID int64, content string) (*Reaction, error) {
return CreateReaction(&ReactionOptions{
Type: content,
DoerID: doerID,
IssueID: issueID,
})
}
// CreateCommentReaction creates a reaction on comment.
func CreateCommentReaction(doerID, issueID, commentID int64, content string) (*Reaction, error) {
return CreateReaction(&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(doerID, issueID, content)
} else {
reaction, err = issues_model.CreateCommentReaction(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)
}
@ -49,7 +52,7 @@ func TestIssueAddDuplicateReaction(t *testing.T) {
addReaction(t, user1.ID, issue1ID, 0, "heart")
reaction, err := issues_model.CreateReaction(&issues_model.ReactionOptions{
reaction, err := issues_model.CreateReaction(db.DefaultContext, &issues_model.ReactionOptions{
DoerID: user1.ID,
IssueID: issue1ID,
Type: "heart",

35
models/user/block.go Normal file
View File

@ -0,0 +1,35 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"code.gitea.io/gitea/models/db"
)
// 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"`
}
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).Get(&BlockedUser{UserID: userID, 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
}

View File

@ -4,6 +4,8 @@
package user
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
)
@ -27,12 +29,12 @@ func IsFollowing(userID, followID int64) bool {
}
// FollowUser marks someone be another's follower.
func FollowUser(userID, followID int64) (err error) {
func FollowUser(ctx context.Context, userID, followID int64) (err error) {
if userID == followID || IsFollowing(userID, followID) {
return nil
}
ctx, committer, err := db.TxContext(db.DefaultContext)
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
@ -53,12 +55,12 @@ func FollowUser(userID, followID int64) (err error) {
}
// UnfollowUser unmarks someone as another's follower.
func UnfollowUser(userID, followID int64) (err error) {
func UnfollowUser(ctx context.Context, userID, followID int64) (err error) {
if userID == followID || !IsFollowing(userID, followID) {
return nil
}
ctx, committer, err := db.TxContext(db.DefaultContext)
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}

View File

@ -447,13 +447,13 @@ func TestFollowUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(followerID, followedID int64) {
assert.NoError(t, user_model.FollowUser(followerID, followedID))
assert.NoError(t, user_model.FollowUser(db.DefaultContext, followerID, followedID))
unittest.AssertExistsAndLoadBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID})
}
testSuccess(4, 2)
testSuccess(5, 2)
assert.NoError(t, user_model.FollowUser(2, 2))
assert.NoError(t, user_model.FollowUser(db.DefaultContext, 2, 2))
unittest.CheckConsistencyFor(t, &user_model.User{})
}
@ -462,7 +462,7 @@ func TestUnfollowUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
testSuccess := func(followerID, followedID int64) {
assert.NoError(t, user_model.UnfollowUser(followerID, followedID))
assert.NoError(t, user_model.UnfollowUser(db.DefaultContext, followerID, followedID))
unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: followerID, FollowID: followedID})
}
testSuccess(4, 2)

View File

@ -544,6 +544,8 @@ projects = Projects
following = Following
follow = Follow
unfollow = Unfollow
block = Block
unblock = Unblock
heatmap.loading = Loading Heatmap…
user_bio = Biography
disabled_public_activity = This user has disabled the public visibility of the activity.
@ -1545,6 +1547,7 @@ 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_poster = You cannot comment on this issue, because the poster has blocked you.
compare.compare_base = base
compare.compare_head = compare

View File

@ -364,7 +364,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, issue_service.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, "CreateIssueComment", err)
} else {
ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
}
return
}

View File

@ -13,6 +13,7 @@ import (
"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
@ -196,9 +197,9 @@ func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOp
if isCreateType {
// PostIssueCommentReaction part
reaction, err := issues_model.CreateCommentReaction(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, issue_service.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, err.Error(), err)
} else if issues_model.IsErrReactionAlreadyExist(err) {
ctx.JSON(http.StatusOK, api.Reaction{
@ -406,9 +407,9 @@ func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, i
if isCreateType {
// PostIssueReaction part
reaction, err := issues_model.CreateIssueReaction(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, issue_service.ErrBlockedByUser) {
ctx.Error(http.StatusForbidden, err.Error(), err)
} else if issues_model.IsErrReactionAlreadyExist(err) {
ctx.JSON(http.StatusOK, api.Reaction{

View File

@ -218,7 +218,7 @@ func Follow(ctx *context.APIContext) {
// "204":
// "$ref": "#/responses/empty"
if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
ctx.Error(http.StatusInternalServerError, "FollowUser", err)
return
}
@ -240,7 +240,7 @@ func Unfollow(ctx *context.APIContext) {
// "204":
// "$ref": "#/responses/empty"
if err := user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil {
ctx.Error(http.StatusInternalServerError, "UnfollowUser", err)
return
}

View File

@ -2774,7 +2774,12 @@ 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, issue_service.ErrBlockedByUser) {
ctx.Flash.Error(ctx.Tr("repo.issues.blocked_by_poster"))
ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index))
} else {
ctx.ServerError("CreateIssueComment", err)
}
return
}
@ -2914,7 +2919,7 @@ func ChangeIssueReaction(ctx *context.Context) {
switch ctx.Params(":action") {
case "react":
reaction, err := issues_model.CreateIssueReaction(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)
@ -3016,7 +3021,7 @@ func ChangeCommentReaction(ctx *context.Context) {
switch ctx.Params(":action") {
case "react":
reaction, err := issues_model.CreateCommentReaction(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

@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed"
"code.gitea.io/gitea/routers/web/org"
user_service "code.gitea.io/gitea/services/user"
)
// Profile render user's profile page
@ -57,8 +58,10 @@ func Profile(ctx *context.Context) {
}
var isFollowing bool
var isBlocked bool
if ctx.Doer != nil {
isFollowing = user_model.IsFollowing(ctx.Doer.ID, ctx.ContextUser.ID)
isBlocked = user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
}
ctx.Data["Title"] = ctx.ContextUser.DisplayName()
@ -66,6 +69,7 @@ func Profile(ctx *context.Context) {
ctx.Data["Owner"] = ctx.ContextUser
ctx.Data["OpenIDs"] = openIDs
ctx.Data["IsFollowing"] = isFollowing
ctx.Data["IsBlocked"] = isBlocked
if setting.Service.EnableUserHeatmap {
data, err := activities_model.GetUserHeatmapDataByUser(ctx.ContextUser, ctx.Doer)
@ -318,9 +322,13 @@ func Action(ctx *context.Context) {
var err error
switch ctx.FormString("action") {
case "follow":
err = user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
err = user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
case "unfollow":
err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID)
err = user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
case "block":
err = user_service.BlockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
case "unblock":
err = user_model.UnblockUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
}
if err != nil {

View File

@ -5,6 +5,7 @@ package issue
import (
"context"
"errors"
"fmt"
"code.gitea.io/gitea/models/db"
@ -64,8 +65,15 @@ func CreateRefComment(doer *user_model.User, repo *repo_model.Repository, issue
return err
}
var ErrBlockedByUser = errors.New("user is blocked by the poster")
// 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, ErrBlockedByUser
}
comment, err := CreateComment(&issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeComment,
Doer: doer,

View File

@ -0,0 +1,39 @@
// 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) {
// Check if the doer is blocked by the issuer's poster.
if user_model.IsBlocked(ctx, issue.PosterID, doer.ID) {
return nil, 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) {
// Check if the doer is blocked by the issuer's poster or by the comment's poster.
if user_model.IsBlocked(ctx, comment.PosterID, doer.ID) || user_model.IsBlocked(ctx, issue.PosterID, doer.ID) {
return nil, ErrBlockedByUser
}
return issues_model.CreateReaction(ctx, &issues_model.ReactionOptions{
Type: content,
DoerID: doer.ID,
IssueID: issue.ID,
CommentID: comment.ID,
})
}

40
services/user/block.go Normal file
View File

@ -0,0 +1,40 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
)
// BlockUser adds a blocked user entry for userID to block blockID.
func BlockUser(ctx context.Context, userID, blockID int64) error {
if userID == blockID || user_model.IsBlocked(ctx, userID, blockID) {
return nil
}
// TODO: Figure out if instance admins should be immune to blocking.
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 block's perspective.
err = user_model.UnfollowUser(ctx, blockID, userID)
if err != nil {
return err
}
// TODO: Add more mechanism like removing blocked user as collaborator on
// repositories where the user is an owner.
return committer.Commit()
}

View File

@ -90,6 +90,8 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
&pull_model.AutoMerge{DoerID: u.ID},
&pull_model.ReviewState{UserID: u.ID},
&user_model.Redirect{RedirectUserID: u.ID},
&user_model.BlockedUser{BlockID: u.ID},
&user_model.BlockedUser{UserID: u.ID},
); err != nil {
return fmt.Errorf("deleteBeans: %w", err)
}

View File

@ -96,6 +96,19 @@
</form>
{{end}}
</li>
<li class="block">
{{if $.IsBlocked}}
<form method="post" action="{{.Link}}?action=unblock&redirect_to={{$.Link}}">
{{$.CsrfTokenHtml}}
<button type="submit" class="ui basic red button">{{svg "octicon-blocked"}} {{.locale.Tr "user.unblock"}}</button>
</form>
{{else}}
<form method="post" action="{{.Link}}?action=block&redirect_to={{$.Link}}">
{{$.CsrfTokenHtml}}
<button type="submit" class="ui basic orange button">{{svg "octicon-blocked"}} {{.locale.Tr "user.block"}}</button>
</form>
{{end}}
</li>
{{end}}
</ul>
</div>

View File

@ -36,8 +36,12 @@
margin-right: 5px;
}
&.follow {
&.follow,
&.block {
.ui.button {
align-items: center;
display: flex;
justify-content: center;
width: 100%;
}
}