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 "admin.issues_with_links"}} ({{.Total}}) +

+ + +
+ + + + + + + + + + + + + + {{range .Items}} + + + + + + + + + + {{else}} + + + + {{end}} + +
{{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"}}
+
+ + {{template "base/paginate" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 72584ec799cc3..21854b872e63a 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -30,6 +30,20 @@ +
+ {{ctx.Locale.Tr "admin.spam_management"}} + +
{{ctx.Locale.Tr "admin.assets"}}