From ebf2aa91212aafc18f77c897444bf4ed5add3d2a Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Fri, 11 Apr 2025 16:57:54 +0200 Subject: [PATCH 01/13] 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 0000000000000..6ab133ec14010 --- /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 9d71ccb6d5314..435c2122be079 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 0000000000000..e99285f3bcbf9 --- /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 4cb0592b4b782..e1c8b9608af28 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 0000000000000..58655de9519fd --- /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 ae5f51d40385c..aa259469e3809 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 3c5737e3718ff..e82373a23ef63 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 0000000000000..1b907ef8c94f6 --- /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 0000000000000..8e7d61f15000b --- /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 0000000000000..830d19baf5207 --- /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 4116357d1d235..c03c50f16b626 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 0000000000000..9e91039f0e07d --- /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 f04f1ef6c411b..bd6531ae8ed05 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 0000000000000..e2313f95ef3fa --- /dev/null +++ b/templates/shared/user/spamreport_user_dialog.tmpl @@ -0,0 +1,14 @@ + From 95f048175f09ad342a2865a45a576422387a8f93 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Tue, 22 Apr 2025 17:40:03 +0200 Subject: [PATCH 02/13] fix fmt and lint errors --- models/user/spamreport.go | 3 +-- routers/web/admin/spamreports.go | 3 --- services/user/spamreport.go | 30 ++++++++++++++++-------------- services/user/spamreport_test.go | 6 +++--- 4 files changed, 20 insertions(+), 22 deletions(-) diff --git a/models/user/spamreport.go b/models/user/spamreport.go index 6ab133ec14010..eb1e9b5771359 100644 --- a/models/user/spamreport.go +++ b/models/user/spamreport.go @@ -122,7 +122,6 @@ func GetSpamReportForUser(ctx context.Context, user *User) (*SpamReport, error) 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/routers/web/admin/spamreports.go b/routers/web/admin/spamreports.go index e99285f3bcbf9..2996c86e7d8f9 100644 --- a/routers/web/admin/spamreports.go +++ b/routers/web/admin/spamreports.go @@ -6,7 +6,6 @@ package admin import ( - "fmt" "net/http" "strconv" @@ -57,8 +56,6 @@ func SpamReports(ctx *context.Context) { 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) diff --git a/services/user/spamreport.go b/services/user/spamreport.go index 8e7d61f15000b..161656a1efdee 100644 --- a/services/user/spamreport.go +++ b/services/user/spamreport.go @@ -10,8 +10,8 @@ import ( "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" @@ -91,7 +91,7 @@ func CreateSpamReport(ctx context.Context, reporter, user *user_model.User) erro // 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 { @@ -155,14 +155,14 @@ 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(""), + 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) @@ -177,7 +177,9 @@ func cleanupSpam(ctx context.Context, user, doer *user_model.User) error { 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) @@ -185,9 +187,9 @@ func cleanupSpam(ctx context.Context, user, doer *user_model.User) error { 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 { @@ -196,7 +198,7 @@ func cleanupSpam(ctx context.Context, user, doer *user_model.User) error { 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) } } } diff --git a/services/user/spamreport_test.go b/services/user/spamreport_test.go index 830d19baf5207..40ae0c8298e5a 100644 --- a/services/user/spamreport_test.go +++ b/services/user/spamreport_test.go @@ -54,13 +54,13 @@ func TestCreateSpamReport(t *testing.T) { func TestProcessSpamReports(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // reporter + 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.Len(t, ids, 1) assert.NoError(t, err) cronDoer := &user_model.User{ ID: -1, @@ -74,6 +74,6 @@ func TestProcessSpamReports(t *testing.T) { 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) } From c1ca28ff69e1e5cbf74fc7392ebfe6e06717e327 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Tue, 22 Apr 2025 18:02:24 +0200 Subject: [PATCH 03/13] UI tweaks: make labels more descriptive, ask for confirmation in admin UI --- options/locale/locale_en-US.ini | 6 +++--- templates/admin/navbar.tmpl | 2 +- templates/admin/spamreports/list.tmpl | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 435c2122be079..c59ca0a768ca8 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -693,9 +693,9 @@ 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.report = Schedule account purge +spamreport.report.user = Purge spam account +spamreport.title = Purge spam account spamreport.existing = The user has already been reported as a spammer, the report is %s. [settings] diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index c03c50f16b626..235547ec0fd27 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -13,7 +13,7 @@
    -
    +
    {{ctx.Locale.Tr "admin.identity_access"}} {{if eq $.FilterStatus 0}}
    - - + + {{end}} {{template "base/paginate" .}} From 0f068e851d8a26cb1de7e833721cc838012ffe02 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Wed, 23 Apr 2025 12:27:47 +0200 Subject: [PATCH 04/13] remove cron --- services/cron/cron.go | 2 -- services/cron/tasks_spamreport.go | 34 ------------------------------- services/user/spamreport.go | 9 ++------ 3 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 services/cron/tasks_spamreport.go diff --git a/services/cron/cron.go b/services/cron/cron.go index e82373a23ef63..3c5737e3718ff 100644 --- a/services/cron/cron.go +++ b/services/cron/cron.go @@ -31,8 +31,6 @@ 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 deleted file mode 100644 index 1b907ef8c94f6..0000000000000 --- a/services/cron/tasks_spamreport.go +++ /dev/null @@ -1,34 +0,0 @@ -// 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 index 161656a1efdee..d3f0700eebc97 100644 --- a/services/user/spamreport.go +++ b/services/user/spamreport.go @@ -37,15 +37,10 @@ func IsTrustedUser(ctx context.Context, user *user_model.User) (bool, error) { // 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. +// for further processing. // 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 { +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) From 474d5cae3b9af78521c42206fb5ad349ea4f9540 Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Wed, 23 Apr 2025 12:28:11 +0200 Subject: [PATCH 05/13] purge_spammer admin endpoint --- routers/web/admin/spamreports.go | 25 +++++++++++++++++++++++++ routers/web/user/setting/spamreport.go | 2 +- routers/web/web.go | 1 + services/user/spamreport.go | 20 ++++++++++++-------- services/user/spamreport_test.go | 8 ++++---- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/routers/web/admin/spamreports.go b/routers/web/admin/spamreports.go index 2996c86e7d8f9..628a586f1fa55 100644 --- a/routers/web/admin/spamreports.go +++ b/routers/web/admin/spamreports.go @@ -100,3 +100,28 @@ func SpamReportsPost(ctx *context.Context) { } 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/user/setting/spamreport.go b/routers/web/user/setting/spamreport.go index 58655de9519fd..254b54c56f1f5 100644 --- a/routers/web/user/setting/spamreport.go +++ b/routers/web/user/setting/spamreport.go @@ -31,7 +31,7 @@ func SpamReportUserPost(ctx *context.Context) { 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 } diff --git a/routers/web/web.go b/routers/web/web.go index aa259469e3809..141dc8786f65c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -756,6 +756,7 @@ func registerRoutes(m *web.Router) { 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/user/spamreport.go b/services/user/spamreport.go index d3f0700eebc97..4a709f49e1679 100644 --- a/services/user/spamreport.go +++ b/services/user/spamreport.go @@ -43,31 +43,35 @@ func IsTrustedUser(ctx context.Context, user *user_model.User) (bool, error) { 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, - }) + } + err = db.Insert(ctx, spamReport) 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 + // Fetch the existing object, because we need to return an object with a populated ID. + _, err := db.GetEngine(ctx).Where("user_id = ?", user.ID).Get(spamReport) + return spamReport, err } } } - return err + return spamReport, err } // ProcessSpamReports performs the cleanup of a reported user account and the content it created. diff --git a/services/user/spamreport_test.go b/services/user/spamreport_test.go index 40ae0c8298e5a..f80cd7db3ebcd 100644 --- a/services/user/spamreport_test.go +++ b/services/user/spamreport_test.go @@ -39,15 +39,15 @@ func TestCreateSpamReport(t *testing.T) { userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8}) - err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) + _, 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) } @@ -56,7 +56,7 @@ func TestProcessSpamReports(t *testing.T) { 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) + _, err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs) assert.NoError(t, err) ids, err := user_model.GetPendingSpamReportIDs(context.Background()) From 6a67cdd53d5b4bbad415efed584351ada9ed224f Mon Sep 17 00:00:00 2001 From: Oleg Komarov Date: Wed, 23 Apr 2025 13:54:33 +0200 Subject: [PATCH 06/13] fix UI: a different button for admins --- options/locale/locale_en-US.ini | 16 +++++++++++----- templates/shared/user/profile_big_avatar.tmpl | 9 +++++++-- .../shared/user/purgespammer_user_dialog.tmpl | 14 ++++++++++++++ .../shared/user/spamreport_user_dialog.tmpl | 6 +++--- 4 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 templates/shared/user/purgespammer_user_dialog.tmpl diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c59ca0a768ca8..01aa3d752bb96 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -692,11 +692,17 @@ 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 = Schedule account purge -spamreport.report.user = Purge spam account -spamreport.title = Purge spam account -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! +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 diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl index bd6531ae8ed05..7c2b376b0003b 100644 --- a/templates/shared/user/profile_big_avatar.tmpl +++ b/templates/shared/user/profile_big_avatar.tmpl @@ -131,10 +131,14 @@
  • {{if .ExistingSpamReport}} - {{ctx.Locale.Tr "user.spamreport.existing" .ExistingSpamReport.Status}} + {{ctx.Locale.Tr "user.spamreport.existing_status" .ExistingSpamReport.Status}} {{else}} - {{ctx.Locale.Tr "user.spamreport.report.user"}} + {{if .IsAdmin}} + {{ctx.Locale.Tr "user.purgespammer.profile_button"}} + {{else}} + {{ctx.Locale.Tr "user.spamreport.profile_button"}} + {{end}} {{end}}
  • {{end}} @@ -144,4 +148,5 @@ {{template "shared/user/block_user_dialog" .}} +{{template "shared/user/purgespammer_user_dialog" .}} {{template "shared/user/spamreport_user_dialog" .}} diff --git a/templates/shared/user/purgespammer_user_dialog.tmpl b/templates/shared/user/purgespammer_user_dialog.tmpl new file mode 100644 index 0000000000000..77960ae49ef40 --- /dev/null +++ b/templates/shared/user/purgespammer_user_dialog.tmpl @@ -0,0 +1,14 @@ + diff --git a/templates/shared/user/spamreport_user_dialog.tmpl b/templates/shared/user/spamreport_user_dialog.tmpl index e2313f95ef3fa..e9015fd16c431 100644 --- a/templates/shared/user/spamreport_user_dialog.tmpl +++ b/templates/shared/user/spamreport_user_dialog.tmpl @@ -1,13 +1,13 @@