feat!: Abusive content reporting (#6977)

This implements milestones 1. and 4. from **Task F. Moderation features: Reporting** (part of [amendment of the workplan](https://codeberg.org/forgejo/sustainability/src/branch/main/2022-12-01-nlnet/2025-02-07-extended-workplan.md#task-f-moderation-features-reporting) for NLnet 2022-12-035):

> 1. A reporting feature is implemented in the database. It ensures that content remains available for review, even if a user deletes it after a report was sent.

> 4. Users can report the most relevant content types (at least: issue comments, repositories, users)

### See also:
- forgejo/discussions#291
- forgejo/discussions#304
- forgejo/design#30

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6977
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Reviewed-by: Otto <otto@codeberg.org>
Co-authored-by: floss4good <floss4good@disroot.org>
Co-committed-by: floss4good <floss4good@disroot.org>
This commit is contained in:
floss4good 2025-05-18 08:05:16 +00:00 committed by David Rotermund
parent fa174e9aca
commit 0bc641e53d
34 changed files with 1040 additions and 6 deletions

View file

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

View file

@ -1,6 +1,6 @@
// Copyright 2018 The Gitea Authors.
// Copyright 2016 The Gogs Authors.
// All rights reserved.
// Copyright 2016 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
@ -324,6 +324,9 @@ type Comment struct {
NewCommit string `xorm:"-"`
CommitsNum int64 `xorm:"-"`
IsForcePush bool `xorm:"-"`
// If you add new fields that might be used to store abusive content (mainly string fields),
// please also add them in the CommentData struct and the corresponding constructor.
}
func init() {
@ -1149,6 +1152,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
}
defer committer.Close()
// If the comment was reported as abusive, a shadow copy should be created before first update.
if err := IfNeededCreateShadowCopyForComment(ctx, c); err != nil {
return err
}
if err := c.LoadIssue(ctx); err != nil {
return err
}
@ -1184,6 +1192,12 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
// DeleteComment deletes the comment
func DeleteComment(ctx context.Context, comment *Comment) error {
e := db.GetEngine(ctx)
// If the comment was reported as abusive, a shadow copy should be created before deletion.
if err := IfNeededCreateShadowCopyForComment(ctx, comment); err != nil {
return err
}
if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil {
return err
}

View file

@ -1,4 +1,5 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
@ -275,6 +276,11 @@ func ChangeIssueContent(ctx context.Context, issue *Issue, doer *user_model.User
}
}
// If the issue was reported as abusive, a shadow copy should be created before first update.
if err := IfNeededCreateShadowCopyForIssue(ctx, issue); err != nil {
return err
}
issue.Content = content
issue.ContentVersion = contentVersion + 1

106
models/issues/moderation.go Normal file
View file

@ -0,0 +1,106 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package issues
import (
"context"
"forgejo.org/models/moderation"
"forgejo.org/modules/json"
"forgejo.org/modules/timeutil"
)
// IssueData represents a trimmed down issue that is used for preserving
// only the fields needed for abusive content reports (mainly string fields).
type IssueData struct {
RepoID int64
Index int64
PosterID int64
Title string
Content string
ContentVersion int
CreatedUnix timeutil.TimeStamp
UpdatedUnix timeutil.TimeStamp
}
// newIssueData creates a trimmed down issue to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newIssueData(issue *Issue) IssueData {
return IssueData{
RepoID: issue.RepoID,
Index: issue.Index,
PosterID: issue.PosterID,
Content: issue.Content,
Title: issue.Title,
ContentVersion: issue.ContentVersion,
CreatedUnix: issue.CreatedUnix,
UpdatedUnix: issue.UpdatedUnix,
}
}
// CommentData represents a trimmed down comment that is used for preserving
// only the fields needed for abusive content reports (mainly string fields).
type CommentData struct {
PosterID int64
IssueID int64
Content string
ContentVersion int
CreatedUnix timeutil.TimeStamp
UpdatedUnix timeutil.TimeStamp
}
// newCommentData creates a trimmed down comment to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newCommentData(comment *Comment) CommentData {
return CommentData{
PosterID: comment.PosterID,
IssueID: comment.IssueID,
Content: comment.Content,
ContentVersion: comment.ContentVersion,
CreatedUnix: comment.CreatedUnix,
UpdatedUnix: comment.UpdatedUnix,
}
}
// IfNeededCreateShadowCopyForIssue checks if for the given issue there are any reports of abusive content submitted
// and if found a shadow copy of relevant issue fields will be stored into DB and linked to the above report(s).
// This function should be called before a issue is deleted or updated.
func IfNeededCreateShadowCopyForIssue(ctx context.Context, issue *Issue) error {
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeIssue, issue.ID)
if err != nil {
return err
}
if shadowCopyNeeded {
issueData := newIssueData(issue)
content, err := json.Marshal(issueData)
if err != nil {
return err
}
return moderation.CreateShadowCopyForIssue(ctx, issue.ID, string(content))
}
return nil
}
// IfNeededCreateShadowCopyForComment checks if for the given comment there are any reports of abusive content submitted
// and if found a shadow copy of relevant comment fields will be stored into DB and linked to the above report(s).
// This function should be called before a comment is deleted or updated.
func IfNeededCreateShadowCopyForComment(ctx context.Context, comment *Comment) error {
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeComment, comment.ID)
if err != nil {
return err
}
if shadowCopyNeeded {
commentData := newCommentData(comment)
content, err := json.Marshal(commentData)
if err != nil {
return err
}
return moderation.CreateShadowCopyForComment(ctx, comment.ID, string(content))
}
return nil
}

View file

@ -0,0 +1,177 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package moderation
import (
"context"
"database/sql"
"errors"
"slices"
"forgejo.org/models/db"
"forgejo.org/modules/log"
"forgejo.org/modules/timeutil"
"xorm.io/builder"
)
// ReportStatusType defines the statuses a report (of abusive content) can have.
type ReportStatusType int
const (
// ReportStatusTypeOpen represents the status of open reports that were not yet handled in any way.
ReportStatusTypeOpen ReportStatusType = iota + 1 // 1
// ReportStatusTypeHandled represents the status of valid reports, that have been acted upon.
ReportStatusTypeHandled // 2
// ReportStatusTypeIgnored represents the status of ignored reports, that were closed without any action.
ReportStatusTypeIgnored // 3
)
type (
// AbuseCategoryType defines the categories in which a user can include the reported content.
AbuseCategoryType int
// AbuseCategoryItem defines a pair of value and it's corresponding translation key
// (used to add options within the dropdown shown when new reports are submitted).
AbuseCategoryItem struct {
Value AbuseCategoryType
TranslationKey string
}
)
const (
AbuseCategoryTypeOther AbuseCategoryType = iota + 1 // 1 (Other violations of platform rules)
AbuseCategoryTypeSpam // 2
AbuseCategoryTypeMalware // 3
AbuseCategoryTypeIllegalContent // 4
)
// GetAbuseCategoriesList returns a list of pairs with the available abuse category types
// and their corresponding translation keys
func GetAbuseCategoriesList() []AbuseCategoryItem {
return []AbuseCategoryItem{
{AbuseCategoryTypeSpam, "moderation.abuse_category.spam"},
{AbuseCategoryTypeMalware, "moderation.abuse_category.malware"},
{AbuseCategoryTypeIllegalContent, "moderation.abuse_category.illegal_content"},
{AbuseCategoryTypeOther, "moderation.abuse_category.other_violations"},
}
}
// ReportedContentType defines the types of content that can be reported
// (i.e. user/organization profile, repository, issue/pull, comment).
type ReportedContentType int
const (
// ReportedContentTypeUser should be used when reporting abusive users or organizations.
ReportedContentTypeUser ReportedContentType = iota + 1 // 1
// ReportedContentTypeRepository should be used when reporting a repository with abusive content.
ReportedContentTypeRepository // 2
// ReportedContentTypeIssue should be used when reporting an issue or pull request with abusive content.
ReportedContentTypeIssue // 3
// ReportedContentTypeComment should be used when reporting a comment with abusive content.
ReportedContentTypeComment // 4
)
var allReportedContentTypes = []ReportedContentType{
ReportedContentTypeUser,
ReportedContentTypeRepository,
ReportedContentTypeIssue,
ReportedContentTypeComment,
}
func (t ReportedContentType) IsValid() bool {
return slices.Contains(allReportedContentTypes, t)
}
// AbuseReport represents a report of abusive content.
type AbuseReport struct {
ID int64 `xorm:"pk autoincr"`
Status ReportStatusType `xorm:"INDEX NOT NULL DEFAULT 1"`
// The ID of the user who submitted the report.
ReporterID int64 `xorm:"NOT NULL"`
// Reported content type: user/organization profile, repository, issue/pull or comment.
ContentType ReportedContentType `xorm:"INDEX NOT NULL"`
// The ID of the reported item (based on ContentType: user, repository, issue or comment).
ContentID int64 `xorm:"NOT NULL"`
// The abuse category selected by the reporter.
Category AbuseCategoryType `xorm:"INDEX NOT NULL"`
// Remarks provided by the reporter.
Remarks string
// The ID of the corresponding shadow-copied content when exists; otherwise null.
ShadowCopyID sql.NullInt64 `xorm:"DEFAULT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
}
var ErrSelfReporting = errors.New("reporting yourself is not allowed")
func init() {
// RegisterModel will create the table if does not already exist
// or any missing columns if the table was previously created.
// It will not drop or rename existing columns (when struct has changed).
db.RegisterModel(new(AbuseReport))
}
// IsShadowCopyNeeded reports whether one or more reports were already submitted
// for contentType and contentID and not yet linked to a shadow copy (regardless their status).
func IsShadowCopyNeeded(ctx context.Context, contentType ReportedContentType, contentID int64) (bool, error) {
return db.GetEngine(ctx).Cols("id").Where(builder.IsNull{"shadow_copy_id"}).Exist(
&AbuseReport{ContentType: contentType, ContentID: contentID},
)
}
// AlreadyReportedByAndOpen returns if doerID has already submitted a report for contentType and contentID that is still Open.
func AlreadyReportedByAndOpen(ctx context.Context, doerID int64, contentType ReportedContentType, contentID int64) bool {
reported, _ := db.GetEngine(ctx).Exist(&AbuseReport{
Status: ReportStatusTypeOpen,
ReporterID: doerID,
ContentType: contentType,
ContentID: contentID,
})
return reported
}
// ReportAbuse creates a new abuse report in the DB with 'Open' status.
// If the reported content is the user profile of the reporter ErrSelfReporting is returned.
// If there is already an open report submitted by the same user for the same content,
// the request will be ignored without returning an error (and a warning will be logged).
func ReportAbuse(ctx context.Context, report *AbuseReport) error {
if report.ContentType == ReportedContentTypeUser && report.ReporterID == report.ContentID {
return ErrSelfReporting
}
if AlreadyReportedByAndOpen(ctx, report.ReporterID, report.ContentType, report.ContentID) {
log.Warn("Seems that user %d wanted to report again the content with type %d and ID %d; this request will be ignored.", report.ReporterID, report.ContentType, report.ContentID)
return nil
}
report.Status = ReportStatusTypeOpen
_, err := db.GetEngine(ctx).Insert(report)
return err
}
/*
// MarkAsHandled will change the status to 'Handled' for all reports linked to the same item (user, repository, issue or comment).
func MarkAsHandled(ctx context.Context, contentType ReportedContentType, contentID int64) error {
return updateStatus(ctx, contentType, contentID, ReportStatusTypeHandled)
}
// MarkAsIgnored will change the status to 'Ignored' for all reports linked to the same item (user, repository, issue or comment).
func MarkAsIgnored(ctx context.Context, contentType ReportedContentType, contentID int64) error {
return updateStatus(ctx, contentType, contentID, ReportStatusTypeIgnored)
}
// updateStatus will set the provided status for any reports linked to the item with the given type and ID.
func updateStatus(ctx context.Context, contentType ReportedContentType, contentID int64, status ReportStatusType) error {
_, err := db.GetEngine(ctx).Where(builder.Eq{
"content_type": contentType,
"content_id": contentID,
}).Cols("status").Update(&AbuseReport{Status: status})
return err
}
*/

View file

@ -0,0 +1,76 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package moderation
import (
"context"
"database/sql"
"fmt"
"forgejo.org/models/db"
"forgejo.org/modules/log"
"forgejo.org/modules/timeutil"
"xorm.io/builder"
)
type AbuseReportShadowCopy struct {
ID int64 `xorm:"pk autoincr"`
RawValue string `xorm:"NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
}
// Returns the ID encapsulated in a sql.NullInt64 struct.
func (sc AbuseReportShadowCopy) NullableID() sql.NullInt64 {
return sql.NullInt64{Int64: sc.ID, Valid: sc.ID > 0}
}
func init() {
// RegisterModel will create the table if does not already exist
// or any missing columns if the table was previously created.
// It will not drop or rename existing columns (when struct has changed).
db.RegisterModel(new(AbuseReportShadowCopy))
}
func CreateShadowCopyForUser(ctx context.Context, userID int64, content string) error {
return createShadowCopy(ctx, ReportedContentTypeUser, userID, content)
}
func CreateShadowCopyForRepository(ctx context.Context, repoID int64, content string) error {
return createShadowCopy(ctx, ReportedContentTypeRepository, repoID, content)
}
func CreateShadowCopyForIssue(ctx context.Context, issueID int64, content string) error {
return createShadowCopy(ctx, ReportedContentTypeIssue, issueID, content)
}
func CreateShadowCopyForComment(ctx context.Context, commentID int64, content string) error {
return createShadowCopy(ctx, ReportedContentTypeComment, commentID, content)
}
func createShadowCopy(ctx context.Context, contentType ReportedContentType, contentID int64, content string) error {
err := db.WithTx(ctx, func(ctx context.Context) error {
sess := db.GetEngine(ctx)
shadowCopy := &AbuseReportShadowCopy{RawValue: content}
affected, err := sess.Insert(shadowCopy)
if err != nil {
return err
} else if affected == 0 {
log.Warn("Something went wrong while trying to create the shadow copy for reported content with type %d and ID %d.", contentType, contentID)
}
_, err = sess.Where(builder.Eq{
"content_type": contentType,
"content_id": contentID,
}).And(builder.IsNull{"shadow_copy_id"}).Update(&AbuseReport{ShadowCopyID: shadowCopy.NullableID()})
if err != nil {
return fmt.Errorf("could not link the shadow copy (%d) to reported content with type %d and ID %d - %w", shadowCopy.ID, contentType, contentID, err)
}
return nil
})
return err
}

70
models/repo/moderation.go Normal file
View file

@ -0,0 +1,70 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"context"
"forgejo.org/models/moderation"
"forgejo.org/modules/json"
"forgejo.org/modules/timeutil"
)
// RepositoryData represents a trimmed down repository that is used for preserving
// only the fields needed for abusive content reports (mainly string fields).
type RepositoryData struct {
OwnerID int64
OwnerName string
Name string
Description string
Website string
Topics []string
Avatar string
CreatedUnix timeutil.TimeStamp
UpdatedUnix timeutil.TimeStamp
}
// newRepositoryData creates a trimmed down repository to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newRepositoryData(repo *Repository) RepositoryData {
return RepositoryData{
OwnerID: repo.OwnerID,
OwnerName: repo.OwnerName,
Name: repo.Name,
Description: repo.Description,
Website: repo.Website,
Topics: repo.Topics,
Avatar: repo.Avatar,
CreatedUnix: repo.CreatedUnix,
UpdatedUnix: repo.UpdatedUnix,
}
}
// IfNeededCreateShadowCopyForRepository checks if for the given repository there are any reports of abusive content submitted
// and if found a shadow copy of relevant repository fields will be stored into DB and linked to the above report(s).
// This function should be called when a repository is deleted or updated.
func IfNeededCreateShadowCopyForRepository(ctx context.Context, repo *Repository, forUpdates bool) error {
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeRepository, repo.ID)
if err != nil {
return err
}
if shadowCopyNeeded {
if forUpdates {
// get the unmodified repository fields
repo, err = GetRepositoryByID(ctx, repo.ID)
if err != nil {
return err
}
}
repoData := newRepositoryData(repo)
content, err := json.Marshal(repoData)
if err != nil {
return err
}
return moderation.CreateShadowCopyForRepository(ctx, repo.ID, string(content))
}
return nil
}

112
models/user/moderation.go Normal file
View file

@ -0,0 +1,112 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package user
import (
"context"
"reflect"
"slices"
"sync"
"forgejo.org/models/moderation"
"forgejo.org/modules/json"
"forgejo.org/modules/timeutil"
"xorm.io/xorm/names"
)
// UserData represents a trimmed down user that is used for preserving
// only the fields needed for abusive content reports (mainly string fields).
type UserData struct { //revive:disable-line:exported
Name string
FullName string
Email string
LoginName string
Location string
Website string
Pronouns string
Description string
CreatedUnix timeutil.TimeStamp
UpdatedUnix timeutil.TimeStamp
// This field was intentionally renamed so that is not the same with the one from User struct.
// If we keep it the same as in User, during login it might trigger the creation of a shadow copy.
// TODO: Should we decide that this field is not that relevant for abuse reporting purposes, better remove it.
LastLogin timeutil.TimeStamp `json:"LastLoginUnix"`
Avatar string
AvatarEmail string
}
// newUserData creates a trimmed down user to be used just to create a JSON structure
// (keeping only the fields relevant for moderation purposes)
func newUserData(user *User) UserData {
return UserData{
Name: user.Name,
FullName: user.FullName,
Email: user.Email,
LoginName: user.LoginName,
Location: user.Location,
Website: user.Website,
Pronouns: user.Pronouns,
Description: user.Description,
CreatedUnix: user.CreatedUnix,
UpdatedUnix: user.UpdatedUnix,
LastLogin: user.LastLoginUnix,
Avatar: user.Avatar,
AvatarEmail: user.AvatarEmail,
}
}
// userDataColumnNames builds (only once) and returns a slice with the column names
// (e.g. FieldName -> field_name) corresponding to UserData struct fields.
var userDataColumnNames = sync.OnceValue(func() []string {
mapper := new(names.GonicMapper)
udType := reflect.TypeOf(UserData{})
columnNames := make([]string, 0, udType.NumField())
for i := 0; i < udType.NumField(); i++ {
columnNames = append(columnNames, mapper.Obj2Table(udType.Field(i).Name))
}
return columnNames
})
// IfNeededCreateShadowCopyForUser checks if for the given user there are any reports of abusive content submitted
// and if found a shadow copy of relevant user fields will be stored into DB and linked to the above report(s).
// This function should be called before a user is deleted or updated.
//
// For deletions alteredCols argument must be omitted.
//
// In case of updates it will first checks whether any of the columns being updated (alteredCols argument)
// is relevant for moderation purposes (i.e. included in the UserData struct).
func IfNeededCreateShadowCopyForUser(ctx context.Context, user *User, alteredCols ...string) error {
// TODO: this can be triggered quite often (e.g. by routers/web/repo/middlewares.go SetDiffViewStyle())
shouldCheckIfNeeded := len(alteredCols) == 0 // no columns being updated, therefore a deletion
if !shouldCheckIfNeeded {
// for updates we need to go further only if certain column are being changed
for _, colName := range userDataColumnNames() {
if shouldCheckIfNeeded = slices.Contains(alteredCols, colName); shouldCheckIfNeeded {
break
}
}
}
if !shouldCheckIfNeeded {
return nil
}
shadowCopyNeeded, err := moderation.IsShadowCopyNeeded(ctx, moderation.ReportedContentTypeUser, user.ID)
if err != nil {
return err
}
if shadowCopyNeeded {
userData := newUserData(user)
content, err := json.Marshal(userData)
if err != nil {
return err
}
return moderation.CreateShadowCopyForUser(ctx, user.ID, string(content))
}
return nil
}

View file

@ -153,6 +153,9 @@ type User struct {
KeepActivityPrivate bool `xorm:"NOT NULL DEFAULT false"`
KeepPronounsPrivate bool `xorm:"NOT NULL DEFAULT false"`
EnableRepoUnitHints bool `xorm:"NOT NULL DEFAULT true"`
// If you add new fields that might be used to store abusive content (mainly string fields),
// please also add them in the UserData struct and the corresponding constructor.
}
func init() {
@ -610,6 +613,7 @@ var (
"pulls",
"milestones",
"notifications",
"report_abuse",
"favicon.ico",
"manifest.json", // web app manifests
@ -919,6 +923,12 @@ func UpdateUserCols(ctx context.Context, u *User, cols ...string) error {
return err
}
// If the user was reported as abusive and any of the columns being updated is relevant
// for moderation purposes a shadow copy should be created before first update.
if err := IfNeededCreateShadowCopyForUser(ctx, u, cols...); err != nil {
return err
}
_, err := db.GetEngine(ctx).ID(u.ID).Cols(cols...).Update(u)
return err
}

View file

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

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

View file

@ -221,6 +221,7 @@ func LoadSettings() {
loadProjectFrom(CfgProvider)
loadMimeTypeMapFrom(CfgProvider)
loadF3From(CfgProvider)
loadModerationFrom(CfgProvider)
}
// LoadSettingsForInstall initializes the settings for install

View file

@ -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",

View file

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

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

View file

@ -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")

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

@ -1,4 +1,5 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package 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 {

View file

@ -1,4 +1,5 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package 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)
}

View file

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

View file

@ -247,6 +247,16 @@
</dl>
</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">
{{ctx.Locale.Tr "admin.config.cache_config"}}
</h4>

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

View file

@ -21,6 +21,14 @@
{{if .IsOrganizationMember}}
<a class="ui basic button tw-mr-0" href="{{.OrgLink}}/dashboard">{{ctx.Locale.Tr "org.open_dashboard"}}</a>
{{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>
</div>
{{if .RenderedDescription}}<div class="render-content markup">{{.RenderedDescription}}</div>{{end}}

View file

@ -67,6 +67,14 @@
{{if not $.DisableForks}}
{{template "repo/header_fork" $}}
{{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>
{{end}}
</div>

View file

@ -23,5 +23,13 @@
{{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>

View file

@ -123,6 +123,11 @@
</button>
{{end}}
</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}}
</ul>
</div>

View file

@ -491,6 +491,7 @@ input:-webkit-autofill:active,
margin: 0;
}
.moderation.new-report form,
.repository.new.repo form,
.repository.new.migrate form,
.repository.new.fork form {
@ -504,11 +505,13 @@ input:-webkit-autofill:active,
}
@media (min-width: 768px) {
.moderation.new-report form,
.repository.new.repo form,
.repository.new.migrate form,
.repository.new.fork form {
width: 800px !important;
}
.moderation.new-report form .header,
.repository.new.repo form .header,
.repository.new.migrate form .header,
.repository.new.fork form .header {
@ -555,6 +558,7 @@ input:-webkit-autofill:active,
margin-right: 0 !important;
}
.moderation.new-report form .header,
.repository.new.repo form .header,
.repository.new.migrate form .header,
.repository.new.fork form .header {

View file

@ -38,7 +38,8 @@
}
.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;
display: flex;
justify-content: center;