mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-08-19 17:01: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.
|
||||
;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]
|
||||
|
|
|
@ -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.
|
||||
|
|
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"
|
||||
"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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
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
|
||||
|
||||
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
|
||||
}
|
||||
|
|
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