{{ctx.Locale.Tr "user.purgespammer.modal_info"}}
+
+ diff --git a/BLENDER_README.md b/BLENDER_README.md
new file mode 100644
index 0000000000000..f5bd495d5e96d
--- /dev/null
+++ b/BLENDER_README.md
@@ -0,0 +1,12 @@
+# Blender Merges
+
+Currently the process for merging upstream changes is to rebase, and keep
+Blender modifications on top. This keeps a clear overview of the modifications
+that were made.
+
+When merging a major new release, cherry-pick all the Blender commits on
+top of it. A simple `git rebase` will not work because the release and main
+branches diverge.
+
+First do changes in `blender-merged-develop`, and deploy on uatest. Then apply
+the changes in `blender-merged` and deploy in production.
diff --git a/Dockerfile.rootless b/Dockerfile.rootless
index c87a965608023..32a3ff259ac26 100644
--- a/Dockerfile.rootless
+++ b/Dockerfile.rootless
@@ -54,6 +54,11 @@ RUN apk --no-cache add \
gnupg \
&& rm -rf /var/cache/apk/*
+# External renderers
+RUN apk --no-cache add \
+ python3-dev \
+ && rm -rf /var/cache/apk/*
+
RUN addgroup \
-S -g 1000 \
git && \
diff --git a/go.mod b/go.mod
index f6d079dbbb336..683ce90aa8068 100644
--- a/go.mod
+++ b/go.mod
@@ -257,6 +257,7 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mozillazg/go-unidecode v0.2.0 // indirect
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
diff --git a/go.sum b/go.sum
index 9b200cc2d9477..2bc60270c1374 100644
--- a/go.sum
+++ b/go.sum
@@ -590,6 +590,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
+github.com/mozillazg/go-unidecode v0.2.0 h1:vFGEzAH9KSwyWmXCOblazEWDh7fOkpmy/Z4ArmamSUc=
+github.com/mozillazg/go-unidecode v0.2.0/go.mod h1:zB48+/Z5toiRolOZy9ksLryJ976VIwmDmpQ2quyt1aA=
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM=
github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
diff --git a/models/git/lfs.go b/models/git/lfs.go
index bb6361050aaef..83a2e7883d1cf 100644
--- a/models/git/lfs.go
+++ b/models/git/lfs.go
@@ -236,7 +236,7 @@ func CountLFSMetaObjects(ctx context.Context, repoID int64) (int64, error) {
// LFSObjectAccessible checks if a provided Oid is accessible to the user
func LFSObjectAccessible(ctx context.Context, user *user_model.User, oid string) (bool, error) {
- if user.IsAdmin {
+ if user != nil && user.IsAdmin {
count, err := db.GetEngine(ctx).Count(&LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}})
return count > 0, err
}
diff --git a/models/issues/comment.go b/models/issues/comment.go
index ab9b2042f386f..7bd23adc4c926 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -1313,3 +1313,21 @@ func InsertIssueComments(ctx context.Context, comments []*Comment) error {
}
return committer.Commit()
}
+
+// GetRecentComments returns the most recent issue comments
+func GetRecentComments(ctx context.Context, opts *db.ListOptions) ([]*Comment, error) {
+ sess := db.GetEngine(ctx).
+ Where("type = ?", CommentTypeComment).
+ OrderBy("created_unix DESC")
+
+ if opts != nil {
+ sess = db.SetSessionPagination(sess, opts)
+ }
+
+ cap := 0
+ if opts != nil {
+ cap = opts.PageSize
+ }
+ comments := make([]*Comment, 0, cap)
+ return comments, sess.Find(&comments)
+}
diff --git a/models/issues/issue.go b/models/issues/issue.go
index a86d50ca9da3c..5efc065b02626 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -824,3 +824,21 @@ func ChangeIssueTimeEstimate(ctx context.Context, issue *Issue, doer *user_model
return nil
})
}
+
+// GetRecentIssues returns the most recently created issues
+func GetRecentIssues(ctx context.Context, opts *db.ListOptions) ([]*Issue, error) {
+ sess := db.GetEngine(ctx).
+ Where("is_pull = ?", false).
+ OrderBy("created_unix DESC")
+
+ if opts != nil {
+ sess = db.SetSessionPagination(sess, opts)
+ }
+
+ cap := 0
+ if opts != nil {
+ cap = opts.PageSize
+ }
+ issues := make([]*Issue, 0, cap)
+ return issues, sess.Find(&issues)
+}
diff --git a/models/issues/label.go b/models/issues/label.go
index cfbe100926990..cb7726aa717d2 100644
--- a/models/issues/label.go
+++ b/models/issues/label.go
@@ -184,11 +184,8 @@ func (l *Label) BelongsToRepo() bool {
return l.RepoID > 0
}
-// ExclusiveScope returns scope substring of label name, or empty string if none exists
-func (l *Label) ExclusiveScope() string {
- if !l.Exclusive {
- return ""
- }
+// Return scope substring of label name, or empty string if none exists
+func (l *Label) Scope() string {
lastIndex := strings.LastIndex(l.Name, "/")
if lastIndex == -1 || lastIndex == 0 || lastIndex == len(l.Name)-1 {
return ""
@@ -196,6 +193,14 @@ func (l *Label) ExclusiveScope() string {
return l.Name[:lastIndex]
}
+// ExclusiveScope returns scope substring of label name, or empty string if none exists
+func (l *Label) ExclusiveScope() string {
+ if !l.Exclusive {
+ return ""
+ }
+ return l.Scope()
+}
+
// NewLabel creates a new label
func NewLabel(ctx context.Context, l *Label) error {
color, err := label.NormalizeColor(l.Color)
diff --git a/models/organization/team.go b/models/organization/team.go
index 7f3a9b3829592..f0717faf2eb36 100644
--- a/models/organization/team.go
+++ b/models/organization/team.go
@@ -247,3 +247,19 @@ func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
return err
}
+
+// Avoid notifying large teams accidentally
+func FilterLargeTeams(teams []*Team, err error) ([]*Team, error) {
+ if err != nil {
+ return nil, err
+ }
+
+ var smallTeams []*Team
+ for _, team := range teams {
+ if team.NumMembers <= 10 {
+ smallTeams = append(smallTeams, team)
+ }
+ }
+
+ return smallTeams, nil
+}
diff --git a/models/user/badge.go b/models/user/badge.go
index 3ff3530a369a5..9f030c7019103 100644
--- a/models/user/badge.go
+++ b/models/user/badge.go
@@ -105,13 +105,23 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error {
// RemoveUserBadges removes badges from a user.
func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error {
return db.WithTx(ctx, func(ctx context.Context) error {
+ badgeSlugs := make([]string, 0, len(badges))
for _, badge := range badges {
- if _, err := db.GetEngine(ctx).
- Join("INNER", "badge", "badge.id = `user_badge`.badge_id").
- Where("`user_badge`.user_id=? AND `badge`.slug=?", u.ID, badge.Slug).
- Delete(&UserBadge{}); err != nil {
- return err
- }
+ badgeSlugs = append(badgeSlugs, badge.Slug)
+ }
+ var userBadges []UserBadge
+ if err := db.GetEngine(ctx).Table("user_badge").
+ Join("INNER", "badge", "badge.id = `user_badge`.badge_id").
+ Where("`user_badge`.user_id = ?", u.ID).In("`badge`.slug", badgeSlugs).
+ Find(&userBadges); err != nil {
+ return err
+ }
+ userBadgeIDs := make([]int64, 0, len(userBadges))
+ for _, ub := range userBadges {
+ userBadgeIDs = append(userBadgeIDs, ub.ID)
+ }
+ if _, err := db.GetEngine(ctx).Table("user_badge").In("id", userBadgeIDs).Delete(); err != nil {
+ return err
}
return nil
})
diff --git a/models/user/spamreport.go b/models/user/spamreport.go
new file mode 100644
index 0000000000000..a4390c9d4a054
--- /dev/null
+++ b/models/user/spamreport.go
@@ -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
+}
diff --git a/modules/git/repo_compare.go b/modules/git/repo_compare.go
index ff44506e13c2d..b718ba65991fb 100644
--- a/modules/git/repo_compare.go
+++ b/modules/git/repo_compare.go
@@ -39,9 +39,12 @@ func (repo *Repository) GetMergeBase(tmpRemote, base, head string) (string, stri
if tmpRemote != "origin" {
tmpBaseName := RemotePrefix + tmpRemote + "/tmp_" + base
// Fetch commit into a temporary branch in order to be able to handle commits and tags
- _, _, err := NewCommand("fetch", "--no-tags").AddDynamicArguments(tmpRemote).AddDashesAndList(base+":"+tmpBaseName).RunStdString(repo.Ctx, &RunOpts{Dir: repo.Path})
+ // --no-write-commit-graph works around issue with commit-graph-chain.lock files that should not be there.
+ _, _, err := NewCommand("fetch", "--no-write-commit-graph", "--no-tags").AddDynamicArguments(tmpRemote).AddDashesAndList(base+":"+tmpBaseName).RunStdString(repo.Ctx, &RunOpts{Dir: repo.Path})
if err == nil {
base = tmpBaseName
+ } else {
+ logger.Trace("GetMergeBase failed to git fetch. Error: %v", err)
}
}
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 8d9ba1000c882..53a16944af01b 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -127,7 +127,7 @@ func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
var extraCSSClasses string
textColor := util.ContrastColor(label.Color)
- labelScope := label.ExclusiveScope()
+ labelScope := label.Scope()
descriptionText := emoji.ReplaceAliases(label.Description)
if label.IsArchived() {
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index b9aae48f8650c..f0aadbb788ea9 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -167,6 +167,8 @@ filter.private = Private
no_results_found = No results found.
internal_error_skipped = Internal error occurred but is skipped: %s
+type = Type
+
[search]
search = Search...
type_tooltip = Search type
@@ -710,6 +712,20 @@ 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
@@ -2944,6 +2960,15 @@ last_page = Last
total = Total: %d
settings = Admin Settings
+spam_management = Spam Management
+spamreports = Spam Reports
+users_with_links = Users with Links
+issues_with_links = Issues/Comments with Links
+issues_with_links.found_links = Found Links
+issues_with_links.created = Created
+issues_with_links.updated = Updated
+issues_with_links.user.created = User Created
+
dashboard.new_version_hint = Gitea %s is now available, you are running %s. Check the blog for more details.
dashboard.statistic = Summary
dashboard.maintenance_operations = Maintenance Operations
@@ -3030,6 +3055,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
@@ -3089,6 +3115,11 @@ users.list_status_filter.is_2fa_enabled = 2FA Enabled
users.list_status_filter.not_2fa_enabled = 2FA Disabled
users.details = User Details
+users.description = Description
+users.location = Location
+users.website = Website
+users.report_spam = Report Users for Spam
+
emails.email_manage_panel = User Email Management
emails.primary = Primary
emails.activated = Activated
@@ -3106,6 +3137,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
diff --git a/public/assets/img/blenderid.png b/public/assets/img/blenderid.png
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/routers/utils/utils.go b/routers/utils/utils.go
index 3035073d5c5ab..ae1547e3dabc4 100644
--- a/routers/utils/utils.go
+++ b/routers/utils/utils.go
@@ -12,3 +12,17 @@ import (
func SanitizeFlashErrorString(x string) string {
return strings.ReplaceAll(html.EscapeString(x), "\n", "
")
}
+
+func ContainsHyperlink(text string) bool {
+ text = strings.ToLower(text)
+ return strings.Contains(text, "http://") || strings.Contains(text, "https://")
+}
+
+func ContainsExcludedDomain(snippet string, domains []string) bool {
+ for _, domain := range domains {
+ if strings.Contains(snippet, domain) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/routers/web/admin/issues_with_links.go b/routers/web/admin/issues_with_links.go
new file mode 100644
index 0000000000000..84511fa838a2c
--- /dev/null
+++ b/routers/web/admin/issues_with_links.go
@@ -0,0 +1,241 @@
+package admin
+
+import (
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/modules/log"
+
+ issue_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/services/context"
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+const tplIssuesWithLinks templates.TplName = "admin/issues_with_links"
+
+type linkItem struct {
+ Type string
+ Content string
+ User *user_model.User
+ UserCreated time.Time
+ RepoName string
+ RepoLink string
+ ItemLink string
+ Created time.Time
+ Updated time.Time
+}
+
+func IssuesWithLinks(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.issues_with_links")
+ ctx.Data["PageIsIssuesWithLinks"] = true
+
+ page := ctx.FormInt("page")
+ if page <= 1 {
+ page = 1
+ }
+
+ sortField := ctx.FormString("sort")
+ sortOrder := strings.ToLower(ctx.FormString("order")) // asc or desc
+ if sortOrder != "asc" {
+ sortOrder = "desc"
+ }
+ ctx.Data["Sort"] = sortField
+ ctx.Data["Order"] = sortOrder
+
+ // fetch recent issues and comments
+ limit := setting.UI.Admin.UserPagingNum
+ issues, err := issue_model.GetRecentIssues(ctx, &db.ListOptions{Page: page, PageSize: limit})
+ if err != nil {
+ ctx.ServerError("GetRecentIssues", err)
+ return
+ }
+ comments, err := issue_model.GetRecentComments(ctx, &db.ListOptions{Page: page, PageSize: limit})
+ if err != nil {
+ ctx.ServerError("GetRecentComments", err)
+ return
+ }
+
+ var excludedDomains = []string{
+ getDomain(setting.AppURL),
+ "github.com",
+ }
+
+ var items []linkItem
+ appendIfHasLink := func(typeLabel, content, itemLink, repoName string, repoLink string, created time.Time, updated time.Time, u *user_model.User) {
+ links := extractAllLinks(content)
+ if len(links) == 0 {
+ return
+ }
+
+ var validLinks []string
+ for _, link := range links {
+ if !containsExcludedDomain(link, excludedDomains) {
+ validLinks = append(validLinks, link)
+ }
+ }
+
+ if len(validLinks) == 0 {
+ return
+ }
+
+ items = append(items, linkItem{
+ Type: typeLabel,
+ Content: strings.Join(validLinks, ", "),
+ User: u,
+ UserCreated: u.CreatedUnix.AsTime(),
+ RepoName: repoName,
+ RepoLink: repoLink,
+ ItemLink: itemLink,
+ Created: created,
+ Updated: updated,
+ })
+ }
+
+ for _, issue := range issues {
+ if issue.Content == "" {
+ continue
+ }
+ if issue.Repo == nil {
+ issue.Repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID)
+ if err != nil {
+ log.Warn("Could not load repo for issue %d: %v", issue.ID, err)
+ continue
+ }
+ }
+ u, err := user_model.GetUserByID(ctx, issue.PosterID)
+ if err != nil {
+ continue
+ }
+ appendIfHasLink("Issue", issue.Content, issue.HTMLURL(), issue.Repo.Name, issue.Repo.HTMLURL(), issue.CreatedUnix.AsTime(), issue.UpdatedUnix.AsTime(), u)
+ }
+
+ for _, comment := range comments {
+ if comment.Issue == nil {
+ comment.Issue, err = issue_model.GetIssueByID(ctx, comment.IssueID)
+ if err != nil {
+ log.Warn("Could not load issue for comment %d: %v", comment.ID, err)
+ continue
+ }
+ }
+ if comment.Issue.Repo == nil {
+ comment.Issue.Repo, err = repo_model.GetRepositoryByID(ctx, comment.Issue.RepoID)
+ if err != nil {
+ log.Warn("Could not load repo for issue %d: %v", comment.Issue.ID, err)
+ continue
+ }
+ }
+ if comment.Content == "" {
+ continue
+ }
+ u, err := user_model.GetUserByID(ctx, comment.PosterID)
+ if err != nil {
+ continue
+ }
+ appendIfHasLink("Comment", comment.Content, comment.HTMLURL(ctx), comment.Issue.Repo.Name, comment.Issue.Repo.HTMLURL(), comment.CreatedUnix.AsTime(), comment.UpdatedUnix.AsTime(), u)
+ }
+
+ // Sort
+ switch sortField {
+ case "usercreated":
+ sort.Slice(items, func(i, j int) bool {
+ if sortOrder == "asc" {
+ return items[i].UserCreated.Before(items[j].UserCreated)
+ }
+ return items[i].UserCreated.After(items[j].UserCreated)
+ })
+ case "created":
+ sort.Slice(items, func(i, j int) bool {
+ if sortOrder == "asc" {
+ return items[i].Created.Before(items[j].Created)
+ }
+ return items[i].Created.After(items[j].Created)
+ })
+ default: // fallback to descending by UserCreated
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].UserCreated.After(items[j].UserCreated)
+ })
+ }
+
+ total := len(items)
+ start := (page - 1) * limit
+ end := start + limit
+ if start > total {
+ start = total
+ }
+ if end > total {
+ end = total
+ }
+ paged := items[start:end]
+
+ ctx.Data["Items"] = paged
+ ctx.Data["Total"] = total
+
+ pager := context.NewPagination(total, limit, page, 5)
+ pager.AddParamFromRequest(ctx.Req)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplIssuesWithLinks)
+}
+
+// extractAllLinks returns all http(s) URLs from raw text and Markdown-style links.
+func extractAllLinks(text string) []string {
+ // Match Markdown-style links: [label](http://example.com)
+ mdLinkRegex := regexp.MustCompile(`\[[^\]]*\]\((https?://[^\s\)]+)\)`)
+ // Match raw URLs
+ rawURLRegex := regexp.MustCompile(`https?://[^\s<>"')\]]+`)
+
+ seen := make(map[string]bool)
+ var links []string
+
+ // Extract Markdown links
+ for _, match := range mdLinkRegex.FindAllStringSubmatch(text, -1) {
+ if len(match) > 1 {
+ url := match[1]
+ if !seen[url] {
+ seen[url] = true
+ links = append(links, url)
+ }
+ }
+ }
+
+ // Extract raw URLs
+ for _, url := range rawURLRegex.FindAllString(text, -1) {
+ if !seen[url] {
+ seen[url] = true
+ links = append(links, url)
+ }
+ }
+
+ return links
+}
+
+// containsExcludedDomain returns true if any domain in the list matches the link
+func containsExcludedDomain(link string, excluded []string) bool {
+ u, err := url.Parse(link)
+ if err != nil || u.Host == "" {
+ return false
+ }
+ for _, d := range excluded {
+ if strings.EqualFold(u.Hostname(), d) {
+ return true
+ }
+ }
+ return false
+}
+
+// getDomain extracts domain from full AppURL (e.g. https://example.com)
+func getDomain(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return ""
+ }
+ return u.Hostname()
+}
diff --git a/routers/web/admin/spamreports.go b/routers/web/admin/spamreports.go
new file mode 100644
index 0000000000000..16fbd615bd396
--- /dev/null
+++ b/routers/web/admin/spamreports.go
@@ -0,0 +1,141 @@
+// 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/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/services/context"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+const (
+ tplSpamReports templates.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)
+ 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)
+}
diff --git a/routers/web/admin/users_with_links.go b/routers/web/admin/users_with_links.go
new file mode 100644
index 0000000000000..9cd5e47577971
--- /dev/null
+++ b/routers/web/admin/users_with_links.go
@@ -0,0 +1,79 @@
+package admin
+
+import (
+ "net/http"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/routers/utils"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/services/context"
+)
+
+const tplUsersWithLinks templates.TplName = "admin/users_with_links"
+
+// UsersWithLinks renders a list of users that contain hyperlinks in bio fields
+func UsersWithLinks(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.users_with_links")
+ ctx.Data["PageIsUsersWithLinks"] = true
+
+ // Parse filters from query parameters
+ statusActive := ctx.FormString("status_filter[is_active]")
+ statusAdmin := ctx.FormString("status_filter[is_admin]")
+ statusRestricted := ctx.FormString("status_filter[is_restricted]")
+ status2fa := ctx.FormString("status_filter[is_2fa_enabled]")
+ statusProhibit := ctx.FormString("status_filter[is_prohibit_login]")
+
+ sort := ctx.FormString("sort")
+ if sort == "" {
+ sort = "created_unix"
+ }
+ ctx.Data["SortType"] = sort
+
+ // Build search options
+ opts := &user_model.SearchUserOptions{
+ ListOptions: db.ListOptions{
+ Page: ctx.FormInt("page"),
+ PageSize: setting.UI.Admin.UserPagingNum,
+ },
+ OrderBy: db.SearchOrderBy(sort),
+ Type: user_model.UserTypeIndividual,
+
+ IsActive: optional.ParseBool(statusActive),
+ IsAdmin: optional.ParseBool(statusAdmin),
+ IsRestricted: optional.ParseBool(statusRestricted),
+ IsTwoFactorEnabled: optional.ParseBool(status2fa),
+ IsProhibitLogin: optional.ParseBool(statusProhibit),
+
+ IncludeReserved: true,
+ SearchByEmail: true,
+ }
+
+ users, count, err := user_model.SearchUsers(ctx, opts)
+ if err != nil {
+ ctx.ServerError("SearchUsers", err)
+ return
+ }
+
+ // Filter users with hyperlinks in bio fields
+ filtered := make([]*user_model.User, 0, len(users))
+ for _, u := range users {
+ if utils.ContainsHyperlink(u.FullName) || utils.ContainsHyperlink(u.Description) ||
+ utils.ContainsHyperlink(u.Location) || utils.ContainsHyperlink(u.Website) {
+ filtered = append(filtered, u)
+ }
+ }
+
+ ctx.Data["Users"] = filtered
+ ctx.Data["Total"] = len(filtered)
+ ctx.Data["CanDeleteUsers"] = true
+
+ // Pagination
+ pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
+ pager.AddParamFromRequest(ctx.Req)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplUsersWithLinks)
+}
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index 87edbc357bcfd..b98fad647aad1 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -164,6 +164,26 @@ func CheckAutoLogin(ctx *context.Context) bool {
return false
}
+// BLENDER: always use OAuth unless ?noredirect=true is set
+func checkForceOAuth(ctx *context.Context) bool {
+ // Check if authentication is forced to OAuth
+ if ctx.FormBool("noredirect") {
+ return false
+ }
+
+ oauth2Providers, err := oauth2.GetOAuth2Providers(ctx, optional.Some(true))
+ if err != nil {
+ return false
+ }
+
+ for _, provider := range oauth2Providers {
+ ctx.Redirect(setting.AppSubURL + "/user/oauth2/" + provider.Name())
+ return true
+ }
+
+ return false
+}
+
func prepareSignInPageData(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("sign_in")
ctx.Data["OAuth2Providers"], _ = oauth2.GetOAuth2Providers(ctx, optional.Some(true))
@@ -185,6 +205,10 @@ func SignIn(ctx *context.Context) {
if CheckAutoLogin(ctx) {
return
}
+ // BLENDER: Check if authentication is forced to OAuth
+ if checkForceOAuth(ctx) {
+ return
+ }
if ctx.IsSigned {
RedirectAfterLogin(ctx)
return
diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go
index a13b987aabeda..8c831e5e66c04 100644
--- a/routers/web/auth/oauth.go
+++ b/routers/web/auth/oauth.go
@@ -21,7 +21,9 @@ import (
"code.gitea.io/gitea/modules/session"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web/middleware"
+ auth_service "code.gitea.io/gitea/services/auth"
source_service "code.gitea.io/gitea/services/auth/source"
"code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/context"
@@ -299,8 +301,32 @@ func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
}
}
+// BLENDER: sync user badges
+func updateBadgesIfNeed(ctx *context.Context, rawData map[string]any, u *user_model.User) error {
+ blenderIDBadges, has := rawData["badges"]
+ if !has {
+ return nil
+ }
+ remoteBadgesMap, ok := blenderIDBadges.(map[string]any)
+ if !ok {
+ return fmt.Errorf("unexpected format of remote badges payload: %+v", blenderIDBadges)
+ }
+
+ remoteBadges := make([]*user_model.Badge, 0, len(remoteBadgesMap))
+ for slug := range remoteBadgesMap {
+ remoteBadges = append(remoteBadges, &user_model.Badge{Slug: slug})
+ }
+ return user_service.UpdateBadgesBestEffort(ctx, u, remoteBadges)
+}
+
func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) {
updateAvatarIfNeed(ctx, gothUser.AvatarURL, u)
+ // BLENDER: sync user badges
+ // Don't escalate any errors, only log them:
+ // we don't want to break login process due to errors in badges sync
+ if err := updateBadgesIfNeed(ctx, gothUser.RawData, u); err != nil {
+ log.Error("Failed to update user badges for %s: %w", u.LoginName, err)
+ }
needs2FA := false
if !source.TwoFactorShouldSkip() {
@@ -368,6 +394,17 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
return
}
+ // BLENDER: remember login for Blender ID.
+ // A proper OAuth implementation would check how long the access token is
+ // valid depending on the provider, but since this is only for Blender ID
+ // we can just set days in the Gitea config.
+ nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
+ if err != nil {
+ ctx.ServerError("CreateAuthTokenForUserID", err)
+ return
+ }
+ ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
+
// force to generate a new CSRF token
ctx.Csrf.PrepareForSessionUser(ctx)
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index a4747964c6f02..97086a363b120 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -673,13 +673,13 @@ func handleMentionableAssigneesAndTeams(ctx *context.Context, assignees []*user_
}
if isAdmin {
- teams, err = org.LoadTeams(ctx)
+ teams, err = organization.FilterLargeTeams(org.LoadTeams(ctx))
if err != nil {
ctx.ServerError("LoadTeams", err)
return
}
} else {
- teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
+ teams, err = organization.FilterLargeTeams(org.GetUserTeams(ctx, ctx.Doer.ID))
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go
index 48a5d58ea427b..6379549bef9ef 100644
--- a/routers/web/shared/user/header.go
+++ b/routers/web/shared/user/header.go
@@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
+ user_service "code.gitea.io/gitea/services/user"
)
// prepareContextForProfileBigAvatar set the context for big avatar view on the profile page
@@ -89,6 +90,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..254b54c56f1f5
--- /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 bd850baec0f46..c954a120d7640 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -282,6 +282,8 @@ func Routes() *web.Router {
mid = append(mid, goGet)
mid = append(mid, common.PageTmplFunctions)
+ // BLENDER: spam reporting
+ mid = append(mid, admin.GetPendingSpamReports)
webRoutes := web.NewRouter()
webRoutes.Use(mid...)
@@ -682,6 +684,9 @@ func registerWebRoutes(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() {
@@ -755,6 +760,20 @@ func registerWebRoutes(m *web.Router) {
m.Post("/delete", admin.DeleteEmail)
})
+ // BLENDER: spam reporting
+ m.Group("/spamreports", func() {
+ m.Get("", admin.SpamReports)
+ m.Post("", admin.SpamReportsPost)
+ })
+ m.Post("/purge_spammer", admin.PurgeSpammerPost)
+
+ m.Group("/users_with_links", func() {
+ m.Get("", admin.UsersWithLinks)
+ })
+ m.Group("/issues_with_links", func() {
+ m.Get("", admin.IssuesWithLinks)
+ })
+
m.Group("/orgs", func() {
m.Get("", admin.Organizations)
})
diff --git a/services/auth/source/oauth2/blenderid/blenderid.go b/services/auth/source/oauth2/blenderid/blenderid.go
new file mode 100644
index 0000000000000..671e5e4f541a5
--- /dev/null
+++ b/services/auth/source/oauth2/blenderid/blenderid.go
@@ -0,0 +1,181 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+// Package blenderid implements the OAuth2 protocol for authenticating users through Blender ID
+// This package can be used as a reference implementation of an OAuth2 provider for Goth.
+package blenderid
+
+// Allow "encoding/json" import.
+import (
+ "bytes"
+ "encoding/json" //nolint:depguard
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+
+ "github.com/markbates/goth"
+ "golang.org/x/oauth2"
+)
+
+// These vars define the default Authentication, Token, and Profile URLS for Blender ID
+//
+// Examples:
+//
+// blenderid.AuthURL = "https://id.blender.org/oauth/authorize
+// blenderid.TokenURL = "https://id.blender.org/oauth/token
+// blenderid.ProfileURL = "https://id.blender.org/api/me
+var (
+ AuthURL = "https://id.blender.org/oauth/authorize"
+ TokenURL = "https://id.blender.org/oauth/token"
+ ProfileURL = "https://id.blender.org/api/me"
+)
+
+// Provider is the implementation of `goth.Provider` for accessing Blender ID
+type Provider struct {
+ ClientKey string
+ Secret string
+ CallbackURL string
+ HTTPClient *http.Client
+ config *oauth2.Config
+ providerName string
+ profileURL string
+}
+
+// New creates a new Blender ID provider and sets up important connection details.
+// You should always call `blenderid.New` to get a new provider. Never try to
+// create one manually.
+func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
+ return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...)
+}
+
+// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to
+func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider {
+ p := &Provider{
+ ClientKey: clientKey,
+ Secret: secret,
+ CallbackURL: callbackURL,
+ providerName: "blenderid",
+ profileURL: profileURL,
+ }
+ p.config = newConfig(p, authURL, tokenURL, scopes)
+ return p
+}
+
+// Name is the name used to retrieve this provider later.
+func (p *Provider) Name() string {
+ return p.providerName
+}
+
+// SetName is to update the name of the provider (needed in case of multiple providers of 1 type)
+func (p *Provider) SetName(name string) {
+ p.providerName = name
+}
+
+func (p *Provider) Client() *http.Client {
+ return goth.HTTPClientWithFallBack(p.HTTPClient)
+}
+
+// Debug is a no-op for the blenderid package.
+func (p *Provider) Debug(debug bool) {}
+
+// BeginAuth asks Blender ID for an authentication end-point.
+func (p *Provider) BeginAuth(state string) (goth.Session, error) {
+ return &Session{
+ AuthURL: p.config.AuthCodeURL(state),
+ }, nil
+}
+
+// FetchUser will go to Blender ID and access basic information about the user.
+func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
+ sess := session.(*Session)
+ user := goth.User{
+ AccessToken: sess.AccessToken,
+ Provider: p.Name(),
+ RefreshToken: sess.RefreshToken,
+ ExpiresAt: sess.ExpiresAt,
+ }
+
+ if user.AccessToken == "" {
+ // data is not yet retrieved since accessToken is still empty
+ return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
+ }
+
+ req, err := http.NewRequest("GET", p.profileURL, nil)
+ if err != nil {
+ return user, err
+ }
+
+ req.Header.Add("Authorization", "Bearer "+sess.AccessToken)
+ response, err := p.Client().Do(req)
+ if err != nil {
+ return user, err
+ }
+ if response.StatusCode != http.StatusOK {
+ return user, fmt.Errorf("Blender ID responded with a %d trying to fetch user information", response.StatusCode)
+ }
+
+ bits, err := io.ReadAll(response.Body)
+ if err != nil {
+ return user, err
+ }
+
+ err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData)
+ if err != nil {
+ return user, err
+ }
+
+ err = userFromReader(bytes.NewReader(bits), &user)
+ if err != nil {
+ return user, err
+ }
+
+ return user, err
+}
+
+func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config {
+ c := &oauth2.Config{
+ ClientID: provider.ClientKey,
+ ClientSecret: provider.Secret,
+ RedirectURL: provider.CallbackURL,
+ Endpoint: oauth2.Endpoint{
+ AuthURL: authURL,
+ TokenURL: tokenURL,
+ },
+ Scopes: []string{},
+ }
+
+ if len(scopes) > 0 {
+ c.Scopes = append(c.Scopes, scopes...)
+ }
+ return c
+}
+
+func userFromReader(r io.Reader, user *goth.User) error {
+ u := struct {
+ Name string `json:"full_name"`
+ Email string `json:"email"`
+ NickName string `json:"nickname"`
+ ID int `json:"id"`
+ }{}
+ err := json.NewDecoder(r).Decode(&u)
+ if err != nil {
+ return err
+ }
+ user.Email = u.Email
+ user.Name = u.Name
+ user.NickName = gitealizeUsername(u.NickName)
+ user.UserID = strconv.Itoa(u.ID)
+ user.AvatarURL = fmt.Sprintf("https://id.blender.org/api/user/%s/avatar", user.UserID)
+ return nil
+}
+
+// RefreshTokenAvailable refresh token is not provided by Blender ID
+func (p *Provider) RefreshTokenAvailable() bool {
+ return true
+}
+
+// RefreshToken refresh token is not provided by Blender ID
+func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
+ return nil, errors.New("Refresh token is not provided by Blender ID")
+}
diff --git a/services/auth/source/oauth2/blenderid/blenderid_test.go b/services/auth/source/oauth2/blenderid/blenderid_test.go
new file mode 100644
index 0000000000000..283ba0898a83f
--- /dev/null
+++ b/services/auth/source/oauth2/blenderid/blenderid_test.go
@@ -0,0 +1,70 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package blenderid_test
+
+import (
+ "os"
+ "testing"
+
+ "code.gitea.io/gitea/services/auth/source/oauth2/blenderid"
+
+ "github.com/markbates/goth"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_New(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ p := provider()
+
+ a.Equal(p.ClientKey, os.Getenv("BLENDERID_KEY"))
+ a.Equal(p.Secret, os.Getenv("BLENDERID_SECRET"))
+ a.Equal("/foo", p.CallbackURL)
+}
+
+func Test_NewCustomisedURL(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ p := urlCustomisedURLProvider()
+ session, err := p.BeginAuth("test_state")
+ s := session.(*blenderid.Session)
+ a.NoError(err)
+ a.Contains(s.AuthURL, "http://authURL")
+}
+
+func Test_Implements_Provider(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ a.Implements((*goth.Provider)(nil), provider())
+}
+
+func Test_BeginAuth(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ p := provider()
+ session, err := p.BeginAuth("test_state")
+ s := session.(*blenderid.Session)
+ a.NoError(err)
+ a.Contains(s.AuthURL, "id.blender.org/oauth/authorize")
+}
+
+func Test_SessionFromJSON(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+
+ p := provider()
+ session, err := p.UnmarshalSession(`{"AuthURL":"https://id.blender.org/oauth/authorize","AccessToken":"1234567890"}`)
+ a.NoError(err)
+
+ s := session.(*blenderid.Session)
+ a.Equal("https://id.blender.org/oauth/authorize", s.AuthURL)
+ a.Equal("1234567890", s.AccessToken)
+}
+
+func provider() *blenderid.Provider {
+ return blenderid.New(os.Getenv("BLENDERID_KEY"), os.Getenv("BLENDERID_SECRET"), "/foo")
+}
+
+func urlCustomisedURLProvider() *blenderid.Provider {
+ return blenderid.NewCustomisedURL(os.Getenv("BLENDERID_KEY"), os.Getenv("BLENDERID_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL")
+}
diff --git a/services/auth/source/oauth2/blenderid/gitealize_usernames.go b/services/auth/source/oauth2/blenderid/gitealize_usernames.go
new file mode 100644
index 0000000000000..880516c8e28f1
--- /dev/null
+++ b/services/auth/source/oauth2/blenderid/gitealize_usernames.go
@@ -0,0 +1,65 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package blenderid
+
+import (
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/models/user"
+
+ "github.com/mozillazg/go-unidecode"
+)
+
+var (
+ reInvalidCharsPattern = regexp.MustCompile(`[^\da-zA-Z.\w-]+`)
+
+ // Consecutive non-alphanumeric at start:
+ reConsPrefix = regexp.MustCompile(`^[._-]+`)
+ reConsSuffix = regexp.MustCompile(`[._-]+$`)
+ reConsInfix = regexp.MustCompile(`[._-]{2,}`)
+)
+
+// gitealizeUsername turns a valid Blender ID nickname into a valid Gitea username.
+func gitealizeUsername(bidNickname string) string {
+ // Remove accents and other non-ASCIIness.
+ asciiUsername := unidecode.Unidecode(bidNickname)
+ asciiUsername = strings.TrimSpace(asciiUsername)
+ asciiUsername = strings.ReplaceAll(asciiUsername, " ", "_")
+
+ err := user.IsUsableUsername(asciiUsername)
+ if err == nil && len(asciiUsername) <= 40 {
+ return asciiUsername
+ }
+
+ newUsername := asciiUsername
+ newUsername = reInvalidCharsPattern.ReplaceAllString(newUsername, "_")
+ newUsername = reConsPrefix.ReplaceAllString(newUsername, "")
+ newUsername = reConsSuffix.ReplaceAllString(newUsername, "")
+ newUsername = reConsInfix.ReplaceAllStringFunc(
+ newUsername,
+ func(match string) string {
+ firstRune := []rune(match)[0]
+ return string(firstRune)
+ })
+
+ if newUsername == "" {
+ // Everything was stripped and nothing was left. Better to keep as-is and
+ // just let Gitea bork on it.
+ return asciiUsername
+ }
+
+ // This includes a test for reserved names, which are easily circumvented by
+ // appending another character.
+ if user.IsUsableUsername(newUsername) != nil {
+ if len(newUsername) > 39 {
+ return newUsername[:39] + "2"
+ }
+ return newUsername + "2"
+ }
+
+ if len(newUsername) > 40 {
+ return newUsername[:40]
+ }
+ return newUsername
+}
diff --git a/services/auth/source/oauth2/blenderid/gitealize_usernames_test.go b/services/auth/source/oauth2/blenderid/gitealize_usernames_test.go
new file mode 100644
index 0000000000000..7d633198e8886
--- /dev/null
+++ b/services/auth/source/oauth2/blenderid/gitealize_usernames_test.go
@@ -0,0 +1,43 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package blenderid
+
+import "testing"
+
+func Test_gitealizeUsername(t *testing.T) {
+ tests := []struct {
+ name string
+ bidNickname string
+ want string
+ }{
+ {"empty", "", ""},
+ {"underscore", "_", "_"},
+ {"reserved-name", "ghost", "ghost2"}, // Reserved name in Gitea.
+ {"short", "x", "x"},
+ {"simple", "simple", "simple"},
+ {"start-bad", "____startbad", "startbad"},
+ {"end-bad", "endbad___", "endbad"},
+ {"mid-bad-1", "mid__bad", "mid_bad"},
+ {"mid-bad-2", "user_.-name", "user_name"},
+ {"plus-mid-single", "RT2+356", "RT2_356"},
+ {"plus-mid-many", "RT2+++356", "RT2_356"},
+ {"plus-end", "RT2356+", "RT2356"},
+ {
+ "too-long", // # Max username length is 40:
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ },
+ {"accented-latin", "Ümlaut-Đenja", "Umlaut-Denja"},
+ {"thai", "แบบไทย", "aebbaithy"},
+ {"mandarin", "普通话", "Pu_Tong_Hua"},
+ {"cyrillic", "ћирилица", "tshirilitsa"},
+ {"all-bad", "------", "------"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := gitealizeUsername(tt.bidNickname); got != tt.want {
+ t.Errorf("gitealizeUsername() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/services/auth/source/oauth2/blenderid/session.go b/services/auth/source/oauth2/blenderid/session.go
new file mode 100644
index 0000000000000..52a2d2174584c
--- /dev/null
+++ b/services/auth/source/oauth2/blenderid/session.go
@@ -0,0 +1,66 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package blenderid
+
+// Allow "encoding/json" import.
+import (
+ "encoding/json" //nolint:depguard
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/markbates/goth"
+)
+
+// Session stores data during the auth process with Blender ID
+type Session struct {
+ AuthURL string
+ AccessToken string
+ RefreshToken string
+ ExpiresAt time.Time
+}
+
+var _ goth.Session = &Session{}
+
+// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Blender ID provider.
+func (s Session) GetAuthURL() (string, error) {
+ if s.AuthURL == "" {
+ return "", errors.New(goth.NoAuthUrlErrorMessage)
+ }
+ return s.AuthURL, nil
+}
+
+// Authorize the session with Blender ID and return the access token to be stored for future use.
+func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
+ p := provider.(*Provider)
+ token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"))
+ if err != nil {
+ return "", err
+ }
+
+ if !token.Valid() {
+ return "", errors.New("Invalid token received from provider")
+ }
+
+ s.AccessToken = token.AccessToken
+ s.RefreshToken = token.RefreshToken
+ s.ExpiresAt = token.Expiry
+ return token.AccessToken, err
+}
+
+// Marshal the session into a string
+func (s Session) Marshal() string {
+ b, _ := json.Marshal(s)
+ return string(b)
+}
+
+func (s Session) String() string {
+ return s.Marshal()
+}
+
+// UnmarshalSession wil unmarshal a JSON string into a session.
+func (p *Provider) UnmarshalSession(data string) (goth.Session, error) {
+ s := &Session{}
+ err := json.NewDecoder(strings.NewReader(data)).Decode(s)
+ return s, err
+}
diff --git a/services/auth/source/oauth2/blenderid/session_test.go b/services/auth/source/oauth2/blenderid/session_test.go
new file mode 100644
index 0000000000000..7f5b6198739fd
--- /dev/null
+++ b/services/auth/source/oauth2/blenderid/session_test.go
@@ -0,0 +1,51 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package blenderid_test
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/services/auth/source/oauth2/blenderid"
+
+ "github.com/markbates/goth"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_Implements_Session(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ s := &blenderid.Session{}
+
+ a.Implements((*goth.Session)(nil), s)
+}
+
+func Test_GetAuthURL(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ s := &blenderid.Session{}
+
+ _, err := s.GetAuthURL()
+ a.Error(err)
+
+ s.AuthURL = "/foo"
+
+ url, _ := s.GetAuthURL()
+ a.Equal("/foo", url)
+}
+
+func Test_ToJSON(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ s := &blenderid.Session{}
+
+ data := s.Marshal()
+ a.JSONEq(`{"AuthURL":"","AccessToken":"","RefreshToken":"","ExpiresAt":"0001-01-01T00:00:00Z"}`, data)
+}
+
+func Test_String(t *testing.T) {
+ t.Parallel()
+ a := assert.New(t)
+ s := &blenderid.Session{}
+
+ a.Equal(s.String(), s.Marshal())
+}
diff --git a/services/auth/source/oauth2/providers_custom.go b/services/auth/source/oauth2/providers_custom.go
index 65cf538ad7386..f6f49ada048aa 100644
--- a/services/auth/source/oauth2/providers_custom.go
+++ b/services/auth/source/oauth2/providers_custom.go
@@ -5,6 +5,7 @@ package oauth2
import (
"code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/auth/source/oauth2/blenderid"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/azureadv2"
@@ -120,4 +121,14 @@ func init() {
}), nil
},
))
+
+ RegisterGothProvider(NewCustomProvider(
+ "blenderid", "Blender ID", &CustomURLSettings{
+ TokenURL: requiredAttribute(blenderid.TokenURL),
+ AuthURL: requiredAttribute(blenderid.AuthURL),
+ ProfileURL: requiredAttribute(blenderid.ProfileURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ return blenderid.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
+ }))
}
diff --git a/services/lfs/server.go b/services/lfs/server.go
index 0a99287ed9cd5..40456cbbbb3d6 100644
--- a/services/lfs/server.go
+++ b/services/lfs/server.go
@@ -254,6 +254,26 @@ func BatchHandler(ctx *context.Context) {
responseObject = buildObjectResponse(rc, p, false, !exists, err)
} else {
var err *lfs_module.ObjectError
+
+ if exists && meta == nil {
+ accessible, accessibleErr := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid)
+ if accessibleErr != nil {
+ log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err)
+ writeStatus(ctx, http.StatusInternalServerError)
+ return
+ }
+ if accessible {
+ _, newMetaObjErr := git_model.NewLFSMetaObject(ctx, repository.ID, p)
+ if newMetaObjErr != nil {
+ log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err)
+ writeStatus(ctx, http.StatusInternalServerError)
+ return
+ }
+ } else {
+ exists = false
+ }
+ }
+
if !exists || meta == nil {
err = &lfs_module.ObjectError{
Code: http.StatusNotFound,
diff --git a/services/pull/reviewer.go b/services/pull/reviewer.go
index bf0d8cb298c8b..c3b0858aa30b1 100644
--- a/services/pull/reviewer.go
+++ b/services/pull/reviewer.go
@@ -85,5 +85,5 @@ func GetReviewerTeams(ctx context.Context, repo *repo_model.Repository) ([]*orga
return nil, nil
}
- return organization.GetTeamsWithAccessToRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
+ return organization.FilterLargeTeams(organization.GetTeamsWithAccessToRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests))
}
diff --git a/services/user/badge.go b/services/user/badge.go
new file mode 100644
index 0000000000000..be6124a7fed65
--- /dev/null
+++ b/services/user/badge.go
@@ -0,0 +1,55 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// BLENDER: sync user badges
+// This function works in a best-effort fashion:
+// it tolerates all errors and tries to perform all badge changes one-by-one.
+func UpdateBadgesBestEffort(ctx context.Context, u *user_model.User, newBadges []*user_model.Badge) error {
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ oldUserBadges, _, err := user_model.GetUserBadges(ctx, u)
+ if err != nil {
+ return fmt.Errorf("failed to fetch local badges for %s: %w", u.LoginName, err)
+ }
+
+ oldBadgeSlugs := map[string]struct{}{}
+ for _, badge := range oldUserBadges {
+ oldBadgeSlugs[badge.Slug] = struct{}{}
+ }
+
+ newBadgeSlugs := map[string]struct{}{}
+ for _, badge := range newBadges {
+ newBadgeSlugs[badge.Slug] = struct{}{}
+ }
+
+ for slug := range newBadgeSlugs {
+ if _, has := oldBadgeSlugs[slug]; has {
+ continue
+ }
+ if err := user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: slug}); err != nil {
+ // Don't escalate, continue processing other badges
+ log.Error("Failed to add badge slug %s to user %s: %v", slug, u.LoginName, err)
+ }
+ }
+ for slug := range oldBadgeSlugs {
+ if _, has := newBadgeSlugs[slug]; has {
+ continue
+ }
+ if err := user_model.RemoveUserBadge(ctx, u, &user_model.Badge{Slug: slug}); err != nil {
+ // Don't escalate, continue processing other badges
+ log.Error("Failed to remove badge slug %s from user %s: %v", slug, u.LoginName, err)
+ }
+ }
+ return nil
+ })
+}
diff --git a/services/user/badge_test.go b/services/user/badge_test.go
new file mode 100644
index 0000000000000..9744355390d1b
--- /dev/null
+++ b/services/user/badge_test.go
@@ -0,0 +1,80 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// BLENDER: sync user badges
+
+package user
+
+import (
+ "fmt"
+ "slices"
+ "sync"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestUpdateBadgesBestEffort executes UpdateBadgesBestEffort concurrently.
+//
+// This test illustrates the need for a database transaction around AddUserBadge and RemoveUserBadge calls.
+// This test is not deterministic, but at least it can demonstrate the problem after a few non-cached runs:
+//
+// go test -count=1 -v -tags sqlite -run TestUpdateBadgesBestEffort ./services/user/...
+func TestUpdateBadgesBestEffort(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ badges := []*user_model.Badge{}
+ for i := range 5 {
+ badge := &user_model.Badge{Slug: fmt.Sprintf("update-badges-test-%d", i)}
+ user_model.CreateBadge(db.DefaultContext, badge)
+ badges = append(badges, badge)
+ }
+ var wg sync.WaitGroup
+ start := make(chan struct{})
+ f := func(wg *sync.WaitGroup, badges []*user_model.Badge) {
+ <-start
+ defer wg.Done()
+ UpdateBadgesBestEffort(db.DefaultContext, user, badges)
+ }
+ updateSets := [][]*user_model.Badge{
+ badges[0:1],
+ badges[1:3],
+ badges[3:5],
+ }
+ for _, s := range updateSets {
+ wg.Add(1)
+ go f(&wg, s)
+ }
+ t.Log("start")
+ // Use the channel to start goroutines' execution as close as possible.
+ close(start)
+ wg.Wait()
+
+ result, _, _ := user_model.GetUserBadges(db.DefaultContext, user)
+ resultSlugs := make([]string, 0, len(result))
+ for _, b := range result {
+ resultSlugs = append(resultSlugs, b.Slug)
+ }
+
+ match := false
+ for _, set := range updateSets {
+ setSlugs := make([]string, 0, len(set))
+ for _, b := range set {
+ setSlugs = append(setSlugs, b.Slug)
+ }
+ // Expecting to confirm that what we get at the end is not a mish-mash of different update attempts,
+ // but one complete attempt.
+ if slices.Equal(setSlugs, resultSlugs) {
+ match = true
+ break
+ }
+ }
+ if !match {
+ t.Fail()
+ }
+}
diff --git a/services/user/spamreport.go b/services/user/spamreport.go
new file mode 100644
index 0000000000000..1d9c2d3236560
--- /dev/null
+++ b/services/user/spamreport.go
@@ -0,0 +1,230 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// BLENDER: spam reporting
+
+package user
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "code.gitea.io/gitea/models/db"
+ 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"
+ "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"
+)
+
+// 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.
+// If a record for a given user already exists, it will be returned.
+func CreateSpamReport(ctx context.Context, reporter, user *user_model.User) (*user_model.SpamReport, error) {
+ reporterIsTrusted, err := IsTrustedUser(ctx, reporter)
+ if err != nil {
+ return nil, fmt.Errorf("failed IsTrustedUser: %w", err)
+ }
+ if !reporterIsTrusted {
+ return nil, fmt.Errorf("reporter %s is not trusted", reporter.Name)
+ }
+ userIsTrusted, err := IsTrustedUser(ctx, user)
+ if err != nil {
+ return nil, fmt.Errorf("failed IsTrustedUser: %w", err)
+ }
+ if userIsTrusted {
+ return nil, fmt.Errorf("can't report a trusted user %s", user.Name)
+ }
+
+ spamReport := &user_model.SpamReport{
+ ReporterID: reporter.ID,
+ UserID: user.ID,
+ }
+ insertErr := db.Insert(ctx, spamReport)
+ if insertErr != nil {
+ // Normally the error may happen due to a duplicate record.
+ // Let's try to fetch the existing record, and if it doesn't exist, escalate the original error.
+ existingSpamReport := &user_model.SpamReport{}
+ if has, _ := db.GetEngine(ctx).Where("user_id = ?", user.ID).Get(existingSpamReport); has {
+ return existingSpamReport, nil
+ }
+ return nil, insertErr
+ }
+ return spamReport, nil
+}
+
+// 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: strconv.FormatInt(user.ID, 10),
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to fetch IssueIDs: %w", err)
+ }
+ for _, issue := range issues {
+ 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)
+ 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 comment: %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..2f4ebcdc86b2c
--- /dev/null
+++ b/services/user/spamreport_test.go
@@ -0,0 +1,93 @@
+// 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/db"
+ "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())
+ // Prevent interaction between tests, for whatever reason db is not reset.
+ db.GetEngine(db.DefaultContext).Exec("delete from user_spamreport")
+
+ userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
+
+ // 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)
+
+ spamReport, err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs)
+ assert.NoError(t, err)
+ assert.NotNil(t, spamReport)
+
+ // Try to create a duplicate report by a different reporter.
+ adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ spamReport2, err := CreateSpamReport(context.Background(), adminUser, userWithoutOrgs)
+ assert.NoError(t, err)
+ assert.NotNil(t, spamReport2)
+ assert.Equal(t, spamReport.ID, spamReport2.ID)
+}
+
+func TestProcessSpamReports(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ // Prevent interaction between tests, for whatever reason db is not reset.
+ db.GetEngine(db.DefaultContext).Exec("delete from user_spamreport")
+
+ userWithOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // reporter
+ userWithoutOrgs := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9}) // spammer, and a different one
+ _, err := CreateSpamReport(context.Background(), userWithOrgs, userWithoutOrgs)
+ assert.NoError(t, err)
+
+ ids, err := user_model.GetPendingSpamReportIDs(context.Background())
+ assert.Len(t, ids, 1)
+ 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: 9}) // reload from db
+ assert.Equal(t, "Confirmed Spammer", userWithoutOrgs.FullName)
+ assert.True(t, userWithoutOrgs.ProhibitLogin)
+
+ ids, err = user_model.GetPendingSpamReportIDs(context.Background())
+ assert.Empty(t, ids)
+ assert.NoError(t, err)
+}
diff --git a/services/user/user.go b/services/user/user.go
index 1aeebff142d32..5763cf015feae 100644
--- a/services/user/user.go
+++ b/services/user/user.go
@@ -36,12 +36,13 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string) err
}
// Non-local users are not allowed to change their username.
- if !u.IsOrganization() && !u.IsLocal() {
- return user_model.ErrUserIsNotLocal{
- UID: u.ID,
- Name: u.Name,
- }
- }
+ // BLENDER: allow renaming local users.
+ //if !u.IsOrganization() && !u.IsLocal() {
+ // return user_model.ErrUserIsNotLocal{
+ // UID: u.ID,
+ // Name: u.Name,
+ // }
+ //}
if err := user_model.IsUsableUsername(newUserName); err != nil {
return err
diff --git a/templates/admin/issues_with_links.tmpl b/templates/admin/issues_with_links.tmpl
new file mode 100644
index 0000000000000..f945ad652afdf
--- /dev/null
+++ b/templates/admin/issues_with_links.tmpl
@@ -0,0 +1,55 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin issues")}}
+
{{ctx.Locale.Tr "type"}} | +{{ctx.Locale.Tr "admin.issues_with_links.found_links"}} | ++ + {{ctx.Locale.Tr "admin.issues_with_links.created"}} {{SortArrow "created" "created" $.Sort false}} + + | ++ + {{ctx.Locale.Tr "admin.issues_with_links.updated"}} {{SortArrow "updated" "updated" $.Sort false}} + + | +{{ctx.Locale.Tr "admin.users.name"}} | ++ + {{ctx.Locale.Tr "admin.issues_with_links.user.created"}} {{SortArrow "usercreated" "usercreated" $.Sort false}} + + | +{{ctx.Locale.Tr "repo.repo_name"}} | +
---|---|---|---|---|---|---|
{{.Type}} | +{{.Content}} | +{{DateUtils.AbsoluteShort .Created}} | +{{DateUtils.AbsoluteShort .Updated}} | +{{.User.Name}} | +{{DateUtils.AbsoluteShort .UserCreated}} | +{{.RepoName}} | +
{{ctx.Locale.Tr "no_results_found"}} | +