Skip to content

spam reporting #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
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