mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-03-25 23:33:03 -04:00
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>
402 lines
14 KiB
Go
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)
|
|
}
|