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<UserFlag> UserFlags { get; init; } = null!; | ||||||
|     public DbSet<MemberFlag> MemberFlags { 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) |     protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) | ||||||
|     { |     { | ||||||
|         // Snowflakes are stored as longs |         // 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("ProductVersion", "9.0.0") | ||||||
|                 .HasAnnotation("Relational:MaxIdentifierLength", 63); |                 .HasAnnotation("Relational:MaxIdentifierLength", 63); | ||||||
| 
 | 
 | ||||||
|  |             NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); | ||||||
|             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); |             NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | ||||||
| 
 | 
 | ||||||
|             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => | ||||||
|  | @ -61,6 +62,62 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     b.ToTable("applications", (string)null); |                     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 => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<long>("Id") |                     b.Property<long>("Id") | ||||||
|  | @ -270,6 +327,45 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     b.ToTable("member_flags", (string)null); |                     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 => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<long>("Id") |                     b.Property<long>("Id") | ||||||
|  | @ -302,6 +398,55 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     b.ToTable("pride_flags", (string)null); |                     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 => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => | ||||||
|                 { |                 { | ||||||
|                     b.Property<long>("Id") |                     b.Property<long>("Id") | ||||||
|  | @ -522,6 +667,16 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     b.ToTable("user_flags", (string)null); |                     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 => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") |                     b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") | ||||||
|  | @ -584,6 +739,18 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                     b.Navigation("PrideFlag"); |                     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 => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.User", null) |                     b.HasOne("Foxnouns.Backend.Database.Models.User", null) | ||||||
|  | @ -594,6 +761,34 @@ namespace Foxnouns.Backend.Database.Migrations | ||||||
|                         .HasConstraintName("fk_pride_flags_users_user_id"); |                         .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 => |             modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => | ||||||
|                 { |                 { | ||||||
|                     b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") |                     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)] bool? MemberListHidden, | ||||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, |     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, | ||||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll, |     [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( | public record AuthMethodResponse( | ||||||
|  |  | ||||||
|  | @ -166,6 +166,8 @@ public enum ErrorCode | ||||||
|     MemberNotFound, |     MemberNotFound, | ||||||
|     AccountAlreadyLinked, |     AccountAlreadyLinked, | ||||||
|     LastAuthMethod, |     LastAuthMethod, | ||||||
|  |     InvalidReportTarget, | ||||||
|  |     InvalidWarningTarget, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| public class ValidationError | public class ValidationError | ||||||
|  |  | ||||||
|  | @ -113,6 +113,8 @@ public static class WebApplicationExtensions | ||||||
|                     .AddSingleton<EmailRateLimiter>() |                     .AddSingleton<EmailRateLimiter>() | ||||||
|                     .AddScoped<UserRendererService>() |                     .AddScoped<UserRendererService>() | ||||||
|                     .AddScoped<MemberRendererService>() |                     .AddScoped<MemberRendererService>() | ||||||
|  |                     .AddScoped<ModerationRendererService>() | ||||||
|  |                     .AddScoped<ModerationService>() | ||||||
|                     .AddScoped<AuthService>() |                     .AddScoped<AuthService>() | ||||||
|                     .AddScoped<KeyCacheService>() |                     .AddScoped<KeyCacheService>() | ||||||
|                     .AddScoped<RemoteAuthService>() |                     .AddScoped<RemoteAuthService>() | ||||||
|  | @ -139,11 +141,13 @@ public static class WebApplicationExtensions | ||||||
|         services |         services | ||||||
|             .AddScoped<ErrorHandlerMiddleware>() |             .AddScoped<ErrorHandlerMiddleware>() | ||||||
|             .AddScoped<AuthenticationMiddleware>() |             .AddScoped<AuthenticationMiddleware>() | ||||||
|  |             .AddScoped<LimitMiddleware>() | ||||||
|             .AddScoped<AuthorizationMiddleware>(); |             .AddScoped<AuthorizationMiddleware>(); | ||||||
| 
 | 
 | ||||||
|     public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => |     public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => | ||||||
|         app.UseMiddleware<ErrorHandlerMiddleware>() |         app.UseMiddleware<ErrorHandlerMiddleware>() | ||||||
|             .UseMiddleware<AuthenticationMiddleware>() |             .UseMiddleware<AuthenticationMiddleware>() | ||||||
|  |             .UseMiddleware<LimitMiddleware>() | ||||||
|             .UseMiddleware<AuthorizationMiddleware>(); |             .UseMiddleware<AuthorizationMiddleware>(); | ||||||
| 
 | 
 | ||||||
|     public static async Task Initialize(this WebApplication app, string[] args) |     public static async Task Initialize(this WebApplication app, string[] args) | ||||||
|  |  | ||||||
|  | @ -35,7 +35,7 @@ | ||||||
|             <PrivateAssets>all</PrivateAssets> |             <PrivateAssets>all</PrivateAssets> | ||||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|         </PackageReference> |         </PackageReference> | ||||||
|         <PackageReference Include="Scalar.AspNetCore" Version="1.2.55" /> |         <PackageReference Include="Scalar.AspNetCore" Version="1.2.55"/> | ||||||
|         <PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/> |         <PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/> | ||||||
|         <PackageReference Include="Serilog" Version="4.2.0"/> |         <PackageReference Include="Serilog" Version="4.2.0"/> | ||||||
|         <PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/> |         <PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/> | ||||||
|  |  | ||||||
|  | @ -22,17 +22,16 @@ public class AuthorizationMiddleware : IMiddleware | ||||||
|     public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) |     public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) | ||||||
|     { |     { | ||||||
|         Endpoint? endpoint = ctx.GetEndpoint(); |         Endpoint? endpoint = ctx.GetEndpoint(); | ||||||
|         AuthorizeAttribute? authorizeAttribute = |         AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); | ||||||
|             endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); |  | ||||||
|         LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>(); |  | ||||||
| 
 | 
 | ||||||
|         if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0) |         if (attribute == null || attribute.Scopes.Length == 0) | ||||||
|         { |         { | ||||||
|             await next(ctx); |             await next(ctx); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         Token? token = ctx.GetToken(); |         Token? token = ctx.GetToken(); | ||||||
|  | 
 | ||||||
|         if (token == null) |         if (token == null) | ||||||
|         { |         { | ||||||
|             throw new ApiError.Unauthorized( |             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 (attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) | ||||||
|         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() |  | ||||||
|         ) |  | ||||||
|         { |         { | ||||||
|             throw new ApiError.Forbidden( |             throw new ApiError.Forbidden( | ||||||
|                 "This endpoint requires ungranted scopes.", |                 "This endpoint requires ungranted scopes.", | ||||||
|                 authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()), |                 attribute.Scopes.Except(token.Scopes.ExpandScopes()), | ||||||
|                 ErrorCode.MissingScopes |                 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); |         await next(ctx); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | @ -84,11 +58,3 @@ public class AuthorizeAttribute(params string[] scopes) : Attribute | ||||||
| { | { | ||||||
|     public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray(); |     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.ListHidden : null, | ||||||
|             tokenHidden ? user.LastActive : null, |             tokenHidden ? user.LastActive : null, | ||||||
|             tokenHidden ? user.LastSidReroll : 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.read_flags", | ||||||
|         "user.create_flags", |         "user.create_flags", | ||||||
|         "user.update_flags", |         "user.update_flags", | ||||||
|  |         "user.moderation", | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|     public static readonly string[] MemberScopes = |     public static readonly string[] MemberScopes = | ||||||
|  |  | ||||||
|  | @ -26,6 +26,7 @@ export type MeUser = UserWithMembers & { | ||||||
| 	last_active: string; | 	last_active: string; | ||||||
| 	last_sid_reroll: string; | 	last_sid_reroll: string; | ||||||
| 	timezone: string; | 	timezone: string; | ||||||
|  | 	deleted: boolean; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export type UserWithMembers = User & { members: PartialMember[] | null }; | export type UserWithMembers = User & { members: PartialMember[] | null }; | ||||||
|  |  | ||||||
|  | @ -9,17 +9,25 @@ | ||||||
| 		NavItem, | 		NavItem, | ||||||
| 	} from "@sveltestrap/sveltestrap"; | 	} from "@sveltestrap/sveltestrap"; | ||||||
| 	import { page } from "$app/stores"; | 	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 Logo from "$components/Logo.svelte"; | ||||||
| 	import { t } from "$lib/i18n"; | 	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 { user, meta }: Props = $props(); | ||||||
| 
 | 
 | ||||||
| 	let isOpen = $state(true); | 	let isOpen = $state(true); | ||||||
| 	const toggleMenu = () => (isOpen = !isOpen); | 	const toggleMenu = () => (isOpen = !isOpen); | ||||||
| </script> | </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"> | <Navbar expand="lg" class="mb-4 mx-2"> | ||||||
| 	<NavbarBrand href="/"> | 	<NavbarBrand href="/"> | ||||||
| 		<Logo /> | 		<Logo /> | ||||||
|  | @ -58,6 +66,11 @@ | ||||||
| </Navbar> | </Navbar> | ||||||
| 
 | 
 | ||||||
| <style> | <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 */ | 	/* These exact values make it look almost identical to the SVG version, which is what we want */ | ||||||
| 	#beta-text { | 	#beta-text { | ||||||
| 		font-size: 0.7em; | 		font-size: 0.7em; | ||||||
|  |  | ||||||
|  | @ -2,7 +2,9 @@ | ||||||
| 	"hello": "Hello, {{name}}!", | 	"hello": "Hello, {{name}}!", | ||||||
| 	"nav": { | 	"nav": { | ||||||
| 		"log-in": "Log in or sign up", | 		"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}}", | 	"avatar-tooltip": "Avatar for {{name}}", | ||||||
| 	"profile": { | 	"profile": { | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue