Skip to content

Commit 9768bce

Browse files
Oleg Komarovbartvdbraak
authored andcommitted
BLENDER: Add Spam Reporting
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.
1 parent eff4ab6 commit 9768bce

File tree

14 files changed

+841
-1
lines changed

14 files changed

+841
-1
lines changed

models/user/spamreport.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
// BLENDER: spam reporting
5+
6+
package user
7+
8+
import (
9+
"context"
10+
"fmt"
11+
12+
"code.gitea.io/gitea/models/db"
13+
"code.gitea.io/gitea/modules/timeutil"
14+
)
15+
16+
// SpamReportStatusType is used to support a spam report lifecycle:
17+
//
18+
// pending -> locked
19+
// locked -> processed | dismissed
20+
//
21+
// "locked" status works as a lock for a record that is being processed.
22+
type SpamReportStatusType int
23+
24+
const (
25+
SpamReportStatusTypePending = iota // 0
26+
SpamReportStatusTypeLocked // 1
27+
SpamReportStatusTypeProcessed // 2
28+
SpamReportStatusTypeDismissed // 3
29+
)
30+
31+
func (t SpamReportStatusType) String() string {
32+
switch t {
33+
case SpamReportStatusTypePending:
34+
return "pending"
35+
case SpamReportStatusTypeLocked:
36+
return "locked"
37+
case SpamReportStatusTypeProcessed:
38+
return "processed"
39+
case SpamReportStatusTypeDismissed:
40+
return "dismissed"
41+
}
42+
return "unknown"
43+
}
44+
45+
type SpamReport struct {
46+
ID int64 `xorm:"pk autoincr"`
47+
UserID int64 `xorm:"UNIQUE"`
48+
ReporterID int64 `xorm:"NOT NULL"`
49+
Status SpamReportStatusType `xorm:"INDEX NOT NULL DEFAULT 0"`
50+
CreatedUnix timeutil.TimeStamp `xorm:"created"`
51+
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
52+
}
53+
54+
func (*SpamReport) TableName() string {
55+
return "user_spamreport"
56+
}
57+
58+
func init() {
59+
// This table doesn't exist in the upstream code.
60+
// We don't introduce migrations for it to avoid migration id clashes.
61+
// Gitea will create the table in the database during startup,
62+
// so no manual action is required until we start modifying the table.
63+
db.RegisterModel(new(SpamReport))
64+
}
65+
66+
type ListSpamReportsOptions struct {
67+
db.ListOptions
68+
Status SpamReportStatusType
69+
}
70+
71+
type ListSpamReportsResults struct {
72+
ID int64
73+
CreatedUnix timeutil.TimeStamp
74+
UpdatedUnix timeutil.TimeStamp
75+
Status SpamReportStatusType
76+
UserName string
77+
UserCreatedUnix timeutil.TimeStamp
78+
ReporterName string
79+
}
80+
81+
func ListSpamReports(ctx context.Context, opts *ListSpamReportsOptions) ([]*ListSpamReportsResults, int64, error) {
82+
opts.SetDefaultValues()
83+
count, err := db.GetEngine(ctx).Count(new(SpamReport))
84+
if err != nil {
85+
return nil, 0, fmt.Errorf("Count: %w", err)
86+
}
87+
spamReports := make([]*ListSpamReportsResults, 0, opts.PageSize)
88+
err = db.GetEngine(ctx).Table("user_spamreport").Select(
89+
"user_spamreport.id, "+
90+
"user_spamreport.created_unix, "+
91+
"user_spamreport.updated_unix, "+
92+
"user_spamreport.status, "+
93+
"`user`.name as user_name, "+
94+
"`user`.created_unix as user_created_unix, "+
95+
"reporter.name as reporter_name",
96+
).
97+
Join("LEFT", "`user`", "`user`.id = user_spamreport.user_id").
98+
Join("LEFT", "`user` as reporter", "`reporter`.id = user_spamreport.reporter_id").
99+
Where("status = ?", opts.Status).
100+
OrderBy("user_spamreport.id").
101+
Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).
102+
Find(&spamReports)
103+
104+
return spamReports, count, err
105+
}
106+
107+
func GetPendingSpamReportIDs(ctx context.Context) ([]int64, error) {
108+
var ids []int64
109+
err := db.GetEngine(ctx).Table("user_spamreport").
110+
Select("id").Where("status = ?", SpamReportStatusTypePending).Find(&ids)
111+
return ids, err
112+
}
113+
114+
type SpamReportStatusCounts struct {
115+
Count int64
116+
Status SpamReportStatusType
117+
}
118+
119+
func GetSpamReportStatusCounts(ctx context.Context) ([]*SpamReportStatusCounts, error) {
120+
statusCounts := make([]*SpamReportStatusCounts, 0, 4) // 4 status types
121+
err := db.GetEngine(ctx).Table("user_spamreport").
122+
Select("count(*) as count, status").
123+
GroupBy("status").
124+
Find(&statusCounts)
125+
126+
return statusCounts, err
127+
}
128+
129+
func GetSpamReportForUser(ctx context.Context, user *User) (*SpamReport, error) {
130+
spamReport := &SpamReport{}
131+
has, err := db.GetEngine(ctx).Where("user_id = ?", user.ID).Get(spamReport)
132+
if has {
133+
return spamReport, err
134+
}
135+
return nil, err
136+
}

options/locale/locale_en-US.ini

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,18 @@ block.note.edit = Edit note
710710
block.list = Blocked users
711711
block.list.none = You have not blocked any users.
712712
713+
purgespammer.modal_title = Purge spam account
714+
purgespammer.modal_info = All content created by the user will be deleted! This cannot be undone.
715+
purgespammer.modal_action = Purge spam account
716+
purgespammer.profile_button = Purge spam account
717+
718+
spamreport.existing_status = The user has already been reported as a spammer, the report is %s.
719+
720+
spamreport.modal_title = Report spam
721+
spamreport.modal_info = Report a user as a spammer to site admins.
722+
spamreport.modal_action = Report spam
723+
spamreport.profile_button = Report spam
724+
713725
[settings]
714726
profile = Profile
715727
account = Account
@@ -2943,6 +2955,7 @@ first_page = First
29432955
last_page = Last
29442956
total = Total: %d
29452957
settings = Admin Settings
2958+
spamreports = Spam Reports
29462959
29472960
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.
29482961
dashboard.statistic = Summary
@@ -3030,6 +3043,7 @@ dashboard.sync_branch.started = Branches Sync started
30303043
dashboard.sync_tag.started = Tags Sync started
30313044
dashboard.rebuild_issue_indexer = Rebuild issue indexer
30323045
dashboard.sync_repo_licenses = Sync repo licenses
3046+
dashboard.process_spam_reports = Process spam reports
30333047
30343048
users.user_manage_panel = User Account Management
30353049
users.new_account = Create User Account
@@ -3106,6 +3120,14 @@ emails.delete_desc = Are you sure you want to delete this email address?
31063120
emails.deletion_success = The email address has been deleted.
31073121
emails.delete_primary_email_error = You can not delete the primary email.
31083122
3123+
spamreports.spamreport_manage_panel = Spam Report Management
3124+
spamreports.user = Reported for spam
3125+
spamreports.user_created = User created
3126+
spamreports.reporter = Reporter
3127+
spamreports.created = Report Created
3128+
spamreports.updated = Report Updated
3129+
spamreports.status = Report Status
3130+
31093131
orgs.org_manage_panel = Organization Management
31103132
orgs.name = Name
31113133
orgs.teams = Teams

routers/web/admin/spamreports.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2025 The Gitea Authors.
2+
// SPDX-License-Identifier: MIT
3+
4+
// BLENDER: spam reporting
5+
6+
package admin
7+
8+
import (
9+
"net/http"
10+
"strconv"
11+
12+
"code.gitea.io/gitea/models/db"
13+
user_model "code.gitea.io/gitea/models/user"
14+
"code.gitea.io/gitea/modules/log"
15+
"code.gitea.io/gitea/modules/setting"
16+
"code.gitea.io/gitea/modules/templates"
17+
"code.gitea.io/gitea/services/context"
18+
user_service "code.gitea.io/gitea/services/user"
19+
)
20+
21+
const (
22+
tplSpamReports templates.TplName = "admin/spamreports/list"
23+
)
24+
25+
// GetPendingSpamReports populates the counter for the header section displayed to site admins.
26+
func GetPendingSpamReports(ctx *context.Context) {
27+
if ctx.Doer == nil || !ctx.Doer.IsAdmin {
28+
return
29+
}
30+
ids, err := user_model.GetPendingSpamReportIDs(ctx)
31+
if err != nil {
32+
log.Error("Failed to GetPendingSpamReportIDs while rendering header: %v", err)
33+
ctx.Data["PendingSpamReports"] = -1
34+
return
35+
}
36+
ctx.Data["PendingSpamReports"] = len(ids)
37+
}
38+
39+
// SpamReports shows spam reports
40+
func SpamReports(ctx *context.Context) {
41+
ctx.Data["Title"] = ctx.Tr("admin.spamreports")
42+
ctx.Data["PageIsSpamReports"] = true
43+
44+
var (
45+
count int64
46+
err error
47+
filterStatus user_model.SpamReportStatusType
48+
)
49+
50+
// When no value is specified reports are filtered by status=pending (=0),
51+
// which luckily makes sense as a default view.
52+
filterStatus = user_model.SpamReportStatusType(ctx.FormInt("status"))
53+
ctx.Data["FilterStatus"] = filterStatus
54+
opts := &user_model.ListSpamReportsOptions{
55+
ListOptions: db.ListOptions{
56+
PageSize: setting.UI.Admin.UserPagingNum,
57+
Page: ctx.FormInt("page"),
58+
},
59+
Status: filterStatus,
60+
}
61+
62+
if opts.Page <= 1 {
63+
opts.Page = 1
64+
}
65+
66+
spamReports, count, err := user_model.ListSpamReports(ctx, opts)
67+
if err != nil {
68+
ctx.ServerError("SpamReports", err)
69+
return
70+
}
71+
72+
ctx.Data["Total"] = count
73+
ctx.Data["SpamReports"] = spamReports
74+
75+
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
76+
ctx.Data["Page"] = pager
77+
78+
statusCounts, err := user_model.GetSpamReportStatusCounts(ctx)
79+
if err != nil {
80+
ctx.ServerError("GetSpamReportStatusCounts", err)
81+
return
82+
}
83+
ctx.Data["StatusCounts"] = statusCounts
84+
85+
ctx.HTML(http.StatusOK, tplSpamReports)
86+
}
87+
88+
// SpamReportsPost handles "process" and "dismiss" actions for pending reports.
89+
// The processing is done synchronously.
90+
func SpamReportsPost(ctx *context.Context) {
91+
action := ctx.FormString("action")
92+
// ctx.Req.PostForm is now parsed due to the call to FormString above
93+
spamReportIDs := make([]int64, 0, len(ctx.Req.PostForm["spamreport_id"]))
94+
for _, idStr := range ctx.Req.PostForm["spamreport_id"] {
95+
id, err := strconv.ParseInt(idStr, 10, 64)
96+
if err != nil {
97+
ctx.ServerError("ParseSpamReportID", err)
98+
return
99+
}
100+
spamReportIDs = append(spamReportIDs, id)
101+
}
102+
103+
if action == "process" {
104+
if err := user_service.ProcessSpamReports(ctx, ctx.Doer, spamReportIDs); err != nil {
105+
ctx.ServerError("ProcessSpamReports", err)
106+
return
107+
}
108+
}
109+
if action == "dismiss" {
110+
if err := user_service.DismissSpamReports(ctx, spamReportIDs); err != nil {
111+
ctx.ServerError("DismissSpamReports", err)
112+
return
113+
}
114+
}
115+
ctx.Redirect(setting.AppSubURL + "/-/admin/spamreports")
116+
}
117+
118+
// PurgeSpammerPost is a shortcut for admins to report and process at the same time.
119+
func PurgeSpammerPost(ctx *context.Context) {
120+
username := ctx.FormString("username")
121+
122+
user, err := user_model.GetUserByName(ctx, username)
123+
if err != nil {
124+
ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, nil)
125+
return
126+
}
127+
spamReport, err := user_service.CreateSpamReport(ctx, ctx.Doer, user)
128+
if err != nil {
129+
ctx.ServerError("CreateSpamReport", err)
130+
return
131+
}
132+
if err := user_service.ProcessSpamReports(ctx, ctx.Doer, []int64{spamReport.ID}); err != nil {
133+
ctx.ServerError("ProcessSpamReports", err)
134+
return
135+
}
136+
137+
if ctx.Written() {
138+
return
139+
}
140+
ctx.Redirect(setting.AppSubURL + "/" + username)
141+
}

routers/web/shared/user/header.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"code.gitea.io/gitea/modules/setting"
2323
"code.gitea.io/gitea/modules/util"
2424
"code.gitea.io/gitea/services/context"
25+
user_service "code.gitea.io/gitea/services/user"
2526
)
2627

2728
// prepareContextForProfileBigAvatar set the context for big avatar view on the profile page
@@ -89,6 +90,25 @@ func prepareContextForProfileBigAvatar(ctx *context.Context) {
8990
} else {
9091
ctx.Data["UserBlocking"] = block
9192
}
93+
94+
// BLENDER: spam reporting
95+
doerIsTrusted, err := user_service.IsTrustedUser(ctx, ctx.Doer)
96+
if err != nil {
97+
ctx.ServerError("IsTrustedUser", err)
98+
return
99+
}
100+
userIsTrusted, err := user_service.IsTrustedUser(ctx, ctx.ContextUser)
101+
if err != nil {
102+
ctx.ServerError("IsTrustedUser", err)
103+
return
104+
}
105+
ctx.Data["CanReportSpam"] = doerIsTrusted && !userIsTrusted
106+
existingSpamReport, err := user_model.GetSpamReportForUser(ctx, ctx.ContextUser)
107+
if err != nil {
108+
ctx.ServerError("GetSpamReportForUser", err)
109+
return
110+
}
111+
ctx.Data["ExistingSpamReport"] = existingSpamReport
92112
}
93113
}
94114

0 commit comments

Comments
 (0)