[backend/api] Add support for creating, forwarding & resolving reports (ISH-116)

This commit is contained in:
Laura Hausmann 2025-03-11 20:36:40 +01:00
parent 7594c652f5
commit f3999f80be
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
6 changed files with 110 additions and 27 deletions

View file

@ -20,7 +20,8 @@ public class ModerationController(
DatabaseContext db, DatabaseContext db,
NoteService noteSvc, NoteService noteSvc,
UserService userSvc, UserService userSvc,
ReportRenderer reportRenderer ReportRenderer reportRenderer,
ReportService reportSvc
) : ControllerBase ) : ControllerBase
{ {
[HttpPost("notes/{id}/delete")] [HttpPost("notes/{id}/delete")]
@ -101,4 +102,38 @@ public class ModerationController(
return await reportRenderer.RenderManyAsync(reports); 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();
}
} }

View file

@ -30,7 +30,8 @@ public class NoteController(
UserRenderer userRenderer, UserRenderer userRenderer,
CacheService cache, CacheService cache,
BiteService biteSvc, BiteService biteSvc,
PollService pollSvc PollService pollSvc,
ReportService reportSvc
) : ControllerBase ) : ControllerBase
{ {
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o => private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
@ -645,4 +646,18 @@ public class NoteController(
return await noteRenderer.RenderOne(note, user); 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);
}
} }

View file

@ -28,7 +28,8 @@ public class ActivityHandlerService(
FollowupTaskService followupTaskSvc, FollowupTaskService followupTaskSvc,
EmojiService emojiSvc, EmojiService emojiSvc,
EventService eventSvc, EventService eventSvc,
RelayService relaySvc RelayService relaySvc,
ReportService reportSvc
) : IScopedService ) : IScopedService
{ {
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId, string? authenticatedUserId) 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)) if (noteMatches.Count != 0 && noteMatches.Any(p => p.User != userMatch))
throw GracefulException.UnprocessableEntity("Refusing to process ASFlag: note author mismatch"); throw GracefulException.UnprocessableEntity("Refusing to process ASFlag: note author mismatch");
var report = new Report await reportSvc.CreateReportAsync(resolvedActor, userMatch, noteMatches, flag.Content ?? "");
{
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();
} }
private async Task UnfollowAsync(ASActor followeeActor, User follower) private async Task UnfollowAsync(ASActor followeeActor, User follower)

View file

@ -240,19 +240,11 @@ public class ActivityRenderer(
To = userRenderer.RenderLite(fallbackTo) To = userRenderer.RenderLite(fallbackTo)
}; };
public ASFlag RenderFlag(User actor, IEnumerable<Note> notes, string comment) => new() public ASFlag RenderFlag(User actor, User user, IEnumerable<Note> notes, string comment) => new()
{ {
Id = GenerateActivityId(), Id = GenerateActivityId(),
Actor = userRenderer.RenderLite(actor), Actor = userRenderer.RenderLite(actor),
Object = notes.Select(noteRenderer.RenderLite).ToArray<ASObject>(), Object = notes.Select(noteRenderer.RenderLite).Prepend<ASObject>(userRenderer.RenderLite(user)).ToArray(),
Content = comment
};
public ASFlag RenderFlag(User actor, User user, string comment) => new()
{
Id = GenerateActivityId(),
Actor = userRenderer.RenderLite(actor),
Object = [userRenderer.RenderLite(user)],
Content = comment Content = comment
}; };
} }

View file

@ -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<Note> 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();
}
}

View file

@ -0,0 +1,6 @@
namespace Iceshrimp.Shared.Schemas.Web;
public class NoteReportRequest
{
public required string Comment { get; set; }
}