diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 71598663b2..8f7c4658ce 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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] diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 384f382c82..a22d35dd21 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -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. diff --git a/models/forgejo_migrations/v38.go b/models/forgejo_migrations/v38.go new file mode 100644 index 0000000000..24240f15a0 --- /dev/null +++ b/models/forgejo_migrations/v38.go @@ -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{}) +} diff --git a/models/moderation/abuse_report.go b/models/moderation/abuse_report.go index 9852268910..0bf8aab174 100644 --- a/models/moderation/abuse_report.go +++ b/models/moderation/abuse_report.go @@ -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 { diff --git a/modules/setting/moderation.go b/modules/setting/moderation.go index 5f35a284d6..799efed761 100644 --- a/modules/setting/moderation.go +++ b/modules/setting/moderation.go @@ -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 } diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 75c24580b2..9644d9b83b 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -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 diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 322fe27ca0..3006601366 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -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() + } } diff --git a/services/moderation/main_test.go b/services/moderation/main_test.go new file mode 100644 index 0000000000..3a268260d2 --- /dev/null +++ b/services/moderation/main_test.go @@ -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) +} diff --git a/services/moderation/reporting.go b/services/moderation/reporting.go index e01156dc11..3d1bb5b32c 100644 --- a/services/moderation/reporting.go +++ b/services/moderation/reporting.go @@ -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 +} diff --git a/services/moderation/reporting_test.go b/services/moderation/reporting_test.go new file mode 100644 index 0000000000..70925bf184 --- /dev/null +++ b/services/moderation/reporting_test.go @@ -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) +}