forgejo/routers/web/admin/reports.go
floss4good 590104b5ca feat: render a link to poster profile next to the ID within shadow copy details (#10194)
Closes #10078 and includes another small improvement (for comments and issues/PRs the title from report/s details page already included the poster name; now it will clickable, opening the poster profile page).

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10194
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: floss4good <floss4good@disroot.org>
Co-committed-by: floss4good <floss4good@disroot.org>
2025-12-09 15:19:10 +01:00

402 lines
14 KiB
Go

// 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"
"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.
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
// 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)
}
// 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)
if contentType == moderation.ReportedContentTypeComment || contentType == moderation.ReportedContentTypeIssue {
reports[0].ShouldGetAbuserFromShadowCopy = true
}
} 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 and URL).
func setReportedContentDetails(ctx *context.Context, report *moderation.AbuseReportDetailed) error {
contentReference := ""
var contentURL string
var poster string
var posterURL 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
posterURL = issue.Poster.HomeLink()
}
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 && !user.IsErrUserNotExist(err) {
return err
}
if comment.Poster != nil {
poster = comment.Poster.Name
posterURL = comment.Poster.HomeLink()
}
contentURL = comment.Link(ctx)
contentReference = contentURL
}
ctx.Data["ContentReference"] = contentReference
ctx.Data["ContentURL"] = contentURL
ctx.Data["Poster"] = poster
ctx.Data["PosterURL"] = posterURL
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)
}