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 @@