1
0
Fork 0
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:
floss4good 2025-05-18 08:05:16 +00:00 committed by Otto
parent c1fad04473
commit dc56486b1f
34 changed files with 1040 additions and 6 deletions

View file

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

View file

@ -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
View 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
}

View 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
}
*/

View 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
View 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
View 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
}

View file

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