feat: moderation API
This commit is contained in:
parent
79b8c4799e
commit
36cb1d2043
24 changed files with 1535 additions and 45 deletions
224
Foxnouns.Backend/Controllers/Moderation/ReportsController.cs
Normal file
224
Foxnouns.Backend/Controllers/Moderation/ReportsController.cs
Normal file
|
@ -0,0 +1,224 @@
|
|||
// 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 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
|
||||
)
|
||||
{
|
||||
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,
|
||||
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
|
||||
)
|
||||
{
|
||||
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,
|
||||
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(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)
|
||||
.OrderByDescending(r => r.Id);
|
||||
|
||||
if (before != null)
|
||||
query = query.Where(r => r.Id < before.Value);
|
||||
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));
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue