Skip to content

Commit 68a119c

Browse files
committed
Implement report processing
1 parent 3cf7dc4 commit 68a119c

File tree

8 files changed

+261
-56
lines changed

8 files changed

+261
-56
lines changed
Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,36 @@ import (
1111
"code.gitea.io/gitea/modules/timeutil"
1212
)
1313

14+
// SpamReportStatusType is used to support a spam report lifecycle:
15+
//
16+
// Pending -> Processing
17+
// Processing -> Processed|Dismissed
18+
//
19+
// "Processing" status works as a lock for a record that is being processed.
1420
type SpamReportStatusType int
1521

1622
const (
17-
SpamReportStatusTypePending = iota // 0
18-
SpamReportStatusTypeProcessed // 1
19-
SpamReportStatusTypeDismissed // 2
23+
SpamReportStatusTypePending = iota // 0
24+
SpamReportStatusTypeProcessing // 1
25+
SpamReportStatusTypeProcessed // 2
26+
SpamReportStatusTypeDismissed // 3
2027
)
2128

29+
func (t SpamReportStatusType) String() string {
30+
switch t {
31+
case SpamReportStatusTypePending:
32+
return "Pending"
33+
case SpamReportStatusTypeProcessing:
34+
return "Processing"
35+
case SpamReportStatusTypeProcessed:
36+
return "Processed"
37+
case SpamReportStatusTypeDismissed:
38+
return "Dismissed"
39+
default:
40+
panic(fmt.Sprintf("unknown SpamReportStatusType: %d", t))
41+
}
42+
}
43+
2244
type SpamReport struct {
2345
ID int64 `xorm:"pk autoincr"`
2446
UserID int64 `xorm:"UNIQUE"`
@@ -38,7 +60,7 @@ func init() {
3860

3961
type ListSpamReportResults struct {
4062
ID int64
41-
Status int
63+
Status SpamReportStatusType
4264
UserName string
4365
ReporterName string
4466
}

routers/web/admin/spamreports.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
// Copyright 2025 The Gitea Authors.
22
// SPDX-License-Identifier: MIT
33

4+
// BLENDER: spam reporting
5+
46
package admin
57

68
import (
79
"net/http"
10+
"strconv"
811

912
"code.gitea.io/gitea/models/db"
1013
user_model "code.gitea.io/gitea/models/user"
1114
"code.gitea.io/gitea/modules/base"
1215
"code.gitea.io/gitea/modules/setting"
1316
"code.gitea.io/gitea/services/context"
17+
user_service "code.gitea.io/gitea/services/user"
1418
)
1519

1620
const (
@@ -50,3 +54,31 @@ func SpamReports(ctx *context.Context) {
5054

5155
ctx.HTML(http.StatusOK, tplSpamReports)
5256
}
57+
58+
func SpamReportsPost(ctx *context.Context) {
59+
action := ctx.FormString("action")
60+
// ctx.Req.PostForm is now parsed due to the call to FormString above
61+
spamReportIDs := make([]int64, 0, len(ctx.Req.PostForm["spamreport_id"]))
62+
for _, idStr := range ctx.Req.PostForm["spamreport_id"] {
63+
id, err := strconv.ParseInt(idStr, 10, 64)
64+
if err != nil {
65+
ctx.ServerError("ParseSpamReportID", err)
66+
return
67+
}
68+
spamReportIDs = append(spamReportIDs, id)
69+
}
70+
71+
if action == "process" {
72+
if err := user_service.ProcessSpamReports(ctx, spamReportIDs); err != nil {
73+
ctx.ServerError("ProcessSpamReports", err)
74+
return
75+
}
76+
}
77+
if action == "dismiss" {
78+
if err := user_service.DismissSpamReports(ctx, spamReportIDs); err != nil {
79+
ctx.ServerError("DismissSpamReports", err)
80+
return
81+
}
82+
}
83+
ctx.Redirect(setting.AppSubURL + "/-/admin/spamreports")
84+
}
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// Copyright 2025 The Gitea Authors. All rights reserved.
22
// SPDX-License-Identifier: MIT
33

4+
// BLENDER: spam reporting
5+
46
package setting
57

68
import (
7-
"fmt"
89
"net/http"
910

1011
user_model "code.gitea.io/gitea/models/user"
@@ -15,9 +16,7 @@ import (
1516

1617
func SpamReportUserPost(ctx *context.Context) {
1718
canReportSpam, err := user_service.CanReportSpam(ctx, ctx.Doer)
18-
fmt.Println("here0")
1919
if err != nil {
20-
fmt.Println("here1")
2120
ctx.ServerError("CanReportSpam", err)
2221
return
2322
}
@@ -28,7 +27,6 @@ func SpamReportUserPost(ctx *context.Context) {
2827

2928
user, err := user_model.GetUserByName(ctx, username)
3029
if err != nil {
31-
fmt.Println("here2")
3230
ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, nil)
3331
return
3432
}

routers/web/web.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ func registerRoutes(m *web.Router) {
677677
m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost)
678678
})
679679

680-
m.Post("/spam_report", user_setting.SpamReportUserPost)
680+
m.Post("/spamreport", user_setting.SpamReportUserPost)
681681
}, reqSignIn, ctxDataSet("PageIsUserSettings", true, "EnablePackages", setting.Packages.Enabled))
682682

683683
m.Group("/user", func() {
@@ -752,6 +752,7 @@ func registerRoutes(m *web.Router) {
752752

753753
m.Group("/spamreports", func() {
754754
m.Get("", admin.SpamReports)
755+
m.Post("", admin.SpamReportsPost)
755756
})
756757

757758
m.Group("/orgs", func() {

services/user/spam_report.go

Lines changed: 0 additions & 28 deletions
This file was deleted.

services/user/spamreport.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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/models/organization"
14+
issues_model "code.gitea.io/gitea/models/issues"
15+
project_model "code.gitea.io/gitea/models/project"
16+
user_model "code.gitea.io/gitea/models/user"
17+
"code.gitea.io/gitea/modules/log"
18+
"code.gitea.io/gitea/modules/optional"
19+
"code.gitea.io/gitea/modules/structs"
20+
repo_service "code.gitea.io/gitea/services/repository"
21+
)
22+
23+
// CanReportSpam tells if a doer is allowed to report spam.
24+
func CanReportSpam(ctx context.Context, doer *user_model.User) (bool, error) {
25+
count, err := organization.GetOrganizationCount(ctx, doer)
26+
if err != nil {
27+
return false, fmt.Errorf("GetOrganizationCount: %w", err)
28+
}
29+
return count > 0, nil
30+
}
31+
32+
// CreateSpamReport inserts a new record in default status=Pending
33+
// for further processing, either manual or automatical.
34+
func CreateSpamReport(ctx context.Context, reporter, user *user_model.User) error {
35+
return db.Insert(ctx, &user_model.SpamReport{
36+
ReporterID: reporter.ID,
37+
UserID: user.ID,
38+
})
39+
}
40+
41+
// ProcessSpamReports updates only reports in status "Pending" to avoid race conditions.
42+
func ProcessSpamReports(ctx context.Context, spamReportIDs []int64) error {
43+
e := db.GetEngine(ctx)
44+
var spamReports []user_model.SpamReport
45+
err := e.In("id", spamReportIDs).Find(&spamReports)
46+
if err != nil {
47+
return fmt.Errorf("failed to fetch SpamReports: %w", err)
48+
}
49+
50+
for _, spamReport := range spamReports {
51+
id := spamReport.ID
52+
count, err := e.ID(id).And("status = ?", user_model.SpamReportStatusTypePending).
53+
Update(&user_model.SpamReport{Status: user_model.SpamReportStatusTypeProcessing})
54+
if err != nil {
55+
return fmt.Errorf("failed to set SpamReport.Status to Processing for id=%d: %w", id, err)
56+
}
57+
if count < 1 {
58+
log.Info("Skipping SpamReport id=%d, status wasn't Pending", id)
59+
continue
60+
}
61+
62+
userID := spamReport.UserID
63+
user := &user_model.User{ID: userID}
64+
has, err := e.Get(user)
65+
if err != nil {
66+
return fmt.Errorf("failed to fetch user userID=%d: %w", userID, err)
67+
}
68+
if !has {
69+
return fmt.Errorf("user id=%d was not found", userID)
70+
}
71+
72+
// Clean up everything and update report status if there were no errors.
73+
// On failure the report will be processed partially and it will be stuck in Processing status.
74+
//
75+
// Not wrapping into a transaction due to a deadlock happening on sqlite
76+
// when querying for comments after they were deleted, may need more investigation.
77+
log.Info("Processing SpamReport id=%d for user %s", id, user.Name)
78+
79+
// UpdateUser and UpdateAuth to clean the profile and prohibit logins.
80+
if err := UpdateUser(ctx, user,
81+
&UpdateOptions{
82+
Description: optional.Some(""),
83+
FullName: optional.Some("Confirmed Spammer"),
84+
IsActive: optional.Some(false),
85+
IsRestricted: optional.Some(true),
86+
Location: optional.Some(""),
87+
MaxRepoCreation: optional.Some(0),
88+
Visibility: optional.Some(structs.VisibleTypeLimited),
89+
Website: optional.Some(""),
90+
},
91+
); err != nil {
92+
return fmt.Errorf("failed to UpdateUser: %w", err)
93+
}
94+
if err := UpdateAuth(ctx, user, &UpdateAuthOptions{ProhibitLogin: optional.Some(true)}); err != nil {
95+
return fmt.Errorf("failed to UpdateAuth: %w", err)
96+
}
97+
98+
// Clean up all comments.
99+
log.Info("Cleaning up comments by user %s", user.Name)
100+
const batchSize = 50
101+
for {
102+
log.Info("started loop")
103+
comments := make([]*issues_model.Comment, 0, batchSize)
104+
if err := e.Where("type=? AND poster_id=?", issues_model.CommentTypeComment, userID).Limit(batchSize, 0).Find(&comments); err != nil {
105+
return fmt.Errorf("failed to find comments to delete: %w", err)
106+
}
107+
log.Info("found %d comments", len(comments))
108+
if len(comments) == 0 {
109+
break
110+
}
111+
112+
for _, comment := range comments {
113+
log.Info("deleting comment %+v", *comment)
114+
if err := issues_model.DeleteComment(ctx, comment); err != nil {
115+
return fmt.Errorf("failed to delete comments: %w", err)
116+
}
117+
}
118+
log.Info("finished loop")
119+
e.Context(ctx).Close()
120+
}
121+
122+
// Clean up all personal repos.
123+
log.Info("Cleaning up personal repositories of user %s", user.Name)
124+
if err := repo_service.DeleteOwnerRepositoriesDirectly(ctx, user); err != nil {
125+
return fmt.Errorf("failed to clean up repositories: %w", err)
126+
}
127+
128+
// Clean up all personal projects.
129+
log.Info("Cleaning up personal projects of user %s", user.Name)
130+
projectIDs, err := project_model.GetAllProjectsIDsByOwnerIDAndType(ctx, user.ID, project_model.TypeIndividual)
131+
if err != nil {
132+
return fmt.Errorf("failed to fetch personal project ids: %w", err)
133+
}
134+
for _, projectID := range projectIDs {
135+
if err := project_model.DeleteProjectByID(ctx, projectID); err != nil {
136+
return fmt.Errorf("failed to clean up personal project id=%d: %w", projectID, err)
137+
}
138+
}
139+
140+
// Everything is cleaned up, marking the spam report as Processed.
141+
count, err = e.ID(id).And("status = ?", user_model.SpamReportStatusTypeProcessing).
142+
Update(&user_model.SpamReport{Status: user_model.SpamReportStatusTypeProcessed})
143+
if err != nil {
144+
return fmt.Errorf("failed to set SpamReport.Status to Processed for id=%d: %w", id, err)
145+
}
146+
if count < 1 {
147+
return fmt.Errorf("SpamReport id=%d status wasn't Processing, rolling back the transaction", id)
148+
}
149+
log.Info("Processed SpamReport id=%d for user %s", id, user.Name)
150+
}
151+
return nil
152+
}
153+
154+
// DismissSpamReports updates only reports in status "Pending" to avoid race conditions
155+
// with the actual processing.
156+
func DismissSpamReports(ctx context.Context, spamReportIDs []int64) error {
157+
_, err := db.GetEngine(ctx).In("id", spamReportIDs).
158+
And("status = ?", user_model.SpamReportStatusTypePending).
159+
Update(&user_model.SpamReport{Status: user_model.SpamReportStatusTypeDismissed})
160+
return err
161+
}

0 commit comments

Comments
 (0)