feat: moderation API
This commit is contained in:
		
							parent
							
								
									79b8c4799e
								
							
						
					
					
						commit
						36cb1d2043
					
				
					 24 changed files with 1535 additions and 45 deletions
				
			
		|  | @ -0,0 +1,55 @@ | |||
| // 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 Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Middleware; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Controllers.Moderation; | ||||
| 
 | ||||
| [Route("/api/v2/moderation/audit-log")] | ||||
| [Authorize("user.moderation")] | ||||
| [Limit(RequireModerator = true)] | ||||
| public class AuditLogController(DatabaseContext db, ModerationRendererService moderationRenderer) | ||||
|     : ApiControllerBase | ||||
| { | ||||
|     public async Task<IActionResult> GetAuditLogAsync( | ||||
|         [FromQuery] AuditLogEntryType? type = null, | ||||
|         [FromQuery] int? limit = null, | ||||
|         [FromQuery] Snowflake? before = null | ||||
|     ) | ||||
|     { | ||||
|         limit = limit switch | ||||
|         { | ||||
|             > 100 => 100, | ||||
|             < 0 => 100, | ||||
|             null => 100, | ||||
|             _ => limit, | ||||
|         }; | ||||
| 
 | ||||
|         IQueryable<AuditLogEntry> query = db.AuditLog.OrderByDescending(e => e.Id); | ||||
| 
 | ||||
|         if (before != null) | ||||
|             query = query.Where(e => e.Id < before.Value); | ||||
|         if (type != null) | ||||
|             query = query.Where(e => e.Type == type); | ||||
| 
 | ||||
|         List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync(); | ||||
| 
 | ||||
|         return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										138
									
								
								Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,138 @@ | |||
| // 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; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Controllers.Moderation; | ||||
| 
 | ||||
| [Route("/api/v2/moderation")] | ||||
| [Authorize("user.moderation")] | ||||
| [Limit(RequireModerator = true)] | ||||
| public class ModActionsController( | ||||
|     DatabaseContext db, | ||||
|     ModerationService moderationService, | ||||
|     ModerationRendererService moderationRenderer | ||||
| ) : ApiControllerBase | ||||
| { | ||||
|     [HttpPost("warnings/{id}")] | ||||
|     public async Task<IActionResult> WarnUserAsync(Snowflake id, [FromBody] WarnUserRequest req) | ||||
|     { | ||||
|         User user = await db.ResolveUserAsync(id); | ||||
|         if (user.Deleted) | ||||
|         { | ||||
|             throw new ApiError( | ||||
|                 "This user is already deleted.", | ||||
|                 HttpStatusCode.BadRequest, | ||||
|                 ErrorCode.InvalidWarningTarget | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         if (user.Id == CurrentUser!.Id) | ||||
|         { | ||||
|             throw new ApiError( | ||||
|                 "You can't warn yourself.", | ||||
|                 HttpStatusCode.BadRequest, | ||||
|                 ErrorCode.InvalidWarningTarget | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         Member? member = null; | ||||
|         if (req.MemberId != null) | ||||
|         { | ||||
|             member = await db.Members.FirstOrDefaultAsync(m => | ||||
|                 m.Id == req.MemberId && m.UserId == user.Id | ||||
|             ); | ||||
|             if (member == null) | ||||
|                 throw new ApiError.NotFound("No member with that ID found."); | ||||
|         } | ||||
| 
 | ||||
|         Report? report = null; | ||||
|         if (req.ReportId != null) | ||||
|         { | ||||
|             report = await db.Reports.FindAsync(req.ReportId); | ||||
|             if (report is not { Status: ReportStatus.Open }) | ||||
|             { | ||||
|                 throw new ApiError.NotFound( | ||||
|                     "No report with that ID found, or it's already closed." | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         AuditLogEntry entry = await moderationService.ExecuteWarningAsync( | ||||
|             CurrentUser, | ||||
|             user, | ||||
|             member, | ||||
|             report, | ||||
|             req.Reason, | ||||
|             req.ClearFields | ||||
|         ); | ||||
| 
 | ||||
|         return Ok(moderationRenderer.RenderAuditLogEntry(entry)); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("suspensions/{id}")] | ||||
|     public async Task<IActionResult> SuspendUserAsync( | ||||
|         Snowflake id, | ||||
|         [FromBody] SuspendUserRequest req | ||||
|     ) | ||||
|     { | ||||
|         User user = await db.ResolveUserAsync(id); | ||||
|         if (user.Deleted) | ||||
|         { | ||||
|             throw new ApiError( | ||||
|                 "This user is already deleted.", | ||||
|                 HttpStatusCode.BadRequest, | ||||
|                 ErrorCode.InvalidWarningTarget | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         if (user.Id == CurrentUser!.Id) | ||||
|         { | ||||
|             throw new ApiError( | ||||
|                 "You can't warn yourself.", | ||||
|                 HttpStatusCode.BadRequest, | ||||
|                 ErrorCode.InvalidWarningTarget | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         Report? report = null; | ||||
|         if (req.ReportId != null) | ||||
|         { | ||||
|             report = await db.Reports.FindAsync(req.ReportId); | ||||
|             if (report is not { Status: ReportStatus.Open }) | ||||
|             { | ||||
|                 throw new ApiError.NotFound( | ||||
|                     "No report with that ID found, or it's already closed." | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         AuditLogEntry entry = await moderationService.ExecuteSuspensionAsync( | ||||
|             CurrentUser, | ||||
|             user, | ||||
|             report, | ||||
|             req.Reason, | ||||
|             req.ClearProfile | ||||
|         ); | ||||
| 
 | ||||
|         return Ok(moderationRenderer.RenderAuditLogEntry(entry)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										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)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										52
									
								
								Foxnouns.Backend/Controllers/NotificationsController.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								Foxnouns.Backend/Controllers/NotificationsController.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,52 @@ | |||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Middleware; | ||||
| using Foxnouns.Backend.Services; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Controllers; | ||||
| 
 | ||||
| [Route("/api/v2/notifications")] | ||||
| public class NotificationsController( | ||||
|     DatabaseContext db, | ||||
|     ModerationRendererService moderationRenderer, | ||||
|     IClock clock | ||||
| ) : ApiControllerBase | ||||
| { | ||||
|     [HttpGet] | ||||
|     [Authorize("user.moderation")] | ||||
|     [Limit(UsableBySuspendedUsers = true)] | ||||
|     public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false) | ||||
|     { | ||||
|         IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id); | ||||
|         if (!all) | ||||
|             query = query.Where(n => n.AcknowledgedAt == null); | ||||
| 
 | ||||
|         List<Notification> notifications = await query.OrderByDescending(n => n.Id).ToListAsync(); | ||||
| 
 | ||||
|         return Ok(notifications.Select(moderationRenderer.RenderNotification)); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPut("{id}/ack")] | ||||
|     [Authorize("user.moderation")] | ||||
|     [Limit(UsableBySuspendedUsers = true)] | ||||
|     public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id) | ||||
|     { | ||||
|         Notification? notification = await db.Notifications.FirstOrDefaultAsync(n => | ||||
|             n.TargetId == CurrentUser!.Id && n.Id == id | ||||
|         ); | ||||
|         if (notification == null) | ||||
|             throw new ApiError.NotFound("Notification not found."); | ||||
| 
 | ||||
|         if (notification.AcknowledgedAt != null) | ||||
|             return Ok(moderationRenderer.RenderNotification(notification)); | ||||
| 
 | ||||
|         notification.AcknowledgedAt = clock.GetCurrentInstant(); | ||||
|         db.Update(notification); | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         return Ok(moderationRenderer.RenderNotification(notification)); | ||||
|     } | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue