diff --git a/Iceshrimp.Backend/Controllers/Web/ModerationController.cs b/Iceshrimp.Backend/Controllers/Web/ModerationController.cs index b8ecedc3..05f86a5b 100644 --- a/Iceshrimp.Backend/Controllers/Web/ModerationController.cs +++ b/Iceshrimp.Backend/Controllers/Web/ModerationController.cs @@ -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); } -} \ No newline at end of file + + [HttpGet("reports")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest)] + [LinkPagination(20, 40)] + public async Task> 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); + } +} diff --git a/Iceshrimp.Backend/Controllers/Web/Renderers/ReportRenderer.cs b/Iceshrimp.Backend/Controllers/Web/Renderers/ReportRenderer.cs new file mode 100644 index 00000000..ce216d32 --- /dev/null +++ b/Iceshrimp.Backend/Controllers/Web/Renderers/ReportRenderer.cs @@ -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 RenderOneAsync(Report report) + { + return Render(report, await BuildDtoAsync(report)); + } + + public async Task> RenderManyAsync(IEnumerable reports) + { + var arr = reports.ToArray(); + var data = await BuildDtoAsync(arr); + return arr.Select(p => Render(p, data)); + } + + private async Task GetUsersAsync(IEnumerable users) + => await userRenderer.RenderManyAsync(users).ToArrayAsync(); + + private async Task GetNotesAsync(IEnumerable notes) + => await noteRenderer.RenderManyAsync(notes, null).ToArrayAsync(); + + private async Task 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; + } +} diff --git a/Iceshrimp.Backend/Core/Database/Tables/Report.cs b/Iceshrimp.Backend/Core/Database/Tables/Report.cs index b89857a3..6612cb34 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/Report.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/Report.cs @@ -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")] diff --git a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs index 9bbd4bb4..101841d5 100644 --- a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs @@ -655,5 +655,14 @@ public static class QueryableExtensions .Include(p => p.Bite); } + public static IQueryable IncludeCommonProperties(this IQueryable 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. } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs b/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs index f7174122..00599410 100644 --- a/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/TaskExtensions.cs @@ -61,6 +61,11 @@ public static class TaskExtensions return (await task).ToList(); } + public static async Task ToArrayAsync(this Task> task) + { + return (await task).ToArray(); + } + public static async Task ContinueWithResult(this Task task, Action continuation) { await task; diff --git a/Iceshrimp.Shared/Schemas/Web/ReportResponse.cs b/Iceshrimp.Shared/Schemas/Web/ReportResponse.cs new file mode 100644 index 00000000..80bcfbaa --- /dev/null +++ b/Iceshrimp.Shared/Schemas/Web/ReportResponse.cs @@ -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; } +}