diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index bdc9ba7b2e..41599d411e 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1572,6 +1572,15 @@ LEVEL = Info ;; - manage_gpg_keys: a user cannot configure gpg keys ;;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] diff --git a/models/issues/comment.go b/models/issues/comment.go index ce4cb6ea93..dc38f83f79 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -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 } diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 9d0bc84454..d15533390e 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -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 diff --git a/models/issues/moderation.go b/models/issues/moderation.go new file mode 100644 index 0000000000..635d295db0 --- /dev/null +++ b/models/issues/moderation.go @@ -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 +} diff --git a/models/moderation/abuse_report.go b/models/moderation/abuse_report.go new file mode 100644 index 0000000000..dadd61a95e --- /dev/null +++ b/models/moderation/abuse_report.go @@ -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 +} +*/ diff --git a/models/moderation/shadow_copy.go b/models/moderation/shadow_copy.go new file mode 100644 index 0000000000..cdd8f69c52 --- /dev/null +++ b/models/moderation/shadow_copy.go @@ -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 +} diff --git a/models/repo/moderation.go b/models/repo/moderation.go new file mode 100644 index 0000000000..d7b87dffa0 --- /dev/null +++ b/models/repo/moderation.go @@ -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 +} diff --git a/models/user/moderation.go b/models/user/moderation.go new file mode 100644 index 0000000000..afda497f02 --- /dev/null +++ b/models/user/moderation.go @@ -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 +} diff --git a/models/user/user.go b/models/user/user.go index 7544e24be6..d75fe56a20 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -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 } diff --git a/modules/repository/create.go b/modules/repository/create.go index cace61341c..060b995bc5 100644 --- a/modules/repository/create.go +++ b/modules/repository/create.go @@ -1,4 +1,5 @@ // Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package repository @@ -241,6 +242,11 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili 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 { return fmt.Errorf("update: %w", err) } diff --git a/modules/setting/moderation.go b/modules/setting/moderation.go new file mode 100644 index 0000000000..5f35a284d6 --- /dev/null +++ b/modules/setting/moderation.go @@ -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) +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 80db4dfa3c..75c24580b2 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -221,6 +221,7 @@ func LoadSettings() { loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadF3From(CfgProvider) + loadModerationFrom(CfgProvider) } // LoadSettingsForInstall initializes the settings for install diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index ffcd606128..ec5c313a90 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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.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", + "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.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", diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index f99a193960..dcc99ff1a8 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -1,5 +1,6 @@ // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package admin @@ -145,6 +146,7 @@ func Config(ctx *context.Context) { ctx.Data["Service"] = setting.Service ctx.Data["DbCfg"] = setting.Database ctx.Data["Webhook"] = setting.Webhook + ctx.Data["Moderation"] = setting.Moderation ctx.Data["MailerEnabled"] = false if setting.MailService != nil { diff --git a/routers/web/moderation/report.go b/routers/web/moderation/report.go new file mode 100644 index 0000000000..39ca9e8824 --- /dev/null +++ b/routers/web/moderation/report.go @@ -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()) +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 2a8f267422..b97c268ae2 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1477,6 +1477,7 @@ func ViewIssue(ctx *context.Context) { ctx.Data["IssueType"] = "all" } + ctx.Data["IsModerationEnabled"] = setting.Moderation.Enabled ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects) ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index c26ff19165..5873df8a24 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -38,6 +38,7 @@ func prepareContextForCommonProfile(ctx *context.Context) { func PrepareContextForProfileBigAvatar(ctx *context.Context) { 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["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 diff --git a/routers/web/web.go b/routers/web/web.go index 3372a5bca2..f8a13dab7e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -32,6 +32,7 @@ import ( "forgejo.org/routers/web/feed" "forgejo.org/routers/web/healthcheck" "forgejo.org/routers/web/misc" + "forgejo.org/routers/web/moderation" "forgejo.org/routers/web/org" org_setting "forgejo.org/routers/web/org/setting" "forgejo.org/routers/web/repo" @@ -474,6 +475,11 @@ func registerRoutes(m *web.Route) { m.Get("/search", repo.SearchIssues) }, 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("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) diff --git a/services/context/org.go b/services/context/org.go index 31ad60704f..3ddc40b6b3 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -1,5 +1,6 @@ // 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 package context @@ -165,6 +166,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["IsModerationEnabled"] = setting.Moderation.Enabled ctx.Data["IsPublicMember"] = func(uid int64) bool { is, _ := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, uid) return is diff --git a/services/context/repo.go b/services/context/repo.go index 369a94c870..cce3a5fa70 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -593,6 +593,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { ctx.Data["CanWriteIssues"] = ctx.Repo.CanWrite(unit_model.TypeIssues) ctx.Data["CanWritePulls"] = ctx.Repo.CanWrite(unit_model.TypePullRequests) 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) if err != nil { diff --git a/services/forms/report_abuse.go b/services/forms/report_abuse.go new file mode 100644 index 0000000000..5e9d7dc45f --- /dev/null +++ b/services/forms/report_abuse.go @@ -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) +} diff --git a/services/issue/issue.go b/services/issue/issue.go index f6a3e90b10..1ec41476b4 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -1,4 +1,5 @@ // Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT 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. func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, title string) error { oldTitle := issue.Title - issue.Title = title if oldTitle == title { return nil @@ -73,6 +73,12 @@ func ChangeTitle(ctx context.Context, issue *issues_model.Issue, doer *user_mode 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 { return err } @@ -252,6 +258,12 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { defer committer.Close() 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 { return err } diff --git a/services/moderation/reporting.go b/services/moderation/reporting.go new file mode 100644 index 0000000000..e01156dc11 --- /dev/null +++ b/services/moderation/reporting.go @@ -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 +} diff --git a/services/repository/delete.go b/services/repository/delete.go index 7c83ba12cd..f4124fb9e2 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -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 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 { return err } else if cnt != 1 { diff --git a/services/user/delete.go b/services/user/delete.go index 9ce917cd27..9caa24c373 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -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 user @@ -216,6 +217,11 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) } // ***** 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 { return fmt.Errorf("delete: %w", err) } diff --git a/services/user/email.go b/services/user/email.go index f49efde1be..7a01fa77b3 100644 --- a/services/user/email.go +++ b/services/user/email.go @@ -1,4 +1,5 @@ // Copyright 2024 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package user @@ -203,6 +204,11 @@ func MakeEmailAddressPrimary(ctx context.Context, u *user_model.User, newPrimary 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 u.Email = newPrimaryEmail.Email if _, err = sess.ID(u.ID).Cols("email").Update(u); err != nil { diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index 8f2b1c12e3..12504b8824 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -247,6 +247,16 @@ +