From 778d2a28f91e6472070c02a8e6519ec8430ed555 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 18 Apr 2025 17:03:33 +0200 Subject: [PATCH 1/2] Spam reporting patch --- patches/0017-BLENDER-spam-reporting.patch | 968 ++++++++++++++++++++++ 1 file changed, 968 insertions(+) create mode 100644 patches/0017-BLENDER-spam-reporting.patch diff --git a/patches/0017-BLENDER-spam-reporting.patch b/patches/0017-BLENDER-spam-reporting.patch new file mode 100644 index 0000000000000..3ac45098f745c --- /dev/null +++ b/patches/0017-BLENDER-spam-reporting.patch @@ -0,0 +1,968 @@ +From ebf2aa91212aafc18f77c897444bf4ed5add3d2a Mon Sep 17 00:00:00 2001 +From: Oleg Komarov +Date: Fri, 11 Apr 2025 16:57:54 +0200 +Subject: [PATCH] BLENDER: spam reporting + +Spam reporting is available for trusted users (org members) via a button on a +spammer's profile page; +processing is done automatically using a new cron task process_spam_reports; +a new section Site Administration > Identity & Access > Spam Reports. +--- + models/user/spamreport.go | 128 ++++++++++ + options/locale/locale_en-US.ini | 15 ++ + routers/web/admin/spamreports.go | 105 ++++++++ + routers/web/shared/user/header.go | 20 ++ + routers/web/user/setting/spamreport.go | 43 ++++ + routers/web/web.go | 9 + + services/cron/cron.go | 2 + + services/cron/tasks_spamreport.go | 34 +++ + services/user/spamreport.go | 229 ++++++++++++++++++ + services/user/spamreport_test.go | 79 ++++++ + templates/admin/navbar.tmpl | 3 + + templates/admin/spamreports/list.tmpl | 86 +++++++ + templates/shared/user/profile_big_avatar.tmpl | 12 + + .../shared/user/spamreport_user_dialog.tmpl | 14 ++ + 14 files changed, 779 insertions(+) + create mode 100644 models/user/spamreport.go + create mode 100644 routers/web/admin/spamreports.go + create mode 100644 routers/web/user/setting/spamreport.go + create mode 100644 services/cron/tasks_spamreport.go + create mode 100644 services/user/spamreport.go + create mode 100644 services/user/spamreport_test.go + create mode 100644 templates/admin/spamreports/list.tmpl + create mode 100644 templates/shared/user/spamreport_user_dialog.tmpl + +diff --git a/models/user/spamreport.go b/models/user/spamreport.go +new file mode 100644 +index 0000000000..6ab133ec14 +--- /dev/null ++++ b/models/user/spamreport.go +@@ -0,0 +1,128 @@ ++// Copyright 2025 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++// BLENDER: spam reporting ++ ++package user ++ ++import ( ++ "context" ++ "fmt" ++ ++ "code.gitea.io/gitea/models/db" ++ "code.gitea.io/gitea/modules/timeutil" ++) ++ ++// SpamReportStatusType is used to support a spam report lifecycle: ++// ++// pending -> locked ++// locked -> processed | dismissed ++// ++// "locked" status works as a lock for a record that is being processed. ++type SpamReportStatusType int ++ ++const ( ++ SpamReportStatusTypePending = iota // 0 ++ SpamReportStatusTypeLocked // 1 ++ SpamReportStatusTypeProcessed // 2 ++ SpamReportStatusTypeDismissed // 3 ++) ++ ++func (t SpamReportStatusType) String() string { ++ switch t { ++ case SpamReportStatusTypePending: ++ return "pending" ++ case SpamReportStatusTypeLocked: ++ return "locked" ++ case SpamReportStatusTypeProcessed: ++ return "processed" ++ case SpamReportStatusTypeDismissed: ++ return "dismissed" ++ } ++ return "unknown" ++} ++ ++type SpamReport struct { ++ ID int64 `xorm:"pk autoincr"` ++ UserID int64 `xorm:"UNIQUE"` ++ ReporterID int64 `xorm:"NOT NULL"` ++ Status SpamReportStatusType `xorm:"INDEX NOT NULL DEFAULT 0"` ++ CreatedUnix timeutil.TimeStamp `xorm:"created"` ++ UpdatedUnix timeutil.TimeStamp `xorm:"updated"` ++} ++ ++func (*SpamReport) TableName() string { ++ return "user_spamreport" ++} ++ ++func init() { ++ // This table doesn't exist in the upstream code. ++ // We don't introduce migrations for it to avoid migration id clashes. ++ // Gitea will create the table in the database during startup, ++ // so no manual action is required until we start modifying the table. ++ db.RegisterModel(new(SpamReport)) ++} ++ ++type ListSpamReportsOptions struct { ++ db.ListOptions ++ Status SpamReportStatusType ++} ++ ++type ListSpamReportsResults struct { ++ ID int64 ++ CreatedUnix timeutil.TimeStamp ++ UpdatedUnix timeutil.TimeStamp ++ Status SpamReportStatusType ++ UserName string ++ ReporterName string ++} ++ ++func ListSpamReports(ctx context.Context, opts *ListSpamReportsOptions) ([]*ListSpamReportsResults, int64, error) { ++ opts.SetDefaultValues() ++ count, err := db.GetEngine(ctx).Count(new(SpamReport)) ++ if err != nil { ++ return nil, 0, fmt.Errorf("Count: %w", err) ++ } ++ spamReports := make([]*ListSpamReportsResults, 0, opts.PageSize) ++ err = db.GetEngine(ctx).Table("user_spamreport"). ++ Select("user_spamreport.id, user_spamreport.created_unix, user_spamreport.updated_unix, user_spamreport.status, `user`.name as user_name, reporter.name as reporter_name"). ++ Join("LEFT", "`user`", "`user`.id = user_spamreport.user_id"). ++ Join("LEFT", "`user` as reporter", "`reporter`.id = user_spamreport.reporter_id"). ++ Where("status = ?", opts.Status). ++ Limit(opts.PageSize, (opts.Page-1)*opts.PageSize). ++ Find(&spamReports) ++ ++ return spamReports, count, err ++} ++ ++func GetPendingSpamReportIDs(ctx context.Context) ([]int64, error) { ++ var ids []int64 ++ err := db.GetEngine(ctx).Table("user_spamreport"). ++ Select("id").Where("status = ?", SpamReportStatusTypePending).Find(&ids) ++ return ids, err ++} ++ ++type SpamReportStatusCounts struct { ++ Count int64 ++ Status SpamReportStatusType ++} ++ ++func GetSpamReportStatusCounts(ctx context.Context) ([]*SpamReportStatusCounts, error) { ++ statusCounts := make([]*SpamReportStatusCounts, 0, 4) // 4 status types ++ err := db.GetEngine(ctx).Table("user_spamreport"). ++ Select("count(*) as count, status"). ++ GroupBy("status"). ++ Find(&statusCounts) ++ ++ return statusCounts, err ++} ++ ++func GetSpamReportForUser(ctx context.Context, user *User) (*SpamReport, error) { ++ spamReport := &SpamReport{} ++ has, err := db.GetEngine(ctx).Where("user_id = ?", user.ID).Get(spamReport) ++ if has { ++ return spamReport, err ++ } else { ++ return nil, err ++ } ++} +diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini +index 9d71ccb6d5..435c2122be 100644 +--- a/options/locale/locale_en-US.ini ++++ b/options/locale/locale_en-US.ini +@@ -692,6 +692,12 @@ block.note.edit = Edit note + block.list = Blocked users + block.list.none = You have not blocked any users. + ++spamreport.info = Report a user as a spammer, reports are processed automatically, all content created by the user will be deleted! ++spamreport.report = Report ++spamreport.report.user = Report spam ++spamreport.title = Report a user ++spamreport.existing = The user has already been reported as a spammer, the report is %s. ++ + [settings] + profile = Profile + account = Account +@@ -2889,6 +2895,7 @@ first_page = First + last_page = Last + total = Total: %d + settings = Admin Settings ++spamreports = Spam Reports + + dashboard.new_version_hint = Gitea %s is now available, you are running %s. Check the blog for more details. + dashboard.statistic = Summary +@@ -2976,6 +2983,7 @@ dashboard.sync_branch.started = Branches Sync started + dashboard.sync_tag.started = Tags Sync started + dashboard.rebuild_issue_indexer = Rebuild issue indexer + dashboard.sync_repo_licenses = Sync repo licenses ++dashboard.process_spam_reports = Process spam reports + + users.user_manage_panel = User Account Management + users.new_account = Create User Account +@@ -3052,6 +3060,13 @@ emails.delete_desc = Are you sure you want to delete this email address? + emails.deletion_success = The email address has been deleted. + emails.delete_primary_email_error = You can not delete the primary email. + ++spamreports.spamreport_manage_panel = Spam Report Management ++spamreports.user = Reported for spam ++spamreports.reporter = Reporter ++spamreports.created = Created ++spamreports.updated = Updated ++spamreports.status = Report Status ++ + orgs.org_manage_panel = Organization Management + orgs.name = Name + orgs.teams = Teams +diff --git a/routers/web/admin/spamreports.go b/routers/web/admin/spamreports.go +new file mode 100644 +index 0000000000..e99285f3bc +--- /dev/null ++++ b/routers/web/admin/spamreports.go +@@ -0,0 +1,105 @@ ++// Copyright 2025 The Gitea Authors. ++// SPDX-License-Identifier: MIT ++ ++// BLENDER: spam reporting ++ ++package admin ++ ++import ( ++ "fmt" ++ "net/http" ++ "strconv" ++ ++ "code.gitea.io/gitea/models/db" ++ user_model "code.gitea.io/gitea/models/user" ++ "code.gitea.io/gitea/modules/base" ++ "code.gitea.io/gitea/modules/setting" ++ "code.gitea.io/gitea/services/context" ++ user_service "code.gitea.io/gitea/services/user" ++) ++ ++const ( ++ tplSpamReports base.TplName = "admin/spamreports/list" ++) ++ ++// SpamReports shows spam reports ++func SpamReports(ctx *context.Context) { ++ ctx.Data["Title"] = ctx.Tr("admin.spamreports") ++ ctx.Data["PageIsSpamReports"] = true ++ ++ var ( ++ count int64 ++ err error ++ filterStatus user_model.SpamReportStatusType ++ ) ++ ++ // When no value is specified reports are filtered by status=pending (=0), ++ // which luckily makes sense as a default view. ++ filterStatus = user_model.SpamReportStatusType(ctx.FormInt("status")) ++ ctx.Data["FilterStatus"] = filterStatus ++ opts := &user_model.ListSpamReportsOptions{ ++ ListOptions: db.ListOptions{ ++ PageSize: setting.UI.Admin.UserPagingNum, ++ Page: ctx.FormInt("page"), ++ }, ++ Status: filterStatus, ++ } ++ ++ if opts.Page <= 1 { ++ opts.Page = 1 ++ } ++ ++ spamReports, count, err := user_model.ListSpamReports(ctx, opts) ++ if err != nil { ++ ctx.ServerError("SpamReports", err) ++ return ++ } ++ ++ ctx.Data["Total"] = count ++ ctx.Data["SpamReports"] = spamReports ++ ids, _ := user_model.GetPendingSpamReportIDs(ctx) ++ fmt.Printf("%v", ids) ++ ++ pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) ++ pager.SetDefaultParams(ctx) ++ ctx.Data["Page"] = pager ++ ++ statusCounts, err := user_model.GetSpamReportStatusCounts(ctx) ++ if err != nil { ++ ctx.ServerError("GetSpamReportStatusCounts", err) ++ return ++ } ++ ctx.Data["StatusCounts"] = statusCounts ++ ++ ctx.HTML(http.StatusOK, tplSpamReports) ++} ++ ++// SpamReportsPost handles "process" and "dismiss" actions for pending reports. ++// The processing is done synchronously. ++func SpamReportsPost(ctx *context.Context) { ++ action := ctx.FormString("action") ++ // ctx.Req.PostForm is now parsed due to the call to FormString above ++ spamReportIDs := make([]int64, 0, len(ctx.Req.PostForm["spamreport_id"])) ++ for _, idStr := range ctx.Req.PostForm["spamreport_id"] { ++ id, err := strconv.ParseInt(idStr, 10, 64) ++ if err != nil { ++ ctx.ServerError("ParseSpamReportID", err) ++ return ++ } ++ spamReportIDs = append(spamReportIDs, id) ++ } ++ ++ if action == "process" { ++ if err := user_service.ProcessSpamReports(ctx, ctx.Doer, spamReportIDs); err != nil { ++ ctx.ServerError("ProcessSpamReports", err) ++ return ++ } ++ } ++ if action == "dismiss" { ++ if err := user_service.DismissSpamReports(ctx, spamReportIDs); err != nil { ++ ctx.ServerError("DismissSpamReports", err) ++ return ++ } ++ } ++ ctx.Redirect(setting.AppSubURL + "/-/admin/spamreports") ++} +diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go +index 4cb0592b4b..e1c8b9608a 100644 +--- a/routers/web/shared/user/header.go ++++ b/routers/web/shared/user/header.go +@@ -21,6 +21,7 @@ import ( + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" ++ user_service "code.gitea.io/gitea/services/user" + ) + + // prepareContextForCommonProfile store some common data into context data for user's profile related pages (including the nav menu) +@@ -90,6 +91,25 @@ func PrepareContextForProfileBigAvatar(ctx *context.Context) { + } else { + ctx.Data["UserBlocking"] = block + } ++ ++ // BLENDER: spam reporting ++ doerIsTrusted, err := user_service.IsTrustedUser(ctx, ctx.Doer) ++ if err != nil { ++ ctx.ServerError("IsTrustedUser", err) ++ return ++ } ++ userIsTrusted, err := user_service.IsTrustedUser(ctx, ctx.ContextUser) ++ if err != nil { ++ ctx.ServerError("IsTrustedUser", err) ++ return ++ } ++ ctx.Data["CanReportSpam"] = doerIsTrusted && !userIsTrusted ++ existingSpamReport, err := user_model.GetSpamReportForUser(ctx, ctx.ContextUser) ++ if err != nil { ++ ctx.ServerError("GetSpamReportForUser", err) ++ return ++ } ++ ctx.Data["ExistingSpamReport"] = existingSpamReport + } + } + +diff --git a/routers/web/user/setting/spamreport.go b/routers/web/user/setting/spamreport.go +new file mode 100644 +index 0000000000..58655de951 +--- /dev/null ++++ b/routers/web/user/setting/spamreport.go +@@ -0,0 +1,43 @@ ++// Copyright 2025 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++// BLENDER: spam reporting ++ ++package setting ++ ++import ( ++ "net/http" ++ ++ user_model "code.gitea.io/gitea/models/user" ++ "code.gitea.io/gitea/modules/setting" ++ "code.gitea.io/gitea/services/context" ++ user_service "code.gitea.io/gitea/services/user" ++) ++ ++// SpamReportUserPost creates a spam report for a given user. ++func SpamReportUserPost(ctx *context.Context) { ++ canReportSpam, err := user_service.IsTrustedUser(ctx, ctx.Doer) ++ if err != nil { ++ ctx.ServerError("IsTrustedUser", err) ++ return ++ } ++ if !canReportSpam { ++ ctx.PlainText(http.StatusForbidden, "you are not allowed to report spam") ++ } ++ username := ctx.FormString("username") ++ ++ user, err := user_model.GetUserByName(ctx, username) ++ if err != nil { ++ ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, nil) ++ return ++ } ++ if err := user_service.CreateSpamReport(ctx, ctx.Doer, user); err != nil { ++ ctx.ServerError("CreateSpamReport", err) ++ return ++ } ++ ++ if ctx.Written() { ++ return ++ } ++ ctx.Redirect(setting.AppSubURL + "/" + username) ++} +diff --git a/routers/web/web.go b/routers/web/web.go +index ae5f51d403..aa259469e3 100644 +--- a/routers/web/web.go ++++ b/routers/web/web.go +@@ -676,6 +676,9 @@ func registerRoutes(m *web.Router) { + m.Get("", user_setting.BlockedUsers) + m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost) + }) ++ ++ // BLENDER: spam reporting ++ m.Post("/spamreport", user_setting.SpamReportUserPost) + }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled)) + + m.Group("/user", func() { +@@ -748,6 +751,12 @@ func registerRoutes(m *web.Router) { + m.Post("/delete", admin.DeleteEmail) + }) + ++ // BLENDER: spam reporting ++ m.Group("/spamreports", func() { ++ m.Get("", admin.SpamReports) ++ m.Post("", admin.SpamReportsPost) ++ }) ++ + m.Group("/orgs", func() { + m.Get("", admin.Organizations) + }) +diff --git a/services/cron/cron.go b/services/cron/cron.go +index 3c5737e371..e82373a23e 100644 +--- a/services/cron/cron.go ++++ b/services/cron/cron.go +@@ -31,6 +31,8 @@ func NewContext(original context.Context) { + initBasicTasks() + initExtendedTasks() + initActionsTasks() ++ // BLENDER: spam reporting ++ initSpamReportTasks() + + lock.Lock() + for _, task := range tasks { +diff --git a/services/cron/tasks_spamreport.go b/services/cron/tasks_spamreport.go +new file mode 100644 +index 0000000000..1b907ef8c9 +--- /dev/null ++++ b/services/cron/tasks_spamreport.go +@@ -0,0 +1,34 @@ ++// Copyright 2025 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++// BLENDER: spam reporting ++ ++package cron ++ ++import ( ++ "context" ++ "fmt" ++ ++ user_model "code.gitea.io/gitea/models/user" ++ user_service "code.gitea.io/gitea/services/user" ++) ++ ++func registerProcessSpamReports() { ++ RegisterTaskFatal("process_spam_reports", &BaseConfig{ ++ Enabled: true, ++ RunAtStart: true, ++ Schedule: "@every 5m", ++ }, func(ctx context.Context, doer *user_model.User, _ Config) error { ++ // This code assumes that all reports may be processed. ++ // If we start accepting reports from non-trusted users, we need to add a check here. ++ ids, err := user_model.GetPendingSpamReportIDs(ctx) ++ if err != nil { ++ return fmt.Errorf("failed to GetPendingSpamReportIDs: %w", err) ++ } ++ return user_service.ProcessSpamReports(ctx, doer, ids) ++ }) ++} ++ ++func initSpamReportTasks() { ++ registerProcessSpamReports() ++} +diff --git a/services/user/spamreport.go b/services/user/spamreport.go +new file mode 100644 +index 0000000000..8e7d61f150 +--- /dev/null ++++ b/services/user/spamreport.go +@@ -0,0 +1,229 @@ ++// Copyright 2025 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++// BLENDER: spam reporting ++ ++package user ++ ++import ( ++ "context" ++ "fmt" ++ ++ "code.gitea.io/gitea/models/db" ++ "code.gitea.io/gitea/models/organization" ++ issues_model "code.gitea.io/gitea/models/issues" ++ project_model "code.gitea.io/gitea/models/project" ++ user_model "code.gitea.io/gitea/models/user" ++ "code.gitea.io/gitea/modules/log" ++ "code.gitea.io/gitea/modules/optional" ++ "code.gitea.io/gitea/modules/structs" ++ issue_service "code.gitea.io/gitea/services/issue" ++ repo_service "code.gitea.io/gitea/services/repository" ++ ++ "github.com/lib/pq" ++) ++ ++// IsTrustedUser tells if a user is trusted to report spam and to be excluded from others' spam reports. ++func IsTrustedUser(ctx context.Context, user *user_model.User) (bool, error) { ++ if user.IsAdmin { ++ return true, nil ++ } ++ count, err := organization.GetOrganizationCount(ctx, user) ++ if err != nil { ++ return false, fmt.Errorf("GetOrganizationCount: %w", err) ++ } ++ return count > 0, nil ++} ++ ++// CreateSpamReport checks that a reporter can report a user, ++// and inserts a new record in default status=pending ++// for further processing, either manual or automatical. ++// If a record for a given user already exists, we try to ignore it ++// (only postgres error is handled). ++// ++// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ++// !!! If you change this code to accept reports from non-trusted users, !!! ++// !!! make sure to update process_spam_reports cron task. !!! ++// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ++func CreateSpamReport(ctx context.Context, reporter, user *user_model.User) error { ++ reporterIsTrusted, err := IsTrustedUser(ctx, reporter) ++ if err != nil { ++ return fmt.Errorf("failed IsTrustedUser: %w", err) ++ } ++ if !reporterIsTrusted { ++ return fmt.Errorf("reporter %s is not trusted", reporter.Name) ++ } ++ userIsTrusted, err := IsTrustedUser(ctx, user) ++ if err != nil { ++ return fmt.Errorf("failed IsTrustedUser: %w", err) ++ } ++ if userIsTrusted { ++ return fmt.Errorf("can't report a trusted user %s", user.Name) ++ } ++ err = db.Insert(ctx, &user_model.SpamReport{ ++ ReporterID: reporter.ID, ++ UserID: user.ID, ++ }) ++ if err != nil { ++ if err, ok := err.(*pq.Error); ok { ++ // unique_violation, a report already exists, our job is done (by some other reporter). ++ if err.Code == "23505" { ++ return nil ++ } ++ } ++ } ++ return err ++} ++ ++// ProcessSpamReports performs the cleanup of a reported user account and the content it created. ++// Only the reports in "pending" status are processed to avoid race conditions. ++// A processed user account becomes inactive, restricted, login prohibited, profile fields erased, ++// and the following objects that were created by the user are deleted: ++// - issues and pulls ++// - comments ++// - personal repositories ++// - personal projects ++// ++// If the processing code fails it leaves the SpamReport record that was being processed in "locked" status. ++// It would need to be handled manually, as the error is assumed to be unrecoverable ++// (which may not always be true, e.g. during transient db downtime). ++// ++// We will have to revisit this approach if it actually causes problems. ++// E.g. we could ++// - either try to unlock the record on failure (this may not always be possible), ++// or unlock after some timeout (according to the record's UpdatedUnix) ++// - add a new field to keep track of an attempt count per record ++// - retry on subsequent runs, until the attempt budget is exhausted ++func ProcessSpamReports(ctx context.Context, doer *user_model.User, spamReportIDs []int64) error { ++ var spamReports []user_model.SpamReport ++ err := db.GetEngine(ctx).In("id", spamReportIDs).Find(&spamReports) ++ if err != nil { ++ return fmt.Errorf("failed to fetch SpamReports: %w", err) ++ } ++ ++ for _, spamReport := range spamReports { ++ id := spamReport.ID ++ count, err := db.GetEngine(ctx).ID(id).And("status = ?", user_model.SpamReportStatusTypePending). ++ Update(&user_model.SpamReport{Status: user_model.SpamReportStatusTypeLocked}) ++ if err != nil { ++ return fmt.Errorf("failed to set SpamReport.Status to locked for id=%d: %w", id, err) ++ } ++ if count < 1 { ++ log.Info("Skipping SpamReport id=%d, status wasn't pending", id) ++ continue ++ } ++ ++ userID := spamReport.UserID ++ user := &user_model.User{ID: userID} ++ has, err := db.GetEngine(ctx).Get(user) ++ if err != nil { ++ return fmt.Errorf("failed to fetch user userID=%d: %w", userID, err) ++ } ++ if !has { ++ return fmt.Errorf("user id=%d was not found", userID) ++ } ++ ++ // Clean up everything and update report status if there were no errors. ++ // On failure the transaction will be rolled back, and the report will be stuck in locked status. ++ log.Info("Processing SpamReport id=%d for user %s", id, user.Name) ++ err = db.WithTx(ctx, func(ctx context.Context) error { ++ if err := cleanupSpam(ctx, user, doer); err != nil { ++ return err ++ } ++ // Everything is cleaned up, marking the spam report as processed. ++ count, err = db.GetEngine(ctx).ID(id).And("status = ?", user_model.SpamReportStatusTypeLocked). ++ Update(&user_model.SpamReport{Status: user_model.SpamReportStatusTypeProcessed}) ++ if err != nil { ++ return fmt.Errorf("failed to set SpamReport.Status to processed for id=%d: %w", id, err) ++ } ++ if count < 1 { ++ return fmt.Errorf("SpamReport id=%d status wasn't locked, rolling back the transaction", id) ++ } ++ return nil ++ }) ++ if err != nil { ++ return fmt.Errorf("failed to process SpamReport id=%d: %w", id, err) ++ } ++ ++ log.Info("Processed SpamReport id=%d for user %s", id, user.Name) ++ } ++ return nil ++} ++ ++// cleanupSpam is supposed to be called as a part of a database transaction. ++func cleanupSpam(ctx context.Context, user, doer *user_model.User) error { ++ // UpdateUser and UpdateAuth to clean the profile and prohibit logins. ++ if err := UpdateUser(ctx, user, ++ &UpdateOptions{ ++ Description: optional.Some(""), ++ FullName: optional.Some("Confirmed Spammer"), ++ IsActive: optional.Some(false), ++ IsRestricted: optional.Some(true), ++ Location: optional.Some(""), ++ MaxRepoCreation: optional.Some(0), ++ Visibility: optional.Some(structs.VisibleTypeLimited), ++ Website: optional.Some(""), ++ }, ++ ); err != nil { ++ return fmt.Errorf("failed to UpdateUser: %w", err) ++ } ++ if err := UpdateAuth(ctx, user, &UpdateAuthOptions{ProhibitLogin: optional.Some(true)}); err != nil { ++ return fmt.Errorf("failed to UpdateAuth: %w", err) ++ } ++ ++ log.Info("Cleaning up issues and pulls by user %s", user.Name) ++ issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{PosterID: optional.Some(user.ID)}) ++ if err != nil { ++ return fmt.Errorf("failed to fetch IssueIDs: %w", err) ++ } ++ for _, issue := range issues { ++ issue_service.DeleteIssue(ctx, doer, nil, issue) ++ } ++ ++ log.Info("Cleaning up comments by user %s", user.Name) ++ const batchSize = 50 ++ for { ++ comments := make([]*issues_model.Comment, 0, batchSize) ++ if err := db.GetEngine(ctx). ++ Where("type=? AND poster_id=?", issues_model.CommentTypeComment, user.ID). ++ Limit(batchSize, 0). ++ Find(&comments); err != nil { ++ return fmt.Errorf("failed to find comments to delete: %w", err) ++ } ++ if len(comments) == 0 { ++ break ++ } ++ ++ for _, comment := range comments { ++ if err := issues_model.DeleteComment(ctx, comment); err != nil { ++ return fmt.Errorf("failed to delete comments: %w", err) ++ } ++ } ++ } ++ ++ log.Info("Cleaning up personal repositories of user %s", user.Name) ++ if err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, user); err != nil { ++ return fmt.Errorf("failed to clean up repositories: %w", err) ++ } ++ ++ log.Info("Cleaning up personal projects of user %s", user.Name) ++ projectIDs, err := project_model.GetAllProjectsIDsByOwnerIDAndType(ctx, user.ID, project_model.TypeIndividual) ++ if err != nil { ++ return fmt.Errorf("failed to fetch personal project ids: %w", err) ++ } ++ for _, projectID := range projectIDs { ++ if err := project_model.DeleteProjectByID(ctx, projectID); err != nil { ++ return fmt.Errorf("failed to clean up personal project id=%d: %w", projectID, err) ++ } ++ } ++ return nil ++} ++ ++// DismissSpamReports updates only reports in "pending" status to avoid race conditions ++// with the actual processing. ++func DismissSpamReports(ctx context.Context, spamReportIDs []int64) error { ++ _, err := db.GetEngine(ctx).In("id", spamReportIDs). ++ And("status = ?", user_model.SpamReportStatusTypePending). ++ Update(&user_model.SpamReport{Status: user_model.SpamReportStatusTypeDismissed}) ++ return err ++} +diff --git a/services/user/spamreport_test.go b/services/user/spamreport_test.go +new file mode 100644 +index 0000000000..830d19baf5 +--- /dev/null ++++ b/services/user/spamreport_test.go +@@ -0,0 +1,79 @@ ++// Copyright 2025 The Gitea Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++// BLENDER: spam reporting ++ ++package user ++ ++import ( ++ "context" ++ "testing" ++ ++ "code.gitea.io/gitea/models/unittest" ++ user_model "code.gitea.io/gitea/models/user" ++ ++ "github.com/stretchr/testify/assert" ++) ++ ++func TestIsTrustedUser(t *testing.T) { ++ assert.NoError(t, unittest.PrepareTestDatabase()) ++ ++ userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) ++ isTrusted, err := IsTrustedUser(context.Background(), userWithOrgs) ++ assert.NoError(t, err) ++ assert.True(t, isTrusted) ++ ++ userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) ++ isTrusted, err = IsTrustedUser(context.Background(), userWithoutOrgs) ++ assert.NoError(t, err) ++ assert.False(t, isTrusted) ++ ++ userWithoutOrgs.IsAdmin = true // now becomes trusted ++ isTrusted, err = IsTrustedUser(context.Background(), userWithoutOrgs) ++ assert.NoError(t, err) ++ assert.True(t, isTrusted) ++} ++ ++func TestCreateSpamReport(t *testing.T) { ++ assert.NoError(t, unittest.PrepareTestDatabase()) ++ ++ userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) ++ userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) ++ err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) ++ assert.NoError(t, err) ++ ++ // An untrusted user can't report. ++ err = CreateSpamReport(context.Background(), userWithoutOrgs, userWithoutOrgs) ++ assert.Error(t, err) ++ ++ // A trusted user can't be reported. ++ err = CreateSpamReport(context.Background(), userWithOrgs, userWithOrgs) ++ assert.Error(t, err) ++} ++ ++func TestProcessSpamReports(t *testing.T) { ++ assert.NoError(t, unittest.PrepareTestDatabase()) ++ ++ userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // reporter ++ userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) // spammer ++ err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) ++ assert.NoError(t, err) ++ ++ ids, err := user_model.GetPendingSpamReportIDs(context.Background()) ++ assert.Equal(t, 1, len(ids)) ++ assert.NoError(t, err) ++ cronDoer := &user_model.User{ ++ ID: -1, ++ Name: "(Cron)", ++ LowerName: "(cron)", ++ } ++ err = ProcessSpamReports(context.Background(), cronDoer, ids) ++ assert.NoError(t, err) ++ userWithoutOrgs = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) // reload from db ++ assert.Equal(t, "Confirmed Spammer", userWithoutOrgs.FullName) ++ assert.True(t, userWithoutOrgs.ProhibitLogin) ++ ++ ids, err = user_model.GetPendingSpamReportIDs(context.Background()) ++ assert.Equal(t, 0, len(ids)) ++ assert.NoError(t, err) ++} +diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl +index 4116357d1d..c03c50f16b 100644 +--- a/templates/admin/navbar.tmpl ++++ b/templates/admin/navbar.tmpl +@@ -28,6 +28,9 @@ + + {{ctx.Locale.Tr "admin.emails"}} + ++ ++ {{ctx.Locale.Tr "admin.spamreports"}} ++ + + +
+diff --git a/templates/admin/spamreports/list.tmpl b/templates/admin/spamreports/list.tmpl +new file mode 100644 +index 0000000000..9e91039f0e +--- /dev/null ++++ b/templates/admin/spamreports/list.tmpl +@@ -0,0 +1,86 @@ ++{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}} ++ ++ ++
++

++ {{ctx.Locale.Tr "admin.spamreports.spamreport_manage_panel"}} ++ ++

++ {{if .SpamReports}} ++
++ {{.CsrfTokenHtml}} ++
++ ++ ++ ++ {{if eq $.FilterStatus 0}} ++ ++ {{end}} ++ ++ ++ ++ ++ ++ ++ ++ ++ {{range .SpamReports}} ++ ++ {{if eq $.FilterStatus 0}} ++ ++ {{end}} ++ ++ ++ ++ ++ ++ ++ {{end}} ++ ++
{{ctx.Locale.Tr "admin.spamreports.user"}}{{ctx.Locale.Tr "admin.spamreports.reporter"}}{{ctx.Locale.Tr "admin.spamreports.created"}}{{ctx.Locale.Tr "admin.spamreports.updated"}}{{ctx.Locale.Tr "admin.spamreports.status"}}
{{.UserName}}{{.ReporterName}}{{DateUtils.TimeSince .CreatedUnix}}{{DateUtils.TimeSince .UpdatedUnix}} ++ {{if eq .Status 0}} ++ {{svg "octicon-clock" 16 "tw-mr-2 text primary"}} ++ {{end}} ++ {{if eq .Status 1}} ++ {{svg "octicon-lock" 16 "tw-mr-2 text grey"}} ++ {{end}} ++ {{if eq .Status 2}} ++ {{svg "octicon-check" 16 "tw-mr-2 text green"}} ++ {{end}} ++ {{if eq .Status 3}} ++ {{svg "octicon-trash" 16 "tw-mr-2 text red"}} ++ {{end}} ++ {{.Status}} ++
++
++ {{if eq $.FilterStatus 0}} ++
++ ++ ++ {{end}} ++
++ {{template "base/paginate" .}} ++ {{else}} ++
++ No {{$.FilterStatus}} reports. ++
++ {{end}} ++
++ ++{{template "admin/layout_footer" .}} +diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl +index f04f1ef6c4..bd6531ae8e 100644 +--- a/templates/shared/user/profile_big_avatar.tmpl ++++ b/templates/shared/user/profile_big_avatar.tmpl +@@ -127,9 +127,21 @@ + {{ctx.Locale.Tr "user.block.unblock"}} + {{end}} + ++ {{if .CanReportSpam}} ++
  • ++ {{if .ExistingSpamReport}} ++ ++ {{ctx.Locale.Tr "user.spamreport.existing" .ExistingSpamReport.Status}} ++ ++ {{else}} ++ {{ctx.Locale.Tr "user.spamreport.report.user"}} ++ {{end}} ++
  • ++ {{end}} + {{end}} + + + + + {{template "shared/user/block_user_dialog" .}} ++{{template "shared/user/spamreport_user_dialog" .}} +diff --git a/templates/shared/user/spamreport_user_dialog.tmpl b/templates/shared/user/spamreport_user_dialog.tmpl +new file mode 100644 +index 0000000000..e2313f95ef +--- /dev/null ++++ b/templates/shared/user/spamreport_user_dialog.tmpl +@@ -0,0 +1,14 @@ ++ +-- +2.43.0 + From b51bf091cb2a53e20fb588239fef2279dba22379 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Thu, 24 Apr 2025 13:00:32 +0200 Subject: [PATCH 2/2] Updated patch based on code review See https://github.com/blender/gitea/pull/7 --- patches/0017-BLENDER-spam-reporting.patch | 414 +++++++++++++--------- 1 file changed, 244 insertions(+), 170 deletions(-) diff --git a/patches/0017-BLENDER-spam-reporting.patch b/patches/0017-BLENDER-spam-reporting.patch index 3ac45098f745c..8844bb84208a3 100644 --- a/patches/0017-BLENDER-spam-reporting.patch +++ b/patches/0017-BLENDER-spam-reporting.patch @@ -1,43 +1,43 @@ -From ebf2aa91212aafc18f77c897444bf4ed5add3d2a Mon Sep 17 00:00:00 2001 +From 06041a4e57aec40f53204cf151aafee4f84a168e Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 11 Apr 2025 16:57:54 +0200 Subject: [PATCH] BLENDER: spam reporting -Spam reporting is available for trusted users (org members) via a button on a -spammer's profile page; -processing is done automatically using a new cron task process_spam_reports; -a new section Site Administration > Identity & Access > Spam Reports. +Spam reporting is available for trusted users (org members and admins) via a +button on a spammer's profile page; +a new section Site Administration > Identity & Access > Spam Reports; +a new "pending spam reports" indicator in the header for admins. --- - models/user/spamreport.go | 128 ++++++++++ - options/locale/locale_en-US.ini | 15 ++ - routers/web/admin/spamreports.go | 105 ++++++++ + models/user/spamreport.go | 136 +++++++++++ + options/locale/locale_en-US.ini | 22 ++ + routers/web/admin/spamreports.go | 142 +++++++++++ routers/web/shared/user/header.go | 20 ++ routers/web/user/setting/spamreport.go | 43 ++++ - routers/web/web.go | 9 + - services/cron/cron.go | 2 + - services/cron/tasks_spamreport.go | 34 +++ - services/user/spamreport.go | 229 ++++++++++++++++++ - services/user/spamreport_test.go | 79 ++++++ - templates/admin/navbar.tmpl | 3 + - templates/admin/spamreports/list.tmpl | 86 +++++++ - templates/shared/user/profile_big_avatar.tmpl | 12 + + routers/web/web.go | 12 + + services/user/spamreport.go | 226 ++++++++++++++++++ + services/user/spamreport_test.go | 93 +++++++ + templates/admin/navbar.tmpl | 5 +- + templates/admin/spamreports/list.tmpl | 88 +++++++ + templates/base/head_navbar.tmpl | 7 + + templates/shared/user/profile_big_avatar.tmpl | 17 ++ + .../shared/user/purgespammer_user_dialog.tmpl | 14 ++ .../shared/user/spamreport_user_dialog.tmpl | 14 ++ - 14 files changed, 779 insertions(+) + 14 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 models/user/spamreport.go create mode 100644 routers/web/admin/spamreports.go create mode 100644 routers/web/user/setting/spamreport.go - create mode 100644 services/cron/tasks_spamreport.go create mode 100644 services/user/spamreport.go create mode 100644 services/user/spamreport_test.go create mode 100644 templates/admin/spamreports/list.tmpl + create mode 100644 templates/shared/user/purgespammer_user_dialog.tmpl create mode 100644 templates/shared/user/spamreport_user_dialog.tmpl diff --git a/models/user/spamreport.go b/models/user/spamreport.go new file mode 100644 -index 0000000000..6ab133ec14 +index 0000000000..a4390c9d4a --- /dev/null +++ b/models/user/spamreport.go -@@ -0,0 +1,128 @@ +@@ -0,0 +1,136 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + @@ -109,12 +109,13 @@ index 0000000000..6ab133ec14 +} + +type ListSpamReportsResults struct { -+ ID int64 -+ CreatedUnix timeutil.TimeStamp -+ UpdatedUnix timeutil.TimeStamp -+ Status SpamReportStatusType -+ UserName string -+ ReporterName string ++ ID int64 ++ CreatedUnix timeutil.TimeStamp ++ UpdatedUnix timeutil.TimeStamp ++ Status SpamReportStatusType ++ UserName string ++ UserCreatedUnix timeutil.TimeStamp ++ ReporterName string +} + +func ListSpamReports(ctx context.Context, opts *ListSpamReportsOptions) ([]*ListSpamReportsResults, int64, error) { @@ -124,11 +125,19 @@ index 0000000000..6ab133ec14 + return nil, 0, fmt.Errorf("Count: %w", err) + } + spamReports := make([]*ListSpamReportsResults, 0, opts.PageSize) -+ err = db.GetEngine(ctx).Table("user_spamreport"). -+ Select("user_spamreport.id, user_spamreport.created_unix, user_spamreport.updated_unix, user_spamreport.status, `user`.name as user_name, reporter.name as reporter_name"). ++ err = db.GetEngine(ctx).Table("user_spamreport").Select( ++ "user_spamreport.id, "+ ++ "user_spamreport.created_unix, "+ ++ "user_spamreport.updated_unix, "+ ++ "user_spamreport.status, "+ ++ "`user`.name as user_name, "+ ++ "`user`.created_unix as user_created_unix, "+ ++ "reporter.name as reporter_name", ++ ). + Join("LEFT", "`user`", "`user`.id = user_spamreport.user_id"). + Join("LEFT", "`user` as reporter", "`reporter`.id = user_spamreport.reporter_id"). + Where("status = ?", opts.Status). ++ OrderBy("user_spamreport.id"). + Limit(opts.PageSize, (opts.Page-1)*opts.PageSize). + Find(&spamReports) + @@ -162,28 +171,33 @@ index 0000000000..6ab133ec14 + has, err := db.GetEngine(ctx).Where("user_id = ?", user.ID).Get(spamReport) + if has { + return spamReport, err -+ } else { -+ return nil, err + } ++ return nil, err +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini -index 9d71ccb6d5..435c2122be 100644 +index 9d71ccb6d5..be52d8f9c3 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini -@@ -692,6 +692,12 @@ block.note.edit = Edit note +@@ -692,6 +692,18 @@ block.note.edit = Edit note block.list = Blocked users block.list.none = You have not blocked any users. -+spamreport.info = Report a user as a spammer, reports are processed automatically, all content created by the user will be deleted! -+spamreport.report = Report -+spamreport.report.user = Report spam -+spamreport.title = Report a user -+spamreport.existing = The user has already been reported as a spammer, the report is %s. ++purgespammer.modal_title = Purge spam account ++purgespammer.modal_info = All content created by the user will be deleted! This cannot be undone. ++purgespammer.modal_action = Purge spam account ++purgespammer.profile_button = Purge spam account ++ ++spamreport.existing_status = The user has already been reported as a spammer, the report is %s. ++ ++spamreport.modal_title = Report spam ++spamreport.modal_info = Report a user as a spammer to site admins. ++spamreport.modal_action = Report spam ++spamreport.profile_button = Report spam + [settings] profile = Profile account = Account -@@ -2889,6 +2895,7 @@ first_page = First +@@ -2889,6 +2901,7 @@ first_page = First last_page = Last total = Total: %d settings = Admin Settings @@ -191,7 +205,7 @@ index 9d71ccb6d5..435c2122be 100644 dashboard.new_version_hint = Gitea %s is now available, you are running %s. Check the blog for more details. dashboard.statistic = Summary -@@ -2976,6 +2983,7 @@ dashboard.sync_branch.started = Branches Sync started +@@ -2976,6 +2989,7 @@ dashboard.sync_branch.started = Branches Sync started dashboard.sync_tag.started = Tags Sync started dashboard.rebuild_issue_indexer = Rebuild issue indexer dashboard.sync_repo_licenses = Sync repo licenses @@ -199,15 +213,16 @@ index 9d71ccb6d5..435c2122be 100644 users.user_manage_panel = User Account Management users.new_account = Create User Account -@@ -3052,6 +3060,13 @@ emails.delete_desc = Are you sure you want to delete this email address? +@@ -3052,6 +3066,14 @@ emails.delete_desc = Are you sure you want to delete this email address? emails.deletion_success = The email address has been deleted. emails.delete_primary_email_error = You can not delete the primary email. +spamreports.spamreport_manage_panel = Spam Report Management +spamreports.user = Reported for spam ++spamreports.user_created = User created +spamreports.reporter = Reporter -+spamreports.created = Created -+spamreports.updated = Updated ++spamreports.created = Report Created ++spamreports.updated = Report Updated +spamreports.status = Report Status + orgs.org_manage_panel = Organization Management @@ -215,10 +230,10 @@ index 9d71ccb6d5..435c2122be 100644 orgs.teams = Teams diff --git a/routers/web/admin/spamreports.go b/routers/web/admin/spamreports.go new file mode 100644 -index 0000000000..e99285f3bc +index 0000000000..6f2ce0c37a --- /dev/null +++ b/routers/web/admin/spamreports.go -@@ -0,0 +1,105 @@ +@@ -0,0 +1,142 @@ +// Copyright 2025 The Gitea Authors. +// SPDX-License-Identifier: MIT + @@ -227,13 +242,13 @@ index 0000000000..e99285f3bc +package admin + +import ( -+ "fmt" + "net/http" + "strconv" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" ++ "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" @@ -243,6 +258,20 @@ index 0000000000..e99285f3bc + tplSpamReports base.TplName = "admin/spamreports/list" +) + ++// GetPendingSpamReports populates the counter for the header section displayed to site admins. ++func GetPendingSpamReports(ctx *context.Context) { ++ if ctx.Doer == nil || !ctx.Doer.IsAdmin { ++ return ++ } ++ ids, err := user_model.GetPendingSpamReportIDs(ctx) ++ if err != nil { ++ log.Error("Failed to GetPendingSpamReportIDs while rendering header: %v", err) ++ ctx.Data["PendingSpamReports"] = -1 ++ return ++ } ++ ctx.Data["PendingSpamReports"] = len(ids) ++} ++ +// SpamReports shows spam reports +func SpamReports(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.spamreports") @@ -278,8 +307,6 @@ index 0000000000..e99285f3bc + + ctx.Data["Total"] = count + ctx.Data["SpamReports"] = spamReports -+ ids, _ := user_model.GetPendingSpamReportIDs(ctx) -+ fmt.Printf("%v", ids) + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) @@ -324,6 +351,31 @@ index 0000000000..e99285f3bc + } + ctx.Redirect(setting.AppSubURL + "/-/admin/spamreports") +} ++ ++// PurgeSpammerPost is a shortcut for admins to report and process at the same time. ++func PurgeSpammerPost(ctx *context.Context) { ++ username := ctx.FormString("username") ++ ++ user, err := user_model.GetUserByName(ctx, username) ++ if err != nil { ++ ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, nil) ++ return ++ } ++ spamReport, err := user_service.CreateSpamReport(ctx, ctx.Doer, user) ++ if err != nil { ++ ctx.ServerError("CreateSpamReport", err) ++ return ++ } ++ if err := user_service.ProcessSpamReports(ctx, ctx.Doer, []int64{spamReport.ID}); err != nil { ++ ctx.ServerError("ProcessSpamReports", err) ++ return ++ } ++ ++ if ctx.Written() { ++ return ++ } ++ ctx.Redirect(setting.AppSubURL + "/" + username) ++} diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 4cb0592b4b..e1c8b9608a 100644 --- a/routers/web/shared/user/header.go @@ -364,7 +416,7 @@ index 4cb0592b4b..e1c8b9608a 100644 diff --git a/routers/web/user/setting/spamreport.go b/routers/web/user/setting/spamreport.go new file mode 100644 -index 0000000000..58655de951 +index 0000000000..254b54c56f --- /dev/null +++ b/routers/web/user/setting/spamreport.go @@ -0,0 +1,43 @@ @@ -401,7 +453,7 @@ index 0000000000..58655de951 + ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, nil) + return + } -+ if err := user_service.CreateSpamReport(ctx, ctx.Doer, user); err != nil { ++ if _, err := user_service.CreateSpamReport(ctx, ctx.Doer, user); err != nil { + ctx.ServerError("CreateSpamReport", err) + return + } @@ -412,10 +464,19 @@ index 0000000000..58655de951 + ctx.Redirect(setting.AppSubURL + "/" + username) +} diff --git a/routers/web/web.go b/routers/web/web.go -index ae5f51d403..aa259469e3 100644 +index ae5f51d403..4bf65eb065 100644 --- a/routers/web/web.go +++ b/routers/web/web.go -@@ -676,6 +676,9 @@ func registerRoutes(m *web.Router) { +@@ -284,6 +284,8 @@ func Routes() *web.Router { + mid = append(mid, user.GetNotificationCount) + mid = append(mid, repo.GetActiveStopwatch) + mid = append(mid, goGet) ++ // BLENDER: spam reporting ++ mid = append(mid, admin.GetPendingSpamReports) + + others := web.NewRouter() + others.Use(mid...) +@@ -676,6 +678,9 @@ func registerRoutes(m *web.Router) { m.Get("", user_setting.BlockedUsers) m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost) }) @@ -425,7 +486,7 @@ index ae5f51d403..aa259469e3 100644 }, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled)) m.Group("/user", func() { -@@ -748,6 +751,12 @@ func registerRoutes(m *web.Router) { +@@ -748,6 +753,13 @@ func registerRoutes(m *web.Router) { m.Post("/delete", admin.DeleteEmail) }) @@ -434,69 +495,17 @@ index ae5f51d403..aa259469e3 100644 + m.Get("", admin.SpamReports) + m.Post("", admin.SpamReportsPost) + }) ++ m.Post("/purge_spammer", admin.PurgeSpammerPost) + m.Group("/orgs", func() { m.Get("", admin.Organizations) }) -diff --git a/services/cron/cron.go b/services/cron/cron.go -index 3c5737e371..e82373a23e 100644 ---- a/services/cron/cron.go -+++ b/services/cron/cron.go -@@ -31,6 +31,8 @@ func NewContext(original context.Context) { - initBasicTasks() - initExtendedTasks() - initActionsTasks() -+ // BLENDER: spam reporting -+ initSpamReportTasks() - - lock.Lock() - for _, task := range tasks { -diff --git a/services/cron/tasks_spamreport.go b/services/cron/tasks_spamreport.go -new file mode 100644 -index 0000000000..1b907ef8c9 ---- /dev/null -+++ b/services/cron/tasks_spamreport.go -@@ -0,0 +1,34 @@ -+// Copyright 2025 The Gitea Authors. All rights reserved. -+// SPDX-License-Identifier: MIT -+ -+// BLENDER: spam reporting -+ -+package cron -+ -+import ( -+ "context" -+ "fmt" -+ -+ user_model "code.gitea.io/gitea/models/user" -+ user_service "code.gitea.io/gitea/services/user" -+) -+ -+func registerProcessSpamReports() { -+ RegisterTaskFatal("process_spam_reports", &BaseConfig{ -+ Enabled: true, -+ RunAtStart: true, -+ Schedule: "@every 5m", -+ }, func(ctx context.Context, doer *user_model.User, _ Config) error { -+ // This code assumes that all reports may be processed. -+ // If we start accepting reports from non-trusted users, we need to add a check here. -+ ids, err := user_model.GetPendingSpamReportIDs(ctx) -+ if err != nil { -+ return fmt.Errorf("failed to GetPendingSpamReportIDs: %w", err) -+ } -+ return user_service.ProcessSpamReports(ctx, doer, ids) -+ }) -+} -+ -+func initSpamReportTasks() { -+ registerProcessSpamReports() -+} diff --git a/services/user/spamreport.go b/services/user/spamreport.go new file mode 100644 -index 0000000000..8e7d61f150 +index 0000000000..d240546cc0 --- /dev/null +++ b/services/user/spamreport.go -@@ -0,0 +1,229 @@ +@@ -0,0 +1,226 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + @@ -509,8 +518,8 @@ index 0000000000..8e7d61f150 + "fmt" + + "code.gitea.io/gitea/models/db" -+ "code.gitea.io/gitea/models/organization" + issues_model "code.gitea.io/gitea/models/issues" ++ "code.gitea.io/gitea/models/organization" + project_model "code.gitea.io/gitea/models/project" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" @@ -518,8 +527,6 @@ index 0000000000..8e7d61f150 + "code.gitea.io/gitea/modules/structs" + issue_service "code.gitea.io/gitea/services/issue" + repo_service "code.gitea.io/gitea/services/repository" -+ -+ "github.com/lib/pq" +) + +// IsTrustedUser tells if a user is trusted to report spam and to be excluded from others' spam reports. @@ -536,42 +543,39 @@ index 0000000000..8e7d61f150 + +// CreateSpamReport checks that a reporter can report a user, +// and inserts a new record in default status=pending -+// for further processing, either manual or automatical. -+// If a record for a given user already exists, we try to ignore it -+// (only postgres error is handled). -+// -+// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -+// !!! If you change this code to accept reports from non-trusted users, !!! -+// !!! make sure to update process_spam_reports cron task. !!! -+// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -+func CreateSpamReport(ctx context.Context, reporter, user *user_model.User) error { ++// for further processing. ++// If a record for a given user already exists, it will be returned. ++func CreateSpamReport(ctx context.Context, reporter, user *user_model.User) (*user_model.SpamReport, error) { + reporterIsTrusted, err := IsTrustedUser(ctx, reporter) + if err != nil { -+ return fmt.Errorf("failed IsTrustedUser: %w", err) ++ return nil, fmt.Errorf("failed IsTrustedUser: %w", err) + } + if !reporterIsTrusted { -+ return fmt.Errorf("reporter %s is not trusted", reporter.Name) ++ return nil, fmt.Errorf("reporter %s is not trusted", reporter.Name) + } + userIsTrusted, err := IsTrustedUser(ctx, user) + if err != nil { -+ return fmt.Errorf("failed IsTrustedUser: %w", err) ++ return nil, fmt.Errorf("failed IsTrustedUser: %w", err) + } + if userIsTrusted { -+ return fmt.Errorf("can't report a trusted user %s", user.Name) ++ return nil, fmt.Errorf("can't report a trusted user %s", user.Name) + } -+ err = db.Insert(ctx, &user_model.SpamReport{ ++ ++ spamReport := &user_model.SpamReport{ + ReporterID: reporter.ID, + UserID: user.ID, -+ }) -+ if err != nil { -+ if err, ok := err.(*pq.Error); ok { -+ // unique_violation, a report already exists, our job is done (by some other reporter). -+ if err.Code == "23505" { -+ return nil -+ } ++ } ++ insertErr := db.Insert(ctx, spamReport) ++ if insertErr != nil { ++ // Normally the error may happen due to a duplicate record. ++ // Let's try to fetch the existing record, and if it doesn't exist, escalate the original error. ++ existingSpamReport := &user_model.SpamReport{} ++ if has, _ := db.GetEngine(ctx).Where("user_id = ?", user.ID).Get(existingSpamReport); has { ++ return existingSpamReport, nil + } ++ return nil, insertErr + } -+ return err ++ return spamReport, nil +} + +// ProcessSpamReports performs the cleanup of a reported user account and the content it created. @@ -590,7 +594,7 @@ index 0000000000..8e7d61f150 +// We will have to revisit this approach if it actually causes problems. +// E.g. we could +// - either try to unlock the record on failure (this may not always be possible), -+// or unlock after some timeout (according to the record's UpdatedUnix) ++// or unlock after some timeout (according to the record's UpdatedUnix) +// - add a new field to keep track of an attempt count per record +// - retry on subsequent runs, until the attempt budget is exhausted +func ProcessSpamReports(ctx context.Context, doer *user_model.User, spamReportIDs []int64) error { @@ -654,14 +658,14 @@ index 0000000000..8e7d61f150 + // UpdateUser and UpdateAuth to clean the profile and prohibit logins. + if err := UpdateUser(ctx, user, + &UpdateOptions{ -+ Description: optional.Some(""), -+ FullName: optional.Some("Confirmed Spammer"), -+ IsActive: optional.Some(false), -+ IsRestricted: optional.Some(true), -+ Location: optional.Some(""), ++ Description: optional.Some(""), ++ FullName: optional.Some("Confirmed Spammer"), ++ IsActive: optional.Some(false), ++ IsRestricted: optional.Some(true), ++ Location: optional.Some(""), + MaxRepoCreation: optional.Some(0), -+ Visibility: optional.Some(structs.VisibleTypeLimited), -+ Website: optional.Some(""), ++ Visibility: optional.Some(structs.VisibleTypeLimited), ++ Website: optional.Some(""), + }, + ); err != nil { + return fmt.Errorf("failed to UpdateUser: %w", err) @@ -676,7 +680,9 @@ index 0000000000..8e7d61f150 + return fmt.Errorf("failed to fetch IssueIDs: %w", err) + } + for _, issue := range issues { -+ issue_service.DeleteIssue(ctx, doer, nil, issue) ++ if err := issue_service.DeleteIssue(ctx, doer, nil, issue); err != nil { ++ return fmt.Errorf("failed to delete issue: %w", err) ++ } + } + + log.Info("Cleaning up comments by user %s", user.Name) @@ -684,9 +690,9 @@ index 0000000000..8e7d61f150 + for { + comments := make([]*issues_model.Comment, 0, batchSize) + if err := db.GetEngine(ctx). -+ Where("type=? AND poster_id=?", issues_model.CommentTypeComment, user.ID). -+ Limit(batchSize, 0). -+ Find(&comments); err != nil { ++ Where("type=? AND poster_id=?", issues_model.CommentTypeComment, user.ID). ++ Limit(batchSize, 0). ++ Find(&comments); err != nil { + return fmt.Errorf("failed to find comments to delete: %w", err) + } + if len(comments) == 0 { @@ -695,7 +701,7 @@ index 0000000000..8e7d61f150 + + for _, comment := range comments { + if err := issues_model.DeleteComment(ctx, comment); err != nil { -+ return fmt.Errorf("failed to delete comments: %w", err) ++ return fmt.Errorf("failed to delete comment: %w", err) + } + } + } @@ -728,10 +734,10 @@ index 0000000000..8e7d61f150 +} diff --git a/services/user/spamreport_test.go b/services/user/spamreport_test.go new file mode 100644 -index 0000000000..830d19baf5 +index 0000000000..2f4ebcdc86 --- /dev/null +++ b/services/user/spamreport_test.go -@@ -0,0 +1,79 @@ +@@ -0,0 +1,93 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + @@ -743,6 +749,7 @@ index 0000000000..830d19baf5 + "context" + "testing" + ++ "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + @@ -770,31 +777,44 @@ index 0000000000..830d19baf5 + +func TestCreateSpamReport(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) ++ // Prevent interaction between tests, for whatever reason db is not reset. ++ db.GetEngine(db.DefaultContext).Exec("delete from user_spamreport") + + userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) -+ err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) -+ assert.NoError(t, err) + + // An untrusted user can't report. -+ err = CreateSpamReport(context.Background(), userWithoutOrgs, userWithoutOrgs) ++ _, err := CreateSpamReport(context.Background(), userWithoutOrgs, userWithoutOrgs) + assert.Error(t, err) + + // A trusted user can't be reported. -+ err = CreateSpamReport(context.Background(), userWithOrgs, userWithOrgs) ++ _, err = CreateSpamReport(context.Background(), userWithOrgs, userWithOrgs) + assert.Error(t, err) ++ ++ spamReport, err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) ++ assert.NoError(t, err) ++ assert.NotNil(t, spamReport) ++ ++ // Try to create a duplicate report by a different reporter. ++ adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) ++ spamReport2, err := CreateSpamReport(context.Background(), adminUser, userWithoutOrgs) ++ assert.NoError(t, err) ++ assert.NotNil(t, spamReport2) ++ assert.Equal(t, spamReport.ID, spamReport2.ID) +} + +func TestProcessSpamReports(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) ++ // Prevent interaction between tests, for whatever reason db is not reset. ++ db.GetEngine(db.DefaultContext).Exec("delete from user_spamreport") + -+ userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // reporter -+ userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) // spammer -+ err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) ++ userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // reporter ++ userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9}) // spammer, and a different one ++ _, err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) + assert.NoError(t, err) + + ids, err := user_model.GetPendingSpamReportIDs(context.Background()) -+ assert.Equal(t, 1, len(ids)) ++ assert.Len(t, ids, 1) + assert.NoError(t, err) + cronDoer := &user_model.User{ + ID: -1, @@ -803,18 +823,27 @@ index 0000000000..830d19baf5 + } + err = ProcessSpamReports(context.Background(), cronDoer, ids) + assert.NoError(t, err) -+ userWithoutOrgs = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) // reload from db ++ userWithoutOrgs = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9}) // reload from db + assert.Equal(t, "Confirmed Spammer", userWithoutOrgs.FullName) + assert.True(t, userWithoutOrgs.ProhibitLogin) + + ids, err = user_model.GetPendingSpamReportIDs(context.Background()) -+ assert.Equal(t, 0, len(ids)) ++ assert.Empty(t, ids) + assert.NoError(t, err) +} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl -index 4116357d1d..c03c50f16b 100644 +index 4116357d1d..235547ec0f 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl +@@ -13,7 +13,7 @@ + + +
    +-
    ++
    + {{ctx.Locale.Tr "admin.identity_access"}} +