From 36cb1d20432f754036b4bfd765e807a7983c99ae Mon Sep 17 00:00:00 2001 From: sam Date: Tue, 17 Dec 2024 17:52:32 +0100 Subject: [PATCH] feat: moderation API --- .../Moderation/AuditLogController.cs | 55 ++++ .../Moderation/ModActionsController.cs | 138 +++++++++ .../Moderation/ReportsController.cs | 224 ++++++++++++++ .../Controllers/NotificationsController.cs | 52 ++++ Foxnouns.Backend/Database/DatabaseContext.cs | 4 + .../Migrations/20241217010207_AddReports.cs | 161 ++++++++++ .../DatabaseContextModelSnapshot.cs | 195 ++++++++++++ .../Database/Models/AuditLogEntry.cs | 43 +++ .../Database/Models/Notification.cs | 41 +++ Foxnouns.Backend/Database/Models/Report.cs | 73 +++++ Foxnouns.Backend/Dto/Moderation.cs | 84 +++++ Foxnouns.Backend/Dto/User.cs | 3 +- Foxnouns.Backend/ExpectedError.cs | 2 + .../Extensions/WebApplicationExtensions.cs | 4 + Foxnouns.Backend/Foxnouns.Backend.csproj | 2 +- .../Middleware/AuthorizationMiddleware.cs | 44 +-- .../Middleware/LimitMiddleware.cs | 64 ++++ .../Services/ModerationRendererService.cs | 73 +++++ .../Services/ModerationService.cs | 292 ++++++++++++++++++ .../Services/UserRendererService.cs | 3 +- Foxnouns.Backend/Utils/AuthUtils.cs | 1 + Foxnouns.Frontend/src/lib/api/models/user.ts | 1 + .../src/lib/components/Navbar.svelte | 17 +- .../src/lib/i18n/locales/en.json | 4 +- 24 files changed, 1535 insertions(+), 45 deletions(-) create mode 100644 Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs create mode 100644 Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs create mode 100644 Foxnouns.Backend/Controllers/Moderation/ReportsController.cs create mode 100644 Foxnouns.Backend/Controllers/NotificationsController.cs create mode 100644 Foxnouns.Backend/Database/Migrations/20241217010207_AddReports.cs create mode 100644 Foxnouns.Backend/Database/Models/AuditLogEntry.cs create mode 100644 Foxnouns.Backend/Database/Models/Notification.cs create mode 100644 Foxnouns.Backend/Database/Models/Report.cs create mode 100644 Foxnouns.Backend/Dto/Moderation.cs create mode 100644 Foxnouns.Backend/Middleware/LimitMiddleware.cs create mode 100644 Foxnouns.Backend/Services/ModerationRendererService.cs create mode 100644 Foxnouns.Backend/Services/ModerationService.cs diff --git a/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs b/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs new file mode 100644 index 0000000..8b556de --- /dev/null +++ b/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs @@ -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 . +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 GetAuditLogAsync( + [FromQuery] AuditLogEntryType? type = null, + [FromQuery] int? limit = null, + [FromQuery] Snowflake? before = null + ) + { + limit = limit switch + { + > 100 => 100, + < 0 => 100, + null => 100, + _ => limit, + }; + + IQueryable 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 entries = await query.Take(limit!.Value).ToListAsync(); + + return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry)); + } +} diff --git a/Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs b/Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs new file mode 100644 index 0000000..2fb4473 --- /dev/null +++ b/Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs @@ -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 . +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 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 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)); + } +} diff --git a/Foxnouns.Backend/Controllers/Moderation/ReportsController.cs b/Foxnouns.Backend/Controllers/Moderation/ReportsController.cs new file mode 100644 index 0000000..b8acc56 --- /dev/null +++ b/Foxnouns.Backend/Controllers/Moderation/ReportsController.cs @@ -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 . +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(); + + private Snowflake MaxReportId() => + Snowflake.FromInstant(clock.GetCurrentInstant() - Duration.FromHours(12)); + + [HttpPost("report-user/{id}")] + [Authorize("user.moderation")] + public async Task 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 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 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 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 reports = await query.Take(limit!.Value).ToListAsync(); + + return Ok(reports.Select(moderationRenderer.RenderReport)); + } + + [HttpPost("reports/{id}/ignore")] + [Limit(RequireModerator = true)] + public async Task 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)); + } +} diff --git a/Foxnouns.Backend/Controllers/NotificationsController.cs b/Foxnouns.Backend/Controllers/NotificationsController.cs new file mode 100644 index 0000000..8bea907 --- /dev/null +++ b/Foxnouns.Backend/Controllers/NotificationsController.cs @@ -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 GetNotificationsAsync([FromQuery] bool all = false) + { + IQueryable query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id); + if (!all) + query = query.Where(n => n.AcknowledgedAt == null); + + List 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 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)); + } +} diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index ddf7853..9baa143 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -71,6 +71,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet UserFlags { get; init; } = null!; public DbSet MemberFlags { get; init; } = null!; + public DbSet Reports { get; init; } = null!; + public DbSet AuditLog { get; init; } = null!; + public DbSet Notifications { get; init; } = null!; + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { // Snowflakes are stored as longs diff --git a/Foxnouns.Backend/Database/Migrations/20241217010207_AddReports.cs b/Foxnouns.Backend/Database/Migrations/20241217010207_AddReports.cs new file mode 100644 index 0000000..22a1cf8 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241217010207_AddReports.cs @@ -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 +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241217010207_AddReports")] + public partial class AddReports : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase().Annotation("Npgsql:PostgresExtension:hstore", ",,"); + + migrationBuilder.CreateTable( + name: "notifications", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + target_id = table.Column(type: "bigint", nullable: false), + type = table.Column(type: "integer", nullable: false), + message = table.Column(type: "text", nullable: true), + localization_key = table.Column(type: "text", nullable: true), + localization_params = table.Column>( + type: "hstore", + nullable: false + ), + acknowledged_at = table.Column( + 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(type: "bigint", nullable: false), + reporter_id = table.Column(type: "bigint", nullable: false), + target_user_id = table.Column(type: "bigint", nullable: false), + target_member_id = table.Column(type: "bigint", nullable: true), + status = table.Column(type: "integer", nullable: false), + reason = table.Column(type: "integer", nullable: false), + target_type = table.Column(type: "integer", nullable: false), + target_snapshot = table.Column(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(type: "bigint", nullable: false), + moderator_id = table.Column(type: "bigint", nullable: false), + moderator_username = table.Column(type: "text", nullable: false), + target_user_id = table.Column(type: "bigint", nullable: true), + target_username = table.Column(type: "text", nullable: true), + target_member_id = table.Column(type: "bigint", nullable: true), + target_member_name = table.Column(type: "text", nullable: true), + report_id = table.Column(type: "bigint", nullable: true), + type = table.Column(type: "integer", nullable: false), + reason = table.Column(type: "text", nullable: true), + cleared_fields = table.Column(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" + ); + } + + /// + 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", ",,"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index cfe2513..83a90fd 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -22,6 +22,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasAnnotation("ProductVersion", "9.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => @@ -61,6 +62,62 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("applications", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.PrimitiveCollection("ClearedFields") + .HasColumnType("text[]") + .HasColumnName("cleared_fields"); + + b.Property("ModeratorId") + .HasColumnType("bigint") + .HasColumnName("moderator_id"); + + b.Property("ModeratorUsername") + .IsRequired() + .HasColumnType("text") + .HasColumnName("moderator_username"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("ReportId") + .HasColumnType("bigint") + .HasColumnName("report_id"); + + b.Property("TargetMemberId") + .HasColumnType("bigint") + .HasColumnName("target_member_id"); + + b.Property("TargetMemberName") + .HasColumnType("text") + .HasColumnName("target_member_name"); + + b.Property("TargetUserId") + .HasColumnType("bigint") + .HasColumnName("target_user_id"); + + b.Property("TargetUsername") + .HasColumnType("text") + .HasColumnName("target_username"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_audit_log"); + + b.HasIndex("ReportId") + .HasDatabaseName("ix_audit_log_report_id"); + + b.ToTable("audit_log", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => { b.Property("Id") @@ -270,6 +327,45 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("member_flags", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("LocalizationKey") + .HasColumnType("text") + .HasColumnName("localization_key"); + + b.Property>("LocalizationParams") + .HasColumnType("hstore") + .HasColumnName("localization_params"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("TargetId") + .HasColumnType("bigint") + .HasColumnName("target_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_notifications"); + + b.HasIndex("TargetId") + .HasDatabaseName("ix_notifications_target_id"); + + b.ToTable("notifications", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => { b.Property("Id") @@ -302,6 +398,55 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("pride_flags", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Reason") + .HasColumnType("integer") + .HasColumnName("reason"); + + b.Property("ReporterId") + .HasColumnType("bigint") + .HasColumnName("reporter_id"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TargetMemberId") + .HasColumnType("bigint") + .HasColumnName("target_member_id"); + + b.Property("TargetSnapshot") + .HasColumnType("text") + .HasColumnName("target_snapshot"); + + b.Property("TargetType") + .HasColumnType("integer") + .HasColumnName("target_type"); + + b.Property("TargetUserId") + .HasColumnType("bigint") + .HasColumnName("target_user_id"); + + b.HasKey("Id") + .HasName("pk_reports"); + + b.HasIndex("ReporterId") + .HasDatabaseName("ix_reports_reporter_id"); + + b.HasIndex("TargetMemberId") + .HasDatabaseName("ix_reports_target_member_id"); + + b.HasIndex("TargetUserId") + .HasDatabaseName("ix_reports_target_user_id"); + + b.ToTable("reports", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => { b.Property("Id") @@ -522,6 +667,16 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("user_flags", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report") + .WithMany() + .HasForeignKey("ReportId") + .HasConstraintName("fk_audit_log_reports_report_id"); + + b.Navigation("Report"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => { b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") @@ -584,6 +739,18 @@ namespace Foxnouns.Backend.Database.Migrations b.Navigation("PrideFlag"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "Target") + .WithMany() + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_users_target_id"); + + b.Navigation("Target"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => { b.HasOne("Foxnouns.Backend.Database.Models.User", null) @@ -594,6 +761,34 @@ namespace Foxnouns.Backend.Database.Migrations .HasConstraintName("fk_pride_flags_users_user_id"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter") + .WithMany() + .HasForeignKey("ReporterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reports_users_reporter_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember") + .WithMany() + .HasForeignKey("TargetMemberId") + .HasConstraintName("fk_reports_members_target_member_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reports_users_target_user_id"); + + b.Navigation("Reporter"); + + b.Navigation("TargetMember"); + + b.Navigation("TargetUser"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => { b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") diff --git a/Foxnouns.Backend/Database/Models/AuditLogEntry.cs b/Foxnouns.Backend/Database/Models/AuditLogEntry.cs new file mode 100644 index 0000000..a4983ae --- /dev/null +++ b/Foxnouns.Backend/Database/Models/AuditLogEntry.cs @@ -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 . +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, +} diff --git a/Foxnouns.Backend/Database/Models/Notification.cs b/Foxnouns.Backend/Database/Models/Notification.cs new file mode 100644 index 0000000..59bf1c3 --- /dev/null +++ b/Foxnouns.Backend/Database/Models/Notification.cs @@ -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 . +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 LocalizationParams { get; init; } = []; + + public Instant? AcknowledgedAt { get; set; } +} + +[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] +public enum NotificationType +{ + Notice, + Warning, + Suspension, +} diff --git a/Foxnouns.Backend/Database/Models/Report.cs b/Foxnouns.Backend/Database/Models/Report.cs new file mode 100644 index 0000000..e668f44 --- /dev/null +++ b/Foxnouns.Backend/Database/Models/Report.cs @@ -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 . +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, +} diff --git a/Foxnouns.Backend/Dto/Moderation.cs b/Foxnouns.Backend/Dto/Moderation.cs new file mode 100644 index 0000000..0de65c7 --- /dev/null +++ b/Foxnouns.Backend/Dto/Moderation.cs @@ -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 . + +// 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 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, +} diff --git a/Foxnouns.Backend/Dto/User.cs b/Foxnouns.Backend/Dto/User.cs index a78aeba..c681001 100644 --- a/Foxnouns.Backend/Dto/User.cs +++ b/Foxnouns.Backend/Dto/User.cs @@ -47,7 +47,8 @@ public record UserResponse( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted ); public record AuthMethodResponse( diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index 6c5c7e9..6a704e2 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -166,6 +166,8 @@ public enum ErrorCode MemberNotFound, AccountAlreadyLinked, LastAuthMethod, + InvalidReportTarget, + InvalidWarningTarget, } public class ValidationError diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index d1b1156..64564b2 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -113,6 +113,8 @@ public static class WebApplicationExtensions .AddSingleton() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -139,11 +141,13 @@ public static class WebApplicationExtensions services .AddScoped() .AddScoped() + .AddScoped() .AddScoped(); public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => app.UseMiddleware() .UseMiddleware() + .UseMiddleware() .UseMiddleware(); public static async Task Initialize(this WebApplication app, string[] args) diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index 168cff6..8238fc8 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -35,7 +35,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs index 976dc5b..fc216f5 100644 --- a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs @@ -22,17 +22,16 @@ public class AuthorizationMiddleware : IMiddleware public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { Endpoint? endpoint = ctx.GetEndpoint(); - AuthorizeAttribute? authorizeAttribute = - endpoint?.Metadata.GetMetadata(); - LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata(); + AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata(); - if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0) + if (attribute == null || attribute.Scopes.Length == 0) { await next(ctx); return; } Token? token = ctx.GetToken(); + if (token == null) { throw new ApiError.Unauthorized( @@ -41,40 +40,15 @@ public class AuthorizationMiddleware : IMiddleware ); } - // Users who got suspended by a moderator can still access *some* endpoints. - if ( - token.User.Deleted - && (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null) - ) - { - throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); - } - - if ( - authorizeAttribute.Scopes.Length > 0 - && authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() - ) + if (attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) { throw new ApiError.Forbidden( "This endpoint requires ungranted scopes.", - authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()), + attribute.Scopes.Except(token.Scopes.ExpandScopes()), ErrorCode.MissingScopes ); } - if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin) - { - throw new ApiError.Forbidden("This endpoint can only be used by admins."); - } - - if ( - limitAttribute?.RequireModerator == true - && token.User.Role is not (UserRole.Admin or UserRole.Moderator) - ) - { - throw new ApiError.Forbidden("This endpoint can only be used by moderators."); - } - await next(ctx); } } @@ -84,11 +58,3 @@ public class AuthorizeAttribute(params string[] scopes) : Attribute { public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray(); } - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public class LimitAttribute : Attribute -{ - public bool UsableBySuspendedUsers { get; init; } - public bool RequireAdmin { get; init; } - public bool RequireModerator { get; init; } -} diff --git a/Foxnouns.Backend/Middleware/LimitMiddleware.cs b/Foxnouns.Backend/Middleware/LimitMiddleware.cs new file mode 100644 index 0000000..82613c5 --- /dev/null +++ b/Foxnouns.Backend/Middleware/LimitMiddleware.cs @@ -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 . +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(); + + 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; } +} diff --git a/Foxnouns.Backend/Services/ModerationRendererService.cs b/Foxnouns.Backend/Services/ModerationRendererService.cs new file mode 100644 index 0000000..e5d8165 --- /dev/null +++ b/Foxnouns.Backend/Services/ModerationRendererService.cs @@ -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 . +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(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; +} diff --git a/Foxnouns.Backend/Services/ModerationService.cs b/Foxnouns.Backend/Services/ModerationService.cs new file mode 100644 index 0000000..5444657 --- /dev/null +++ b/Foxnouns.Backend/Services/ModerationService.cs @@ -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 . +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(); + + public async Task 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 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( + new AvatarUpdatePayload(target.Id, null) + ); + + // TODO: also clear member profiles? + + db.Update(target); + await db.SaveChangesAsync(); + return entry; + } + + public async Task 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; + } +} diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 028fc75..7a00328 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -114,7 +114,8 @@ public class UserRendererService( tokenHidden ? user.ListHidden : null, tokenHidden ? user.LastActive : null, tokenHidden ? user.LastSidReroll : null, - tokenHidden ? user.Timezone ?? "" : null + tokenHidden ? user.Timezone ?? "" : null, + tokenHidden ? user.Deleted : null ); } diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index 5ebd745..2ce46e2 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -38,6 +38,7 @@ public static class AuthUtils "user.read_flags", "user.create_flags", "user.update_flags", + "user.moderation", ]; public static readonly string[] MemberScopes = diff --git a/Foxnouns.Frontend/src/lib/api/models/user.ts b/Foxnouns.Frontend/src/lib/api/models/user.ts index f830983..29740e6 100644 --- a/Foxnouns.Frontend/src/lib/api/models/user.ts +++ b/Foxnouns.Frontend/src/lib/api/models/user.ts @@ -26,6 +26,7 @@ export type MeUser = UserWithMembers & { last_active: string; last_sid_reroll: string; timezone: string; + deleted: boolean; }; export type UserWithMembers = User & { members: PartialMember[] | null }; diff --git a/Foxnouns.Frontend/src/lib/components/Navbar.svelte b/Foxnouns.Frontend/src/lib/components/Navbar.svelte index 2661fc9..365ede2 100644 --- a/Foxnouns.Frontend/src/lib/components/Navbar.svelte +++ b/Foxnouns.Frontend/src/lib/components/Navbar.svelte @@ -9,17 +9,25 @@ NavItem, } from "@sveltestrap/sveltestrap"; import { page } from "$app/stores"; - import type { User, Meta } from "$api/models/index"; + import type { Meta, MeUser } from "$api/models/index"; import Logo from "$components/Logo.svelte"; import { t } from "$lib/i18n"; - type Props = { user: User | null; meta: Meta }; + type Props = { user: MeUser | null; meta: Meta }; let { user, meta }: Props = $props(); let isOpen = $state(true); const toggleMenu = () => (isOpen = !isOpen); +{#if user && user.deleted} +
+ {$t("nav.suspended-account-hint")} +
+ {$t("nav.appeal-suspension-link")} +
+{/if} + @@ -58,6 +66,11 @@