diff --git a/models/issues/moderation.go b/models/issues/moderation.go index 921f770d4d..9afb711d65 100644 --- a/models/issues/moderation.go +++ b/models/issues/moderation.go @@ -5,6 +5,7 @@ package issues import ( "context" + "strconv" "forgejo.org/models/moderation" "forgejo.org/modules/json" @@ -24,6 +25,21 @@ type IssueData struct { UpdatedUnix timeutil.TimeStamp } +// Implements GetFieldsMap() from ShadowCopyData interface, returning a list of pairs +// to be used when rendering the shadow copy for admins reviewing the corresponding abuse report(s). +func (cd IssueData) GetFieldsMap() []moderation.ShadowCopyField { + return []moderation.ShadowCopyField{ + {Key: "RepoID", Value: strconv.FormatInt(cd.RepoID, 10)}, + {Key: "Index", Value: strconv.FormatInt(cd.Index, 10)}, + {Key: "PosterID", Value: strconv.FormatInt(cd.PosterID, 10)}, + {Key: "Title", Value: cd.Title}, + {Key: "Content", Value: cd.Content}, + {Key: "ContentVersion", Value: strconv.Itoa(cd.ContentVersion)}, + {Key: "CreatedUnix", Value: cd.CreatedUnix.AsLocalTime().String()}, + {Key: "UpdatedUnix", Value: cd.UpdatedUnix.AsLocalTime().String()}, + } +} + // newIssueData creates a trimmed down issue to be used just to create a JSON structure // (keeping only the fields relevant for moderation purposes) func newIssueData(issue *Issue) IssueData { @@ -31,8 +47,8 @@ func newIssueData(issue *Issue) IssueData { RepoID: issue.RepoID, Index: issue.Index, PosterID: issue.PosterID, - Content: issue.Content, Title: issue.Title, + Content: issue.Content, ContentVersion: issue.ContentVersion, CreatedUnix: issue.CreatedUnix, UpdatedUnix: issue.UpdatedUnix, @@ -50,6 +66,19 @@ type CommentData struct { UpdatedUnix timeutil.TimeStamp } +// Implements GetFieldsMap() from ShadowCopyData interface, returning a list of pairs +// to be used when rendering the shadow copy for admins reviewing the corresponding abuse report(s). +func (cd CommentData) GetFieldsMap() []moderation.ShadowCopyField { + return []moderation.ShadowCopyField{ + {Key: "PosterID", Value: strconv.FormatInt(cd.PosterID, 10)}, + {Key: "IssueID", Value: strconv.FormatInt(cd.IssueID, 10)}, + {Key: "Content", Value: cd.Content}, + {Key: "ContentVersion", Value: strconv.Itoa(cd.ContentVersion)}, + {Key: "CreatedUnix", Value: cd.CreatedUnix.AsLocalTime().String()}, + {Key: "UpdatedUnix", Value: cd.UpdatedUnix.AsLocalTime().String()}, + } +} + // newCommentData creates a trimmed down comment to be used just to create a JSON structure // (keeping only the fields relevant for moderation purposes) func newCommentData(comment *Comment) CommentData { diff --git a/models/issues/moderation_test.go b/models/issues/moderation_test.go new file mode 100644 index 0000000000..adb07bd63a --- /dev/null +++ b/models/issues/moderation_test.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package issues_test + +import ( + "testing" + + "forgejo.org/models/issues" + "forgejo.org/models/moderation" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +const ( + tsCreated timeutil.TimeStamp = timeutil.TimeStamp(1753093500) // 2025-07-21 10:25:00 UTC + tsUpdated timeutil.TimeStamp = timeutil.TimeStamp(1753093525) // 2025-07-21 10:25:25 UTC +) + +func testShadowCopyField(t *testing.T, scField moderation.ShadowCopyField, key, value string) { + assert.Equal(t, key, scField.Key) + assert.Equal(t, value, scField.Value) +} + +func TestIssueDataGetFieldsMap(t *testing.T) { + id := issues.IssueData{ + RepoID: 2001, + Index: 2, + PosterID: 1002, + Title: "Professional marketing services", + Content: "Visit my website at promote-your-business.biz for a list of available services.", + ContentVersion: 0, + CreatedUnix: tsCreated, + UpdatedUnix: tsUpdated, + } + scFields := id.GetFieldsMap() + + if assert.Len(t, scFields, 8) { + testShadowCopyField(t, scFields[0], "RepoID", "2001") + testShadowCopyField(t, scFields[1], "Index", "2") + testShadowCopyField(t, scFields[2], "PosterID", "1002") + testShadowCopyField(t, scFields[3], "Title", "Professional marketing services") + testShadowCopyField(t, scFields[4], "Content", "Visit my website at promote-your-business.biz for a list of available services.") + testShadowCopyField(t, scFields[5], "ContentVersion", "0") + testShadowCopyField(t, scFields[6], "CreatedUnix", tsCreated.AsLocalTime().String()) + testShadowCopyField(t, scFields[7], "UpdatedUnix", tsUpdated.AsLocalTime().String()) + } +} + +func TestCommentDataGetFieldsMap(t *testing.T) { + cd := issues.CommentData{ + PosterID: 1002, + IssueID: 3001, + Content: "Check out [alexsmith/website](/alexsmith/website)", + ContentVersion: 0, + CreatedUnix: tsCreated, + UpdatedUnix: tsUpdated, + } + scFields := cd.GetFieldsMap() + + if assert.Len(t, scFields, 6) { + testShadowCopyField(t, scFields[0], "PosterID", "1002") + testShadowCopyField(t, scFields[1], "IssueID", "3001") + testShadowCopyField(t, scFields[2], "Content", "Check out [alexsmith/website](/alexsmith/website)") + testShadowCopyField(t, scFields[3], "ContentVersion", "0") + testShadowCopyField(t, scFields[4], "CreatedUnix", tsCreated.AsLocalTime().String()) + testShadowCopyField(t, scFields[5], "UpdatedUnix", tsUpdated.AsLocalTime().String()) + } +} diff --git a/models/moderation/abuse_report.go b/models/moderation/abuse_report.go index 3a6244ef4c..9852268910 100644 --- a/models/moderation/abuse_report.go +++ b/models/moderation/abuse_report.go @@ -47,14 +47,21 @@ const ( AbuseCategoryTypeIllegalContent // 4 ) +var AbuseCategoriesTranslationKeys = map[AbuseCategoryType]string{ + AbuseCategoryTypeSpam: "moderation.abuse_category.spam", + AbuseCategoryTypeMalware: "moderation.abuse_category.malware", + AbuseCategoryTypeIllegalContent: "moderation.abuse_category.illegal_content", + AbuseCategoryTypeOther: "moderation.abuse_category.other_violations", +} + // GetAbuseCategoriesList returns a list of pairs with the available abuse category types // and their corresponding translation keys func GetAbuseCategoriesList() []AbuseCategoryItem { return []AbuseCategoryItem{ - {AbuseCategoryTypeSpam, "moderation.abuse_category.spam"}, - {AbuseCategoryTypeMalware, "moderation.abuse_category.malware"}, - {AbuseCategoryTypeIllegalContent, "moderation.abuse_category.illegal_content"}, - {AbuseCategoryTypeOther, "moderation.abuse_category.other_violations"}, + {AbuseCategoryTypeSpam, AbuseCategoriesTranslationKeys[AbuseCategoryTypeSpam]}, + {AbuseCategoryTypeMalware, AbuseCategoriesTranslationKeys[AbuseCategoryTypeMalware]}, + {AbuseCategoryTypeIllegalContent, AbuseCategoriesTranslationKeys[AbuseCategoryTypeIllegalContent]}, + {AbuseCategoryTypeOther, AbuseCategoriesTranslationKeys[AbuseCategoryTypeOther]}, } } diff --git a/models/moderation/abuse_report_detailed.go b/models/moderation/abuse_report_detailed.go new file mode 100644 index 0000000000..265d143709 --- /dev/null +++ b/models/moderation/abuse_report_detailed.go @@ -0,0 +1,135 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package moderation + +import ( + "context" + "fmt" + "strings" + + "forgejo.org/models/db" + "forgejo.org/modules/setting" + "forgejo.org/modules/timeutil" + + "xorm.io/builder" +) + +type AbuseReportDetailed struct { + AbuseReport `xorm:"extends"` + ReportedTimes int // only for overview + ReporterName string + ContentReference string + ShadowCopyDate timeutil.TimeStamp // only for details + ShadowCopyRawValue string // only for details +} + +func (ard AbuseReportDetailed) ContentTypeIconName() string { + switch ard.ContentType { + case ReportedContentTypeUser: + return "octicon-person" + case ReportedContentTypeRepository: + return "octicon-repo" + case ReportedContentTypeIssue: + return "octicon-issue-opened" + case ReportedContentTypeComment: + return "octicon-comment" + default: + return "octicon-question" + } +} + +func (ard AbuseReportDetailed) ContentURL() string { + switch ard.ContentType { + case ReportedContentTypeUser: + return strings.TrimLeft(ard.ContentReference, "@") + case ReportedContentTypeIssue: + return strings.ReplaceAll(ard.ContentReference, "#", "/issues/") + default: + return ard.ContentReference + } +} + +func GetOpenReports(ctx context.Context) ([]*AbuseReportDetailed, error) { + var reports []*AbuseReportDetailed + + // - For PostgreSQL user table name should be escaped. + // - Escaping can be done with double quotes (") but this doesn't work for MariaDB. + // - For SQLite index column name should be escaped. + // - Escaping can be done with double quotes (") or backticks (`). + // - For MariaDB/MySQL there is no need to escape the above. + // - Therefore we will use double quotes (") but only for PostgreSQL and SQLite. + identifierEscapeChar := `` + if setting.Database.Type.IsPostgreSQL() || setting.Database.Type.IsSQLite3() { + identifierEscapeChar = `"` + } + + err := db.GetEngine(ctx).SQL(fmt.Sprintf(`SELECT AR.*, ARD.reported_times, U.name AS reporter_name, REFS.ref AS content_reference + FROM abuse_report AR + INNER JOIN ( + SELECT min(id) AS id, count(id) AS reported_times + FROM abuse_report + WHERE status = %[2]d + GROUP BY content_type, content_id + ) ARD ON ARD.id = AR.id + LEFT JOIN %[1]suser%[1]s U ON U.id = AR.reporter_id + LEFT JOIN ( + SELECT %[3]d AS type, id, concat('@', name) AS "ref" + FROM %[1]suser%[1]s WHERE id IN ( + SELECT content_id FROM abuse_report WHERE status = %[2]d AND content_type = %[3]d + ) + UNION + SELECT %[4]d AS "type", id, concat(owner_name, '/', name) AS "ref" + FROM repository WHERE id IN ( + SELECT content_id FROM abuse_report WHERE status = %[2]d AND content_type = %[4]d + ) + UNION + SELECT %[5]d AS "type", I.id, concat(IR.owner_name, '/', IR.name, '#', I.%[1]sindex%[1]s) AS "ref" + FROM issue I + LEFT JOIN repository IR ON IR.id = I.repo_id + WHERE I.id IN ( + SELECT content_id FROM abuse_report WHERE status = %[2]d AND content_type = %[5]d + ) + UNION + SELECT %[6]d AS "type", C.id, concat(CIR.owner_name, '/', CIR.name, '/issues/', CI.%[1]sindex%[1]s, '#issuecomment-', C.id) AS "ref" + FROM comment C + LEFT JOIN issue CI ON CI.id = C.issue_id + LEFT JOIN repository CIR ON CIR.id = CI.repo_id + WHERE C.id IN ( + SELECT content_id FROM abuse_report WHERE status = %[2]d AND content_type = %[6]d + ) + ) REFS ON REFS.type = AR.content_type AND REFS.id = AR.content_id + ORDER BY AR.created_unix ASC`, identifierEscapeChar, ReportStatusTypeOpen, + ReportedContentTypeUser, ReportedContentTypeRepository, ReportedContentTypeIssue, ReportedContentTypeComment)). + Find(&reports) + if err != nil { + return nil, err + } + return reports, nil +} + +func GetOpenReportsByTypeAndContentID(ctx context.Context, contentType ReportedContentType, contentID int64) ([]*AbuseReportDetailed, error) { + var reports []*AbuseReportDetailed + + // Some remarks concerning PostgreSQL: + // - user table should be escaped (e.g. `user`); + // - tried to use aliases for table names but errors like 'invalid reference to FROM-clause entry' + // or 'missing FROM-clause entry' were returned; + err := db.GetEngine(ctx). + Select("abuse_report.*, `user`.name AS reporter_name, abuse_report_shadow_copy.created_unix AS shadow_copy_date, abuse_report_shadow_copy.raw_value AS shadow_copy_raw_value"). + Table("abuse_report"). + Join("LEFT", "user", "`user`.id = abuse_report.reporter_id"). + Join("LEFT", "abuse_report_shadow_copy", "abuse_report_shadow_copy.id = abuse_report.shadow_copy_id"). + Where(builder.Eq{ + "content_type": contentType, + "content_id": contentID, + "status": ReportStatusTypeOpen, + }). + Asc("abuse_report.created_unix"). + Find(&reports) + if err != nil { + return nil, err + } + + return reports, nil +} diff --git a/models/moderation/shadow_copy.go b/models/moderation/shadow_copy.go index d363610a48..8abb32e8ec 100644 --- a/models/moderation/shadow_copy.go +++ b/models/moderation/shadow_copy.go @@ -26,6 +26,22 @@ func (sc AbuseReportShadowCopy) NullableID() sql.NullInt64 { return sql.NullInt64{Int64: sc.ID, Valid: sc.ID > 0} } +// ShadowCopyField defines a pair of a value stored within the shadow copy +// (of some content reported as abusive) and a corresponding key (caption). +// A list of such pairs is used when rendering shadow copies for admins reviewing abuse reports. +type ShadowCopyField struct { + Key string + Value string +} + +// ShadowCopyData interface should be implemented by the type structs used for marshaling/unmarshaling the fields +// preserved as shadow copies for abusive content reports (i.e. UserData, RepositoryData, IssueData, CommentData). +type ShadowCopyData interface { + // GetFieldsMap returns a list of pairs with the fields stored within shadow copies + // of content reported as abusive, to be used when rendering a shadow copy in the admin UI. + GetFieldsMap() []ShadowCopyField +} + func init() { // RegisterModel will create the table if does not already exist // or any missing columns if the table was previously created. diff --git a/models/repo/moderation.go b/models/repo/moderation.go index d7b87dffa0..0d2672227b 100644 --- a/models/repo/moderation.go +++ b/models/repo/moderation.go @@ -5,6 +5,8 @@ package repo import ( "context" + "strconv" + "strings" "forgejo.org/models/moderation" "forgejo.org/modules/json" @@ -25,6 +27,22 @@ type RepositoryData struct { UpdatedUnix timeutil.TimeStamp } +// Implements GetFieldsMap() from ShadowCopyData interface, returning a list of pairs +// to be used when rendering the shadow copy for admins reviewing the corresponding abuse report(s). +func (rd RepositoryData) GetFieldsMap() []moderation.ShadowCopyField { + return []moderation.ShadowCopyField{ + {Key: "OwnerID", Value: strconv.FormatInt(rd.OwnerID, 10)}, + {Key: "OwnerName", Value: rd.OwnerName}, + {Key: "Name", Value: rd.Name}, + {Key: "Description", Value: rd.Description}, + {Key: "Website", Value: rd.Website}, + {Key: "Topics", Value: strings.Join(rd.Topics, ", ")}, + {Key: "Avatar", Value: rd.Avatar}, + {Key: "CreatedUnix", Value: rd.CreatedUnix.AsLocalTime().String()}, + {Key: "UpdatedUnix", Value: rd.UpdatedUnix.AsLocalTime().String()}, + } +} + // newRepositoryData creates a trimmed down repository to be used just to create a JSON structure // (keeping only the fields relevant for moderation purposes) func newRepositoryData(repo *Repository) RepositoryData { diff --git a/models/repo/moderation_test.go b/models/repo/moderation_test.go new file mode 100644 index 0000000000..9852db1b51 --- /dev/null +++ b/models/repo/moderation_test.go @@ -0,0 +1,51 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo_test + +import ( + "testing" + + "forgejo.org/models/moderation" + "forgejo.org/models/repo" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +const ( + tsCreated timeutil.TimeStamp = timeutil.TimeStamp(1753093500) // 2025-07-21 10:25:00 UTC + tsUpdated timeutil.TimeStamp = timeutil.TimeStamp(1753093525) // 2025-07-21 10:25:25 UTC +) + +func testShadowCopyField(t *testing.T, scField moderation.ShadowCopyField, key, value string) { + assert.Equal(t, key, scField.Key) + assert.Equal(t, value, scField.Value) +} + +func TestRepositoryDataGetFieldsMap(t *testing.T) { + rd := repo.RepositoryData{ + OwnerID: 1002, + OwnerName: "alexsmith", + Name: "website", + Description: "My static website.", + Website: "http://promote-your-business.biz", + Topics: []string{"bulk-email", "email-services"}, + Avatar: "avatar-hash-repo-2002", + CreatedUnix: tsCreated, + UpdatedUnix: tsUpdated, + } + scFields := rd.GetFieldsMap() + + if assert.Len(t, scFields, 9) { + testShadowCopyField(t, scFields[0], "OwnerID", "1002") + testShadowCopyField(t, scFields[1], "OwnerName", "alexsmith") + testShadowCopyField(t, scFields[2], "Name", "website") + testShadowCopyField(t, scFields[3], "Description", "My static website.") + testShadowCopyField(t, scFields[4], "Website", "http://promote-your-business.biz") + testShadowCopyField(t, scFields[5], "Topics", "bulk-email, email-services") + testShadowCopyField(t, scFields[6], "Avatar", "avatar-hash-repo-2002") + testShadowCopyField(t, scFields[7], "CreatedUnix", tsCreated.AsLocalTime().String()) + testShadowCopyField(t, scFields[8], "UpdatedUnix", tsUpdated.AsLocalTime().String()) + } +} diff --git a/models/user/moderation.go b/models/user/moderation.go index f9c16a17b3..17901f84ec 100644 --- a/models/user/moderation.go +++ b/models/user/moderation.go @@ -37,6 +37,26 @@ type UserData struct { //revive:disable-line:exported AvatarEmail string } +// Implements GetFieldsMap() from ShadowCopyData interface, returning a list of pairs +// to be used when rendering the shadow copy for admins reviewing the corresponding abuse report(s). +func (ud UserData) GetFieldsMap() []moderation.ShadowCopyField { + return []moderation.ShadowCopyField{ + {Key: "Name", Value: ud.Name}, + {Key: "FullName", Value: ud.FullName}, + {Key: "Email", Value: ud.Email}, + {Key: "LoginName", Value: ud.LoginName}, + {Key: "Location", Value: ud.Location}, + {Key: "Website", Value: ud.Website}, + {Key: "Pronouns", Value: ud.Pronouns}, + {Key: "Description", Value: ud.Description}, + {Key: "CreatedUnix", Value: ud.CreatedUnix.AsLocalTime().String()}, + {Key: "UpdatedUnix", Value: ud.UpdatedUnix.AsLocalTime().String()}, + {Key: "LastLogin", Value: ud.LastLogin.AsLocalTime().String()}, + {Key: "Avatar", Value: ud.Avatar}, + {Key: "AvatarEmail", Value: ud.AvatarEmail}, + } +} + // newUserData creates a trimmed down user to be used just to create a JSON structure // (keeping only the fields relevant for moderation purposes) func newUserData(user *User) UserData { diff --git a/models/user/moderation_test.go b/models/user/moderation_test.go new file mode 100644 index 0000000000..f951e41e11 --- /dev/null +++ b/models/user/moderation_test.go @@ -0,0 +1,60 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package user_test + +import ( + "testing" + + "forgejo.org/models/moderation" + "forgejo.org/models/user" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +const ( + tsCreated timeutil.TimeStamp = timeutil.TimeStamp(1753093200) // 2025-07-21 10:20:00 UTC + tsUpdated timeutil.TimeStamp = timeutil.TimeStamp(1753093320) // 2025-07-21 10:22:00 UTC + tsLastLogin timeutil.TimeStamp = timeutil.TimeStamp(1753093800) // 2025-07-21 10:30:00 UTC +) + +func testShadowCopyField(t *testing.T, scField moderation.ShadowCopyField, key, value string) { + assert.Equal(t, key, scField.Key) + assert.Equal(t, value, scField.Value) +} + +func TestUserDataGetFieldsMap(t *testing.T) { + ud := user.UserData{ + Name: "alexsmith", + FullName: "Alex Smith", + Email: "alexsmith@example.org", + LoginName: "", + Location: "@master@seo.net", + Website: "http://promote-your-business.biz", + Pronouns: "SEO", + Description: "I can help you promote your business online using SEO.", + CreatedUnix: tsCreated, + UpdatedUnix: tsUpdated, + LastLogin: tsLastLogin, + Avatar: "avatar-hash-user-1002", + AvatarEmail: "alexsmith@example.org", + } + scFields := ud.GetFieldsMap() + + if assert.Len(t, scFields, 13) { + testShadowCopyField(t, scFields[0], "Name", "alexsmith") + testShadowCopyField(t, scFields[1], "FullName", "Alex Smith") + testShadowCopyField(t, scFields[2], "Email", "alexsmith@example.org") + testShadowCopyField(t, scFields[3], "LoginName", "") + testShadowCopyField(t, scFields[4], "Location", "@master@seo.net") + testShadowCopyField(t, scFields[5], "Website", "http://promote-your-business.biz") + testShadowCopyField(t, scFields[6], "Pronouns", "SEO") + testShadowCopyField(t, scFields[7], "Description", "I can help you promote your business online using SEO.") + testShadowCopyField(t, scFields[8], "CreatedUnix", tsCreated.AsLocalTime().String()) + testShadowCopyField(t, scFields[9], "UpdatedUnix", tsUpdated.AsLocalTime().String()) + testShadowCopyField(t, scFields[10], "LastLogin", tsLastLogin.AsLocalTime().String()) + testShadowCopyField(t, scFields[11], "Avatar", "avatar-hash-user-1002") + testShadowCopyField(t, scFields[12], "AvatarEmail", "alexsmith@example.org") + } +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index be3d7f92c4..c2c682a4db 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -71,6 +71,10 @@ "keys.ssh.link": "SSH keys", "keys.gpg.link": "GPG keys", "admin.config.moderation_config": "Moderation configuration", + "admin.moderation.moderation_reports": "Moderation reports", + "admin.moderation.reports": "Reports", + "admin.moderation.no_open_reports": "There are currently no open reports.", + "admin.moderation.deleted_content_ref": "Reported content with type %[1]v and id %[2]d no longer exists", "moderation.report_abuse": "Report abuse", "moderation.report_content": "Report content", "moderation.report_abuse_form.header": "Report abuse to administrator", diff --git a/routers/web/admin/reports.go b/routers/web/admin/reports.go new file mode 100644 index 0000000000..ac43d1296f --- /dev/null +++ b/routers/web/admin/reports.go @@ -0,0 +1,157 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package admin + +import ( + "fmt" + "net/http" + + "forgejo.org/models/issues" + "forgejo.org/models/moderation" + repo_model "forgejo.org/models/repo" + "forgejo.org/models/user" + "forgejo.org/modules/base" + "forgejo.org/services/context" + moderation_service "forgejo.org/services/moderation" +) + +const ( + tplModerationReports base.TplName = "admin/moderation/reports" + tplModerationReportDetails base.TplName = "admin/moderation/report_details" +) + +// AbuseReports renders the reports overview page from admin moderation section. +func AbuseReports(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.moderation.reports") + ctx.Data["PageIsAdminModerationReports"] = true + + reports, err := moderation.GetOpenReports(ctx) + if err != nil { + ctx.ServerError("Failed to load abuse reports", err) + return + } + + ctx.Data["Reports"] = reports + ctx.Data["AbuseCategories"] = moderation.AbuseCategoriesTranslationKeys + ctx.Data["GhostUserName"] = user.GhostUserName + + ctx.HTML(http.StatusOK, tplModerationReports) +} + +// AbuseReportDetails renders a report details page opened from the reports overview from admin moderation section. +func AbuseReportDetails(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.moderation.reports") + ctx.Data["PageIsAdminModerationReports"] = true + + ctx.Data["Type"] = ctx.ParamsInt64(":type") + ctx.Data["ID"] = ctx.ParamsInt64(":id") + + contentType := moderation.ReportedContentType(ctx.ParamsInt64(":type")) + + if !contentType.IsValid() { + ctx.Flash.Error("Invalid content type") + return + } + + reports, err := moderation.GetOpenReportsByTypeAndContentID(ctx, contentType, ctx.ParamsInt64(":id")) + if err != nil { + ctx.ServerError("Failed to load reports", err) + return + } + if len(reports) == 0 { + // something is wrong + ctx.HTML(http.StatusOK, tplModerationReportDetails) + return + } + + ctx.Data["Reports"] = reports + ctx.Data["AbuseCategories"] = moderation.AbuseCategoriesTranslationKeys + ctx.Data["GhostUserName"] = user.GhostUserName + + ctx.Data["GetShadowCopyMap"] = moderation_service.GetShadowCopyMap + + if err = setReportedContentDetails(ctx, reports[0]); err != nil { + if user.IsErrUserNotExist(err) || issues.IsErrCommentNotExist(err) || issues.IsErrIssueNotExist(err) || repo_model.IsErrRepoNotExist(err) { + ctx.Data["ContentReference"] = ctx.Tr("admin.moderation.deleted_content_ref", reports[0].ContentType, reports[0].ContentID) + } else { + ctx.ServerError("Failed to load reported content details", err) + return + } + } + + ctx.HTML(http.StatusOK, tplModerationReportDetails) +} + +// setReportedContentDetails adds some values into context data for the given report +// (icon name, a reference, the URL and in case of issues and comments also the poster name). +func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseReportDetailed) error { + contentReference := "" + var contentURL string + var poster string + contentType := report.ContentType + contentID := report.ContentID + + ctx.Data["ContentTypeIconName"] = report.ContentTypeIconName() + + switch contentType { + case moderation.ReportedContentTypeUser: + reportedUser, err := user.GetUserByID(ctx, contentID) + if err != nil { + return err + } + + contentReference = reportedUser.Name + contentURL = reportedUser.HomeLink() + case moderation.ReportedContentTypeRepository: + repo, err := repo_model.GetRepositoryByID(ctx, contentID) + if err != nil { + return err + } + + contentReference = repo.FullName() + contentURL = repo.Link() + case moderation.ReportedContentTypeIssue: + issue, err := issues.GetIssueByID(ctx, contentID) + if err != nil { + return err + } + if err = issue.LoadRepo(ctx); err != nil { + return err + } + if err = issue.LoadPoster(ctx); err != nil { + return err + } + if issue.Poster != nil { + poster = issue.Poster.Name + } + + contentReference = fmt.Sprintf("%s#%d", issue.Repo.FullName(), issue.Index) + contentURL = issue.Link() + case moderation.ReportedContentTypeComment: + comment, err := issues.GetCommentByID(ctx, contentID) + if err != nil { + return err + } + if err = comment.LoadIssue(ctx); err != nil { + return err + } + if err = comment.Issue.LoadRepo(ctx); err != nil { + return err + } + if err = comment.LoadPoster(ctx); err != nil { + return err + } + if comment.Poster != nil { + poster = comment.Poster.Name + } + + contentURL = comment.Link(ctx) + contentReference = contentURL + } + + ctx.Data["ContentReference"] = contentReference + ctx.Data["ContentURL"] = contentURL + ctx.Data["Poster"] = poster + return nil +} diff --git a/routers/web/web.go b/routers/web/web.go index 6cca2a9f2e..497352cdc7 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -781,7 +781,14 @@ func registerRoutes(m *web.Route) { addSettingsRunnersRoutes() addSettingsVariablesRoutes() }) - }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) + + if setting.Moderation.Enabled { + m.Group("/moderation/reports", func() { + m.Get("", admin.AbuseReports) + m.Get("/type/{type:1|2|3|4}/id/{id}", admin.AbuseReportDetails) + }) + } + }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "EnableModeration", setting.Moderation.Enabled)) // ***** END: Admin ***** m.Group("", func() { diff --git a/services/moderation/moderating.go b/services/moderation/moderating.go new file mode 100644 index 0000000000..f329070963 --- /dev/null +++ b/services/moderation/moderating.go @@ -0,0 +1,41 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package moderation + +import ( + "forgejo.org/models/issues" + "forgejo.org/models/moderation" + "forgejo.org/models/repo" + "forgejo.org/models/user" + "forgejo.org/modules/json" + "forgejo.org/modules/log" + "forgejo.org/services/context" +) + +// GetShadowCopyMap unmarshals the shadow copy raw value of the given abuse report and returns a list of pairs +// (to be rendered when the report is reviewed by an admin). +// If the report does not have a shadow copy ID or the raw value is empty, returns nil. +// If the unmarshal fails a warning is added in the logs and returns nil. +func GetShadowCopyMap(ctx *context.Context, ard *moderation.AbuseReportDetailed) []moderation.ShadowCopyField { + if ard.ShadowCopyID.Valid && len(ard.ShadowCopyRawValue) > 0 { + var data moderation.ShadowCopyData + + switch ard.ContentType { + case moderation.ReportedContentTypeUser: + data = new(user.UserData) + case moderation.ReportedContentTypeRepository: + data = new(repo.RepositoryData) + case moderation.ReportedContentTypeIssue: + data = new(issues.IssueData) + case moderation.ReportedContentTypeComment: + data = new(issues.CommentData) + } + if err := json.Unmarshal([]byte(ard.ShadowCopyRawValue), &data); err != nil { + log.Warn("Unmarshal failed for shadow copy #%d. %v", ard.ShadowCopyID.Int64, err) + return nil + } + return data.GetFieldsMap() + } + return nil +} diff --git a/templates/admin/moderation/report_details.tmpl b/templates/admin/moderation/report_details.tmpl new file mode 100644 index 0000000000..26a8b5964b --- /dev/null +++ b/templates/admin/moderation/report_details.tmpl @@ -0,0 +1,65 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}} +
+

+ {{ctx.Locale.Tr "admin.moderation.reports"}} +

+ {{if .Reports}} +
+
+
+ {{svg .ContentTypeIconName 24}} +
+
+
+ {{if .ContentURL}}{{.ContentReference}}{{else}}{{.ContentReference}}{{end}} + {{if .Poster}} — {{.Poster}}{{end}} +
+
+
+
+ {{end}} +
+ {{if .Reports}} +
+ {{range .Reports}} +
+
+
+ + {{svg "octicon-calendar"}} + {{DateUtils.AbsoluteShort .CreatedUnix}} + + + {{svg "octicon-report"}} + {{if .ReporterName}}{{.ReporterName}}{{else}}{{$.GhostUserName}}{{end}} + + + {{svg "octicon-tag" 12}} + {{ctx.Locale.Tr (index $.AbuseCategories .Category)}} + +
+ +
{{.Remarks}}
+ + {{if .ShadowCopyID.Valid}} +
{{DateUtils.FullTime .ShadowCopyDate}} shadow copy + + {{range $scField := (call $.GetShadowCopyMap $.Context .)}} + + + + + {{end}} +
{{$scField.Key}}{{$scField.Value}}
+
+ {{end}} +
+
+ {{end}} +
+ {{else}} +

{{ctx.Locale.Tr "admin.moderation.no_open_reports"}}

+ {{end}} +
+
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/moderation/reports.tmpl b/templates/admin/moderation/reports.tmpl new file mode 100644 index 0000000000..151a079673 --- /dev/null +++ b/templates/admin/moderation/reports.tmpl @@ -0,0 +1,66 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}} +
+

+ {{ctx.Locale.Tr "admin.moderation.reports"}} +

+ +
+ {{if .Reports}} +
+
+
+ Type +
+
+ Summary +
+
+ {{ctx.Locale.Tr "admin.moderation.reports"}} +
+
+ {{range .Reports}} +
+
+ {{svg .ContentTypeIconName 24}} +
+
+
+ {{if .ContentReference}} + {{.ContentReference}} + {{else}} + {{ctx.Locale.Tr "admin.moderation.deleted_content_ref" .ContentType .ContentID}} + {{end}} +
+
+ + {{svg "octicon-calendar"}} + {{DateUtils.TimeSince .CreatedUnix}} + + + {{svg "octicon-report"}} + {{if .ReporterName}}{{.ReporterName}}{{else}}{{$.GhostUserName}}{{end}} + + + {{svg "octicon-tag" 12}} + {{ctx.Locale.Tr (index $.AbuseCategories .Category)}} + +
+ +
+ {{ctx.Locale.Tr "moderation.report_remarks"}}: {{.Remarks}} +
+
+ +
+ {{.ReportedTimes}}{{svg "octicon-report" "tw-ml-2"}} +
+
+
+ {{end}} +
+ {{else}} +

{{ctx.Locale.Tr "admin.moderation.no_open_reports"}}

+ {{end}} +
+
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 1ec703b296..7ca8538bce 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -108,5 +108,10 @@ + {{if .EnableModeration}} + + {{ctx.Locale.Tr "admin.moderation.moderation_reports"}} + + {{end}} diff --git a/tests/integration/admin_moderation_test.go b/tests/integration/admin_moderation_test.go new file mode 100644 index 0000000000..de0daebe86 --- /dev/null +++ b/tests/integration/admin_moderation_test.go @@ -0,0 +1,158 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "net/http" + "strings" + "testing" + + "forgejo.org/models/unittest" + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/routers" + "forgejo.org/tests" + + "github.com/stretchr/testify/assert" +) + +func testReportDetails(t *testing.T, htmlDoc *HTMLDoc, reportID, contentIcon, contentRef, contentURL, category, reportsNo string) { + // Check icon octicon + icon := htmlDoc.Find("#report-" + reportID + " svg." + contentIcon) + assert.Equal(t, 1, icon.Length()) + + // Check content reference and URL + title := htmlDoc.Find("#report-" + reportID + " .flex-item-main .flex-item-title a") + if len(contentURL) == 0 { + // No URL means that the content was already deleted, so we should not find the anchor element. + assert.Zero(t, title.Length()) + // Instead we should find an emphasis element. + title = htmlDoc.Find("#report-" + reportID + " .flex-item-main .flex-item-title em") + assert.Equal(t, 1, title.Length()) + assert.Equal(t, contentRef, title.Text()) + } else { + assert.Equal(t, 1, title.Length()) + assert.Equal(t, contentRef, title.Text()) + + href, exists := title.Attr("href") + assert.True(t, exists) + assert.Equal(t, contentURL, href) + } + + // Check category + cat := htmlDoc.Find("#report-" + reportID + " .flex-item-main .flex-items-inline .item:nth-child(3)") + assert.Equal(t, 1, cat.Length()) + assert.Equal(t, category, strings.TrimSpace(cat.Text())) + + // Check number of reports for the same content + count := htmlDoc.Find("#report-" + reportID + " a span") + assert.Equal(t, 1, count.Length()) + assert.Equal(t, reportsNo, count.Text()) +} + +func TestAdminModerationViewReports(t *testing.T) { + defer unittest.OverrideFixtures("tests/integration/fixtures/TestAdminModerationViewReports")() + defer tests.PrepareTestEnv(t)() + + t.Run("Moderation enabled", func(t *testing.T) { + defer test.MockVariableValue(&setting.Moderation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + t.Run("Anonymous user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/admin/moderation/reports") + MakeRequest(t, req, http.StatusSeeOther) + req = NewRequest(t, "GET", "/admin/moderation/reports/type/1/id/1002") + MakeRequest(t, req, http.StatusSeeOther) + }) + + t.Run("Normal user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/admin/moderation/reports") + session.MakeRequest(t, req, http.StatusForbidden) + req = NewRequest(t, "GET", "/admin/moderation/reports/type/1/id/1002") + session.MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("Admin user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/admin/moderation/reports") + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + // Check how many reports are being displayed. + // Reports linked to the same content (type and id) should be grouped; therefore we should see only 6 instead of 9. + reports := htmlDoc.Find(".admin-setting-content .flex-list .flex-item.report") + assert.Equal(t, 7, reports.Length()) + + // Check details for shown reports. + testReportDetails(t, htmlDoc, "1", "octicon-person", "@SPAM-services", "/SPAM-services", "Illegal content", "1") + testReportDetails(t, htmlDoc, "2", "octicon-repo", "SPAM-services/spammer-Tools", "/SPAM-services/spammer-Tools", "Illegal content", "1") + testReportDetails(t, htmlDoc, "3", "octicon-issue-opened", "SPAM-services/spammer-Tools#1", "/SPAM-services/spammer-Tools/issues/1", "Spam", "1") + // #4 is combined with #7 and #9 + testReportDetails(t, htmlDoc, "4", "octicon-person", "@spammer01", "/spammer01", "Spam", "3") + // #5 is combined with #6 + testReportDetails(t, htmlDoc, "5", "octicon-comment", "contributor/first/issues/1#issuecomment-1001", "/contributor/first/issues/1#issuecomment-1001", "Malware", "2") + testReportDetails(t, htmlDoc, "8", "octicon-issue-opened", "contributor/first#1", "/contributor/first/issues/1", "Other violations of platform rules", "1") + // #10 is for a Ghost user + testReportDetails(t, htmlDoc, "10", "octicon-person", "Reported content with type 1 and id 9999 no longer exists", "", "Other violations of platform rules", "1") + + t.Run("reports details page", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req = NewRequest(t, "GET", "/admin/moderation/reports/type/1/id/1002") + resp = session.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + // Check the title (content reference) and corresponding URL + title := htmlDoc.Find(".admin-setting-content .flex-item-main .flex-item-title a") + assert.Equal(t, 1, title.Length()) + assert.Equal(t, "spammer01", title.Text()) + href, exists := title.Attr("href") + assert.True(t, exists) + assert.Equal(t, "/spammer01", href) + + // Check how many reports are being displayed for user 1002. + reports = htmlDoc.Find(".admin-setting-content .flex-list .flex-item") + assert.Equal(t, 3, reports.Length()) + }) + }) + }) + + t.Run("Moderation disabled", func(t *testing.T) { + t.Run("Anonymous user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequest(t, "GET", "/admin/moderation/reports") + MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/admin/moderation/reports/type/1/id/1002") + MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Normal user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/admin/moderation/reports") + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/admin/moderation/reports/type/1/id/1002") + session.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("Admin user", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + session := loginUser(t, "user1") + req := NewRequest(t, "GET", "/admin/moderation/reports") + session.MakeRequest(t, req, http.StatusNotFound) + req = NewRequest(t, "GET", "/admin/moderation/reports/type/1/id/1002") + session.MakeRequest(t, req, http.StatusNotFound) + }) + }) +} diff --git a/tests/integration/fixtures/TestAdminModerationViewReports/abuse_report.yml b/tests/integration/fixtures/TestAdminModerationViewReports/abuse_report.yml new file mode 100644 index 0000000000..8bc3c0834b --- /dev/null +++ b/tests/integration/fixtures/TestAdminModerationViewReports/abuse_report.yml @@ -0,0 +1,111 @@ +- + id: 1 + status: 1 # Open + reporter_id: 1004 # @reporter1 + content_type: 1 # User (users or organizations) + content_id: 1003 # @SPAM-services + category: 4 # IllegalContent + remarks: This organization was created for spamming. + shadow_copy_id: null + created_unix: 1121423460 # 2005-07-15 10:31:00 + +- + id: 2 + status: 1 + reporter_id: 1004 # @reporter1 + content_type: 2 # Repository + content_id: 1002 # SPAM-services/spammer-tools + category: 4 # IllegalContent + remarks: This repository was created for building spamming tools. + shadow_copy_id: null + created_unix: 1121423520 # 2005-07-15 10:32:00 + +- + id: 3 + status: 1 + reporter_id: 1004 # @reporter1 + content_type: 3 # Issue (issues or pull requests) + content_id: 1002 # SPAM-services/spammer-tools#1 + category: 2 # Spam + remarks: This issue advertises spam services. + shadow_copy_id: null + created_unix: 1121423580 # 2005-07-15 10:33:00 + +- + id: 4 + status: 1 + reporter_id: 1004 # @reporter1 + content_type: 1 # User (users or organizations) + content_id: 1002 # @spammer01 + category: 2 # Spam + remarks: | + This profile advertises spam services and the user already created spam content. + I have reported some of them. + shadow_copy_id: null + created_unix: 1121423640 # 2005-07-15 10:34:00 + +- + id: 5 + status: 1 + reporter_id: 1004 # @reporter1 + content_type: 4 # Comment + content_id: 1001 # contributor/first/issues/1#issuecomment-1001 + category: 3 # Malware + remarks: This comment references a spammy issue from a spammy repository of a spammy organization created by a spammer. + shadow_copy_id: null + created_unix: 1121423700 # 2005-07-15 10:35:00 + +- + id: 6 + status: 1 + reporter_id: 1001 # @contributor + content_type: 4 # Comment + content_id: 1001 # contributor/first/issues/1#issuecomment-1001 + category: 2 # Spam + remarks: I should delete this, since I can; but first I want to test the reporting functionality. + shadow_copy_id: null + created_unix: 1121423730 # 2005-07-15 10:35:30 + +- + id: 7 + status: 1 + reporter_id: 1001 # @contributor + content_type: 1 # User (users or organizations) + content_id: 1002 # @spammer01 + category: 1 # Other + remarks: Should investigate the origin of this abuser. + shadow_copy_id: null + created_unix: 1121423760 # 2005-07-15 10:36:00 + +- + id: 8 + status: 1 + reporter_id: 1002 # @spammer01 + content_type: 3 # Issue (issues or pull requests) + content_id: 1001 # contributor/first#1 + category: 1 # Other + remarks: Just because you are the administrator of this Forgejo instance this doesn't mean that you should be more privileged compared to the rest of average users. I believe it is my right to post links to external websites where users can find more about myself and my own work, even if they are professional services. I strongly believe you should reconsider your totalitarian behaviour. The users of this instance deserve better, more inclusive rules that should prevent abuses from administrators or mod. + shadow_copy_id: null + created_unix: 1121424000 # 2005-07-15 10:40:00 + +- + id: 9 + status: 1 + reporter_id: 1005 # @reporter2 + content_type: 1 # User (users or organizations) + content_id: 1002 # @spammer01 + category: 2 # Spam + remarks: This user is just spamming wherever they can. + shadow_copy_id: null + created_unix: 1121424030 # 2005-07-15 10:40:30 + +- + id: 10 + status: 1 + reporter_id: 1005 # @reporter2 + content_type: 1 # User (users or organizations) + content_id: 9999 # Ghost user + category: 1 # Other + remarks: Check this spammer as soon as possible, before they delete their account. + shadow_copy_id: null + created_unix: 1121424150 # 2005-07-15 10:42:30 diff --git a/tests/integration/fixtures/TestAdminModerationViewReports/comment.yml b/tests/integration/fixtures/TestAdminModerationViewReports/comment.yml new file mode 100644 index 0000000000..a7ee83d53e --- /dev/null +++ b/tests/integration/fixtures/TestAdminModerationViewReports/comment.yml @@ -0,0 +1,21 @@ +- + id: 1001 + type: 0 # Standard comment + poster_id: 1002 # @spammer01 + issue_id: 1001 # contributor/first#1 + content: And the first spammer; check SPAM-services/spammer-Tools#1 + created_unix: 1121422990 # 2005-07-15 10:23:10 + +- + id: 1002 + type: 5 # Reference from a comment + poster_id: 1002 # @spammer01 + issue_id: 1002 # SPAM-services/spammer-tools#1 + content: '' + content_version: 0 + created_unix: 1121422990 + ref_repo_id: 1001 + ref_issue_id: 1001 + ref_comment_id: 1001 + ref_action: 0 + ref_is_pull: false diff --git a/tests/integration/fixtures/TestAdminModerationViewReports/issue.yml b/tests/integration/fixtures/TestAdminModerationViewReports/issue.yml new file mode 100644 index 0000000000..74af88b3b6 --- /dev/null +++ b/tests/integration/fixtures/TestAdminModerationViewReports/issue.yml @@ -0,0 +1,23 @@ +- + id: 1001 + repo_id: 1001 # contributor/first + index: 1 + poster_id: 1001 + name: first repo should have a first issue + content: so here we go + is_closed: false + is_pull: false + num_comments: 1 + created_unix: 1121422320 # 2005-07-15 10:12:00 + +- + id: 1002 + repo_id: 1002 # SPAM-services/spammer-tools + index: 1 + poster_id: 1002 + name: Professional marketing services + content: Visit my website at spammer.xyz/services for a list of available services. + is_closed: false + is_pull: false + num_comments: 0 + created_unix: 1121422980 # 2005-07-15 10:23:00 diff --git a/tests/integration/fixtures/TestAdminModerationViewReports/issue_index.yml b/tests/integration/fixtures/TestAdminModerationViewReports/issue_index.yml new file mode 100644 index 0000000000..cecf437282 --- /dev/null +++ b/tests/integration/fixtures/TestAdminModerationViewReports/issue_index.yml @@ -0,0 +1,7 @@ +- + group_id: 1001 + max_index: 1 + +- + group_id: 1002 + max_index: 1 diff --git a/tests/integration/fixtures/TestAdminModerationViewReports/repository.yml b/tests/integration/fixtures/TestAdminModerationViewReports/repository.yml new file mode 100644 index 0000000000..cee5d6a74c --- /dev/null +++ b/tests/integration/fixtures/TestAdminModerationViewReports/repository.yml @@ -0,0 +1,45 @@ +- + id: 1001 + owner_id: 1001 + owner_name: contributor + lower_name: first + name: first + description: '' + website: '' + default_branch: main + num_watches: 1 + num_stars: 0 + num_forks: 0 + num_issues: 1 + num_closed_issues: 0 + num_pulls: 0 + num_closed_pulls: 0 + is_private: false + is_empty: false + is_archived: false + size: 0 + topics: '[]' + created_unix: 1121422260 # 2005-07-15 10:11:00 + +- + id: 1002 + owner_id: 1003 + owner_name: SPAM-services + lower_name: spammer-tools + name: spammer-Tools + description: Another _place_ for abusive content. + website: '' + default_branch: main + num_watches: 1 + num_stars: 0 + num_forks: 0 + num_issues: 1 + num_closed_issues: 0 + num_pulls: 0 + num_closed_pulls: 0 + is_private: false + is_empty: false + is_archived: false + size: 0 + topics: "[\"bulk-email\",\"email-services\",\"spam\",\"spamservices.co\"]" + created_unix: 1121422920 # 2005-07-15 10:22:00 diff --git a/tests/integration/fixtures/TestAdminModerationViewReports/user.yml b/tests/integration/fixtures/TestAdminModerationViewReports/user.yml new file mode 100644 index 0000000000..f00f3de338 --- /dev/null +++ b/tests/integration/fixtures/TestAdminModerationViewReports/user.yml @@ -0,0 +1,108 @@ +- + id: 1001 + lower_name: contributor + name: contributor + full_name: The Contributor + email: contributor@example.org + keep_email_private: true + passwd: passwdSalt:password + passwd_hash_algo: dummy + type: 0 + salt: passwdSalt + description: '' + created_unix: 1121422200 # 2005-07-15 10:10:00 + is_active: true + is_admin: false + is_restricted: false + avatar: avatar-hash-1001 + avatar_email: contributor@example.org + use_custom_avatar: false + num_repos: 1 + +- + id: 1002 + lower_name: spammer01 + name: spammer01 + full_name: King of SPAM + email: spammer01@example.org + keep_email_private: false + passwd: passwdSalt:password + passwd_hash_algo: dummy + type: 0 + location: '@master@smap.net' + website: http://spammer.xyz + pronouns: http://spam.me + salt: passwdSalt + description: I can help you abuse others inboxes. Updated prices on spammer.xyz/services + created_unix: 1121422800 # 2005-07-15 10:20:00 + is_active: true + is_admin: false + is_restricted: false + avatar: avatar-hash-1002 + avatar_email: spammer01@example.org + use_custom_avatar: false + +- + id: 1003 + lower_name: spam-services + name: SPAM-services + full_name: SPAM services + email: get@spamservices.co + keep_email_private: false + email_notifications_preference: '' + passwd: '' + passwd_hash_algo: '' + type: 1 + location: www.spamservices.co + website: https://spamservices.co + salt: 1888c34e04642082a791b49cf147cc88 + description: Contact us for **bulk emails** sending. + created_unix: 1121422860 # 2005-07-15 10:21:00 + is_active: true + is_admin: false + is_restricted: false + allow_create_organization: false + avatar: avatar-hash-1003 + avatar_email: '' + use_custom_avatar: true + num_repos: 1 + +- + id: 1004 + lower_name: reporter1 + name: reporter1 + full_name: Reporter One + email: reporter1@example.org + keep_email_private: true + passwd: passwdSalt:password + passwd_hash_algo: dummy + type: 0 + salt: passwdSalt + description: '' + created_unix: 1121423400 # 2005-07-15 10:30:00 + is_active: true + is_admin: false + is_restricted: false + avatar: avatar-hash-1004 + avatar_email: reporter1@example.org + use_custom_avatar: false + +- + id: 1005 + lower_name: reporter2 + name: reporter2 + full_name: Reporter Two + email: reporter2@example.org + keep_email_private: true + passwd: passwdSalt:password + passwd_hash_algo: dummy + type: 0 + salt: passwdSalt + description: '' + created_unix: 1121424000 # 2005-07-15 10:40:00 + is_active: true + is_admin: false + is_restricted: false + avatar: avatar-hash-1005 + avatar_email: reporter2@example.org + use_custom_avatar: false