mirror of
https://codeberg.org/davrot/forgejo.git
synced 2025-06-17 17:00:03 +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
fa174e9aca
commit
0bc641e53d
34 changed files with 1040 additions and 6 deletions
|
@ -1572,6 +1572,15 @@ LEVEL = Info
|
||||||
;; - manage_gpg_keys: a user cannot configure gpg keys
|
;; - manage_gpg_keys: a user cannot configure gpg keys
|
||||||
;;EXTERNAL_USER_DISABLE_FEATURES =
|
;;EXTERNAL_USER_DISABLE_FEATURES =
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;[moderation]
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; When true enables moderation capabilities; default is false.
|
||||||
|
;; If enabled it will be possible for users to report abusive content (new actions are added in the UI and /report_abuse route will be enabled) and a new Moderation section will be added to Admin settings where the reports can be reviewed.
|
||||||
|
;ENABLED = false
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;[openid]
|
;[openid]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// Copyright 2018 The Gitea Authors.
|
// Copyright 2016 The Gogs Authors. All rights reserved.
|
||||||
// Copyright 2016 The Gogs Authors.
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||||
// All rights reserved.
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package issues
|
package issues
|
||||||
|
@ -324,6 +324,9 @@ type Comment struct {
|
||||||
NewCommit string `xorm:"-"`
|
NewCommit string `xorm:"-"`
|
||||||
CommitsNum int64 `xorm:"-"`
|
CommitsNum int64 `xorm:"-"`
|
||||||
IsForcePush bool `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() {
|
func init() {
|
||||||
|
@ -1149,6 +1152,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
|
||||||
}
|
}
|
||||||
defer committer.Close()
|
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 {
|
if err := c.LoadIssue(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -1184,6 +1192,12 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
|
||||||
// DeleteComment deletes the comment
|
// DeleteComment deletes the comment
|
||||||
func DeleteComment(ctx context.Context, comment *Comment) error {
|
func DeleteComment(ctx context.Context, comment *Comment) error {
|
||||||
e := db.GetEngine(ctx)
|
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 {
|
if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package issues
|
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.Content = content
|
||||||
issue.ContentVersion = contentVersion + 1
|
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"`
|
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
KeepPronounsPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
KeepPronounsPrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||||
EnableRepoUnitHints bool `xorm:"NOT NULL DEFAULT true"`
|
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() {
|
func init() {
|
||||||
|
@ -610,6 +613,7 @@ var (
|
||||||
"pulls",
|
"pulls",
|
||||||
"milestones",
|
"milestones",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"report_abuse",
|
||||||
|
|
||||||
"favicon.ico",
|
"favicon.ico",
|
||||||
"manifest.json", // web app manifests
|
"manifest.json", // web app manifests
|
||||||
|
@ -919,6 +923,12 @@ func UpdateUserCols(ctx context.Context, u *User, cols ...string) error {
|
||||||
return err
|
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)
|
_, err := db.GetEngine(ctx).ID(u.ID).Cols(cols...).Update(u)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
@ -241,6 +242,11 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili
|
||||||
|
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
|
|
||||||
|
// If the repository was reported as abusive, a shadow copy should be created before first update.
|
||||||
|
if err := repo_model.IfNeededCreateShadowCopyForRepository(ctx, repo, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = e.ID(repo.ID).AllCols().Update(repo); err != nil {
|
if _, err = e.ID(repo.ID).AllCols().Update(repo); err != nil {
|
||||||
return fmt.Errorf("update: %w", err)
|
return fmt.Errorf("update: %w", err)
|
||||||
}
|
}
|
||||||
|
|
15
modules/setting/moderation.go
Normal file
15
modules/setting/moderation.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
// Moderation settings
|
||||||
|
var Moderation = struct {
|
||||||
|
Enabled bool `ini:"ENABLED"`
|
||||||
|
}{
|
||||||
|
Enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadModerationFrom(rootCfg ConfigProvider) {
|
||||||
|
mustMapSetting(rootCfg, "moderation", &Moderation)
|
||||||
|
}
|
|
@ -221,6 +221,7 @@ func LoadSettings() {
|
||||||
loadProjectFrom(CfgProvider)
|
loadProjectFrom(CfgProvider)
|
||||||
loadMimeTypeMapFrom(CfgProvider)
|
loadMimeTypeMapFrom(CfgProvider)
|
||||||
loadF3From(CfgProvider)
|
loadF3From(CfgProvider)
|
||||||
|
loadModerationFrom(CfgProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSettingsForInstall initializes the settings for install
|
// LoadSettingsForInstall initializes the settings for install
|
||||||
|
|
|
@ -55,6 +55,24 @@
|
||||||
"alert.asset_load_failed": "Failed to load asset files from {path}. Please make sure the asset files can be accessed.",
|
"alert.asset_load_failed": "Failed to load asset files from {path}. Please make sure the asset files can be accessed.",
|
||||||
"alert.range_error": " must be a number between %[1]s and %[2]s.",
|
"alert.range_error": " must be a number between %[1]s and %[2]s.",
|
||||||
"install.invalid_lfs_path": "Unable to create the LFS root at the specified path: %[1]s",
|
"install.invalid_lfs_path": "Unable to create the LFS root at the specified path: %[1]s",
|
||||||
|
"admin.config.moderation_config": "Moderation configuration",
|
||||||
|
"moderation.report_abuse": "Report abuse",
|
||||||
|
"moderation.report_content": "Report content",
|
||||||
|
"moderation.report_abuse_form.header": "Report abuse to administrator",
|
||||||
|
"moderation.report_abuse_form.details": "This form should be used to report users who create spam profiles, repositories, issues, comments or behave inappropriately.",
|
||||||
|
"moderation.report_abuse_form.invalid": "Invalid arguments",
|
||||||
|
"moderation.report_abuse_form.already_reported": "You've already reported this content",
|
||||||
|
"moderation.abuse_category": "Category",
|
||||||
|
"moderation.abuse_category.placeholder": "Select a category",
|
||||||
|
"moderation.abuse_category.spam": "Spam",
|
||||||
|
"moderation.abuse_category.malware": "Malware",
|
||||||
|
"moderation.abuse_category.illegal_content": "Illegal content",
|
||||||
|
"moderation.abuse_category.other_violations": "Other violations of platform rules",
|
||||||
|
"moderation.report_remarks": "Remarks",
|
||||||
|
"moderation.report_remarks.placeholder": "Please provide some details regarding the abuse you are reporting.",
|
||||||
|
"moderation.submit_report": "Submit report",
|
||||||
|
"moderation.reporting_failed": "Unable to submit the new abuse report: %v",
|
||||||
|
"moderation.reported_thank_you": "Thank you for your report. The administration has been made aware of it.",
|
||||||
"mail.actions.successful_run_after_failure_subject": "Workflow %[1]s recovered in repository %[2]s",
|
"mail.actions.successful_run_after_failure_subject": "Workflow %[1]s recovered in repository %[2]s",
|
||||||
"mail.actions.not_successful_run_subject": "Workflow %[1]s failed in repository %[2]s",
|
"mail.actions.not_successful_run_subject": "Workflow %[1]s failed in repository %[2]s",
|
||||||
"mail.actions.successful_run_after_failure": "Workflow %[1]s recovered in repository %[2]s",
|
"mail.actions.successful_run_after_failure": "Workflow %[1]s recovered in repository %[2]s",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package admin
|
package admin
|
||||||
|
@ -145,6 +146,7 @@ func Config(ctx *context.Context) {
|
||||||
ctx.Data["Service"] = setting.Service
|
ctx.Data["Service"] = setting.Service
|
||||||
ctx.Data["DbCfg"] = setting.Database
|
ctx.Data["DbCfg"] = setting.Database
|
||||||
ctx.Data["Webhook"] = setting.Webhook
|
ctx.Data["Webhook"] = setting.Webhook
|
||||||
|
ctx.Data["Moderation"] = setting.Moderation
|
||||||
|
|
||||||
ctx.Data["MailerEnabled"] = false
|
ctx.Data["MailerEnabled"] = false
|
||||||
if setting.MailService != nil {
|
if setting.MailService != nil {
|
||||||
|
|
125
routers/web/moderation/report.go
Normal file
125
routers/web/moderation/report.go
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/models/moderation"
|
||||||
|
"forgejo.org/modules/base"
|
||||||
|
"forgejo.org/modules/log"
|
||||||
|
"forgejo.org/modules/web"
|
||||||
|
"forgejo.org/services/context"
|
||||||
|
"forgejo.org/services/forms"
|
||||||
|
moderation_service "forgejo.org/services/moderation"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tplSubmitAbuseReport base.TplName = "moderation/new_abuse_report"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewReport renders the page for new abuse reports.
|
||||||
|
func NewReport(ctx *context.Context) {
|
||||||
|
contentID := ctx.FormInt64("id")
|
||||||
|
if contentID <= 0 {
|
||||||
|
setMinimalContextData(ctx)
|
||||||
|
ctx.RenderWithErr(ctx.Tr("moderation.report_abuse_form.invalid"), tplSubmitAbuseReport, nil)
|
||||||
|
log.Warn("The content ID is expected to be an integer greater that 0; the provided value is %s.", ctx.FormString("id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentTypeString := ctx.FormString("type")
|
||||||
|
var contentType moderation.ReportedContentType
|
||||||
|
switch contentTypeString {
|
||||||
|
case "user", "org":
|
||||||
|
contentType = moderation.ReportedContentTypeUser
|
||||||
|
case "repo":
|
||||||
|
contentType = moderation.ReportedContentTypeRepository
|
||||||
|
case "issue", "pull":
|
||||||
|
contentType = moderation.ReportedContentTypeIssue
|
||||||
|
case "comment":
|
||||||
|
contentType = moderation.ReportedContentTypeComment
|
||||||
|
default:
|
||||||
|
setMinimalContextData(ctx)
|
||||||
|
ctx.RenderWithErr(ctx.Tr("moderation.report_abuse_form.invalid"), tplSubmitAbuseReport, nil)
|
||||||
|
log.Warn("The provided content type `%s` is not among the expected values.", contentTypeString)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if moderation.AlreadyReportedByAndOpen(ctx, ctx.Doer.ID, contentType, contentID) {
|
||||||
|
setMinimalContextData(ctx)
|
||||||
|
ctx.RenderWithErr(ctx.Tr("moderation.report_abuse_form.already_reported"), tplSubmitAbuseReport, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContextDataAndRender(ctx, contentType, contentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMinimalContextData adds minimal values (Title and CancelLink) into context data.
|
||||||
|
func setMinimalContextData(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = ctx.Tr("moderation.report_abuse")
|
||||||
|
ctx.Data["CancelLink"] = ctx.Doer.DashboardLink()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setContextDataAndRender adds some values into context data and renders the new abuse report page.
|
||||||
|
func setContextDataAndRender(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64) {
|
||||||
|
setMinimalContextData(ctx)
|
||||||
|
ctx.Data["ContentID"] = contentID
|
||||||
|
ctx.Data["ContentType"] = contentType
|
||||||
|
ctx.Data["AbuseCategories"] = moderation.GetAbuseCategoriesList()
|
||||||
|
ctx.HTML(http.StatusOK, tplSubmitAbuseReport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePost handles the POST for creating a new abuse report.
|
||||||
|
func CreatePost(ctx *context.Context) {
|
||||||
|
form := *web.GetForm(ctx).(*forms.ReportAbuseForm)
|
||||||
|
|
||||||
|
if form.ContentID <= 0 || !form.ContentType.IsValid() {
|
||||||
|
setMinimalContextData(ctx)
|
||||||
|
ctx.RenderWithErr(ctx.Tr("moderation.report_abuse_form.invalid"), tplSubmitAbuseReport, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.HasError() {
|
||||||
|
setContextDataAndRender(ctx, form.ContentType, form.ContentID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
can, err := moderation_service.CanReport(*ctx, ctx.Doer, form.ContentType, form.ContentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, moderation_service.ErrContentDoesNotExist) || errors.Is(err, moderation_service.ErrDoerNotAllowed) {
|
||||||
|
ctx.Flash.Error(ctx.Tr("moderation.report_abuse_form.invalid"))
|
||||||
|
ctx.Redirect(ctx.Doer.DashboardLink())
|
||||||
|
} else {
|
||||||
|
ctx.ServerError("Failed to check if user can report content", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if !can {
|
||||||
|
ctx.Flash.Error(ctx.Tr("moderation.report_abuse_form.invalid"))
|
||||||
|
ctx.Redirect(ctx.Doer.DashboardLink())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report := moderation.AbuseReport{
|
||||||
|
ReporterID: ctx.Doer.ID,
|
||||||
|
ContentType: form.ContentType,
|
||||||
|
ContentID: form.ContentID,
|
||||||
|
Category: form.AbuseCategory,
|
||||||
|
Remarks: form.Remarks,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := moderation.ReportAbuse(ctx, &report); err != nil {
|
||||||
|
if errors.Is(err, moderation.ErrSelfReporting) {
|
||||||
|
ctx.Flash.Error(ctx.Tr("moderation.reporting_failed", err))
|
||||||
|
ctx.Redirect(ctx.Doer.DashboardLink())
|
||||||
|
} else {
|
||||||
|
ctx.ServerError("Failed to save new abuse report", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Flash.Success(ctx.Tr("moderation.reported_thank_you"))
|
||||||
|
ctx.Redirect(ctx.Doer.DashboardLink())
|
||||||
|
}
|
|
@ -1477,6 +1477,7 @@ func ViewIssue(ctx *context.Context) {
|
||||||
ctx.Data["IssueType"] = "all"
|
ctx.Data["IssueType"] = "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.Data["IsModerationEnabled"] = setting.Moderation.Enabled
|
||||||
ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects)
|
ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects)
|
||||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||||
upload.AddUploadContext(ctx, "comment")
|
upload.AddUploadContext(ctx, "comment")
|
||||||
|
|
|
@ -38,6 +38,7 @@ func prepareContextForCommonProfile(ctx *context.Context) {
|
||||||
func PrepareContextForProfileBigAvatar(ctx *context.Context) {
|
func PrepareContextForProfileBigAvatar(ctx *context.Context) {
|
||||||
prepareContextForCommonProfile(ctx)
|
prepareContextForCommonProfile(ctx)
|
||||||
|
|
||||||
|
ctx.Data["IsModerationEnabled"] = setting.Moderation.Enabled
|
||||||
ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
|
ctx.Data["IsBlocked"] = ctx.Doer != nil && user_model.IsBlocked(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
|
||||||
ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
|
ctx.Data["IsFollowing"] = ctx.Doer != nil && user_model.IsFollowing(ctx, ctx.Doer.ID, ctx.ContextUser.ID)
|
||||||
ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate
|
ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail && ctx.ContextUser.Email != "" && ctx.IsSigned && !ctx.ContextUser.KeepEmailPrivate
|
||||||
|
|
|
@ -32,6 +32,7 @@ import (
|
||||||
"forgejo.org/routers/web/feed"
|
"forgejo.org/routers/web/feed"
|
||||||
"forgejo.org/routers/web/healthcheck"
|
"forgejo.org/routers/web/healthcheck"
|
||||||
"forgejo.org/routers/web/misc"
|
"forgejo.org/routers/web/misc"
|
||||||
|
"forgejo.org/routers/web/moderation"
|
||||||
"forgejo.org/routers/web/org"
|
"forgejo.org/routers/web/org"
|
||||||
org_setting "forgejo.org/routers/web/org/setting"
|
org_setting "forgejo.org/routers/web/org/setting"
|
||||||
"forgejo.org/routers/web/repo"
|
"forgejo.org/routers/web/repo"
|
||||||
|
@ -474,6 +475,11 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Get("/search", repo.SearchIssues)
|
m.Get("/search", repo.SearchIssues)
|
||||||
}, reqSignIn)
|
}, reqSignIn)
|
||||||
|
|
||||||
|
if setting.Moderation.Enabled {
|
||||||
|
m.Get("/report_abuse", reqSignIn, moderation.NewReport)
|
||||||
|
m.Post("/report_abuse", reqSignIn, web.Bind(forms.ReportAbuseForm{}), moderation.CreatePost)
|
||||||
|
}
|
||||||
|
|
||||||
m.Get("/pulls", reqSignIn, user.Pulls)
|
m.Get("/pulls", reqSignIn, user.Pulls)
|
||||||
m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones)
|
m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||||
// Copyright 2020 The Gitea Authors.
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package context
|
package context
|
||||||
|
@ -165,6 +166,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
|
||||||
ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
|
ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
|
||||||
ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
|
ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled
|
||||||
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
|
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
|
||||||
|
ctx.Data["IsModerationEnabled"] = setting.Moderation.Enabled
|
||||||
ctx.Data["IsPublicMember"] = func(uid int64) bool {
|
ctx.Data["IsPublicMember"] = func(uid int64) bool {
|
||||||
is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid)
|
is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid)
|
||||||
return is
|
return is
|
||||||
|
|
|
@ -593,6 +593,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc {
|
||||||
ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues)
|
ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues)
|
||||||
ctx.Data["CanWritePulls"] = ctx.Repo.CanWrite(unit_model.TypePullRequests)
|
ctx.Data["CanWritePulls"] = ctx.Repo.CanWrite(unit_model.TypePullRequests)
|
||||||
ctx.Data["CanWriteActions"] = ctx.Repo.CanWrite(unit_model.TypeActions)
|
ctx.Data["CanWriteActions"] = ctx.Repo.CanWrite(unit_model.TypeActions)
|
||||||
|
ctx.Data["IsModerationEnabled"] = setting.Moderation.Enabled
|
||||||
|
|
||||||
canSignedUserFork, err := repo_module.CanUserForkRepo(ctx, ctx.Doer, ctx.Repo.Repository)
|
canSignedUserFork, err := repo_module.CanUserForkRepo(ctx, ctx.Doer, ctx.Repo.Repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
28
services/forms/report_abuse.go
Normal file
28
services/forms/report_abuse.go
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package forms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/models/moderation"
|
||||||
|
"forgejo.org/modules/web/middleware"
|
||||||
|
"forgejo.org/services/context"
|
||||||
|
|
||||||
|
"code.forgejo.org/go-chi/binding"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReportAbuseForm is used to interact with the UI of the form that submits new abuse reports.
|
||||||
|
type ReportAbuseForm struct {
|
||||||
|
ContentID int64
|
||||||
|
ContentType moderation.ReportedContentType
|
||||||
|
AbuseCategory moderation.AbuseCategoryType `binding:"Required" locale:"moderation.abuse_category"`
|
||||||
|
Remarks string `binding:"Required;MinSize(20);MaxSize(500)" preprocess:"TrimSpace" locale:"moderation.report_remarks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the fields of ReportAbuseForm.
|
||||||
|
func (f *ReportAbuseForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
||||||
|
ctx := context.GetValidateContext(req)
|
||||||
|
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package issue
|
package issue
|
||||||
|
@ -59,7 +60,6 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_mo
|
||||||
// ChangeTitle changes the title of this issue, as the given user.
|
// ChangeTitle changes the title of this issue, as the given user.
|
||||||
func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, title string) error {
|
func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, title string) error {
|
||||||
oldTitle := issue.Title
|
oldTitle := issue.Title
|
||||||
issue.Title = title
|
|
||||||
|
|
||||||
if oldTitle == title {
|
if oldTitle == title {
|
||||||
return nil
|
return nil
|
||||||
|
@ -73,6 +73,12 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode
|
||||||
return user_model.ErrBlockedByUser
|
return user_model.ErrBlockedByUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the issue was reported as abusive, a shadow copy should be created before first update.
|
||||||
|
if err := issues_model.IfNeededCreateShadowCopyForIssue(ctx, issue); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
issue.Title = title
|
||||||
if err := issues_model.ChangeIssueTitle(ctx, issue, doer, oldTitle); err != nil {
|
if err := issues_model.ChangeIssueTitle(ctx, issue, doer, oldTitle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -252,6 +258,12 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error {
|
||||||
defer committer.Close()
|
defer committer.Close()
|
||||||
|
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
|
|
||||||
|
// If the issue was reported as abusive, a shadow copy should be created before deletion.
|
||||||
|
if err := issues_model.IfNeededCreateShadowCopyForIssue(ctx, issue); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := e.ID(issue.ID).NoAutoCondition().Delete(issue); err != nil {
|
if _, err := e.ID(issue.ID).NoAutoCondition().Delete(issue); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
129
services/moderation/reporting.go
Normal file
129
services/moderation/reporting.go
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package moderation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"forgejo.org/models/issues"
|
||||||
|
"forgejo.org/models/moderation"
|
||||||
|
"forgejo.org/models/perm"
|
||||||
|
access_model "forgejo.org/models/perm/access"
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
"forgejo.org/models/user"
|
||||||
|
"forgejo.org/modules/log"
|
||||||
|
"forgejo.org/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrContentDoesNotExist = errors.New("the content to be reported does not exist")
|
||||||
|
ErrDoerNotAllowed = errors.New("doer not allowed to access the content to be reported")
|
||||||
|
)
|
||||||
|
|
||||||
|
// CanReport checks if doer has access to the content they are reporting
|
||||||
|
// (user, organization, repository, issue, pull request or comment).
|
||||||
|
// When reporting repositories the user should have at least read access to any repo unit type.
|
||||||
|
// When reporting issues, pull requests or comments the user should have at least read access
|
||||||
|
// to 'TypeIssues', respectively 'TypePullRequests' unit for the repository where the content belongs.
|
||||||
|
// When reporting users or organizations doer should be able to view the reported entity.
|
||||||
|
func CanReport(ctx context.Context, doer *user.User, contentType moderation.ReportedContentType, contentID int64) (bool, error) {
|
||||||
|
hasAccess := false
|
||||||
|
var issueID int64
|
||||||
|
var repoID int64
|
||||||
|
unitType := unit.TypeInvalid // used when checking access for issues, pull requests or comments
|
||||||
|
|
||||||
|
if contentType == moderation.ReportedContentTypeUser {
|
||||||
|
reportedUser, err := user.GetUserByID(ctx, contentID)
|
||||||
|
if err != nil {
|
||||||
|
if user.IsErrUserNotExist(err) {
|
||||||
|
log.Warn("User #%d wanted to report user #%d but it does not exist.", doer.ID, contentID)
|
||||||
|
return false, ErrContentDoesNotExist
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAccess = user.IsUserVisibleToViewer(ctx, reportedUser, ctx.Doer)
|
||||||
|
if !hasAccess {
|
||||||
|
log.Warn("User #%d wanted to report user/org #%d but they are not able to see that profile.", doer.ID, contentID)
|
||||||
|
return false, ErrDoerNotAllowed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// for comments and issues/pulls we need to get the parent repository
|
||||||
|
switch contentType {
|
||||||
|
case moderation.ReportedContentTypeComment:
|
||||||
|
comment, err := issues.GetCommentByID(ctx, contentID)
|
||||||
|
if err != nil {
|
||||||
|
if issues.IsErrCommentNotExist(err) {
|
||||||
|
log.Warn("User #%d wanted to report comment #%d but it does not exist.", doer.ID, contentID)
|
||||||
|
return false, ErrContentDoesNotExist
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !comment.Type.HasContentSupport() {
|
||||||
|
// this is not a comment with text and/or attachments
|
||||||
|
log.Warn("User #%d wanted to report comment #%d but it is not a comment with content.", doer.ID, contentID)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
issueID = comment.IssueID
|
||||||
|
case moderation.ReportedContentTypeIssue:
|
||||||
|
issueID = contentID
|
||||||
|
case moderation.ReportedContentTypeRepository:
|
||||||
|
repoID = contentID
|
||||||
|
}
|
||||||
|
|
||||||
|
if issueID > 0 {
|
||||||
|
issue, err := issues.GetIssueByID(ctx, issueID)
|
||||||
|
if err != nil {
|
||||||
|
if issues.IsErrIssueNotExist(err) {
|
||||||
|
log.Warn("User #%d wanted to report issue #%d (or one of its comments) but it does not exist.", doer.ID, issueID)
|
||||||
|
return false, ErrContentDoesNotExist
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repoID = issue.RepoID
|
||||||
|
if issue.IsPull {
|
||||||
|
unitType = unit.TypePullRequests
|
||||||
|
} else {
|
||||||
|
unitType = unit.TypeIssues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if repoID > 0 {
|
||||||
|
repo, err := repo_model.GetRepositoryByID(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
log.Warn("User #%d wanted to report repository #%d (or one of its issues / comments) but it does not exist.", doer.ID, repoID)
|
||||||
|
return false, ErrContentDoesNotExist
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if issueID > 0 {
|
||||||
|
// for comments and issues/pulls doer should have at least read access to the corresponding repo unit (issues, respectively pull requests)
|
||||||
|
hasAccess, err = access_model.HasAccessUnit(ctx, doer, repo, unitType, perm.AccessModeRead)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if !hasAccess {
|
||||||
|
log.Warn("User #%d wanted to report issue #%d or one of its comments from repository #%d but they don't have access to it.", doer.ID, issueID, repoID)
|
||||||
|
return false, ErrDoerNotAllowed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// for repositories doer should have at least read access to at least one repo unit
|
||||||
|
perm, err := access_model.GetUserRepoPermission(ctx, repo, doer)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
hasAccess = perm.CanReadAny(unit.AllRepoUnitTypes...)
|
||||||
|
if !hasAccess {
|
||||||
|
log.Warn("User #%d wanted to report repository #%d but they don't have access to it.", doer.ID, repoID)
|
||||||
|
return false, ErrDoerNotAllowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAccess, nil
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package repository
|
package repository
|
||||||
|
@ -89,6 +90,11 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the repository was reported as abusive, a shadow copy should be created before deletion.
|
||||||
|
if err := repo_model.IfNeededCreateShadowCopyForRepository(ctx, repo, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if cnt, err := sess.ID(repoID).Delete(&repo_model.Repository{}); err != nil {
|
if cnt, err := sess.ID(repoID).Delete(&repo_model.Repository{}); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if cnt != 1 {
|
} else if cnt != 1 {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package user
|
package user
|
||||||
|
@ -216,6 +217,11 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error)
|
||||||
}
|
}
|
||||||
// ***** END: ExternalLoginUser *****
|
// ***** END: ExternalLoginUser *****
|
||||||
|
|
||||||
|
// If the user was reported as abusive, a shadow copy should be created before deletion.
|
||||||
|
if err = user_model.IfNeededCreateShadowCopyForUser(ctx, u); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil {
|
if _, err = db.DeleteByID[user_model.User](ctx, u.ID); err != nil {
|
||||||
return fmt.Errorf("delete: %w", err)
|
return fmt.Errorf("delete: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package user
|
package user
|
||||||
|
@ -203,6 +204,11 @@ func MakeEmailAddressPrimary(ctx context.Context, u *user_model.User, newPrimary
|
||||||
|
|
||||||
oldPrimaryEmail := u.Email
|
oldPrimaryEmail := u.Email
|
||||||
|
|
||||||
|
// If the user was reported as abusive, a shadow copy should be created before first update (of certain columns).
|
||||||
|
if err = user_model.IfNeededCreateShadowCopyForUser(ctx, u, "email"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Update user table
|
// 1. Update user table
|
||||||
u.Email = newPrimaryEmail.Email
|
u.Email = newPrimaryEmail.Email
|
||||||
if _, err = sess.ID(u.ID).Cols("email").Update(u); err != nil {
|
if _, err = sess.ID(u.ID).Cols("email").Update(u); err != nil {
|
||||||
|
|
|
@ -247,6 +247,16 @@
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "admin.config.moderation_config"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached table segment">
|
||||||
|
<dl class="admin-dl-horizontal">
|
||||||
|
<dt>{{ctx.Locale.Tr "enabled"}}</dt>
|
||||||
|
<dd>{{if .Moderation.Enabled}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="ui top attached header">
|
<h4 class="ui top attached header">
|
||||||
{{ctx.Locale.Tr "admin.config.cache_config"}}
|
{{ctx.Locale.Tr "admin.config.cache_config"}}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
45
templates/moderation/new_abuse_report.tmpl
Normal file
45
templates/moderation/new_abuse_report.tmpl
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{{template "base/head" .}}
|
||||||
|
<div role="main" aria-label="{{.Title}}" class="page-content moderation new-report">
|
||||||
|
<div class="ui middle very relaxed page grid">
|
||||||
|
<div class="column">
|
||||||
|
<form class="ui form" action="{{.Link}}" method="post">
|
||||||
|
{{.CsrfTokenHtml}}
|
||||||
|
<h3 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "moderation.report_abuse_form.header"}}
|
||||||
|
</h3>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
<p class="ui center">{{ctx.Locale.Tr "moderation.report_abuse_form.details"}}</p>
|
||||||
|
|
||||||
|
<input type="hidden" name="content_id" value="{{.ContentID}}" />
|
||||||
|
<input type="hidden" name="content_type" value="{{.ContentType}}" />
|
||||||
|
|
||||||
|
<fieldset{{if not .ContentID}} disabled{{end}}>
|
||||||
|
<label{{if .Err_AbuseCategory}} class="field error"{{end}}>
|
||||||
|
{{ctx.Locale.Tr "moderation.abuse_category"}}
|
||||||
|
<select class="ui selection dropdown" id="abuse_category" name="abuse_category" required autofocus>
|
||||||
|
<option value="">{{ctx.Locale.Tr "moderation.abuse_category.placeholder"}}</option>
|
||||||
|
{{range $cat := .AbuseCategories}}
|
||||||
|
<option value="{{$cat.Value}}"{{if eq $.abuse_category $cat.Value}} selected{{end}}>{{ctx.Locale.Tr $cat.TranslationKey}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label{{if .Err_Remarks}} class="field error"{{end}}>
|
||||||
|
{{ctx.Locale.Tr "moderation.report_remarks"}}
|
||||||
|
<textarea id="remarks" name="remarks" required minlength="20" maxlength="500" placeholder="{{ctx.Locale.Tr "moderation.report_remarks.placeholder"}}">{{.remarks}}</textarea>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="text right actions">
|
||||||
|
<a class="ui cancel button" href="{{$.CancelLink}}">{{ctx.Locale.Tr "cancel"}}</a>
|
||||||
|
{{if .ContentID}}
|
||||||
|
<button class="ui primary button">{{ctx.Locale.Tr "moderation.submit_report"}}</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
|
@ -21,6 +21,14 @@
|
||||||
{{if .IsOrganizationMember}}
|
{{if .IsOrganizationMember}}
|
||||||
<a class="ui basic button tw-mr-0" href="{{.OrgLink}}/dashboard">{{ctx.Locale.Tr "org.open_dashboard"}}</a>
|
<a class="ui basic button tw-mr-0" href="{{.OrgLink}}/dashboard">{{ctx.Locale.Tr "org.open_dashboard"}}</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if and .IsModerationEnabled .IsSigned (not .IsOrganizationOwner)}}
|
||||||
|
<button class="ui dropdown icon button" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}" aria-label="{{ctx.Locale.Tr "toggle_menu"}}">
|
||||||
|
{{svg "octicon-kebab-horizontal" 14}}
|
||||||
|
<div class="menu top left">
|
||||||
|
<a class="item context" href="{{AppSubUrl}}/report_abuse?type=org&id={{$.Org.ID}}">{{ctx.Locale.Tr "moderation.report_abuse"}}</a>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .RenderedDescription}}<div class="render-content markup">{{.RenderedDescription}}</div>{{end}}
|
{{if .RenderedDescription}}<div class="render-content markup">{{.RenderedDescription}}</div>{{end}}
|
||||||
|
|
|
@ -67,6 +67,14 @@
|
||||||
{{if not $.DisableForks}}
|
{{if not $.DisableForks}}
|
||||||
{{template "repo/header_fork" $}}
|
{{template "repo/header_fork" $}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if and $.IsModerationEnabled $.IsSigned (not $.IsRepositoryAdmin)}}
|
||||||
|
<button class="ui small compact jump dropdown icon button" data-tooltip-content="{{ctx.Locale.Tr "repo.more_operations"}}" aria-label="{{ctx.Locale.Tr "toggle_menu"}}">
|
||||||
|
{{svg "octicon-kebab-horizontal"}}
|
||||||
|
<div class="menu top left">
|
||||||
|
<a class="item context" href="{{AppSubUrl}}/report_abuse?type=repo&id={{$.Repository.ID}}">{{ctx.Locale.Tr "moderation.report_content"}}</a>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -23,5 +23,13 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if and .ctxData.IsModerationEnabled .ctxData.IsSigned (not .IsCommentPoster)}}
|
||||||
|
{{$contentType := "comment"}}
|
||||||
|
{{if eq .item .ctxData.Issue}}
|
||||||
|
{{if .ctxData.Issue.IsPull}} {{$contentType = "pull"}} {{else}} {{$contentType = "issue"}} {{end}}
|
||||||
|
{{end}}
|
||||||
|
<div class="divider"></div>
|
||||||
|
<a class="item context" href="{{AppSubUrl}}/report_abuse?type={{$contentType}}&id={{.item.ID}}">{{ctx.Locale.Tr "moderation.report_content"}}</a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -123,6 +123,11 @@
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</li>
|
</li>
|
||||||
|
{{if .IsModerationEnabled}}
|
||||||
|
<li class="report">
|
||||||
|
<a class="ui basic orange button" href="{{AppSubUrl}}/report_abuse?type=user&id={{.ContextUser.ID}}">{{ctx.Locale.Tr "moderation.report_abuse"}}</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -491,6 +491,7 @@ input:-webkit-autofill:active,
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.moderation.new-report form,
|
||||||
.repository.new.repo form,
|
.repository.new.repo form,
|
||||||
.repository.new.migrate form,
|
.repository.new.migrate form,
|
||||||
.repository.new.fork form {
|
.repository.new.fork form {
|
||||||
|
@ -504,11 +505,13 @@ input:-webkit-autofill:active,
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
.moderation.new-report form,
|
||||||
.repository.new.repo form,
|
.repository.new.repo form,
|
||||||
.repository.new.migrate form,
|
.repository.new.migrate form,
|
||||||
.repository.new.fork form {
|
.repository.new.fork form {
|
||||||
width: 800px !important;
|
width: 800px !important;
|
||||||
}
|
}
|
||||||
|
.moderation.new-report form .header,
|
||||||
.repository.new.repo form .header,
|
.repository.new.repo form .header,
|
||||||
.repository.new.migrate form .header,
|
.repository.new.migrate form .header,
|
||||||
.repository.new.fork form .header {
|
.repository.new.fork form .header {
|
||||||
|
@ -555,6 +558,7 @@ input:-webkit-autofill:active,
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.moderation.new-report form .header,
|
||||||
.repository.new.repo form .header,
|
.repository.new.repo form .header,
|
||||||
.repository.new.migrate form .header,
|
.repository.new.migrate form .header,
|
||||||
.repository.new.fork form .header {
|
.repository.new.fork form .header {
|
||||||
|
|
|
@ -38,7 +38,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user.profile .ui.card .extra.content > ul > li.follow .ui.button,
|
.user.profile .ui.card .extra.content > ul > li.follow .ui.button,
|
||||||
.user.profile .ui.card .extra.content > ul > li.block .ui.button {
|
.user.profile .ui.card .extra.content > ul > li.block .ui.button,
|
||||||
|
.user.profile .ui.card .extra.content > ul > li.report .ui.button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue