diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index edc7b6a..3dcc817 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -44,7 +44,7 @@ public class FediverseAuthController( [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetFediverseUrlAsync( [FromQuery] string instance, - [FromQuery(Name = "force-refresh")] bool forceRefresh = false + [FromQuery] bool forceRefresh = false ) { if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) @@ -139,7 +139,7 @@ public class FediverseAuthController( [Authorize("*")] public async Task AddFediverseAccountAsync( [FromQuery] string instance, - [FromQuery(Name = "force-refresh")] bool forceRefresh = false + [FromQuery] bool forceRefresh = false ) { if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) diff --git a/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs b/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs deleted file mode 100644 index 8b556de..0000000 --- a/Foxnouns.Backend/Controllers/Moderation/AuditLogController.cs +++ /dev/null @@ -1,55 +0,0 @@ -// 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 deleted file mode 100644 index 2fb4473..0000000 --- a/Foxnouns.Backend/Controllers/Moderation/ModActionsController.cs +++ /dev/null @@ -1,138 +0,0 @@ -// 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 deleted file mode 100644 index 3e9f905..0000000 --- a/Foxnouns.Backend/Controllers/Moderation/ReportsController.cs +++ /dev/null @@ -1,237 +0,0 @@ -// 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] Snowflake? after = null, - [FromQuery(Name = "by-reporter")] Snowflake? byReporter = null, - [FromQuery(Name = "by-target")] Snowflake? byTarget = 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); - - if (byTarget != null && await db.Users.AnyAsync(u => u.Id == byTarget.Value)) - query = query.Where(r => r.TargetUserId == byTarget.Value); - - if (byReporter != null && await db.Users.AnyAsync(u => u.Id == byReporter.Value)) - query = query.Where(r => r.ReporterId == byReporter.Value); - - if (before != null) - query = query.Where(r => r.Id < before.Value).OrderByDescending(r => r.Id); - else if (after != null) - query = query.Where(r => r.Id > after.Value).OrderBy(r => r.Id); - else - query = query.OrderByDescending(r => r.Id); - - 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 deleted file mode 100644 index 8bea907..0000000 --- a/Foxnouns.Backend/Controllers/NotificationsController.cs +++ /dev/null @@ -1,52 +0,0 @@ -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 9baa143..ddf7853 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -71,10 +71,6 @@ 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 deleted file mode 100644 index 22a1cf8..0000000 --- a/Foxnouns.Backend/Database/Migrations/20241217010207_AddReports.cs +++ /dev/null @@ -1,161 +0,0 @@ -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 83a90fd..cfe2513 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -22,7 +22,6 @@ 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 => @@ -62,62 +61,6 @@ 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") @@ -327,45 +270,6 @@ 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") @@ -398,55 +302,6 @@ 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") @@ -667,16 +522,6 @@ 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") @@ -739,18 +584,6 @@ 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) @@ -761,34 +594,6 @@ 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 deleted file mode 100644 index a4983ae..0000000 --- a/Foxnouns.Backend/Database/Models/AuditLogEntry.cs +++ /dev/null @@ -1,43 +0,0 @@ -// 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 deleted file mode 100644 index 59bf1c3..0000000 --- a/Foxnouns.Backend/Database/Models/Notification.cs +++ /dev/null @@ -1,41 +0,0 @@ -// 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 deleted file mode 100644 index e668f44..0000000 --- a/Foxnouns.Backend/Database/Models/Report.cs +++ /dev/null @@ -1,73 +0,0 @@ -// 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 deleted file mode 100644 index 0de65c7..0000000 --- a/Foxnouns.Backend/Dto/Moderation.cs +++ /dev/null @@ -1,84 +0,0 @@ -// 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 f193811..a78aeba 100644 --- a/Foxnouns.Backend/Dto/User.cs +++ b/Foxnouns.Backend/Dto/User.cs @@ -47,9 +47,7 @@ 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)] bool? Suspended, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone ); public record AuthMethodResponse( diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index 6a704e2..6c5c7e9 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -166,8 +166,6 @@ 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 64564b2..d1b1156 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -113,8 +113,6 @@ public static class WebApplicationExtensions .AddSingleton() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -141,13 +139,11 @@ 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 8238fc8..168cff6 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 fc216f5..976dc5b 100644 --- a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs @@ -22,16 +22,17 @@ public class AuthorizationMiddleware : IMiddleware public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { Endpoint? endpoint = ctx.GetEndpoint(); - AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata(); + AuthorizeAttribute? authorizeAttribute = + endpoint?.Metadata.GetMetadata(); + LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata(); - if (attribute == null || attribute.Scopes.Length == 0) + if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0) { await next(ctx); return; } Token? token = ctx.GetToken(); - if (token == null) { throw new ApiError.Unauthorized( @@ -40,15 +41,40 @@ public class AuthorizationMiddleware : IMiddleware ); } - if (attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) + // 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() + ) { throw new ApiError.Forbidden( "This endpoint requires ungranted scopes.", - attribute.Scopes.Except(token.Scopes.ExpandScopes()), + authorizeAttribute.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); } } @@ -58,3 +84,11 @@ 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 deleted file mode 100644 index 1c5f522..0000000 --- a/Foxnouns.Backend/Middleware/LimitMiddleware.cs +++ /dev/null @@ -1,68 +0,0 @@ -// 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(); - - Token? token = ctx.GetToken(); - - if (attribute == null) - { - // Check for authorize attribute - // If it exists, and the user is deleted, throw an error. - if ( - endpoint?.Metadata.GetMetadata() != null - && token?.User.Deleted == true - ) - { - throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); - } - - await next(ctx); - return; - } - - if (token?.User.Deleted == true && !attribute.UsableBySuspendedUsers) - 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 deleted file mode 100644 index deed9c5..0000000 --- a/Foxnouns.Backend/Services/ModerationRendererService.cs +++ /dev/null @@ -1,72 +0,0 @@ -// 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( - 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 deleted file mode 100644 index 5444657..0000000 --- a/Foxnouns.Backend/Services/ModerationService.cs +++ /dev/null @@ -1,292 +0,0 @@ -// 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 df40e1a..028fc75 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -114,9 +114,7 @@ public class UserRendererService( tokenHidden ? user.ListHidden : null, tokenHidden ? user.LastActive : null, tokenHidden ? user.LastSidReroll : null, - tokenHidden ? user.Timezone ?? "" : null, - tokenHidden ? user is { Deleted: true, DeletedBy: not null } : null, - tokenHidden ? user.Deleted : null + tokenHidden ? user.Timezone ?? "" : null ); } diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index 2ce46e2..5ebd745 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -38,7 +38,6 @@ 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 6cd8e4c..f830983 100644 --- a/Foxnouns.Frontend/src/lib/api/models/user.ts +++ b/Foxnouns.Frontend/src/lib/api/models/user.ts @@ -26,8 +26,6 @@ export type MeUser = UserWithMembers & { last_active: string; last_sid_reroll: string; timezone: string; - suspended: boolean; - 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 68c1b14..2661fc9 100644 --- a/Foxnouns.Frontend/src/lib/components/Navbar.svelte +++ b/Foxnouns.Frontend/src/lib/components/Navbar.svelte @@ -9,32 +9,17 @@ NavItem, } from "@sveltestrap/sveltestrap"; import { page } from "$app/stores"; - import type { Meta, MeUser } from "$api/models/index"; + import type { User, Meta } from "$api/models/index"; import Logo from "$components/Logo.svelte"; import { t } from "$lib/i18n"; - type Props = { user: MeUser | null; meta: Meta }; + type Props = { user: User | null; meta: Meta }; let { user, meta }: Props = $props(); let isOpen = $state(true); const toggleMenu = () => (isOpen = !isOpen); -{#if user && user.deleted} -
- {#if user.suspended} - {$t("nav.suspended-account-hint")} -
- {$t("nav.appeal-suspension-link")} - {:else} - {$t("nav.deleted-account-hint")} -
- {$t("nav.reactivate-account-link")} • - {$t("nav.delete-permanently-link")} - {/if} -
-{/if} - @@ -73,11 +58,6 @@