From f3999f80be415e9fb465834527fb72d4cccf55d4 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Tue, 11 Mar 2025 20:36:40 +0100 Subject: [PATCH] [backend/api] Add support for creating, forwarding & resolving reports (ISH-116) --- .../Controllers/Web/ModerationController.cs | 37 ++++++++++++++- .../Controllers/Web/NoteController.cs | 17 ++++++- .../ActivityPub/ActivityHandlerService.cs | 18 ++----- .../ActivityPub/ActivityRenderer.cs | 12 +---- .../Core/Services/ReportService.cs | 47 +++++++++++++++++++ .../Schemas/Web/NoteReportRequest.cs | 6 +++ 6 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 Iceshrimp.Backend/Core/Services/ReportService.cs create mode 100644 Iceshrimp.Shared/Schemas/Web/NoteReportRequest.cs diff --git a/Iceshrimp.Backend/Controllers/Web/ModerationController.cs b/Iceshrimp.Backend/Controllers/Web/ModerationController.cs index 05f86a5b..1660406a 100644 --- a/Iceshrimp.Backend/Controllers/Web/ModerationController.cs +++ b/Iceshrimp.Backend/Controllers/Web/ModerationController.cs @@ -20,7 +20,8 @@ public class ModerationController( DatabaseContext db, NoteService noteSvc, UserService userSvc, - ReportRenderer reportRenderer + ReportRenderer reportRenderer, + ReportService reportSvc ) : ControllerBase { [HttpPost("notes/{id}/delete")] @@ -101,4 +102,38 @@ public class ModerationController( return await reportRenderer.RenderManyAsync(reports); } + + [HttpPost("reports/{id}/resolve")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task ResolveReport(string id) + { + var report = await db.Reports.FirstOrDefaultAsync(p => p.Id == id) + ?? throw GracefulException.NotFound("Report not found"); + + report.Resolved = true; + await db.SaveChangesAsync(); + } + + [HttpPost("reports/{id}/forward")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] + public async Task ForwardReport(string id, [FromBody] NoteReportRequest? request) + { + var report = await db.Reports + .Include(p => p.TargetUser) + .Include(p => p.Notes) + .FirstOrDefaultAsync(p => p.Id == id) + ?? throw GracefulException.NotFound("Report not found"); + + if (report.TargetUserHost == null) + throw GracefulException.BadRequest("Cannot forward report to local instance"); + if (report.Forwarded) + return; + + await reportSvc.ForwardReportAsync(report, request?.Comment); + + report.Forwarded = true; + await db.SaveChangesAsync(); + } } diff --git a/Iceshrimp.Backend/Controllers/Web/NoteController.cs b/Iceshrimp.Backend/Controllers/Web/NoteController.cs index 26cc0a89..bb9f3564 100644 --- a/Iceshrimp.Backend/Controllers/Web/NoteController.cs +++ b/Iceshrimp.Backend/Controllers/Web/NoteController.cs @@ -30,7 +30,8 @@ public class NoteController( UserRenderer userRenderer, CacheService cache, BiteService biteSvc, - PollService pollSvc + PollService pollSvc, + ReportService reportSvc ) : ControllerBase { private static readonly AsyncKeyedLocker KeyedLocker = new(o => @@ -645,4 +646,18 @@ public class NoteController( return await noteRenderer.RenderOne(note, user); } + + [HttpPost("{id}/report")] + [Authenticate] + [Authorize] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task ReportNote(string id, [FromBody] NoteReportRequest request) + { + var user = HttpContext.GetUserOrFail(); + var note = await db.Notes.Include(p => p.User).EnsureVisibleFor(user).FirstOrDefaultAsync(p => p.Id == id) + ?? throw GracefulException.NotFound("Note not found"); + + await reportSvc.CreateReportAsync(user, note.User, [note], request.Comment); + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index a5211448..da87784d 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -28,7 +28,8 @@ public class ActivityHandlerService( FollowupTaskService followupTaskSvc, EmojiService emojiSvc, EventService eventSvc, - RelayService relaySvc + RelayService relaySvc, + ReportService reportSvc ) : IScopedService { public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId, string? authenticatedUserId) @@ -556,20 +557,7 @@ public class ActivityHandlerService( if (noteMatches.Count != 0 && noteMatches.Any(p => p.User != userMatch)) throw GracefulException.UnprocessableEntity("Refusing to process ASFlag: note author mismatch"); - var report = new Report - { - Id = IdHelpers.GenerateSnowflakeId(), - CreatedAt = DateTime.UtcNow, - TargetUser = userMatch, - TargetUserHost = userMatch.Host, - Reporter = resolvedActor, - ReporterHost = resolvedActor.Host, - Notes = noteMatches, - Comment = flag.Content ?? "" - }; - - db.Add(report); - await db.SaveChangesAsync(); + await reportSvc.CreateReportAsync(resolvedActor, userMatch, noteMatches, flag.Content ?? ""); } private async Task UnfollowAsync(ASActor followeeActor, User follower) diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs index 72d73601..978a1c43 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs @@ -240,19 +240,11 @@ public class ActivityRenderer( To = userRenderer.RenderLite(fallbackTo) }; - public ASFlag RenderFlag(User actor, IEnumerable notes, string comment) => new() + public ASFlag RenderFlag(User actor, User user, IEnumerable notes, string comment) => new() { Id = GenerateActivityId(), Actor = userRenderer.RenderLite(actor), - Object = notes.Select(noteRenderer.RenderLite).ToArray(), - Content = comment - }; - - public ASFlag RenderFlag(User actor, User user, string comment) => new() - { - Id = GenerateActivityId(), - Actor = userRenderer.RenderLite(actor), - Object = [userRenderer.RenderLite(user)], + Object = notes.Select(noteRenderer.RenderLite).Prepend(userRenderer.RenderLite(user)).ToArray(), Content = comment }; } diff --git a/Iceshrimp.Backend/Core/Services/ReportService.cs b/Iceshrimp.Backend/Core/Services/ReportService.cs new file mode 100644 index 00000000..5894b4f6 --- /dev/null +++ b/Iceshrimp.Backend/Core/Services/ReportService.cs @@ -0,0 +1,47 @@ +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Extensions; +using Iceshrimp.Backend.Core.Helpers; + +namespace Iceshrimp.Backend.Core.Services; + +public class ReportService( + ActivityPub.ActivityDeliverService deliverSvc, + ActivityPub.ActivityRenderer activityRenderer, + SystemUserService sysUserSvc, + DatabaseContext db +) : IScopedService +{ + public async Task ForwardReportAsync(Report report, string? comment) + { + if (report.TargetUser.IsLocalUser) + throw new Exception("Refusing to forward report to local instance"); + + var actor = await sysUserSvc.GetInstanceActorAsync(); + var activity = activityRenderer.RenderFlag(actor, report.TargetUser, report.Notes, comment ?? report.Comment); + + var inbox = report.TargetUser.SharedInbox + ?? report.TargetUser.Inbox + ?? throw new Exception("Target user does not have inbox"); + + await deliverSvc.DeliverToAsync(activity, actor, inbox); + } + + public async Task CreateReportAsync(User reporter, User target, IEnumerable notes, string comment) + { + var report = new Report + { + Id = IdHelpers.GenerateSnowflakeId(), + CreatedAt = DateTime.UtcNow, + TargetUser = target, + TargetUserHost = target.Host, + Reporter = reporter, + ReporterHost = reporter.Host, + Notes = notes.ToArray(), + Comment = comment + }; + + db.Add(report); + await db.SaveChangesAsync(); + } +} diff --git a/Iceshrimp.Shared/Schemas/Web/NoteReportRequest.cs b/Iceshrimp.Shared/Schemas/Web/NoteReportRequest.cs new file mode 100644 index 00000000..a8d74227 --- /dev/null +++ b/Iceshrimp.Shared/Schemas/Web/NoteReportRequest.cs @@ -0,0 +1,6 @@ +namespace Iceshrimp.Shared.Schemas.Web; + +public class NoteReportRequest +{ + public required string Comment { get; set; } +}