mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-08-22 02:11:12 +00:00
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:
parent
b2c8a1cfd3
commit
29eaab5ff4
10 changed files with 247 additions and 4 deletions
|
@ -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.
|
;; 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
|
;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]
|
;[openid]
|
||||||
|
|
|
@ -111,7 +111,10 @@ var migrations = []*Migration{
|
||||||
NewMigration("Noop because of https://codeberg.org/forgejo/forgejo/issues/8373", NoopAddIndexToActionRunStopped),
|
NewMigration("Noop because of https://codeberg.org/forgejo/forgejo/issues/8373", NoopAddIndexToActionRunStopped),
|
||||||
// v35 -> v36
|
// v35 -> v36
|
||||||
NewMigration("Fix wiki unit default permission", FixWikiUnitDefaultPermission),
|
NewMigration("Fix wiki unit default permission", FixWikiUnitDefaultPermission),
|
||||||
|
// v36 -> v37
|
||||||
NewMigration("Add `branch_filter` to `push_mirror` table", AddPushMirrorBranchFilter),
|
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.
|
// GetCurrentDBVersion returns the current Forgejo database version.
|
||||||
|
|
19
models/forgejo_migrations/v38.go
Normal file
19
models/forgejo_migrations/v38.go
Normal 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{})
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
"forgejo.org/models/db"
|
"forgejo.org/models/db"
|
||||||
"forgejo.org/modules/log"
|
"forgejo.org/modules/log"
|
||||||
|
@ -111,6 +112,7 @@ type AbuseReport struct {
|
||||||
// The ID of the corresponding shadow-copied content when exists; otherwise null.
|
// The ID of the corresponding shadow-copied content when exists; otherwise null.
|
||||||
ShadowCopyID sql.NullInt64 `xorm:"DEFAULT NULL"`
|
ShadowCopyID sql.NullInt64 `xorm:"DEFAULT NULL"`
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL"`
|
||||||
|
ResolvedUnix timeutil.TimeStamp `xorm:"DEFAULT NULL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrSelfReporting = errors.New("reporting yourself is not allowed")
|
var ErrSelfReporting = errors.New("reporting yourself is not allowed")
|
||||||
|
@ -161,6 +163,25 @@ func ReportAbuse(ctx context.Context, report *AbuseReport) error {
|
||||||
return err
|
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).
|
// 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 {
|
func MarkAsHandled(ctx context.Context, contentType ReportedContentType, contentID int64) error {
|
||||||
|
|
|
@ -3,13 +3,28 @@
|
||||||
|
|
||||||
package setting
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Moderation settings
|
// Moderation settings
|
||||||
var Moderation = struct {
|
var Moderation = struct {
|
||||||
Enabled bool `ini:"ENABLED"`
|
Enabled bool `ini:"ENABLED"`
|
||||||
|
KeepResolvedReportsFor time.Duration `ini:"KEEP_RESOLVED_REPORTS_FOR"`
|
||||||
}{
|
}{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadModerationFrom(rootCfg ConfigProvider) {
|
func loadModerationFrom(rootCfg ConfigProvider) error {
|
||||||
mustMapSetting(rootCfg, "moderation", &Moderation)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,6 +140,10 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
||||||
if err := loadActionsFrom(cfg); err != nil {
|
if err := loadActionsFrom(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := loadModerationFrom(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
loadUIFrom(cfg)
|
loadUIFrom(cfg)
|
||||||
loadAdminFrom(cfg)
|
loadAdminFrom(cfg)
|
||||||
loadAPIFrom(cfg)
|
loadAPIFrom(cfg)
|
||||||
|
@ -221,7 +225,6 @@ func LoadSettings() {
|
||||||
loadProjectFrom(CfgProvider)
|
loadProjectFrom(CfgProvider)
|
||||||
loadMimeTypeMapFrom(CfgProvider)
|
loadMimeTypeMapFrom(CfgProvider)
|
||||||
loadF3From(CfgProvider)
|
loadF3From(CfgProvider)
|
||||||
loadModerationFrom(CfgProvider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSettingsForInstall initializes the settings for install
|
// LoadSettingsForInstall initializes the settings for install
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
issue_indexer "forgejo.org/modules/indexer/issues"
|
issue_indexer "forgejo.org/modules/indexer/issues"
|
||||||
"forgejo.org/modules/setting"
|
"forgejo.org/modules/setting"
|
||||||
"forgejo.org/modules/updatechecker"
|
"forgejo.org/modules/updatechecker"
|
||||||
|
moderation_service "forgejo.org/services/moderation"
|
||||||
repo_service "forgejo.org/services/repository"
|
repo_service "forgejo.org/services/repository"
|
||||||
archiver_service "forgejo.org/services/repository/archiver"
|
archiver_service "forgejo.org/services/repository/archiver"
|
||||||
user_service "forgejo.org/services/user"
|
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() {
|
func initExtendedTasks() {
|
||||||
registerDeleteInactiveUsers()
|
registerDeleteInactiveUsers()
|
||||||
registerDeleteRepositoryArchives()
|
registerDeleteRepositoryArchives()
|
||||||
|
@ -240,4 +259,7 @@ func initExtendedTasks() {
|
||||||
registerDeleteOldSystemNotices()
|
registerDeleteOldSystemNotices()
|
||||||
registerGCLFS()
|
registerGCLFS()
|
||||||
registerRebuildIssueIndexer()
|
registerRebuildIssueIndexer()
|
||||||
|
if setting.Moderation.Enabled {
|
||||||
|
registerRemoveResolvedReports()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
17
services/moderation/main_test.go
Normal file
17
services/moderation/main_test.go
Normal 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)
|
||||||
|
}
|
|
@ -4,8 +4,11 @@
|
||||||
package moderation
|
package moderation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
stdCtx "context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.org/models/db"
|
||||||
"forgejo.org/models/issues"
|
"forgejo.org/models/issues"
|
||||||
"forgejo.org/models/moderation"
|
"forgejo.org/models/moderation"
|
||||||
"forgejo.org/models/perm"
|
"forgejo.org/models/perm"
|
||||||
|
@ -127,3 +130,41 @@ func CanReport(ctx context.Context, doer *user.User, contentType moderation.Repo
|
||||||
|
|
||||||
return hasAccess, nil
|
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
|
||||||
|
}
|
||||||
|
|
97
services/moderation/reporting_test.go
Normal file
97
services/moderation/reporting_test.go
Normal 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)
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue