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)); | ||||
|     } | ||||
| } | ||||
|  | @ -71,6 +71,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) | |||
|     public DbSet<UserFlag> UserFlags { get; init; } = null!; | ||||
|     public DbSet<MemberFlag> MemberFlags { get; init; } = null!; | ||||
| 
 | ||||
|     public DbSet<Report> Reports { get; init; } = null!; | ||||
|     public DbSet<AuditLogEntry> AuditLog { get; init; } = null!; | ||||
|     public DbSet<Notification> Notifications { get; init; } = null!; | ||||
| 
 | ||||
|     protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) | ||||
|     { | ||||
|         // Snowflakes are stored as longs | ||||
|  |  | |||
|  | @ -0,0 +1,161 @@ | |||
| using System.Collections.Generic; | ||||
| using Microsoft.EntityFrameworkCore.Infrastructure; | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
| using NodaTime; | ||||
| 
 | ||||
| #nullable disable | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [DbContext(typeof(DatabaseContext))] | ||||
|     [Migration("20241217010207_AddReports")] | ||||
|     public partial class AddReports : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AlterDatabase().Annotation("Npgsql:PostgresExtension:hstore", ",,"); | ||||
| 
 | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "notifications", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     target_id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     type = table.Column<int>(type: "integer", nullable: false), | ||||
|                     message = table.Column<string>(type: "text", nullable: true), | ||||
|                     localization_key = table.Column<string>(type: "text", nullable: true), | ||||
|                     localization_params = table.Column<Dictionary<string, string>>( | ||||
|                         type: "hstore", | ||||
|                         nullable: false | ||||
|                     ), | ||||
|                     acknowledged_at = table.Column<Instant>( | ||||
|                         type: "timestamp with time zone", | ||||
|                         nullable: true | ||||
|                     ), | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_notifications", x => x.id); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_notifications_users_target_id", | ||||
|                         column: x => x.target_id, | ||||
|                         principalTable: "users", | ||||
|                         principalColumn: "id", | ||||
|                         onDelete: ReferentialAction.Cascade | ||||
|                     ); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "reports", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     reporter_id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     target_user_id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     target_member_id = table.Column<long>(type: "bigint", nullable: true), | ||||
|                     status = table.Column<int>(type: "integer", nullable: false), | ||||
|                     reason = table.Column<int>(type: "integer", nullable: false), | ||||
|                     target_type = table.Column<int>(type: "integer", nullable: false), | ||||
|                     target_snapshot = table.Column<string>(type: "text", nullable: true), | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_reports", x => x.id); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_reports_members_target_member_id", | ||||
|                         column: x => x.target_member_id, | ||||
|                         principalTable: "members", | ||||
|                         principalColumn: "id" | ||||
|                     ); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_reports_users_reporter_id", | ||||
|                         column: x => x.reporter_id, | ||||
|                         principalTable: "users", | ||||
|                         principalColumn: "id", | ||||
|                         onDelete: ReferentialAction.Cascade | ||||
|                     ); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_reports_users_target_user_id", | ||||
|                         column: x => x.target_user_id, | ||||
|                         principalTable: "users", | ||||
|                         principalColumn: "id", | ||||
|                         onDelete: ReferentialAction.Cascade | ||||
|                     ); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateTable( | ||||
|                 name: "audit_log", | ||||
|                 columns: table => new | ||||
|                 { | ||||
|                     id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     moderator_id = table.Column<long>(type: "bigint", nullable: false), | ||||
|                     moderator_username = table.Column<string>(type: "text", nullable: false), | ||||
|                     target_user_id = table.Column<long>(type: "bigint", nullable: true), | ||||
|                     target_username = table.Column<string>(type: "text", nullable: true), | ||||
|                     target_member_id = table.Column<long>(type: "bigint", nullable: true), | ||||
|                     target_member_name = table.Column<string>(type: "text", nullable: true), | ||||
|                     report_id = table.Column<long>(type: "bigint", nullable: true), | ||||
|                     type = table.Column<int>(type: "integer", nullable: false), | ||||
|                     reason = table.Column<string>(type: "text", nullable: true), | ||||
|                     cleared_fields = table.Column<string[]>(type: "text[]", nullable: true), | ||||
|                 }, | ||||
|                 constraints: table => | ||||
|                 { | ||||
|                     table.PrimaryKey("pk_audit_log", x => x.id); | ||||
|                     table.ForeignKey( | ||||
|                         name: "fk_audit_log_reports_report_id", | ||||
|                         column: x => x.report_id, | ||||
|                         principalTable: "reports", | ||||
|                         principalColumn: "id" | ||||
|                     ); | ||||
|                 } | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_audit_log_report_id", | ||||
|                 table: "audit_log", | ||||
|                 column: "report_id" | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_notifications_target_id", | ||||
|                 table: "notifications", | ||||
|                 column: "target_id" | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_reports_reporter_id", | ||||
|                 table: "reports", | ||||
|                 column: "reporter_id" | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_reports_target_member_id", | ||||
|                 table: "reports", | ||||
|                 column: "target_member_id" | ||||
|             ); | ||||
| 
 | ||||
|             migrationBuilder.CreateIndex( | ||||
|                 name: "ix_reports_target_user_id", | ||||
|                 table: "reports", | ||||
|                 column: "target_user_id" | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropTable(name: "audit_log"); | ||||
| 
 | ||||
|             migrationBuilder.DropTable(name: "notifications"); | ||||
| 
 | ||||
|             migrationBuilder.DropTable(name: "reports"); | ||||
| 
 | ||||
|             migrationBuilder.AlterDatabase().OldAnnotation("Npgsql:PostgresExtension:hstore", ",,"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -22,6 +22,7 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                 .HasAnnotation("ProductVersion", "9.0.0") | ||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||
| 
 | ||||
|             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); | ||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => | ||||
|  | @ -61,6 +62,62 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                     b.ToTable("applications", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.PrimitiveCollection<string[]>("ClearedFields") | ||||
|                         .HasColumnType("text[]") | ||||
|                         .HasColumnName("cleared_fields"); | ||||
| 
 | ||||
|                     b.Property<long>("ModeratorId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("moderator_id"); | ||||
| 
 | ||||
|                     b.Property<string>("ModeratorUsername") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("moderator_username"); | ||||
| 
 | ||||
|                     b.Property<string>("Reason") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("reason"); | ||||
| 
 | ||||
|                     b.Property<long?>("ReportId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("report_id"); | ||||
| 
 | ||||
|                     b.Property<long?>("TargetMemberId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_member_id"); | ||||
| 
 | ||||
|                     b.Property<string>("TargetMemberName") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("target_member_name"); | ||||
| 
 | ||||
|                     b.Property<long?>("TargetUserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_user_id"); | ||||
| 
 | ||||
|                     b.Property<string>("TargetUsername") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("target_username"); | ||||
| 
 | ||||
|                     b.Property<int>("Type") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("type"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_audit_log"); | ||||
| 
 | ||||
|                     b.HasIndex("ReportId") | ||||
|                         .HasDatabaseName("ix_audit_log_report_id"); | ||||
| 
 | ||||
|                     b.ToTable("audit_log", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|  | @ -270,6 +327,45 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                     b.ToTable("member_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<Instant?>("AcknowledgedAt") | ||||
|                         .HasColumnType("timestamp with time zone") | ||||
|                         .HasColumnName("acknowledged_at"); | ||||
| 
 | ||||
|                     b.Property<string>("LocalizationKey") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("localization_key"); | ||||
| 
 | ||||
|                     b.Property<Dictionary<string, string>>("LocalizationParams") | ||||
|                         .HasColumnType("hstore") | ||||
|                         .HasColumnName("localization_params"); | ||||
| 
 | ||||
|                     b.Property<string>("Message") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("message"); | ||||
| 
 | ||||
|                     b.Property<long>("TargetId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_id"); | ||||
| 
 | ||||
|                     b.Property<int>("Type") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("type"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_notifications"); | ||||
| 
 | ||||
|                     b.HasIndex("TargetId") | ||||
|                         .HasDatabaseName("ix_notifications_target_id"); | ||||
| 
 | ||||
|                     b.ToTable("notifications", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|  | @ -302,6 +398,55 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                     b.ToTable("pride_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("id"); | ||||
| 
 | ||||
|                     b.Property<int>("Reason") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("reason"); | ||||
| 
 | ||||
|                     b.Property<long>("ReporterId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("reporter_id"); | ||||
|                      | ||||
|                     b.Property<int>("Status") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("status"); | ||||
| 
 | ||||
|                     b.Property<long?>("TargetMemberId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_member_id"); | ||||
| 
 | ||||
|                     b.Property<string>("TargetSnapshot") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("target_snapshot"); | ||||
| 
 | ||||
|                     b.Property<int>("TargetType") | ||||
|                         .HasColumnType("integer") | ||||
|                         .HasColumnName("target_type"); | ||||
| 
 | ||||
|                     b.Property<long>("TargetUserId") | ||||
|                         .HasColumnType("bigint") | ||||
|                         .HasColumnName("target_user_id"); | ||||
| 
 | ||||
|                     b.HasKey("Id") | ||||
|                         .HasName("pk_reports"); | ||||
| 
 | ||||
|                     b.HasIndex("ReporterId") | ||||
|                         .HasDatabaseName("ix_reports_reporter_id"); | ||||
| 
 | ||||
|                     b.HasIndex("TargetMemberId") | ||||
|                         .HasDatabaseName("ix_reports_target_member_id"); | ||||
| 
 | ||||
|                     b.HasIndex("TargetUserId") | ||||
|                         .HasDatabaseName("ix_reports_target_user_id"); | ||||
| 
 | ||||
|                     b.ToTable("reports", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => | ||||
|                 { | ||||
|                     b.Property<long>("Id") | ||||
|  | @ -522,6 +667,16 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                     b.ToTable("user_flags", (string)null); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ReportId") | ||||
|                         .HasConstraintName("fk_audit_log_reports_report_id"); | ||||
| 
 | ||||
|                     b.Navigation("Report"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") | ||||
|  | @ -584,6 +739,18 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                     b.Navigation("PrideFlag"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Target") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_notifications_users_target_id"); | ||||
| 
 | ||||
|                     b.Navigation("Target"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", null) | ||||
|  | @ -594,6 +761,34 @@ namespace Foxnouns.Backend.Database.Migrations | |||
|                         .HasConstraintName("fk_pride_flags_users_user_id"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("ReporterId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_reports_users_reporter_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetMemberId") | ||||
|                         .HasConstraintName("fk_reports_members_target_member_id"); | ||||
| 
 | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser") | ||||
|                         .WithMany() | ||||
|                         .HasForeignKey("TargetUserId") | ||||
|                         .OnDelete(DeleteBehavior.Cascade) | ||||
|                         .IsRequired() | ||||
|                         .HasConstraintName("fk_reports_users_target_user_id"); | ||||
| 
 | ||||
|                     b.Navigation("Reporter"); | ||||
| 
 | ||||
|                     b.Navigation("TargetMember"); | ||||
| 
 | ||||
|                     b.Navigation("TargetUser"); | ||||
|                 }); | ||||
| 
 | ||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => | ||||
|                 { | ||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") | ||||
|  |  | |||
							
								
								
									
										43
									
								
								Foxnouns.Backend/Database/Models/AuditLogEntry.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								Foxnouns.Backend/Database/Models/AuditLogEntry.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| // 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.Utils; | ||||
| using Newtonsoft.Json; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| public class AuditLogEntry : BaseModel | ||||
| { | ||||
|     public Snowflake ModeratorId { get; init; } | ||||
|     public string ModeratorUsername { get; init; } = string.Empty; | ||||
|     public Snowflake? TargetUserId { get; init; } | ||||
|     public string? TargetUsername { get; init; } | ||||
|     public Snowflake? TargetMemberId { get; init; } | ||||
|     public string? TargetMemberName { get; init; } | ||||
|     public Snowflake? ReportId { get; init; } | ||||
|     public Report? Report { get; init; } | ||||
| 
 | ||||
|     public AuditLogEntryType Type { get; init; } | ||||
|     public string? Reason { get; init; } | ||||
|     public string[]? ClearedFields { get; init; } | ||||
| } | ||||
| 
 | ||||
| [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] | ||||
| public enum AuditLogEntryType | ||||
| { | ||||
|     IgnoreReport, | ||||
|     WarnUser, | ||||
|     WarnUserAndClearProfile, | ||||
|     SuspendUser, | ||||
| } | ||||
							
								
								
									
										41
									
								
								Foxnouns.Backend/Database/Models/Notification.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								Foxnouns.Backend/Database/Models/Notification.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | |||
| // 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.Utils; | ||||
| using Newtonsoft.Json; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| public class Notification : BaseModel | ||||
| { | ||||
|     public Snowflake TargetId { get; init; } | ||||
|     public User Target { get; init; } = null!; | ||||
| 
 | ||||
|     public NotificationType Type { get; init; } | ||||
| 
 | ||||
|     public string? Message { get; init; } | ||||
|     public string? LocalizationKey { get; init; } | ||||
|     public Dictionary<string, string> LocalizationParams { get; init; } = []; | ||||
| 
 | ||||
|     public Instant? AcknowledgedAt { get; set; } | ||||
| } | ||||
| 
 | ||||
| [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] | ||||
| public enum NotificationType | ||||
| { | ||||
|     Notice, | ||||
|     Warning, | ||||
|     Suspension, | ||||
| } | ||||
							
								
								
									
										73
									
								
								Foxnouns.Backend/Database/Models/Report.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								Foxnouns.Backend/Database/Models/Report.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | |||
| // 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.Utils; | ||||
| using Newtonsoft.Json; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Database.Models; | ||||
| 
 | ||||
| public class Report : BaseModel | ||||
| { | ||||
|     public Snowflake ReporterId { get; init; } | ||||
|     public User Reporter { get; init; } = null!; | ||||
|     public Snowflake TargetUserId { get; init; } | ||||
|     public User TargetUser { get; init; } = null!; | ||||
| 
 | ||||
|     public Snowflake? TargetMemberId { get; init; } | ||||
|     public Member? TargetMember { get; init; } | ||||
| 
 | ||||
|     public ReportStatus Status { get; set; } | ||||
|     public ReportReason Reason { get; init; } | ||||
| 
 | ||||
|     public ReportTargetType TargetType { get; init; } | ||||
|     public string? TargetSnapshot { get; init; } | ||||
| } | ||||
| 
 | ||||
| [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] | ||||
| public enum ReportTargetType | ||||
| { | ||||
|     User, | ||||
|     Member, | ||||
| } | ||||
| 
 | ||||
| [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] | ||||
| public enum ReportStatus | ||||
| { | ||||
|     Open, | ||||
|     Closed, | ||||
| } | ||||
| 
 | ||||
| [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] | ||||
| public enum ReportReason | ||||
| { | ||||
|     Totalitarianism, | ||||
|     HateSpeech, | ||||
|     Racism, | ||||
|     Homophobia, | ||||
|     Transphobia, | ||||
|     Queerphobia, | ||||
|     Exclusionism, | ||||
|     Sexism, | ||||
|     Ableism, | ||||
|     ChildPornography, | ||||
|     PedophiliaAdvocacy, | ||||
|     Harassment, | ||||
|     Impersonation, | ||||
|     Doxxing, | ||||
|     EncouragingSelfHarm, | ||||
|     Spam, | ||||
|     Trolling, | ||||
|     Advertisement, | ||||
|     CopyrightViolation, | ||||
| } | ||||
							
								
								
									
										84
									
								
								Foxnouns.Backend/Dto/Moderation.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								Foxnouns.Backend/Dto/Moderation.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,84 @@ | |||
| // 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/>. | ||||
| 
 | ||||
| // ReSharper disable NotAccessedPositionalProperty.Global | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Newtonsoft.Json; | ||||
| using Newtonsoft.Json.Linq; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Dto; | ||||
| 
 | ||||
| public record ReportResponse( | ||||
|     Snowflake Id, | ||||
|     PartialUser Reporter, | ||||
|     PartialUser TargetUser, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||
|         PartialMember? TargetMember, | ||||
|     ReportStatus Status, | ||||
|     ReportReason Reason, | ||||
|     ReportTargetType TargetType, | ||||
|     JObject? Snapshot | ||||
| ); | ||||
| 
 | ||||
| public record AuditLogResponse( | ||||
|     Snowflake Id, | ||||
|     AuditLogEntity Moderator, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||
|         AuditLogEntity? TargetUser, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||
|         AuditLogEntity? TargetMember, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ReportId, | ||||
|     AuditLogEntryType Type, | ||||
|     string? Reason, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields | ||||
| ); | ||||
| 
 | ||||
| public record NotificationResponse( | ||||
|     Snowflake Id, | ||||
|     NotificationType Type, | ||||
|     string? Message, | ||||
|     string? LocalizationKey, | ||||
|     Dictionary<string, string> LocalizationParams, | ||||
|     bool Acknowledged | ||||
| ); | ||||
| 
 | ||||
| public record AuditLogEntity(Snowflake Id, string Username); | ||||
| 
 | ||||
| public record CreateReportRequest(ReportReason Reason); | ||||
| 
 | ||||
| public record IgnoreReportRequest(string? Reason = null); | ||||
| 
 | ||||
| public record WarnUserRequest( | ||||
|     string Reason, | ||||
|     FieldsToClear[]? ClearFields = null, | ||||
|     Snowflake? MemberId = null, | ||||
|     Snowflake? ReportId = null | ||||
| ); | ||||
| 
 | ||||
| public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null); | ||||
| 
 | ||||
| public enum FieldsToClear | ||||
| { | ||||
|     DisplayName, | ||||
|     Avatar, | ||||
|     Bio, | ||||
|     Links, | ||||
|     Names, | ||||
|     Pronouns, | ||||
|     Fields, | ||||
|     Flags, | ||||
|     CustomPreferences, | ||||
| } | ||||
|  | @ -47,7 +47,8 @@ public record UserResponse( | |||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone, | ||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted | ||||
| ); | ||||
| 
 | ||||
| public record AuthMethodResponse( | ||||
|  |  | |||
|  | @ -166,6 +166,8 @@ public enum ErrorCode | |||
|     MemberNotFound, | ||||
|     AccountAlreadyLinked, | ||||
|     LastAuthMethod, | ||||
|     InvalidReportTarget, | ||||
|     InvalidWarningTarget, | ||||
| } | ||||
| 
 | ||||
| public class ValidationError | ||||
|  |  | |||
|  | @ -113,6 +113,8 @@ public static class WebApplicationExtensions | |||
|                     .AddSingleton<EmailRateLimiter>() | ||||
|                     .AddScoped<UserRendererService>() | ||||
|                     .AddScoped<MemberRendererService>() | ||||
|                     .AddScoped<ModerationRendererService>() | ||||
|                     .AddScoped<ModerationService>() | ||||
|                     .AddScoped<AuthService>() | ||||
|                     .AddScoped<KeyCacheService>() | ||||
|                     .AddScoped<RemoteAuthService>() | ||||
|  | @ -139,11 +141,13 @@ public static class WebApplicationExtensions | |||
|         services | ||||
|             .AddScoped<ErrorHandlerMiddleware>() | ||||
|             .AddScoped<AuthenticationMiddleware>() | ||||
|             .AddScoped<LimitMiddleware>() | ||||
|             .AddScoped<AuthorizationMiddleware>(); | ||||
| 
 | ||||
|     public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => | ||||
|         app.UseMiddleware<ErrorHandlerMiddleware>() | ||||
|             .UseMiddleware<AuthenticationMiddleware>() | ||||
|             .UseMiddleware<LimitMiddleware>() | ||||
|             .UseMiddleware<AuthorizationMiddleware>(); | ||||
| 
 | ||||
|     public static async Task Initialize(this WebApplication app, string[] args) | ||||
|  |  | |||
|  | @ -22,17 +22,16 @@ public class AuthorizationMiddleware : IMiddleware | |||
|     public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) | ||||
|     { | ||||
|         Endpoint? endpoint = ctx.GetEndpoint(); | ||||
|         AuthorizeAttribute? authorizeAttribute = | ||||
|             endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); | ||||
|         LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>(); | ||||
|         AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); | ||||
| 
 | ||||
|         if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0) | ||||
|         if (attribute == null || attribute.Scopes.Length == 0) | ||||
|         { | ||||
|             await next(ctx); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         Token? token = ctx.GetToken(); | ||||
| 
 | ||||
|         if (token == null) | ||||
|         { | ||||
|             throw new ApiError.Unauthorized( | ||||
|  | @ -41,40 +40,15 @@ public class AuthorizationMiddleware : IMiddleware | |||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         // Users who got suspended by a moderator can still access *some* endpoints. | ||||
|         if ( | ||||
|             token.User.Deleted | ||||
|             && (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null) | ||||
|         ) | ||||
|         { | ||||
|             throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); | ||||
|         } | ||||
| 
 | ||||
|         if ( | ||||
|             authorizeAttribute.Scopes.Length > 0 | ||||
|             && authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() | ||||
|         ) | ||||
|         if (attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) | ||||
|         { | ||||
|             throw new ApiError.Forbidden( | ||||
|                 "This endpoint requires ungranted scopes.", | ||||
|                 authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()), | ||||
|                 attribute.Scopes.Except(token.Scopes.ExpandScopes()), | ||||
|                 ErrorCode.MissingScopes | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin) | ||||
|         { | ||||
|             throw new ApiError.Forbidden("This endpoint can only be used by admins."); | ||||
|         } | ||||
| 
 | ||||
|         if ( | ||||
|             limitAttribute?.RequireModerator == true | ||||
|             && token.User.Role is not (UserRole.Admin or UserRole.Moderator) | ||||
|         ) | ||||
|         { | ||||
|             throw new ApiError.Forbidden("This endpoint can only be used by moderators."); | ||||
|         } | ||||
| 
 | ||||
|         await next(ctx); | ||||
|     } | ||||
| } | ||||
|  | @ -84,11 +58,3 @@ public class AuthorizeAttribute(params string[] scopes) : Attribute | |||
| { | ||||
|     public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray(); | ||||
| } | ||||
| 
 | ||||
| [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] | ||||
| public class LimitAttribute : Attribute | ||||
| { | ||||
|     public bool UsableBySuspendedUsers { get; init; } | ||||
|     public bool RequireAdmin { get; init; } | ||||
|     public bool RequireModerator { get; init; } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										64
									
								
								Foxnouns.Backend/Middleware/LimitMiddleware.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								Foxnouns.Backend/Middleware/LimitMiddleware.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| // 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.Models; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Middleware; | ||||
| 
 | ||||
| public class LimitMiddleware : IMiddleware | ||||
| { | ||||
|     public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) | ||||
|     { | ||||
|         Endpoint? endpoint = ctx.GetEndpoint(); | ||||
|         LimitAttribute? attribute = endpoint?.Metadata.GetMetadata<LimitAttribute>(); | ||||
| 
 | ||||
|         if (attribute == null) | ||||
|         { | ||||
|             await next(ctx); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         Token? token = ctx.GetToken(); | ||||
|         if ( | ||||
|             token?.User.Deleted == true | ||||
|             && (!attribute.UsableBySuspendedUsers || token.User.DeletedBy == null) | ||||
|         ) | ||||
|         { | ||||
|             throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); | ||||
|         } | ||||
| 
 | ||||
|         if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin) | ||||
|         { | ||||
|             throw new ApiError.Forbidden("This endpoint can only be used by admins."); | ||||
|         } | ||||
| 
 | ||||
|         if ( | ||||
|             attribute.RequireModerator | ||||
|             && token?.User.Role is not (UserRole.Admin or UserRole.Moderator) | ||||
|         ) | ||||
|         { | ||||
|             throw new ApiError.Forbidden("This endpoint can only be used by moderators."); | ||||
|         } | ||||
| 
 | ||||
|         await next(ctx); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] | ||||
| public class LimitAttribute : Attribute | ||||
| { | ||||
|     public bool UsableBySuspendedUsers { get; init; } | ||||
|     public bool RequireAdmin { get; init; } | ||||
|     public bool RequireModerator { get; init; } | ||||
| } | ||||
							
								
								
									
										73
									
								
								Foxnouns.Backend/Services/ModerationRendererService.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								Foxnouns.Backend/Services/ModerationRendererService.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | |||
| // 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.Dto; | ||||
| using Newtonsoft.Json; | ||||
| using Newtonsoft.Json.Linq; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Services; | ||||
| 
 | ||||
| public class ModerationRendererService( | ||||
|     DatabaseContext db, | ||||
|     UserRendererService userRenderer, | ||||
|     MemberRendererService memberRenderer | ||||
| ) | ||||
| { | ||||
|     public ReportResponse RenderReport(Report report) | ||||
|     { | ||||
|         return new ReportResponse( | ||||
|             report.Id, | ||||
|             userRenderer.RenderPartialUser(report.Reporter), | ||||
|             userRenderer.RenderPartialUser(report.TargetUser), | ||||
|             report.TargetMemberId != null | ||||
|                 ? memberRenderer.RenderPartialMember(report.TargetMember!) | ||||
|                 : null, | ||||
|             report.Status, | ||||
|             report.Reason, | ||||
|             report.TargetType, | ||||
|             report.TargetSnapshot != null | ||||
|                 ? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot) | ||||
|                 : null | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry) | ||||
|     { | ||||
|         return new AuditLogResponse( | ||||
|             Id: entry.Id, | ||||
|             Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!, | ||||
|             TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername), | ||||
|             TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName), | ||||
|             ReportId: entry.ReportId, | ||||
|             Type: entry.Type, | ||||
|             Reason: entry.Reason, | ||||
|             ClearedFields: entry.ClearedFields | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public NotificationResponse RenderNotification(Notification notification) => | ||||
|         new( | ||||
|             notification.Id, | ||||
|             notification.Type, | ||||
|             notification.Message, | ||||
|             notification.LocalizationKey, | ||||
|             notification.LocalizationParams, | ||||
|             notification.AcknowledgedAt != null | ||||
|         ); | ||||
| 
 | ||||
|     private static AuditLogEntity? ToEntity(Snowflake? id, string? username) => | ||||
|         id != null && username != null ? new AuditLogEntity(id.Value, username) : null; | ||||
| } | ||||
							
								
								
									
										292
									
								
								Foxnouns.Backend/Services/ModerationService.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								Foxnouns.Backend/Services/ModerationService.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,292 @@ | |||
| // 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 Coravel.Queuing.Interfaces; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Dto; | ||||
| using Foxnouns.Backend.Jobs; | ||||
| using Humanizer; | ||||
| using NodaTime; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Services; | ||||
| 
 | ||||
| public class ModerationService( | ||||
|     ILogger logger, | ||||
|     DatabaseContext db, | ||||
|     ISnowflakeGenerator snowflakeGenerator, | ||||
|     IQueue queue, | ||||
|     IClock clock | ||||
| ) | ||||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<ModerationService>(); | ||||
| 
 | ||||
|     public async Task<AuditLogEntry> IgnoreReportAsync( | ||||
|         User moderator, | ||||
|         Report report, | ||||
|         string? reason = null | ||||
|     ) | ||||
|     { | ||||
|         _logger.Information( | ||||
|             "Moderator {ModeratorId} is ignoring report {ReportId} on user {TargetId}", | ||||
|             moderator.Id, | ||||
|             report.Id, | ||||
|             report.TargetUserId | ||||
|         ); | ||||
| 
 | ||||
|         var entry = new AuditLogEntry | ||||
|         { | ||||
|             Id = snowflakeGenerator.GenerateSnowflake(), | ||||
|             ModeratorId = moderator.Id, | ||||
|             ModeratorUsername = moderator.Username, | ||||
|             ReportId = report.Id, | ||||
|             Type = AuditLogEntryType.IgnoreReport, | ||||
|             Reason = reason, | ||||
|         }; | ||||
|         db.AuditLog.Add(entry); | ||||
| 
 | ||||
|         report.Status = ReportStatus.Closed; | ||||
|         db.Update(report); | ||||
| 
 | ||||
|         await db.SaveChangesAsync(); | ||||
|         return entry; | ||||
|     } | ||||
| 
 | ||||
|     public async Task<AuditLogEntry> ExecuteSuspensionAsync( | ||||
|         User moderator, | ||||
|         User target, | ||||
|         Report? report, | ||||
|         string reason, | ||||
|         bool clearProfile | ||||
|     ) | ||||
|     { | ||||
|         _logger.Information( | ||||
|             "Moderator {ModeratorId} is suspending user {TargetId}", | ||||
|             moderator.Id, | ||||
|             target.Id | ||||
|         ); | ||||
|         var entry = new AuditLogEntry | ||||
|         { | ||||
|             Id = snowflakeGenerator.GenerateSnowflake(), | ||||
|             ModeratorId = moderator.Id, | ||||
|             ModeratorUsername = moderator.Username, | ||||
|             TargetUserId = target.Id, | ||||
|             TargetUsername = target.Username, | ||||
|             ReportId = report?.Id, | ||||
|             Type = AuditLogEntryType.SuspendUser, | ||||
|             Reason = reason, | ||||
|         }; | ||||
|         db.AuditLog.Add(entry); | ||||
| 
 | ||||
|         db.Notifications.Add( | ||||
|             new Notification | ||||
|             { | ||||
|                 Id = snowflakeGenerator.GenerateSnowflake(), | ||||
|                 TargetId = target.Id, | ||||
|                 Type = NotificationType.Warning, | ||||
|                 Message = null, | ||||
|                 LocalizationKey = "notification.suspension", | ||||
|                 LocalizationParams = { { "reason", reason } }, | ||||
|             } | ||||
|         ); | ||||
| 
 | ||||
|         target.Deleted = true; | ||||
|         target.DeletedAt = clock.GetCurrentInstant(); | ||||
|         target.DeletedBy = moderator.Id; | ||||
| 
 | ||||
|         if (!clearProfile) | ||||
|         { | ||||
|             db.Update(target); | ||||
|             await db.SaveChangesAsync(); | ||||
|             return entry; | ||||
|         } | ||||
| 
 | ||||
|         _logger.Information("Clearing profile of user {TargetId}", target.Id); | ||||
| 
 | ||||
|         target.Username = $"deleted-user-{target.Id}"; | ||||
|         target.DisplayName = null; | ||||
|         target.Bio = null; | ||||
|         target.MemberTitle = null; | ||||
|         target.Links = []; | ||||
|         target.Timezone = null; | ||||
|         target.Names = []; | ||||
|         target.Pronouns = []; | ||||
|         target.Fields = []; | ||||
|         target.CustomPreferences = []; | ||||
|         target.ProfileFlags = []; | ||||
| 
 | ||||
|         queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>( | ||||
|             new AvatarUpdatePayload(target.Id, null) | ||||
|         ); | ||||
| 
 | ||||
|         // TODO: also clear member profiles? | ||||
| 
 | ||||
|         db.Update(target); | ||||
|         await db.SaveChangesAsync(); | ||||
|         return entry; | ||||
|     } | ||||
| 
 | ||||
|     public async Task<AuditLogEntry> ExecuteWarningAsync( | ||||
|         User moderator, | ||||
|         User targetUser, | ||||
|         Member? targetMember, | ||||
|         Report? report, | ||||
|         string reason, | ||||
|         FieldsToClear[]? fieldsToClear | ||||
|     ) | ||||
|     { | ||||
|         _logger.Information( | ||||
|             "Moderator {ModeratorId} is warning user {TargetId} (member {TargetMemberId})", | ||||
|             moderator.Id, | ||||
|             targetUser.Id, | ||||
|             targetMember?.Id | ||||
|         ); | ||||
| 
 | ||||
|         string[]? fields = fieldsToClear?.Select(f => f.Humanize(LetterCasing.LowerCase)).ToArray(); | ||||
| 
 | ||||
|         var entry = new AuditLogEntry | ||||
|         { | ||||
|             Id = snowflakeGenerator.GenerateSnowflake(), | ||||
|             ModeratorId = moderator.Id, | ||||
|             ModeratorUsername = moderator.Username, | ||||
|             TargetUserId = targetUser.Id, | ||||
|             TargetUsername = targetUser.Username, | ||||
|             TargetMemberId = targetMember?.Id, | ||||
|             TargetMemberName = targetMember?.Name, | ||||
|             ReportId = report?.Id, | ||||
|             Type = | ||||
|                 fields != null | ||||
|                     ? AuditLogEntryType.WarnUserAndClearProfile | ||||
|                     : AuditLogEntryType.WarnUser, | ||||
|             Reason = reason, | ||||
|             ClearedFields = fields, | ||||
|         }; | ||||
|         db.AuditLog.Add(entry); | ||||
| 
 | ||||
|         db.Notifications.Add( | ||||
|             new Notification | ||||
|             { | ||||
|                 Id = snowflakeGenerator.GenerateSnowflake(), | ||||
|                 TargetId = targetUser.Id, | ||||
|                 Type = NotificationType.Warning, | ||||
|                 Message = null, | ||||
|                 LocalizationKey = | ||||
|                     fieldsToClear != null | ||||
|                         ? "notification.warning-cleared-fields" | ||||
|                         : "notification.warning", | ||||
|                 LocalizationParams = | ||||
|                 { | ||||
|                     { "reason", reason }, | ||||
|                     { | ||||
|                         "clearedFields", | ||||
|                         string.Join( | ||||
|                             "\n", | ||||
|                             fieldsToClear?.Select(f => f.Humanize(LetterCasing.LowerCase)) ?? [] | ||||
|                         ) | ||||
|                     }, | ||||
|                 }, | ||||
|             } | ||||
|         ); | ||||
| 
 | ||||
|         if (targetMember != null && fieldsToClear != null) | ||||
|         { | ||||
|             foreach (FieldsToClear field in fieldsToClear) | ||||
|             { | ||||
|                 switch (field) | ||||
|                 { | ||||
|                     case FieldsToClear.DisplayName: | ||||
|                         targetMember.DisplayName = null; | ||||
|                         break; | ||||
|                     case FieldsToClear.Avatar: | ||||
|                         queue.QueueInvocableWithPayload< | ||||
|                             MemberAvatarUpdateInvocable, | ||||
|                             AvatarUpdatePayload | ||||
|                         >(new AvatarUpdatePayload(targetMember.Id, null)); | ||||
|                         break; | ||||
|                     case FieldsToClear.Bio: | ||||
|                         targetMember.Bio = null; | ||||
|                         break; | ||||
|                     case FieldsToClear.Links: | ||||
|                         targetMember.Links = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Names: | ||||
|                         targetMember.Names = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Pronouns: | ||||
|                         targetMember.Pronouns = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Fields: | ||||
|                         targetMember.Fields = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Flags: | ||||
|                         targetMember.ProfileFlags = []; | ||||
|                         break; | ||||
|                     // custom preferences can't be cleared on member-scoped warnings | ||||
|                     case FieldsToClear.CustomPreferences: | ||||
|                     default: | ||||
|                         break; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             db.Update(targetMember); | ||||
|         } | ||||
|         else if (fieldsToClear != null) | ||||
|         { | ||||
|             foreach (FieldsToClear field in fieldsToClear) | ||||
|             { | ||||
|                 switch (field) | ||||
|                 { | ||||
|                     case FieldsToClear.DisplayName: | ||||
|                         targetUser.DisplayName = null; | ||||
|                         break; | ||||
|                     case FieldsToClear.Avatar: | ||||
|                         queue.QueueInvocableWithPayload< | ||||
|                             UserAvatarUpdateInvocable, | ||||
|                             AvatarUpdatePayload | ||||
|                         >(new AvatarUpdatePayload(targetUser.Id, null)); | ||||
|                         break; | ||||
|                     case FieldsToClear.Bio: | ||||
|                         targetUser.Bio = null; | ||||
|                         break; | ||||
|                     case FieldsToClear.Links: | ||||
|                         targetUser.Links = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Names: | ||||
|                         targetUser.Names = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Pronouns: | ||||
|                         targetUser.Pronouns = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Fields: | ||||
|                         targetUser.Fields = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.Flags: | ||||
|                         targetUser.ProfileFlags = []; | ||||
|                         break; | ||||
|                     case FieldsToClear.CustomPreferences: | ||||
|                         targetUser.CustomPreferences = []; | ||||
|                         break; | ||||
|                     default: | ||||
|                         break; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             db.Update(targetUser); | ||||
|         } | ||||
| 
 | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         return entry; | ||||
|     } | ||||
| } | ||||
|  | @ -114,7 +114,8 @@ public class UserRendererService( | |||
|             tokenHidden ? user.ListHidden : null, | ||||
|             tokenHidden ? user.LastActive : null, | ||||
|             tokenHidden ? user.LastSidReroll : null, | ||||
|             tokenHidden ? user.Timezone ?? "<none>" : null | ||||
|             tokenHidden ? user.Timezone ?? "<none>" : null, | ||||
|             tokenHidden ? user.Deleted : null | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -38,6 +38,7 @@ public static class AuthUtils | |||
|         "user.read_flags", | ||||
|         "user.create_flags", | ||||
|         "user.update_flags", | ||||
|         "user.moderation", | ||||
|     ]; | ||||
| 
 | ||||
|     public static readonly string[] MemberScopes = | ||||
|  |  | |||
|  | @ -26,6 +26,7 @@ export type MeUser = UserWithMembers & { | |||
| 	last_active: string; | ||||
| 	last_sid_reroll: string; | ||||
| 	timezone: string; | ||||
| 	deleted: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type UserWithMembers = User & { members: PartialMember[] | null }; | ||||
|  |  | |||
|  | @ -9,17 +9,25 @@ | |||
| 		NavItem, | ||||
| 	} from "@sveltestrap/sveltestrap"; | ||||
| 	import { page } from "$app/stores"; | ||||
| 	import type { User, Meta } from "$api/models/index"; | ||||
| 	import type { Meta, MeUser } from "$api/models/index"; | ||||
| 	import Logo from "$components/Logo.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 
 | ||||
| 	type Props = { user: User | null; meta: Meta }; | ||||
| 	type Props = { user: MeUser | null; meta: Meta }; | ||||
| 	let { user, meta }: Props = $props(); | ||||
| 
 | ||||
| 	let isOpen = $state(true); | ||||
| 	const toggleMenu = () => (isOpen = !isOpen); | ||||
| </script> | ||||
| 
 | ||||
| {#if user && user.deleted} | ||||
| 	<div class="suspended-alert text-center py-3 mb-2 px-2"> | ||||
| 		<strong>{$t("nav.suspended-account-hint")}</strong> | ||||
| 		<br /> | ||||
| 		<a href="/contact">{$t("nav.appeal-suspension-link")}</a> | ||||
| 	</div> | ||||
| {/if} | ||||
| 
 | ||||
| <Navbar expand="lg" class="mb-4 mx-2"> | ||||
| 	<NavbarBrand href="/"> | ||||
| 		<Logo /> | ||||
|  | @ -58,6 +66,11 @@ | |||
| </Navbar> | ||||
| 
 | ||||
| <style> | ||||
| 	.suspended-alert { | ||||
| 		color: var(--bs-danger-text-emphasis); | ||||
| 		background-color: var(--bs-danger-bg-subtle); | ||||
| 	} | ||||
| 
 | ||||
| 	/* These exact values make it look almost identical to the SVG version, which is what we want */ | ||||
| 	#beta-text { | ||||
| 		font-size: 0.7em; | ||||
|  |  | |||
|  | @ -2,7 +2,9 @@ | |||
| 	"hello": "Hello, {{name}}!", | ||||
| 	"nav": { | ||||
| 		"log-in": "Log in or sign up", | ||||
| 		"settings": "Settings" | ||||
| 		"settings": "Settings", | ||||
| 		"suspended-account-hint": "Your account has been suspended. Your profile has been hidden and you will not be able to change any settings.", | ||||
| 		"appeal-suspension-link": "I want to appeal" | ||||
| 	}, | ||||
| 	"avatar-tooltip": "Avatar for {{name}}", | ||||
| 	"profile": { | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue