diff --git a/models/moderation/abuse_report.go b/models/moderation/abuse_report.go index 152b81bb51..5bd66ee9e4 100644 --- a/models/moderation/abuse_report.go +++ b/models/moderation/abuse_report.go @@ -183,7 +183,6 @@ func GetResolvedReports(ctx context.Context, keepReportsFor time.Duration) ([]*A Find(&abuseReports) } -/* // MarkAsHandled will change the status to 'Handled' for all reports linked to the same item (user, repository, issue or comment). func MarkAsHandled(ctx context.Context, contentType ReportedContentType, contentID int64) error { return updateStatus(ctx, contentType, contentID, ReportStatusTypeHandled) @@ -199,8 +198,7 @@ func updateStatus(ctx context.Context, contentType ReportedContentType, contentI _, err := db.GetEngine(ctx).Where(builder.Eq{ "content_type": contentType, "content_id": contentID, - }).Cols("status").Update(&AbuseReport{Status: status}) + }).Update(&AbuseReport{Status: status, ResolvedUnix: timeutil.TimeStampNow()}) return err } -*/ diff --git a/models/moderation/abuse_report_detailed.go b/models/moderation/abuse_report_detailed.go index 265d143709..3d6a6fc4a4 100644 --- a/models/moderation/abuse_report_detailed.go +++ b/models/moderation/abuse_report_detailed.go @@ -50,6 +50,22 @@ func (ard AbuseReportDetailed) ContentURL() string { } } +func (ard AbuseReportDetailed) ReportedContentIsUser() bool { + return ard.ContentType == ReportedContentTypeUser +} + +func (ard AbuseReportDetailed) ReportedContentIsRepo() bool { + return ard.ContentType == ReportedContentTypeRepository +} + +func (ard AbuseReportDetailed) ReportedContentIsIssue() bool { + return ard.ContentType == ReportedContentTypeIssue +} + +func (ard AbuseReportDetailed) ReportedContentIsComment() bool { + return ard.ContentType == ReportedContentTypeComment +} + func GetOpenReports(ctx context.Context) ([]*AbuseReportDetailed, error) { var reports []*AbuseReportDetailed @@ -59,6 +75,7 @@ func GetOpenReports(ctx context.Context) ([]*AbuseReportDetailed, error) { // - 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. + // - Also, note that builder.Union() is broken: gitea.com/xorm/builder/issues/71 identifierEscapeChar := `` if setting.Database.Type.IsPostgreSQL() || setting.Database.Type.IsSQLite3() { identifierEscapeChar = `"` @@ -113,8 +130,8 @@ func GetOpenReportsByTypeAndContentID(ctx context.Context, contentType ReportedC // 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; + // - tried to use aliases for table names but errors like 'pq: invalid reference to FROM-clause entry' + // or 'pq: 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"). diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index de1110436e..b8337095e6 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -122,6 +122,22 @@ "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.mark_as_handled": "Mark as handled", + "moderation.report.mark_as_ignored": "Mark as ignored", + "moderation.action.account.delete": "Delete account", + "moderation.action.account.suspend": "Suspend account", + "moderation.action.repo.delete": "Delete repository", + "moderation.action.issue.delete": "Delete issue", + "moderation.action.comment.delete": "Delete comment", + "moderation.unknown_action": "Unknown action", + "moderation.users.cannot_suspend_self": "You cannot suspend yourself.", + "moderation.users.cannot_suspend_admins": "Users with admin privileges cannot be suspended.", + "moderation.users.cannot_suspend_org": "Organizations cannot be suspended.", + "moderation.users.already_suspended": "User account is already suspended.", + "moderation.users.suspend_success": "The user account has been suspended.", + "moderation.users.cannot_delete_admins": "Users with admin privileges cannot be deleted.", + "moderation.issue.deletion_success": "The issue has been deleted.", + "moderation.comment.deletion_success": "The comment has been deleted.", "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 index 2df66a3803..1d4cb092e3 100644 --- a/routers/web/admin/reports.go +++ b/routers/web/admin/reports.go @@ -9,16 +9,24 @@ import ( "forgejo.org/models/issues" "forgejo.org/models/moderation" + "forgejo.org/models/organization" repo_model "forgejo.org/models/repo" "forgejo.org/models/user" "forgejo.org/modules/base" + "forgejo.org/modules/log" + "forgejo.org/modules/optional" "forgejo.org/services/context" + issue_service "forgejo.org/services/issue" moderation_service "forgejo.org/services/moderation" + org_service "forgejo.org/services/org" + repo_service "forgejo.org/services/repository" + user_service "forgejo.org/services/user" ) const ( tplModerationReports base.TplName = "admin/moderation/reports" tplModerationReportDetails base.TplName = "admin/moderation/report_details" + tplAlert base.TplName = "base/alert" ) // AbuseReports renders the reports overview page from admin moderation section. @@ -36,6 +44,17 @@ func AbuseReports(ctx *context.Context) { ctx.Data["AbuseCategories"] = moderation.AbuseCategoriesTranslationKeys ctx.Data["GhostUserName"] = user.GhostUserName + // available actions that can be done for reports + ctx.Data["MarkAsHandled"] = int(moderation_service.ReportActionMarkAsHandled) + ctx.Data["MarkAsIgnored"] = int(moderation_service.ReportActionMarkAsIgnored) + + // available actions that can be done for reported content + ctx.Data["ActionSuspendAccount"] = int(moderation_service.ContentActionSuspendAccount) + ctx.Data["ActionDeleteAccount"] = int(moderation_service.ContentActionDeleteAccount) + ctx.Data["ActionDeleteRepo"] = int(moderation_service.ContentActionDeleteRepo) + ctx.Data["ActionDeleteIssue"] = int(moderation_service.ContentActionDeleteIssue) + ctx.Data["ActionDeleteComment"] = int(moderation_service.ContentActionDeleteComment) + ctx.HTML(http.StatusOK, tplModerationReports) } @@ -155,3 +174,222 @@ func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseRep ctx.Data["Poster"] = poster return nil } + +func PerformAction(ctx *context.Context) { + var contentID int64 + var contentType moderation.ReportedContentType + + contentID = ctx.FormInt64("content_id") + if contentID <= 0 { + ctx.Error(http.StatusBadRequest, "Invalid parameter: content_id") + return + } + + contentType = moderation.ReportedContentType(ctx.FormInt64("content_type")) + if !contentType.IsValid() { + ctx.Error(http.StatusBadRequest, "Invalid parameter: content_type") + return + } + + reportAction := moderation_service.ReportAction(ctx.FormInt64("report_action")) + if !reportAction.IsValid() { + ctx.Error(http.StatusBadRequest, "Invalid parameter: report_action") + return + } + + contentAction := moderation_service.ContentAction(ctx.FormInt64("content_action")) + if !contentAction.IsValid() { + ctx.Error(http.StatusBadRequest, "Invalid parameter: content_action") + return + } + + if contentAction == moderation_service.ContentActionNone && reportAction == moderation_service.ReportActionNone { + ctx.Error(http.StatusBadRequest, "Invalid combination of content_action and report_action parameters") + return + } + + switch contentAction { + case moderation_service.ContentActionNone: + updateReportStatus(ctx, contentType, contentID, reportAction) + case moderation_service.ContentActionSuspendAccount: + suspendAccount(ctx, contentType, contentID, reportAction) + case moderation_service.ContentActionDeleteAccount: + deleteAccount(ctx, contentType, contentID, reportAction) + case moderation_service.ContentActionDeleteRepo: + deleteRepository(ctx, contentType, contentID, reportAction) + case moderation_service.ContentActionDeleteIssue: + deleteIssue(ctx, contentType, contentID, reportAction) + case moderation_service.ContentActionDeleteComment: + deleteComment(ctx, contentType, contentID, reportAction) + default: + ctx.Flash.Warning(ctx.Tr("moderation.unknown_action"), true) + ctx.HTML(http.StatusOK, tplAlert) + } +} + +func updateReportStatus(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) { + var err error + + switch reportAction { + case moderation_service.ReportActionMarkAsHandled: + err = moderation.MarkAsHandled(ctx, contentType, contentID) + case moderation_service.ReportActionMarkAsIgnored: + err = moderation.MarkAsIgnored(ctx, contentType, contentID) + default: + return + } + + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to update the status of the report: %s", err.Error())) + return + } + + // TODO: translate and maybe use a more specific message (e.g. saying that the status was changed to 'Handled' or 'Ignored')? + ctx.Flash.Success(fmt.Sprintf("Status updated for report(s) with type #%d and id #%d", contentType, contentID), true) + ctx.HTML(http.StatusOK, tplAlert) +} + +func suspendAccount(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) { + if contentID == ctx.Doer.ID { + ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_suspend_self"), true) + ctx.HTML(http.StatusOK, tplAlert) + return + } + + reportedUser, err := user.GetUserByID(ctx, contentID) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the user: %s", err.Error())) + return + } + + if reportedUser.IsAdmin { + ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_suspend_admins"), true) + ctx.HTML(http.StatusOK, tplAlert) + return + } + + if reportedUser.IsOrganization() { + ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_suspend_org"), true) + ctx.HTML(http.StatusOK, tplAlert) + return + } + + if reportedUser.ProhibitLogin { + ctx.Flash.Info(ctx.Tr("moderation.users.already_suspended"), true) + ctx.HTML(http.StatusOK, tplAlert) + return + } + + authOpts := &user_service.UpdateAuthOptions{ProhibitLogin: optional.Some(true)} + // TODO: should we implement a new, simpler, SuspendAccount() method?! + if err = user_service.UpdateAuth(ctx, reportedUser, authOpts); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to suspend the user: %s", err.Error())) + return + } + + if reportAction != moderation_service.ReportActionNone { + // TODO: currently not implemented + updateReportStatus(ctx, contentType, contentID, reportAction) + } + + ctx.Flash.Success(ctx.Tr("moderation.users.suspend_success"), true) + ctx.HTML(http.StatusOK, tplAlert) +} + +func deleteAccount(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) { + if contentID == ctx.Doer.ID { + ctx.Resp.Header().Add("HX-Reswap", "none") // prevent removing the report from the list + ctx.Flash.Warning(ctx.Tr("admin.users.cannot_delete_self"), true) + ctx.HTML(http.StatusOK, tplAlert) + return + } + + reportedUser, err := user.GetUserByID(ctx, contentID) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the user: %s", err.Error())) + return + } + + if reportedUser.IsAdmin { + ctx.Resp.Header().Add("HX-Reswap", "none") // prevent removing the report from the list + ctx.Flash.Warning(ctx.Tr("moderation.users.cannot_delete_admins"), true) + ctx.HTML(http.StatusOK, tplAlert) + return + } + + if reportedUser.IsOrganization() { + reportedOrg := organization.OrgFromUser(reportedUser) + if err = org_service.DeleteOrganization(ctx, reportedOrg, true); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the organization: %s", err.Error())) + return + } + log.Trace("Organization deleted by admin (%s): %s", ctx.Doer.Name, reportedOrg.Name) + } else { + if err = user_service.DeleteUser(ctx, reportedUser, true); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the user: %s", err.Error())) + return + } + log.Trace("Account deleted by admin (%s): %s", ctx.Doer.Name, reportedUser.Name) + } + + // TODO: when deleting content maybe we should always mark the reports as handled (does it makes sense to keep them open?!) + updateReportStatus(ctx, contentType, contentID, reportAction) // TODO: combine success messages + + ctx.Flash.Success(ctx.Tr("admin.users.deletion_success"), true) + ctx.HTML(http.StatusOK, tplAlert) +} + +func deleteRepository(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) { + repo, err := repo_model.GetRepositoryByID(ctx, contentID) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the repository: %s", err.Error())) + return + } + + if err = repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the repository: %s", err.Error())) + return + } + log.Trace("Repository deleted: %s", repo.FullName()) + + updateReportStatus(ctx, contentType, contentID, reportAction) + + ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success"), true) + ctx.HTML(http.StatusOK, tplAlert) +} + +func deleteIssue(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) { + issue, err := issues.GetIssueByID(ctx, contentID) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the issue: %s", err.Error())) + return + } + + if err = issue_service.DeleteIssue(ctx, ctx.Doer, nil, issue); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the issue: %s", err.Error())) + return + } + + updateReportStatus(ctx, contentType, contentID, reportAction) + + ctx.Flash.Success(ctx.Tr("moderation.issue.deletion_success"), true) + ctx.HTML(http.StatusOK, tplAlert) +} + +func deleteComment(ctx *context.Context, contentType moderation.ReportedContentType, contentID int64, reportAction moderation_service.ReportAction) { + comment, err := issues.GetCommentByID(ctx, contentID) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to retrieve the comment: %s", err.Error())) + return + } + + if err = issue_service.DeleteComment(ctx, ctx.Doer, comment); err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Failed to delete the comment: %s", err.Error())) + return + } + + updateReportStatus(ctx, contentType, contentID, reportAction) + + ctx.Flash.Success(ctx.Tr("moderation.comment.deletion_success"), true) + ctx.HTML(http.StatusOK, tplAlert) +} diff --git a/routers/web/web.go b/routers/web/web.go index a7cc105683..90cffac552 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -822,6 +822,7 @@ func registerRoutes(m *web.Route) { m.Get("", admin.AbuseReports) m.Get("/type/{type:1|2|3|4}/id/{id}", admin.AbuseReportDetails) }) + m.Post("/abuse_reports/act", admin.PerformAction) } }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "EnableModeration", setting.Moderation.Enabled)) // ***** END: Admin ***** diff --git a/services/moderation/moderating.go b/services/moderation/moderating.go index f329070963..a8970cd08d 100644 --- a/services/moderation/moderating.go +++ b/services/moderation/moderating.go @@ -4,6 +4,8 @@ package moderation import ( + "slices" + "forgejo.org/models/issues" "forgejo.org/models/moderation" "forgejo.org/models/repo" @@ -13,6 +15,50 @@ import ( "forgejo.org/services/context" ) +type ReportAction int + +const ( + ReportActionNone ReportAction = iota + ReportActionMarkAsHandled + ReportActionMarkAsIgnored +) + +var allReportActions = []ReportAction{ + ReportActionNone, + ReportActionMarkAsHandled, + ReportActionMarkAsIgnored, +} + +func (ra ReportAction) IsValid() bool { + return slices.Contains(allReportActions, ra) +} + +type ContentAction int + +const ( + // ContentActionNone means that no action should be done for the reported content itself; + // is should be used when needed to just update the status of the report. + ContentActionNone ContentAction = iota + ContentActionSuspendAccount + ContentActionDeleteAccount + ContentActionDeleteRepo + ContentActionDeleteIssue + ContentActionDeleteComment +) + +var allContentActions = []ContentAction{ + ContentActionNone, + ContentActionSuspendAccount, + ContentActionDeleteAccount, + ContentActionDeleteRepo, + ContentActionDeleteIssue, + ContentActionDeleteComment, +} + +func (ca ContentAction) IsValid() bool { + return slices.Contains(allContentActions, ca) +} + // 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. diff --git a/templates/admin/moderation/reports.tmpl b/templates/admin/moderation/reports.tmpl index 151a079673..d1de0106ce 100644 --- a/templates/admin/moderation/reports.tmpl +++ b/templates/admin/moderation/reports.tmpl @@ -19,7 +19,9 @@ {{range .Reports}} -
+ {{$reportDivID := print "report-" .ID}} + {{$reportDivSelector := print "#" $reportDivID}} +
{{svg .ContentTypeIconName 24}}
@@ -50,11 +52,67 @@ {{ctx.Locale.Tr "moderation.report_remarks"}}: {{.Remarks}}
- + + {{$postURL := print AppSubUrl "/admin/abuse_reports/act"}} +
- {{.ReportedTimes}}{{svg "octicon-report" "tw-ml-2"}} +
+ + {{.ReportedTimes}}{{svg "octicon-report" "tw-ml-2"}} + +
+ + + {{if .ContentReference}} {{/* the reference is missing when the content was already deleted */}} + + {{end}} +
+
- {{end}} diff --git a/web_src/js/htmx.js b/web_src/js/htmx.js index c4893f7c1b..680383c532 100644 --- a/web_src/js/htmx.js +++ b/web_src/js/htmx.js @@ -16,6 +16,13 @@ document.body.addEventListener('htmx:sendError', (event) => { // https://htmx.org/events/#htmx:responseError document.body.addEventListener('htmx:responseError', (event) => { + // hide any previous flash message to avoid confusions (in case the + // error toast would have been shown over a success/info message) + const flashMsgDiv = document.getElementById('flash-message'); + if (flashMsgDiv) { + flashMsgDiv.innerHTML = ''; + flashMsgDiv.className = ''; + } // TODO: add translations - showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}`); + showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}: ${event.detail.xhr.responseText}`); });