mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-08-06 10:25:22 +02:00
feat!: Abusive content reporting (#6977)
This implements milestones 1. and 4. from **Task F. Moderation features: Reporting** (part of [amendment of the workplan](https://codeberg.org/forgejo/sustainability/src/branch/main/2022-12-01-nlnet/2025-02-07-extended-workplan.md#task-f-moderation-features-reporting) for NLnet 2022-12-035): > 1. A reporting feature is implemented in the database. It ensures that content remains available for review, even if a user deletes it after a report was sent. > 4. Users can report the most relevant content types (at least: issue comments, repositories, users) ### See also: - forgejo/discussions#291 - forgejo/discussions#304 - forgejo/design#30 Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6977 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Reviewed-by: Otto <otto@codeberg.org> Co-authored-by: floss4good <floss4good@disroot.org> Co-committed-by: floss4good <floss4good@disroot.org>
This commit is contained in:
parent
c1fad04473
commit
dc56486b1f
34 changed files with 1040 additions and 6 deletions
|
@ -1,6 +1,6 @@
|
|||
// Copyright 2018 The Gitea Authors.
|
||||
// Copyright 2016 The Gogs Authors.
|
||||
// All rights reserved.
|
||||
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues
|
||||
|
@ -324,6 +324,9 @@ type Comment struct {
|
|||
NewCommit string `xorm:"-"`
|
||||
CommitsNum int64 `xorm:"-"`
|
||||
IsForcePush bool `xorm:"-"`
|
||||
|
||||
// If you add new fields that might be used to store abusive content (mainly string fields),
|
||||
// please also add them in the CommentData struct and the corresponding constructor.
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
@ -1149,6 +1152,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
|
|||
}
|
||||
defer committer.Close()
|
||||
|
||||
// If the comment was reported as abusive, a shadow copy should be created before first update.
|
||||
if err := IfNeededCreateShadowCopyForComment(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -1184,6 +1192,12 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
|
|||
// DeleteComment deletes the comment
|
||||
func DeleteComment(ctx context.Context, comment *Comment) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
// If the comment was reported as abusive, a shadow copy should be created before deletion.
|
||||
if err := IfNeededCreateShadowCopyForComment(ctx, comment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues
|
||||
|
@ -275,6 +276,11 @@ func ChangeIssueContent(ctx context.Context, issue *Issue, doer *user_model.User
|
|||
}
|
||||
}
|
||||
|
||||
// If the issue was reported as abusive, a shadow copy should be created before first update.
|
||||
if err := IfNeededCreateShadowCopyForIssue(ctx, issue); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issue.Content = content
|
||||
issue.ContentVersion = contentVersion + 1
|
||||
|
||||
|
|
106
models/issues/moderation.go
Normal file
106
models/issues/moderation.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package issues
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forgejo.org/models/moderation"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/timeutil"
|
||||
)
|
||||
|
||||
// IssueData represents a trimmed down issue that is used for preserving
|
||||
// only the fields needed for abusive content reports (mainly string fields).
|
||||
type IssueData struct {
|
||||
RepoID int64
|
||||
Index int64
|
||||
PosterID int64
|
||||
Title string
|
||||
Content string
|
||||
ContentVersion int
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
UpdatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// newIssueData creates a trimmed down issue to be used just to create a JSON structure
|
||||
// (keeping only the fields relevant for moderation purposes)
|
||||
func newIssueData(issue *Issue) IssueData {
|
||||
return IssueData{
|
||||
RepoID: issue.RepoID,
|
||||
Index: issue.Index,
|
||||
PosterID: issue.PosterID,
|
||||
Content: issue.Content,
|
||||
Title: issue.Title,
|
||||
ContentVersion: issue.ContentVersion,
|
||||
CreatedUnix: issue.CreatedUnix,
|
||||
UpdatedUnix: issue.UpdatedUnix,
|
||||
}
|
||||
}
|
||||
|
||||
// CommentData represents a trimmed down comment that is used for preserving
|
||||
// only the fields needed for abusive content reports (mainly string fields).
|
||||
type CommentData struct {
|
||||
PosterID int64
|
||||
IssueID int64
|
||||
Content string
|
||||
ContentVersion int
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
UpdatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// newCommentData creates a trimmed down comment to be used just to create a JSON structure
|
||||
// (keeping only the fields relevant for moderation purposes)
|
||||
func newCommentData(comment *Comment) CommentData {
|
||||
return CommentData{
|
||||
PosterID: comment.PosterID,
|
||||
IssueID: comment.IssueID,
|
||||
Content: comment.Content,
|
||||
ContentVersion: comment.ContentVersion,
|
||||
CreatedUnix: comment.CreatedUnix,
|
||||
UpdatedUnix: comment.UpdatedUnix,
|
||||
}
|
||||
}
|
||||
|
||||
// IfNeededCreateShadowCopyForIssue checks if for the given issue there are any reports of abusive content submitted
|
||||
// and if found a shadow copy of relevant issue fields will be stored into DB and linked to the above report(s).
|
||||
// This function should be called before a issue is deleted or updated.
|
||||
func IfNeededCreateShadowCopyForIssue(ctx context.Context, issue *Issue) error {
|
||||
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeIssue, issue.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if shadowCopyNeeded {
|
||||
issueData := newIssueData(issue)
|
||||
content, err := json.Marshal(issueData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return moderation.CreateShadowCopyForIssue(ctx, issue.ID, string(content))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IfNeededCreateShadowCopyForComment checks if for the given comment there are any reports of abusive content submitted
|
||||
// and if found a shadow copy of relevant comment fields will be stored into DB and linked to the above report(s).
|
||||
// This function should be called before a comment is deleted or updated.
|
||||
func IfNeededCreateShadowCopyForComment(ctx context.Context, comment *Comment) error {
|
||||
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeComment, comment.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if shadowCopyNeeded {
|
||||
commentData := newCommentData(comment)
|
||||
content, err := json.Marshal(commentData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return moderation.CreateShadowCopyForComment(ctx, comment.ID, string(content))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
177
models/moderation/abuse_report.go
Normal file
177
models/moderation/abuse_report.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ReportStatusType defines the statuses a report (of abusive content) can have.
|
||||
type ReportStatusType int
|
||||
|
||||
const (
|
||||
// ReportStatusTypeOpen represents the status of open reports that were not yet handled in any way.
|
||||
ReportStatusTypeOpen ReportStatusType = iota + 1 // 1
|
||||
// ReportStatusTypeHandled represents the status of valid reports, that have been acted upon.
|
||||
ReportStatusTypeHandled // 2
|
||||
// ReportStatusTypeIgnored represents the status of ignored reports, that were closed without any action.
|
||||
ReportStatusTypeIgnored // 3
|
||||
)
|
||||
|
||||
type (
|
||||
// AbuseCategoryType defines the categories in which a user can include the reported content.
|
||||
AbuseCategoryType int
|
||||
|
||||
// AbuseCategoryItem defines a pair of value and it's corresponding translation key
|
||||
// (used to add options within the dropdown shown when new reports are submitted).
|
||||
AbuseCategoryItem struct {
|
||||
Value AbuseCategoryType
|
||||
TranslationKey string
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
AbuseCategoryTypeOther AbuseCategoryType = iota + 1 // 1 (Other violations of platform rules)
|
||||
AbuseCategoryTypeSpam // 2
|
||||
AbuseCategoryTypeMalware // 3
|
||||
AbuseCategoryTypeIllegalContent // 4
|
||||
)
|
||||
|
||||
// GetAbuseCategoriesList returns a list of pairs with the available abuse category types
|
||||
// and their corresponding translation keys
|
||||
func GetAbuseCategoriesList() []AbuseCategoryItem {
|
||||
return []AbuseCategoryItem{
|
||||
{AbuseCategoryTypeSpam, "moderation.abuse_category.spam"},
|
||||
{AbuseCategoryTypeMalware, "moderation.abuse_category.malware"},
|
||||
{AbuseCategoryTypeIllegalContent, "moderation.abuse_category.illegal_content"},
|
||||
{AbuseCategoryTypeOther, "moderation.abuse_category.other_violations"},
|
||||
}
|
||||
}
|
||||
|
||||
// ReportedContentType defines the types of content that can be reported
|
||||
// (i.e. user/organization profile, repository, issue/pull, comment).
|
||||
type ReportedContentType int
|
||||
|
||||
const (
|
||||
// ReportedContentTypeUser should be used when reporting abusive users or organizations.
|
||||
ReportedContentTypeUser ReportedContentType = iota + 1 // 1
|
||||
|
||||
// ReportedContentTypeRepository should be used when reporting a repository with abusive content.
|
||||
ReportedContentTypeRepository // 2
|
||||
|
||||
// ReportedContentTypeIssue should be used when reporting an issue or pull request with abusive content.
|
||||
ReportedContentTypeIssue // 3
|
||||
|
||||
// ReportedContentTypeComment should be used when reporting a comment with abusive content.
|
||||
ReportedContentTypeComment // 4
|
||||
)
|
||||
|
||||
var allReportedContentTypes = []ReportedContentType{
|
||||
ReportedContentTypeUser,
|
||||
ReportedContentTypeRepository,
|
||||
ReportedContentTypeIssue,
|
||||
ReportedContentTypeComment,
|
||||
}
|
||||
|
||||
func (t ReportedContentType) IsValid() bool {
|
||||
return slices.Contains(allReportedContentTypes, t)
|
||||
}
|
||||
|
||||
// AbuseReport represents a report of abusive content.
|
||||
type AbuseReport struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Status ReportStatusType `xorm:"INDEX NOT NULL DEFAULT 1"`
|
||||
// The ID of the user who submitted the report.
|
||||
ReporterID int64 `xorm:"NOT NULL"`
|
||||
// Reported content type: user/organization profile, repository, issue/pull or comment.
|
||||
ContentType ReportedContentType `xorm:"INDEX NOT NULL"`
|
||||
// The ID of the reported item (based on ContentType: user, repository, issue or comment).
|
||||
ContentID int64 `xorm:"NOT NULL"`
|
||||
// The abuse category selected by the reporter.
|
||||
Category AbuseCategoryType `xorm:"INDEX NOT NULL"`
|
||||
// Remarks provided by the reporter.
|
||||
Remarks string
|
||||
// The ID of the corresponding shadow-copied content when exists; otherwise null.
|
||||
ShadowCopyID sql.NullInt64 `xorm:"DEFAULT NULL"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
||||
}
|
||||
|
||||
var ErrSelfReporting = errors.New("reporting yourself is not allowed")
|
||||
|
||||
func init() {
|
||||
// RegisterModel will create the table if does not already exist
|
||||
// or any missing columns if the table was previously created.
|
||||
// It will not drop or rename existing columns (when struct has changed).
|
||||
db.RegisterModel(new(AbuseReport))
|
||||
}
|
||||
|
||||
// IsShadowCopyNeeded reports whether one or more reports were already submitted
|
||||
// for contentType and contentID and not yet linked to a shadow copy (regardless their status).
|
||||
func IsShadowCopyNeeded(ctx context.Context, contentType ReportedContentType, contentID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).Cols("id").Where(builder.IsNull{"shadow_copy_id"}).Exist(
|
||||
&AbuseReport{ContentType: contentType, ContentID: contentID},
|
||||
)
|
||||
}
|
||||
|
||||
// AlreadyReportedByAndOpen returns if doerID has already submitted a report for contentType and contentID that is still Open.
|
||||
func AlreadyReportedByAndOpen(ctx context.Context, doerID int64, contentType ReportedContentType, contentID int64) bool {
|
||||
reported, _ := db.GetEngine(ctx).Exist(&AbuseReport{
|
||||
Status: ReportStatusTypeOpen,
|
||||
ReporterID: doerID,
|
||||
ContentType: contentType,
|
||||
ContentID: contentID,
|
||||
})
|
||||
return reported
|
||||
}
|
||||
|
||||
// ReportAbuse creates a new abuse report in the DB with 'Open' status.
|
||||
// If the reported content is the user profile of the reporter ErrSelfReporting is returned.
|
||||
// If there is already an open report submitted by the same user for the same content,
|
||||
// the request will be ignored without returning an error (and a warning will be logged).
|
||||
func ReportAbuse(ctx context.Context, report *AbuseReport) error {
|
||||
if report.ContentType == ReportedContentTypeUser && report.ReporterID == report.ContentID {
|
||||
return ErrSelfReporting
|
||||
}
|
||||
|
||||
if AlreadyReportedByAndOpen(ctx, report.ReporterID, report.ContentType, report.ContentID) {
|
||||
log.Warn("Seems that user %d wanted to report again the content with type %d and ID %d; this request will be ignored.", report.ReporterID, report.ContentType, report.ContentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
report.Status = ReportStatusTypeOpen
|
||||
_, err := db.GetEngine(ctx).Insert(report)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
/*
|
||||
// MarkAsHandled will change the status to 'Handled' for all reports linked to the same item (user, repository, issue or comment).
|
||||
func MarkAsHandled(ctx context.Context, contentType ReportedContentType, contentID int64) error {
|
||||
return updateStatus(ctx, contentType, contentID, ReportStatusTypeHandled)
|
||||
}
|
||||
|
||||
// MarkAsIgnored will change the status to 'Ignored' for all reports linked to the same item (user, repository, issue or comment).
|
||||
func MarkAsIgnored(ctx context.Context, contentType ReportedContentType, contentID int64) error {
|
||||
return updateStatus(ctx, contentType, contentID, ReportStatusTypeIgnored)
|
||||
}
|
||||
|
||||
// updateStatus will set the provided status for any reports linked to the item with the given type and ID.
|
||||
func updateStatus(ctx context.Context, contentType ReportedContentType, contentID int64, status ReportStatusType) error {
|
||||
_, err := db.GetEngine(ctx).Where(builder.Eq{
|
||||
"content_type": contentType,
|
||||
"content_id": contentID,
|
||||
}).Cols("status").Update(&AbuseReport{Status: status})
|
||||
|
||||
return err
|
||||
}
|
||||
*/
|
76
models/moderation/shadow_copy.go
Normal file
76
models/moderation/shadow_copy.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type AbuseReportShadowCopy struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RawValue string `xorm:"NOT NULL"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
||||
}
|
||||
|
||||
// Returns the ID encapsulated in a sql.NullInt64 struct.
|
||||
func (sc AbuseReportShadowCopy) NullableID() sql.NullInt64 {
|
||||
return sql.NullInt64{Int64: sc.ID, Valid: sc.ID > 0}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// RegisterModel will create the table if does not already exist
|
||||
// or any missing columns if the table was previously created.
|
||||
// It will not drop or rename existing columns (when struct has changed).
|
||||
db.RegisterModel(new(AbuseReportShadowCopy))
|
||||
}
|
||||
|
||||
func CreateShadowCopyForUser(ctx context.Context, userID int64, content string) error {
|
||||
return createShadowCopy(ctx, ReportedContentTypeUser, userID, content)
|
||||
}
|
||||
|
||||
func CreateShadowCopyForRepository(ctx context.Context, repoID int64, content string) error {
|
||||
return createShadowCopy(ctx, ReportedContentTypeRepository, repoID, content)
|
||||
}
|
||||
|
||||
func CreateShadowCopyForIssue(ctx context.Context, issueID int64, content string) error {
|
||||
return createShadowCopy(ctx, ReportedContentTypeIssue, issueID, content)
|
||||
}
|
||||
|
||||
func CreateShadowCopyForComment(ctx context.Context, commentID int64, content string) error {
|
||||
return createShadowCopy(ctx, ReportedContentTypeComment, commentID, content)
|
||||
}
|
||||
|
||||
func createShadowCopy(ctx context.Context, contentType ReportedContentType, contentID int64, content string) error {
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
shadowCopy := &AbuseReportShadowCopy{RawValue: content}
|
||||
affected, err := sess.Insert(shadowCopy)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if affected == 0 {
|
||||
log.Warn("Something went wrong while trying to create the shadow copy for reported content with type %d and ID %d.", contentType, contentID)
|
||||
}
|
||||
|
||||
_, err = sess.Where(builder.Eq{
|
||||
"content_type": contentType,
|
||||
"content_id": contentID,
|
||||
}).And(builder.IsNull{"shadow_copy_id"}).Update(&AbuseReport{ShadowCopyID: shadowCopy.NullableID()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not link the shadow copy (%d) to reported content with type %d and ID %d - %w", shadowCopy.ID, contentType, contentID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
70
models/repo/moderation.go
Normal file
70
models/repo/moderation.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"forgejo.org/models/moderation"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/timeutil"
|
||||
)
|
||||
|
||||
// RepositoryData represents a trimmed down repository that is used for preserving
|
||||
// only the fields needed for abusive content reports (mainly string fields).
|
||||
type RepositoryData struct {
|
||||
OwnerID int64
|
||||
OwnerName string
|
||||
Name string
|
||||
Description string
|
||||
Website string
|
||||
Topics []string
|
||||
Avatar string
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
UpdatedUnix timeutil.TimeStamp
|
||||
}
|
||||
|
||||
// newRepositoryData creates a trimmed down repository to be used just to create a JSON structure
|
||||
// (keeping only the fields relevant for moderation purposes)
|
||||
func newRepositoryData(repo *Repository) RepositoryData {
|
||||
return RepositoryData{
|
||||
OwnerID: repo.OwnerID,
|
||||
OwnerName: repo.OwnerName,
|
||||
Name: repo.Name,
|
||||
Description: repo.Description,
|
||||
Website: repo.Website,
|
||||
Topics: repo.Topics,
|
||||
Avatar: repo.Avatar,
|
||||
CreatedUnix: repo.CreatedUnix,
|
||||
UpdatedUnix: repo.UpdatedUnix,
|
||||
}
|
||||
}
|
||||
|
||||
// IfNeededCreateShadowCopyForRepository checks if for the given repository there are any reports of abusive content submitted
|
||||
// and if found a shadow copy of relevant repository fields will be stored into DB and linked to the above report(s).
|
||||
// This function should be called when a repository is deleted or updated.
|
||||
func IfNeededCreateShadowCopyForRepository(ctx context.Context, repo *Repository, forUpdates bool) error {
|
||||
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeRepository, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if shadowCopyNeeded {
|
||||
if forUpdates {
|
||||
// get the unmodified repository fields
|
||||
repo, err = GetRepositoryByID(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
repoData := newRepositoryData(repo)
|
||||
content, err := json.Marshal(repoData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return moderation.CreateShadowCopyForRepository(ctx, repo.ID, string(content))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
112
models/user/moderation.go
Normal file
112
models/user/moderation.go
Normal file
|
@ -0,0 +1,112 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"forgejo.org/models/moderation"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm/names"
|
||||
)
|
||||
|
||||
// UserData represents a trimmed down user that is used for preserving
|
||||
// only the fields needed for abusive content reports (mainly string fields).
|
||||
type UserData struct { //revive:disable-line:exported
|
||||
Name string
|
||||
FullName string
|
||||
Email string
|
||||
LoginName string
|
||||
Location string
|
||||
Website string
|
||||
Pronouns string
|
||||
Description string
|
||||
CreatedUnix timeutil.TimeStamp
|
||||
UpdatedUnix timeutil.TimeStamp
|
||||
// This field was intentionally renamed so that is not the same with the one from User struct.
|
||||
// If we keep it the same as in User, during login it might trigger the creation of a shadow copy.
|
||||
// TODO: Should we decide that this field is not that relevant for abuse reporting purposes, better remove it.
|
||||
LastLogin timeutil.TimeStamp `json:"LastLoginUnix"`
|
||||
Avatar string
|
||||
AvatarEmail string
|
||||
}
|
||||
|
||||
// newUserData creates a trimmed down user to be used just to create a JSON structure
|
||||
// (keeping only the fields relevant for moderation purposes)
|
||||
func newUserData(user *User) UserData {
|
||||
return UserData{
|
||||
Name: user.Name,
|
||||
FullName: user.FullName,
|
||||
Email: user.Email,
|
||||
LoginName: user.LoginName,
|
||||
Location: user.Location,
|
||||
Website: user.Website,
|
||||
Pronouns: user.Pronouns,
|
||||
Description: user.Description,
|
||||
CreatedUnix: user.CreatedUnix,
|
||||
UpdatedUnix: user.UpdatedUnix,
|
||||
LastLogin: user.LastLoginUnix,
|
||||
Avatar: user.Avatar,
|
||||
AvatarEmail: user.AvatarEmail,
|
||||
}
|
||||
}
|
||||
|
||||
// userDataColumnNames builds (only once) and returns a slice with the column names
|
||||
// (e.g. FieldName -> field_name) corresponding to UserData struct fields.
|
||||
var userDataColumnNames = sync.OnceValue(func() []string {
|
||||
mapper := new(names.GonicMapper)
|
||||
udType := reflect.TypeOf(UserData{})
|
||||
columnNames := make([]string, 0, udType.NumField())
|
||||
for i := 0; i < udType.NumField(); i++ {
|
||||
columnNames = append(columnNames, mapper.Obj2Table(udType.Field(i).Name))
|
||||
}
|
||||
return columnNames
|
||||
})
|
||||
|
||||
// IfNeededCreateShadowCopyForUser checks if for the given user there are any reports of abusive content submitted
|
||||
// and if found a shadow copy of relevant user fields will be stored into DB and linked to the above report(s).
|
||||
// This function should be called before a user is deleted or updated.
|
||||
//
|
||||
// For deletions alteredCols argument must be omitted.
|
||||
//
|
||||
// In case of updates it will first checks whether any of the columns being updated (alteredCols argument)
|
||||
// is relevant for moderation purposes (i.e. included in the UserData struct).
|
||||
func IfNeededCreateShadowCopyForUser(ctx context.Context, user *User, alteredCols ...string) error {
|
||||
// TODO: this can be triggered quite often (e.g. by routers/web/repo/middlewares.go SetDiffViewStyle())
|
||||
|
||||
shouldCheckIfNeeded := len(alteredCols) == 0 // no columns being updated, therefore a deletion
|
||||
if !shouldCheckIfNeeded {
|
||||
// for updates we need to go further only if certain column are being changed
|
||||
for _, colName := range userDataColumnNames() {
|
||||
if shouldCheckIfNeeded = slices.Contains(alteredCols, colName); shouldCheckIfNeeded {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !shouldCheckIfNeeded {
|
||||
return nil
|
||||
}
|
||||
|
||||
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeUser, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if shadowCopyNeeded {
|
||||
userData := newUserData(user)
|
||||
content, err := json.Marshal(userData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return moderation.CreateShadowCopyForUser(ctx, user.ID, string(content))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -153,6 +153,9 @@ type User struct {
|
|||
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
KeepPronounsPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
EnableRepoUnitHints bool `xorm:"NOT NULL DEFAULT true"`
|
||||
|
||||
// If you add new fields that might be used to store abusive content (mainly string fields),
|
||||
// please also add them in the UserData struct and the corresponding constructor.
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
@ -610,6 +613,7 @@ var (
|
|||
"pulls",
|
||||
"milestones",
|
||||
"notifications",
|
||||
"report_abuse",
|
||||
|
||||
"favicon.ico",
|
||||
"manifest.json", // web app manifests
|
||||
|
@ -919,6 +923,12 @@ func UpdateUserCols(ctx context.Context, u *User, cols ...string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// If the user was reported as abusive and any of the columns being updated is relevant
|
||||
// for moderation purposes a shadow copy should be created before first update.
|
||||
if err := IfNeededCreateShadowCopyForUser(ctx, u, cols...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(ctx).ID(u.ID).Cols(cols...).Update(u)
|
||||
return err
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue