mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-12-07 14:09:47 +00:00
- Implementation of milestone 6. 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): `6. Forgejo admins can perform common actions on the listed reports (content deletion, locking of user account)` --- Follow-up of !7905 (and !6977) --- This adds some action buttons within the _Moderation reports_ section (/admin/moderation/reports) within the _Site administration_ page, so that administrators can: - mark a report as Handled or as Ignored (without performing any action on the reported content); - mark a user account as suspended (set `prohibit_login` = true); - delete (and purge) a user / organization and mark the linked reports as Handled; - delete a repository and mark the linked reports as Handled; - delete an issue / pull request and mark the linked reports as Handled; - delete a comment and mark the linked reports as Handled; The buttons were added on the sight side of each report from the overview, below the existing counter (that show how many times the content was reported and opens the details page). Only the buttons for updating the status of the report are directly visible - as `✓` and `✗` icons with some tooltips - while the content actions are hidden under a `⋯` dropdown. The implementation was done using HTMX so that the page is not refreshed after each action. Some discussions regarding the UI/UX started with https://codeberg.org/forgejo/design/issues/30#issuecomment-5958634 ### Manual testing - First make sure that moderation in enabled ([moderation] ENABLED config is set as true within app.ini). - Report multiple users, organizations, repositories, issues, pull requests and comments. - Go to _Moderation reports_ overview section section and make sure the buttons are visible; - The `✓` and `✗` should be available for each shown report; - The horizontal dropdown menu (`⋯`) should not be visible for reports linked to already deleted content. - The actions available within the dropdown menu should correspond to the reported content type (e.g. 'Suspend account' and 'Delete account' for users/organizations, 'Delete repository' for repositories, etc.). - When an action is successful a flash message should be displayed above the overview. - Warnings should be displayed (as flash messages) when trying to suspend or delete your account (in case someone reported you) or an organization. - An info (flash message) should be displayed when trying to suspend a user that is already suspended. - Mark a report as Handled / Ignored and observe that a success flash message confirms the action and the report is removed from the list without reloading the page; - Refresh the page to make sure the report will not be loaded again (also check in the DB that the status was updated and the resolved timestamp is correctly set). - Suspend a user account and make sure the report remains in the list (it is not resolved); - Make sure the above user gets the 'Suspended account' notice after login. - Delete a user account and observe that a success flash message confirms the action and the report is removed from the list without reloading the page; - Make sure that all owned organizations and repositories as well as all the issues, PRs and comments posted in other repositories were deleted; - Make sure the linked abuse reports are marked as Handled (and resolved timestamp is set). - Delete an organization and make sure that owned repositories were also deleted. - Similarly, delete a repository / issue / PR / comment and check that the contents are not available any more and the linked reports are resolved. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8716 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: floss4good <floss4good@disroot.org> Co-committed-by: floss4good <floss4good@disroot.org>
204 lines
8 KiB
Go
204 lines
8 KiB
Go
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package moderation
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"slices"
|
|
"time"
|
|
|
|
"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
|
|
)
|
|
|
|
// llu:TrKeys
|
|
var AbuseCategoriesTranslationKeys = map[AbuseCategoryType]string{
|
|
AbuseCategoryTypeSpam: "moderation.abuse_category.spam",
|
|
AbuseCategoryTypeMalware: "moderation.abuse_category.malware",
|
|
AbuseCategoryTypeIllegalContent: "moderation.abuse_category.illegal_content",
|
|
AbuseCategoryTypeOther: "moderation.abuse_category.other_violations",
|
|
}
|
|
|
|
// GetAbuseCategoriesList returns a list of pairs with the available abuse category types
|
|
// and their corresponding translation keys
|
|
func GetAbuseCategoriesList() []AbuseCategoryItem {
|
|
return []AbuseCategoryItem{
|
|
{AbuseCategoryTypeSpam, AbuseCategoriesTranslationKeys[AbuseCategoryTypeSpam]},
|
|
{AbuseCategoryTypeMalware, AbuseCategoriesTranslationKeys[AbuseCategoryTypeMalware]},
|
|
{AbuseCategoryTypeIllegalContent, AbuseCategoriesTranslationKeys[AbuseCategoryTypeIllegalContent]},
|
|
{AbuseCategoryTypeOther, AbuseCategoriesTranslationKeys[AbuseCategoryTypeOther]},
|
|
}
|
|
}
|
|
|
|
// 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 `xorm:"VARCHAR(500)"`
|
|
// 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"`
|
|
ResolvedUnix timeutil.TimeStamp `xorm:"DEFAULT 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
|
|
}
|
|
|
|
// GetResolvedReports gets all resolved reports
|
|
func GetResolvedReports(ctx context.Context, keepReportsFor time.Duration) ([]*AbuseReport, error) {
|
|
cond := builder.And(
|
|
builder.Or(
|
|
builder.Eq{"`status`": ReportStatusTypeHandled},
|
|
builder.Eq{"`status`": ReportStatusTypeIgnored},
|
|
),
|
|
)
|
|
|
|
if keepReportsFor > 0 {
|
|
cond = cond.And(builder.Lt{"resolved_unix": time.Now().Add(-keepReportsFor).Unix()})
|
|
}
|
|
|
|
abuseReports := make([]*AbuseReport, 0, 30)
|
|
return abuseReports, db.GetEngine(ctx).
|
|
Where(cond).
|
|
Find(&abuseReports)
|
|
}
|
|
|
|
// 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,
|
|
}).Update(&AbuseReport{Status: status, ResolvedUnix: timeutil.TimeStampNow()})
|
|
|
|
return err
|
|
}
|