Skip to content
Closed
136 changes: 136 additions & 0 deletions models/user/spamreport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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
UserCreatedUnix timeutil.TimeStamp
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, "+
"`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)

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
}
return nil, err
}
22 changes: 22 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,18 @@ block.note.edit = Edit note
block.list = Blocked users
block.list.none = You have not blocked any users.
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
Expand Down Expand Up @@ -2889,6 +2901,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 <a target="_blank" rel="noreferrer" href="%s">the blog</a> for more details.
dashboard.statistic = Summary
Expand Down Expand Up @@ -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
dashboard.process_spam_reports = Process spam reports
users.user_manage_panel = User Account Management
users.new_account = Create User Account
Expand Down Expand Up @@ -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 = Report Created
spamreports.updated = Report Updated
spamreports.status = Report Status
orgs.org_manage_panel = Organization Management
orgs.name = Name
orgs.teams = Teams
Expand Down
142 changes: 142 additions & 0 deletions routers/web/admin/spamreports.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2025 The Gitea Authors.
// SPDX-License-Identifier: MIT

// BLENDER: spam reporting

package admin

import (
"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"
)

const (
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")
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

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")
}

// 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)
}
20 changes: 20 additions & 0 deletions routers/web/shared/user/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}

Expand Down
Loading