[backend/api] Add report endpoints (ISH-116)

This commit is contained in:
Laura Hausmann 2025-03-11 18:38:07 +01:00
parent afaea61fa0
commit 7594c652f5
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
6 changed files with 133 additions and 13 deletions

View file

@ -1,9 +1,12 @@
using System.Net;
using Iceshrimp.Backend.Controllers.Shared.Attributes;
using Iceshrimp.Backend.Controllers.Shared.Schemas;
using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Iceshrimp.Shared.Schemas.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -13,15 +16,20 @@ namespace Iceshrimp.Backend.Controllers.Web;
[Authorize("role:moderator")]
[ApiController]
[Route("/api/iceshrimp/moderation")]
public class ModerationController(DatabaseContext db, NoteService noteSvc, UserService userSvc) : ControllerBase
public class ModerationController(
DatabaseContext db,
NoteService noteSvc,
UserService userSvc,
ReportRenderer reportRenderer
) : ControllerBase
{
[HttpPost("notes/{id}/delete")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteNote(string id)
{
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id) ??
throw GracefulException.NotFound("Note not found");
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id)
?? throw GracefulException.NotFound("Note not found");
await noteSvc.DeleteNoteAsync(note);
}
@ -31,8 +39,8 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task SuspendUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot suspend yourself.");
@ -45,8 +53,8 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task UnsuspendUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot unsuspend yourself.");
@ -59,8 +67,8 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task DeleteUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
if (user == HttpContext.GetUserOrFail())
throw GracefulException.BadRequest("You cannot delete yourself.");
@ -73,9 +81,24 @@ public class ModerationController(DatabaseContext db, NoteService noteSvc, UserS
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task PurgeUser(string id)
{
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser) ??
throw GracefulException.NotFound("User not found");
var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && !p.IsSystemUser)
?? throw GracefulException.NotFound("User not found");
await userSvc.PurgeUserAsync(user);
}
[HttpGet("reports")]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.BadRequest)]
[LinkPagination(20, 40)]
public async Task<IEnumerable<ReportResponse>> GetReports(PaginationQuery pq, bool resolved = false)
{
var reports = await db.Reports
.IncludeCommonProperties()
.Where(p => p.Resolved == resolved)
.Paginate(pq, ControllerContext)
.ToListAsync();
return await reportRenderer.RenderManyAsync(reports);
}
}

View file

@ -0,0 +1,66 @@
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Shared.Schemas.Web;
namespace Iceshrimp.Backend.Controllers.Web.Renderers;
public class ReportRenderer(UserRenderer userRenderer, NoteRenderer noteRenderer) : IScopedService
{
private static ReportResponse Render(Report report, ReportRendererDto data)
{
var noteIds = report.Notes.Select(i => i.Id).ToArray();
return new ReportResponse
{
Id = report.Id,
CreatedAt = report.CreatedAt,
Comment = report.Comment,
Forwarded = report.Forwarded,
Resolved = report.Resolved,
Assignee = data.Users.FirstOrDefault(p => p.Id == report.AssigneeId),
TargetUser = data.Users.First(p => p.Id == report.TargetUserId),
Reporter = data.Users.First(p => p.Id == report.ReporterId),
Notes = data.Notes.Where(p => noteIds.Contains(p.Id)).ToArray()
};
}
public async Task<ReportResponse> RenderOneAsync(Report report)
{
return Render(report, await BuildDtoAsync(report));
}
public async Task<IEnumerable<ReportResponse>> RenderManyAsync(IEnumerable<Report> reports)
{
var arr = reports.ToArray();
var data = await BuildDtoAsync(arr);
return arr.Select(p => Render(p, data));
}
private async Task<UserResponse[]> GetUsersAsync(IEnumerable<User> users)
=> await userRenderer.RenderManyAsync(users).ToArrayAsync();
private async Task<NoteResponse[]> GetNotesAsync(IEnumerable<Note> notes)
=> await noteRenderer.RenderManyAsync(notes, null).ToArrayAsync();
private async Task<ReportRendererDto> BuildDtoAsync(params Report[] reports)
{
var notes = await GetNotesAsync(reports.SelectMany(p => p.Notes));
var users = notes.Select(p => p.User).DistinctBy(p => p.Id).ToList();
var missingUsers = reports.Select(p => p.TargetUser)
.Concat(reports.Select(p => p.Assignee))
.Concat(reports.Select(p => p.Reporter))
.NotNull()
.DistinctBy(p => p.Id)
.ExceptBy(users.Select(p => p.Id), p => p.Id);
users.AddRange(await GetUsersAsync(missingUsers));
return new ReportRendererDto { Users = users.ToArray(), Notes = notes };
}
private class ReportRendererDto
{
public required UserResponse[] Users;
public required NoteResponse[] Notes;
}
}

View file

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -13,7 +14,7 @@ namespace Iceshrimp.Backend.Core.Database.Tables;
[Index(nameof(TargetUserId))]
[Index(nameof(CreatedAt))]
[Index(nameof(ReporterHost))]
public class Report
public class Report : IIdentifiable
{
[Key]
[Column("id")]

View file

@ -655,5 +655,14 @@ public static class QueryableExtensions
.Include(p => p.Bite);
}
public static IQueryable<Report> IncludeCommonProperties(this IQueryable<Report> query)
{
return query.Include(p => p.Reporter.UserProfile)
.Include(p => p.TargetUser.UserProfile)
.Include(p => p.Assignee.UserProfile)
.Include(p => p.Notes)
.ThenInclude(p => p.User.UserProfile);
}
#pragma warning restore CS8602 // Dereference of a possibly null reference.
}

View file

@ -61,6 +61,11 @@ public static class TaskExtensions
return (await task).ToList();
}
public static async Task<T[]> ToArrayAsync<T>(this Task<IEnumerable<T>> task)
{
return (await task).ToArray();
}
public static async Task ContinueWithResult(this Task task, Action continuation)
{
await task;

View file

@ -0,0 +1,16 @@
using Iceshrimp.Shared.Helpers;
namespace Iceshrimp.Shared.Schemas.Web;
public class ReportResponse : IIdentifiable
{
public required string Id { get; set; }
public required DateTime CreatedAt { get; set; }
public required UserResponse TargetUser { get; set; }
public required UserResponse Reporter { get; set; }
public required UserResponse? Assignee { get; set; }
public required NoteResponse[] Notes { get; set; }
public required bool Resolved { get; set; }
public required bool Forwarded { get; set; }
public required string Comment { get; set; }
}