244 lines
7.9 KiB
C#
244 lines
7.9 KiB
C#
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published
|
|
// by the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
using System.Net;
|
|
using Foxnouns.Backend.Database;
|
|
using Foxnouns.Backend.Database.Models;
|
|
using Foxnouns.Backend.Dto;
|
|
using Foxnouns.Backend.Middleware;
|
|
using Foxnouns.Backend.Services;
|
|
using Foxnouns.Backend.Utils;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Newtonsoft.Json;
|
|
using NodaTime;
|
|
|
|
namespace Foxnouns.Backend.Controllers.Moderation;
|
|
|
|
[Route("/api/v2/moderation")]
|
|
public class ReportsController(
|
|
ILogger logger,
|
|
DatabaseContext db,
|
|
IClock clock,
|
|
ISnowflakeGenerator snowflakeGenerator,
|
|
UserRendererService userRenderer,
|
|
MemberRendererService memberRenderer,
|
|
ModerationRendererService moderationRenderer,
|
|
ModerationService moderationService
|
|
) : ApiControllerBase
|
|
{
|
|
private readonly ILogger _logger = logger.ForContext<ReportsController>();
|
|
|
|
private Snowflake MaxReportId() =>
|
|
Snowflake.FromInstant(clock.GetCurrentInstant() - Duration.FromHours(12));
|
|
|
|
[HttpPost("report-user/{id}")]
|
|
[Authorize("user.moderation")]
|
|
public async Task<IActionResult> ReportUserAsync(
|
|
Snowflake id,
|
|
[FromBody] CreateReportRequest req
|
|
)
|
|
{
|
|
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
|
|
|
|
User target = await db.ResolveUserAsync(id);
|
|
|
|
if (target.Id == CurrentUser!.Id)
|
|
{
|
|
throw new ApiError(
|
|
"You can't report yourself.",
|
|
HttpStatusCode.BadRequest,
|
|
ErrorCode.InvalidReportTarget
|
|
);
|
|
}
|
|
|
|
Snowflake reportCutoff = MaxReportId();
|
|
if (
|
|
await db
|
|
.Reports.Where(r =>
|
|
r.ReporterId == CurrentUser!.Id
|
|
&& r.TargetUserId == target.Id
|
|
&& r.Id > reportCutoff
|
|
)
|
|
.AnyAsync()
|
|
)
|
|
{
|
|
_logger.Debug(
|
|
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
|
|
CurrentUser!.Id,
|
|
target.Id
|
|
);
|
|
return NoContent();
|
|
}
|
|
|
|
_logger.Information(
|
|
"Creating report on {TargetId} by {ReporterId}",
|
|
target.Id,
|
|
CurrentUser!.Id
|
|
);
|
|
|
|
string snapshot = JsonConvert.SerializeObject(
|
|
await userRenderer.RenderUserAsync(target, renderMembers: false)
|
|
);
|
|
|
|
var report = new Report
|
|
{
|
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
|
ReporterId = CurrentUser.Id,
|
|
TargetUserId = target.Id,
|
|
TargetMemberId = null,
|
|
Reason = req.Reason,
|
|
Context = req.Context,
|
|
TargetType = ReportTargetType.User,
|
|
TargetSnapshot = snapshot,
|
|
};
|
|
|
|
db.Reports.Add(report);
|
|
await db.SaveChangesAsync();
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("report-member/{id}")]
|
|
[Authorize("user.moderation")]
|
|
public async Task<IActionResult> ReportMemberAsync(
|
|
Snowflake id,
|
|
[FromBody] CreateReportRequest req
|
|
)
|
|
{
|
|
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
|
|
|
|
Member target = await db.ResolveMemberAsync(id);
|
|
|
|
if (target.User.Id == CurrentUser!.Id)
|
|
{
|
|
throw new ApiError(
|
|
"You can't report yourself.",
|
|
HttpStatusCode.BadRequest,
|
|
ErrorCode.InvalidReportTarget
|
|
);
|
|
}
|
|
|
|
Snowflake reportCutoff = MaxReportId();
|
|
if (
|
|
await db
|
|
.Reports.Where(r =>
|
|
r.ReporterId == CurrentUser!.Id
|
|
&& r.TargetUserId == target.User.Id
|
|
&& r.Id > reportCutoff
|
|
)
|
|
.AnyAsync()
|
|
)
|
|
{
|
|
_logger.Debug(
|
|
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
|
|
CurrentUser!.Id,
|
|
target.User.Id
|
|
);
|
|
return NoContent();
|
|
}
|
|
|
|
_logger.Information(
|
|
"Creating report on {TargetId} (member {TargetMemberId}) by {ReporterId}",
|
|
target.User.Id,
|
|
target.Id,
|
|
CurrentUser!.Id
|
|
);
|
|
|
|
string snapshot = JsonConvert.SerializeObject(memberRenderer.RenderMember(target));
|
|
|
|
var report = new Report
|
|
{
|
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
|
ReporterId = CurrentUser.Id,
|
|
TargetUserId = target.User.Id,
|
|
TargetMemberId = target.Id,
|
|
Reason = req.Reason,
|
|
Context = req.Context,
|
|
TargetType = ReportTargetType.Member,
|
|
TargetSnapshot = snapshot,
|
|
};
|
|
|
|
db.Reports.Add(report);
|
|
await db.SaveChangesAsync();
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpGet("reports")]
|
|
[Authorize("user.moderation")]
|
|
[Limit(RequireModerator = true)]
|
|
public async Task<IActionResult> GetReportsAsync(
|
|
[FromQuery] int? limit = null,
|
|
[FromQuery] Snowflake? before = null,
|
|
[FromQuery] Snowflake? after = null,
|
|
[FromQuery(Name = "by-reporter")] Snowflake? byReporter = null,
|
|
[FromQuery(Name = "by-target")] Snowflake? byTarget = null,
|
|
[FromQuery(Name = "include-closed")] bool includeClosed = false
|
|
)
|
|
{
|
|
limit = limit switch
|
|
{
|
|
> 100 => 100,
|
|
< 0 => 100,
|
|
null => 100,
|
|
_ => limit,
|
|
};
|
|
|
|
IQueryable<Report> query = db
|
|
.Reports.Include(r => r.Reporter)
|
|
.Include(r => r.TargetUser)
|
|
.Include(r => r.TargetMember);
|
|
|
|
if (byTarget != null && await db.Users.AnyAsync(u => u.Id == byTarget.Value))
|
|
query = query.Where(r => r.TargetUserId == byTarget.Value);
|
|
|
|
if (byReporter != null && await db.Users.AnyAsync(u => u.Id == byReporter.Value))
|
|
query = query.Where(r => r.ReporterId == byReporter.Value);
|
|
|
|
if (before != null)
|
|
query = query.Where(r => r.Id < before.Value).OrderByDescending(r => r.Id);
|
|
else if (after != null)
|
|
query = query.Where(r => r.Id > after.Value).OrderBy(r => r.Id);
|
|
else
|
|
query = query.OrderByDescending(r => r.Id);
|
|
|
|
if (!includeClosed)
|
|
query = query.Where(r => r.Status == ReportStatus.Open);
|
|
|
|
List<Report> reports = await query.Take(limit!.Value).ToListAsync();
|
|
|
|
return Ok(reports.Select(moderationRenderer.RenderReport));
|
|
}
|
|
|
|
[HttpPost("reports/{id}/ignore")]
|
|
[Limit(RequireModerator = true)]
|
|
public async Task<IActionResult> IgnoreReportAsync(
|
|
Snowflake id,
|
|
[FromBody] IgnoreReportRequest req
|
|
)
|
|
{
|
|
Report? report = await db.Reports.FindAsync(id);
|
|
if (report == null)
|
|
throw new ApiError.NotFound("No report with that ID found.");
|
|
if (report.Status != ReportStatus.Open)
|
|
throw new ApiError.BadRequest("That report has already been handled.");
|
|
|
|
AuditLogEntry entry = await moderationService.IgnoreReportAsync(
|
|
CurrentUser!,
|
|
report,
|
|
req.Reason
|
|
);
|
|
|
|
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
|
|
}
|
|
}
|