feat: add configurable timeout for automatically removing resolved reports (#7940)

Supersedes [this PR](https://codeberg.org/lenikadali/forgejo/pulls/1)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7940
Reviewed-by: Otto <otto@codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Leni Kadali <lenikadali@noreply.codeberg.org>
Co-committed-by: Leni Kadali <lenikadali@noreply.codeberg.org>
This commit is contained in:
Leni Kadali 2025-07-28 14:52:13 +02:00 committed by Earl Warren
commit 29eaab5ff4
10 changed files with 247 additions and 4 deletions

View file

@ -1586,6 +1586,11 @@ LEVEL = Info
;; 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
;; How long to keep resolved abuse reports for.
;; Applies to reports that have been marked as ignored or handled
;; Can be 1 hour, 7 days etc
;KEEP_RESOLVED_REPORTS_FOR = 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;[openid]

View file

@ -111,7 +111,10 @@ var migrations = []*Migration{
NewMigration("Noop because of https://codeberg.org/forgejo/forgejo/issues/8373", NoopAddIndexToActionRunStopped),
// v35 -> v36
NewMigration("Fix wiki unit default permission", FixWikiUnitDefaultPermission),
// v36 -> v37
NewMigration("Add `branch_filter` to `push_mirror` table", AddPushMirrorBranchFilter),
// v37 -> v38
NewMigration("Add `resolved_unix` column to `abuse_report` table", AddResolvedUnixToAbuseReport),
}
// GetCurrentDBVersion returns the current Forgejo database version.

View file

@ -0,0 +1,19 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"forgejo.org/modules/timeutil"
"xorm.io/xorm"
)
func AddResolvedUnixToAbuseReport(x *xorm.Engine) error {
type AbuseReport struct {
ID int64 `xorm:"pk autoincr"`
ResolvedUnix timeutil.TimeStamp `xorm:"DEFAULT NULL"`
}
return x.Sync(&AbuseReport{})
}

View file

@ -8,6 +8,7 @@ import (
"database/sql"
"errors"
"slices"
"time"
"forgejo.org/models/db"
"forgejo.org/modules/log"
@ -111,6 +112,7 @@ type AbuseReport struct {
// The ID of the corresponding shadow-copied content when exists; otherwise null.
ShadowCopyID sql.NullInt64 `xorm:"DEFAULT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
ResolvedUnix timeutil.TimeStamp `xorm:"DEFAULT NULL"`
}
var ErrSelfReporting = errors.New("reporting yourself is not allowed")
@ -161,6 +163,25 @@ func ReportAbuse(ctx context.Context, report *AbuseReport) error {
return err
}
// GetResolvedReports gets all resolved reports
func GetResolvedReports(ctx context.Context, keepReportsFor time.Duration) ([]*AbuseReport, error) {
cond := builder.And(
builder.Or(
builder.Eq{"`status`": ReportStatusTypeHandled},
builder.Eq{"`status`": ReportStatusTypeIgnored},
),
)
if keepReportsFor > 0 {
cond = cond.And(builder.Lt{"resolved_unix": time.Now().Add(-keepReportsFor).Unix()})
}
abuseReports := make([]*AbuseReport, 0, 30)
return abuseReports, db.GetEngine(ctx).
Where(cond).
Find(&abuseReports)
}
/*
// MarkAsHandled will change the status to 'Handled' for all reports linked to the same item (user, repository, issue or comment).
func MarkAsHandled(ctx context.Context, contentType ReportedContentType, contentID int64) error {

View file

@ -3,13 +3,28 @@
package setting
import (
"fmt"
"time"
)
// Moderation settings
var Moderation = struct {
Enabled bool `ini:"ENABLED"`
Enabled bool `ini:"ENABLED"`
KeepResolvedReportsFor time.Duration `ini:"KEEP_RESOLVED_REPORTS_FOR"`
}{
Enabled: false,
}
func loadModerationFrom(rootCfg ConfigProvider) {
mustMapSetting(rootCfg, "moderation", &Moderation)
func loadModerationFrom(rootCfg ConfigProvider) error {
sec := rootCfg.Section("moderation")
err := sec.MapTo(&Moderation)
if err != nil {
return fmt.Errorf("failed to map Moderation settings: %v", err)
}
// keep reports for one week by default. Since time.Duration stops at the unit of an hour
// we are using the value of 24 (hours) * 7 (days) which gives us the value of 168
Moderation.KeepResolvedReportsFor = sec.Key("KEEP_RESOLVED_REPORTS_FOR").MustDuration(168 * time.Hour)
return nil
}

View file

@ -140,6 +140,10 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
if err := loadActionsFrom(cfg); err != nil {
return err
}
if err := loadModerationFrom(cfg); err != nil {
return err
}
loadUIFrom(cfg)
loadAdminFrom(cfg)
loadAPIFrom(cfg)
@ -221,7 +225,6 @@ func LoadSettings() {
loadProjectFrom(CfgProvider)
loadMimeTypeMapFrom(CfgProvider)
loadF3From(CfgProvider)
loadModerationFrom(CfgProvider)
}
// LoadSettingsForInstall initializes the settings for install

View file

@ -15,6 +15,7 @@ import (
issue_indexer "forgejo.org/modules/indexer/issues"
"forgejo.org/modules/setting"
"forgejo.org/modules/updatechecker"
moderation_service "forgejo.org/services/moderation"
repo_service "forgejo.org/services/repository"
archiver_service "forgejo.org/services/repository/archiver"
user_service "forgejo.org/services/user"
@ -225,6 +226,24 @@ func registerRebuildIssueIndexer() {
})
}
func registerRemoveResolvedReports() {
type ReportConfig struct {
BaseConfig
ConfigKeepResolvedReportsFor time.Duration
}
RegisterTaskFatal("remove_resolved_reports", &ReportConfig{
BaseConfig: BaseConfig{
Enabled: false,
RunAtStart: false,
Schedule: "@every 24h",
},
ConfigKeepResolvedReportsFor: setting.Moderation.KeepResolvedReportsFor,
}, func(ctx context.Context, _ *user_model.User, config Config) error {
reportConfig := config.(*ReportConfig)
return moderation_service.RemoveResolvedReports(ctx, reportConfig.ConfigKeepResolvedReportsFor)
})
}
func initExtendedTasks() {
registerDeleteInactiveUsers()
registerDeleteRepositoryArchives()
@ -240,4 +259,7 @@ func initExtendedTasks() {
registerDeleteOldSystemNotices()
registerGCLFS()
registerRebuildIssueIndexer()
if setting.Moderation.Enabled {
registerRemoveResolvedReports()
}
}

View file

@ -0,0 +1,17 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package moderation
import (
"testing"
"forgejo.org/models/unittest"
_ "forgejo.org/models/forgefed"
_ "forgejo.org/models/moderation"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

View file

@ -4,8 +4,11 @@
package moderation
import (
stdCtx "context"
"errors"
"time"
"forgejo.org/models/db"
"forgejo.org/models/issues"
"forgejo.org/models/moderation"
"forgejo.org/models/perm"
@ -127,3 +130,41 @@ func CanReport(ctx context.Context, doer *user.User, contentType moderation.Repo
return hasAccess, nil
}
// RemoveResolvedReports removes resolved reports
func RemoveResolvedReports(ctx stdCtx.Context, keepReportsFor time.Duration) error {
log.Trace("Doing: RemoveResolvedReports")
if keepReportsFor <= 0 {
return nil
}
err := db.WithTx(ctx, func(ctx stdCtx.Context) error {
resolvedReports, err := moderation.GetResolvedReports(ctx, keepReportsFor)
if err != nil {
return err
}
for _, report := range resolvedReports {
_, err := db.GetEngine(ctx).ID(report.ID).Delete(&moderation.AbuseReport{})
if err != nil {
return err
}
if report.ShadowCopyID.Valid {
_, err := db.GetEngine(ctx).ID(report.ShadowCopyID).Delete(&moderation.AbuseReportShadowCopy{})
if err != nil {
return err
}
}
}
return nil
})
if err != nil {
return err
}
log.Trace("Finished: RemoveResolvedReports")
return nil
}

View file

@ -0,0 +1,97 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package moderation
import (
"testing"
"time"
"forgejo.org/models/db"
report_model "forgejo.org/models/moderation"
"forgejo.org/models/unittest"
"forgejo.org/modules/timeutil"
"github.com/stretchr/testify/require"
)
func TestRemoveResolvedReportsWhenNoTimeSet(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// reportAge needs to be an int64 to match what timeutil.Day expects so we cast the value
reportAge := int64(20)
resolvedReport := &report_model.AbuseReport{
Status: report_model.ReportStatusTypeHandled,
ReporterID: 1, ContentType: report_model.ReportedContentTypeRepository,
ContentID: 2, Category: report_model.AbuseCategoryTypeOther,
CreatedUnix: timeutil.TimeStampNow(),
ResolvedUnix: timeutil.TimeStamp(time.Now().Unix() - timeutil.Day*reportAge),
}
_, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(resolvedReport)
require.NoError(t, err)
// No reports should be deleted when the default time to keep is 0
err = RemoveResolvedReports(db.DefaultContext, time.Second*0)
require.NoError(t, err)
unittest.AssertExistsIf(t, true, resolvedReport)
}
func TestRemoveResolvedReportsWhenMatchTimeSet(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// keepReportsFor needs to an int64 to match what timeutil.Day expects so we cast the value
keepReportsFor := int64(4)
resolvedReport := &report_model.AbuseReport{
Status: report_model.ReportStatusTypeHandled,
ReporterID: 1, ContentType: report_model.ReportedContentTypeRepository,
ContentID: 2, Category: report_model.AbuseCategoryTypeOther,
CreatedUnix: timeutil.TimeStampNow(),
ResolvedUnix: timeutil.TimeStamp(time.Now().Unix() - timeutil.Day*keepReportsFor),
}
_, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(resolvedReport)
require.NoError(t, err)
// Report should be deleted when older than the default time to keep
err = RemoveResolvedReports(db.DefaultContext, time.Second*4)
require.NoError(t, err)
unittest.AssertExistsIf(t, false, resolvedReport)
}
func TestRemoveResolvedReportsWhenTimeSetButReportNew(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
resolvedReport := &report_model.AbuseReport{
Status: report_model.ReportStatusTypeHandled,
ReporterID: 1, ContentType: report_model.ReportedContentTypeRepository,
ContentID: 2, Category: report_model.AbuseCategoryTypeOther,
CreatedUnix: timeutil.TimeStampNow(),
ResolvedUnix: timeutil.TimeStampNow(),
}
_, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(resolvedReport)
require.NoError(t, err)
// Report should not be deleted when newer than the default time to keep
err = RemoveResolvedReports(db.DefaultContext, time.Second*4)
require.NoError(t, err)
unittest.AssertExistsIf(t, true, resolvedReport)
}
func TestDoesNotRemoveOpenReports(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
// keepReportsFor needs to an int64 to match what timeutil.Day expects so we cast the value
keepReportsFor := int64(4)
resolvedReport := &report_model.AbuseReport{
Status: report_model.ReportStatusTypeOpen,
ReporterID: 1, ContentType: report_model.ReportedContentTypeRepository,
ContentID: 2, Category: report_model.AbuseCategoryTypeOther,
CreatedUnix: timeutil.TimeStampNow(),
ResolvedUnix: timeutil.TimeStamp(time.Now().Unix() - timeutil.Day*keepReportsFor),
}
_, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(resolvedReport)
require.NoError(t, err)
// Report should not be deleted when open
// and older than the default time to keep
err = RemoveResolvedReports(db.DefaultContext, time.Second*4)
require.NoError(t, err)
unittest.AssertExistsIf(t, true, resolvedReport)
}