feat: moderation API

This commit is contained in:
sam 2024-12-17 17:52:32 +01:00
parent 79b8c4799e
commit 36cb1d2043
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
24 changed files with 1535 additions and 45 deletions

View file

@ -0,0 +1,55 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation/audit-log")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class AuditLogController(DatabaseContext db, ModerationRendererService moderationRenderer)
: ApiControllerBase
{
public async Task<IActionResult> GetAuditLogAsync(
[FromQuery] AuditLogEntryType? type = null,
[FromQuery] int? limit = null,
[FromQuery] Snowflake? before = null
)
{
limit = limit switch
{
> 100 => 100,
< 0 => 100,
null => 100,
_ => limit,
};
IQueryable<AuditLogEntry> query = db.AuditLog.OrderByDescending(e => e.Id);
if (before != null)
query = query.Where(e => e.Id < before.Value);
if (type != null)
query = query.Where(e => e.Type == type);
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
}
}

View file

@ -0,0 +1,138 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class ModActionsController(
DatabaseContext db,
ModerationService moderationService,
ModerationRendererService moderationRenderer
) : ApiControllerBase
{
[HttpPost("warnings/{id}")]
public async Task<IActionResult> WarnUserAsync(Snowflake id, [FromBody] WarnUserRequest req)
{
User user = await db.ResolveUserAsync(id);
if (user.Deleted)
{
throw new ApiError(
"This user is already deleted.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
if (user.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't warn yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
Member? member = null;
if (req.MemberId != null)
{
member = await db.Members.FirstOrDefaultAsync(m =>
m.Id == req.MemberId && m.UserId == user.Id
);
if (member == null)
throw new ApiError.NotFound("No member with that ID found.");
}
Report? report = null;
if (req.ReportId != null)
{
report = await db.Reports.FindAsync(req.ReportId);
if (report is not { Status: ReportStatus.Open })
{
throw new ApiError.NotFound(
"No report with that ID found, or it's already closed."
);
}
}
AuditLogEntry entry = await moderationService.ExecuteWarningAsync(
CurrentUser,
user,
member,
report,
req.Reason,
req.ClearFields
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
[HttpPost("suspensions/{id}")]
public async Task<IActionResult> SuspendUserAsync(
Snowflake id,
[FromBody] SuspendUserRequest req
)
{
User user = await db.ResolveUserAsync(id);
if (user.Deleted)
{
throw new ApiError(
"This user is already deleted.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
if (user.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't warn yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
Report? report = null;
if (req.ReportId != null)
{
report = await db.Reports.FindAsync(req.ReportId);
if (report is not { Status: ReportStatus.Open })
{
throw new ApiError.NotFound(
"No report with that ID found, or it's already closed."
);
}
}
AuditLogEntry entry = await moderationService.ExecuteSuspensionAsync(
CurrentUser,
user,
report,
req.Reason,
req.ClearProfile
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
}

View file

@ -0,0 +1,224 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation")]
public class ReportsController(
ILogger logger,
DatabaseContext db,
IClock clock,
ISnowflakeGenerator snowflakeGenerator,
UserRendererService userRenderer,
MemberRendererService memberRenderer,
ModerationRendererService moderationRenderer,
ModerationService moderationService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<ReportsController>();
private Snowflake MaxReportId() =>
Snowflake.FromInstant(clock.GetCurrentInstant() - Duration.FromHours(12));
[HttpPost("report-user/{id}")]
[Authorize("user.moderation")]
public async Task<IActionResult> ReportUserAsync(
Snowflake id,
[FromBody] CreateReportRequest req
)
{
User target = await db.ResolveUserAsync(id);
if (target.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't report yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidReportTarget
);
}
Snowflake reportCutoff = MaxReportId();
if (
await db
.Reports.Where(r =>
r.ReporterId == CurrentUser!.Id
&& r.TargetUserId == target.Id
&& r.Id > reportCutoff
)
.AnyAsync()
)
{
_logger.Debug(
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
CurrentUser!.Id,
target.Id
);
return NoContent();
}
_logger.Information(
"Creating report on {TargetId} by {ReporterId}",
target.Id,
CurrentUser!.Id
);
string snapshot = JsonConvert.SerializeObject(
await userRenderer.RenderUserAsync(target, renderMembers: false)
);
var report = new Report
{
Id = snowflakeGenerator.GenerateSnowflake(),
ReporterId = CurrentUser.Id,
TargetUserId = target.Id,
TargetMemberId = null,
Reason = req.Reason,
TargetType = ReportTargetType.User,
TargetSnapshot = snapshot,
};
db.Reports.Add(report);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("report-member/{id}")]
[Authorize("user.moderation")]
public async Task<IActionResult> ReportMemberAsync(
Snowflake id,
[FromBody] CreateReportRequest req
)
{
Member target = await db.ResolveMemberAsync(id);
if (target.User.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't report yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidReportTarget
);
}
Snowflake reportCutoff = MaxReportId();
if (
await db
.Reports.Where(r =>
r.ReporterId == CurrentUser!.Id
&& r.TargetUserId == target.User.Id
&& r.Id > reportCutoff
)
.AnyAsync()
)
{
_logger.Debug(
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
CurrentUser!.Id,
target.User.Id
);
return NoContent();
}
_logger.Information(
"Creating report on {TargetId} (member {TargetMemberId}) by {ReporterId}",
target.User.Id,
target.Id,
CurrentUser!.Id
);
string snapshot = JsonConvert.SerializeObject(memberRenderer.RenderMember(target));
var report = new Report
{
Id = snowflakeGenerator.GenerateSnowflake(),
ReporterId = CurrentUser.Id,
TargetUserId = target.User.Id,
TargetMemberId = target.Id,
Reason = req.Reason,
TargetType = ReportTargetType.Member,
TargetSnapshot = snapshot,
};
db.Reports.Add(report);
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("reports")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public async Task<IActionResult> GetReportsAsync(
[FromQuery] int? limit = null,
[FromQuery] Snowflake? before = null,
[FromQuery(Name = "include-closed")] bool includeClosed = false
)
{
limit = limit switch
{
> 100 => 100,
< 0 => 100,
null => 100,
_ => limit,
};
IQueryable<Report> query = db
.Reports.Include(r => r.Reporter)
.Include(r => r.TargetUser)
.Include(r => r.TargetMember)
.OrderByDescending(r => r.Id);
if (before != null)
query = query.Where(r => r.Id < before.Value);
if (!includeClosed)
query = query.Where(r => r.Status == ReportStatus.Open);
List<Report> reports = await query.Take(limit!.Value).ToListAsync();
return Ok(reports.Select(moderationRenderer.RenderReport));
}
[HttpPost("reports/{id}/ignore")]
[Limit(RequireModerator = true)]
public async Task<IActionResult> IgnoreReportAsync(
Snowflake id,
[FromBody] IgnoreReportRequest req
)
{
Report? report = await db.Reports.FindAsync(id);
if (report == null)
throw new ApiError.NotFound("No report with that ID found.");
if (report.Status != ReportStatus.Open)
throw new ApiError.BadRequest("That report has already been handled.");
AuditLogEntry entry = await moderationService.IgnoreReportAsync(
CurrentUser!,
report,
req.Reason
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
}

View file

@ -0,0 +1,52 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/notifications")]
public class NotificationsController(
DatabaseContext db,
ModerationRendererService moderationRenderer,
IClock clock
) : ApiControllerBase
{
[HttpGet]
[Authorize("user.moderation")]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
{
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
if (!all)
query = query.Where(n => n.AcknowledgedAt == null);
List<Notification> notifications = await query.OrderByDescending(n => n.Id).ToListAsync();
return Ok(notifications.Select(moderationRenderer.RenderNotification));
}
[HttpPut("{id}/ack")]
[Authorize("user.moderation")]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
{
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
n.TargetId == CurrentUser!.Id && n.Id == id
);
if (notification == null)
throw new ApiError.NotFound("Notification not found.");
if (notification.AcknowledgedAt != null)
return Ok(moderationRenderer.RenderNotification(notification));
notification.AcknowledgedAt = clock.GetCurrentInstant();
db.Update(notification);
await db.SaveChangesAsync();
return Ok(moderationRenderer.RenderNotification(notification));
}
}

View file

@ -71,6 +71,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
public DbSet<UserFlag> UserFlags { get; init; } = null!; public DbSet<UserFlag> UserFlags { get; init; } = null!;
public DbSet<MemberFlag> MemberFlags { get; init; } = null!; public DbSet<MemberFlag> MemberFlags { get; init; } = null!;
public DbSet<Report> Reports { get; init; } = null!;
public DbSet<AuditLogEntry> AuditLog { get; init; } = null!;
public DbSet<Notification> Notifications { get; init; } = null!;
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{ {
// Snowflakes are stored as longs // Snowflakes are stored as longs

View file

@ -0,0 +1,161 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241217010207_AddReports")]
public partial class AddReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase().Annotation("Npgsql:PostgresExtension:hstore", ",,");
migrationBuilder.CreateTable(
name: "notifications",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
target_id = table.Column<long>(type: "bigint", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
message = table.Column<string>(type: "text", nullable: true),
localization_key = table.Column<string>(type: "text", nullable: true),
localization_params = table.Column<Dictionary<string, string>>(
type: "hstore",
nullable: false
),
acknowledged_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("pk_notifications", x => x.id);
table.ForeignKey(
name: "fk_notifications_users_target_id",
column: x => x.target_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "reports",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
reporter_id = table.Column<long>(type: "bigint", nullable: false),
target_user_id = table.Column<long>(type: "bigint", nullable: false),
target_member_id = table.Column<long>(type: "bigint", nullable: true),
status = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<int>(type: "integer", nullable: false),
target_type = table.Column<int>(type: "integer", nullable: false),
target_snapshot = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_reports", x => x.id);
table.ForeignKey(
name: "fk_reports_members_target_member_id",
column: x => x.target_member_id,
principalTable: "members",
principalColumn: "id"
);
table.ForeignKey(
name: "fk_reports_users_reporter_id",
column: x => x.reporter_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "fk_reports_users_target_user_id",
column: x => x.target_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "audit_log",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
moderator_id = table.Column<long>(type: "bigint", nullable: false),
moderator_username = table.Column<string>(type: "text", nullable: false),
target_user_id = table.Column<long>(type: "bigint", nullable: true),
target_username = table.Column<string>(type: "text", nullable: true),
target_member_id = table.Column<long>(type: "bigint", nullable: true),
target_member_name = table.Column<string>(type: "text", nullable: true),
report_id = table.Column<long>(type: "bigint", nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<string>(type: "text", nullable: true),
cleared_fields = table.Column<string[]>(type: "text[]", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_audit_log", x => x.id);
table.ForeignKey(
name: "fk_audit_log_reports_report_id",
column: x => x.report_id,
principalTable: "reports",
principalColumn: "id"
);
}
);
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id"
);
migrationBuilder.CreateIndex(
name: "ix_notifications_target_id",
table: "notifications",
column: "target_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_reporter_id",
table: "reports",
column: "reporter_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_target_member_id",
table: "reports",
column: "target_member_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_target_user_id",
table: "reports",
column: "target_user_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "audit_log");
migrationBuilder.DropTable(name: "notifications");
migrationBuilder.DropTable(name: "reports");
migrationBuilder.AlterDatabase().OldAnnotation("Npgsql:PostgresExtension:hstore", ",,");
}
}
}

View file

@ -22,6 +22,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasAnnotation("ProductVersion", "9.0.0") .HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
@ -61,6 +62,62 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("applications", (string)null); b.ToTable("applications", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.PrimitiveCollection<string[]>("ClearedFields")
.HasColumnType("text[]")
.HasColumnName("cleared_fields");
b.Property<long>("ModeratorId")
.HasColumnType("bigint")
.HasColumnName("moderator_id");
b.Property<string>("ModeratorUsername")
.IsRequired()
.HasColumnType("text")
.HasColumnName("moderator_username");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<long?>("ReportId")
.HasColumnType("bigint")
.HasColumnName("report_id");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetMemberName")
.HasColumnType("text")
.HasColumnName("target_member_name");
b.Property<long?>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.Property<string>("TargetUsername")
.HasColumnType("text")
.HasColumnName("target_username");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_audit_log");
b.HasIndex("ReportId")
.HasDatabaseName("ix_audit_log_report_id");
b.ToTable("audit_log", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -270,6 +327,45 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("member_flags", (string)null); b.ToTable("member_flags", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<Instant?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("LocalizationKey")
.HasColumnType("text")
.HasColumnName("localization_key");
b.Property<Dictionary<string, string>>("LocalizationParams")
.HasColumnType("hstore")
.HasColumnName("localization_params");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<long>("TargetId")
.HasColumnType("bigint")
.HasColumnName("target_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_notifications");
b.HasIndex("TargetId")
.HasDatabaseName("ix_notifications_target_id");
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -302,6 +398,55 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("pride_flags", (string)null); b.ToTable("pride_flags", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("Reason")
.HasColumnType("integer")
.HasColumnName("reason");
b.Property<long>("ReporterId")
.HasColumnType("bigint")
.HasColumnName("reporter_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetSnapshot")
.HasColumnType("text")
.HasColumnName("target_snapshot");
b.Property<int>("TargetType")
.HasColumnType("integer")
.HasColumnName("target_type");
b.Property<long>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.HasKey("Id")
.HasName("pk_reports");
b.HasIndex("ReporterId")
.HasDatabaseName("ix_reports_reporter_id");
b.HasIndex("TargetMemberId")
.HasDatabaseName("ix_reports_target_member_id");
b.HasIndex("TargetUserId")
.HasDatabaseName("ix_reports_target_user_id");
b.ToTable("reports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -522,6 +667,16 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("user_flags", (string)null); b.ToTable("user_flags", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
.WithMany()
.HasForeignKey("ReportId")
.HasConstraintName("fk_audit_log_reports_report_id");
b.Navigation("Report");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
@ -584,6 +739,18 @@ namespace Foxnouns.Backend.Database.Migrations
b.Navigation("PrideFlag"); b.Navigation("PrideFlag");
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
.WithMany()
.HasForeignKey("TargetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifications_users_target_id");
b.Navigation("Target");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.User", null) b.HasOne("Foxnouns.Backend.Database.Models.User", null)
@ -594,6 +761,34 @@ namespace Foxnouns.Backend.Database.Migrations
.HasConstraintName("fk_pride_flags_users_user_id"); .HasConstraintName("fk_pride_flags_users_user_id");
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
.WithMany()
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_reporter_id");
b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
.WithMany()
.HasForeignKey("TargetMemberId")
.HasConstraintName("fk_reports_members_target_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_target_user_id");
b.Navigation("Reporter");
b.Navigation("TargetMember");
b.Navigation("TargetUser");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")

View file

@ -0,0 +1,43 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Database.Models;
public class AuditLogEntry : BaseModel
{
public Snowflake ModeratorId { get; init; }
public string ModeratorUsername { get; init; } = string.Empty;
public Snowflake? TargetUserId { get; init; }
public string? TargetUsername { get; init; }
public Snowflake? TargetMemberId { get; init; }
public string? TargetMemberName { get; init; }
public Snowflake? ReportId { get; init; }
public Report? Report { get; init; }
public AuditLogEntryType Type { get; init; }
public string? Reason { get; init; }
public string[]? ClearedFields { get; init; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum AuditLogEntryType
{
IgnoreReport,
WarnUser,
WarnUserAndClearProfile,
SuspendUser,
}

View file

@ -0,0 +1,41 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class Notification : BaseModel
{
public Snowflake TargetId { get; init; }
public User Target { get; init; } = null!;
public NotificationType Type { get; init; }
public string? Message { get; init; }
public string? LocalizationKey { get; init; }
public Dictionary<string, string> LocalizationParams { get; init; } = [];
public Instant? AcknowledgedAt { get; set; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum NotificationType
{
Notice,
Warning,
Suspension,
}

View file

@ -0,0 +1,73 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Database.Models;
public class Report : BaseModel
{
public Snowflake ReporterId { get; init; }
public User Reporter { get; init; } = null!;
public Snowflake TargetUserId { get; init; }
public User TargetUser { get; init; } = null!;
public Snowflake? TargetMemberId { get; init; }
public Member? TargetMember { get; init; }
public ReportStatus Status { get; set; }
public ReportReason Reason { get; init; }
public ReportTargetType TargetType { get; init; }
public string? TargetSnapshot { get; init; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportTargetType
{
User,
Member,
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportStatus
{
Open,
Closed,
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportReason
{
Totalitarianism,
HateSpeech,
Racism,
Homophobia,
Transphobia,
Queerphobia,
Exclusionism,
Sexism,
Ableism,
ChildPornography,
PedophiliaAdvocacy,
Harassment,
Impersonation,
Doxxing,
EncouragingSelfHarm,
Spam,
Trolling,
Advertisement,
CopyrightViolation,
}

View file

@ -0,0 +1,84 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Foxnouns.Backend.Dto;
public record ReportResponse(
Snowflake Id,
PartialUser Reporter,
PartialUser TargetUser,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
PartialMember? TargetMember,
ReportStatus Status,
ReportReason Reason,
ReportTargetType TargetType,
JObject? Snapshot
);
public record AuditLogResponse(
Snowflake Id,
AuditLogEntity Moderator,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
AuditLogEntity? TargetUser,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
AuditLogEntity? TargetMember,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ReportId,
AuditLogEntryType Type,
string? Reason,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string[]? ClearedFields
);
public record NotificationResponse(
Snowflake Id,
NotificationType Type,
string? Message,
string? LocalizationKey,
Dictionary<string, string> LocalizationParams,
bool Acknowledged
);
public record AuditLogEntity(Snowflake Id, string Username);
public record CreateReportRequest(ReportReason Reason);
public record IgnoreReportRequest(string? Reason = null);
public record WarnUserRequest(
string Reason,
FieldsToClear[]? ClearFields = null,
Snowflake? MemberId = null,
Snowflake? ReportId = null
);
public record SuspendUserRequest(string Reason, bool ClearProfile, Snowflake? ReportId = null);
public enum FieldsToClear
{
DisplayName,
Avatar,
Bio,
Links,
Names,
Pronouns,
Fields,
Flags,
CustomPreferences,
}

View file

@ -47,7 +47,8 @@ public record UserResponse(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted
); );
public record AuthMethodResponse( public record AuthMethodResponse(

View file

@ -166,6 +166,8 @@ public enum ErrorCode
MemberNotFound, MemberNotFound,
AccountAlreadyLinked, AccountAlreadyLinked,
LastAuthMethod, LastAuthMethod,
InvalidReportTarget,
InvalidWarningTarget,
} }
public class ValidationError public class ValidationError

View file

@ -113,6 +113,8 @@ public static class WebApplicationExtensions
.AddSingleton<EmailRateLimiter>() .AddSingleton<EmailRateLimiter>()
.AddScoped<UserRendererService>() .AddScoped<UserRendererService>()
.AddScoped<MemberRendererService>() .AddScoped<MemberRendererService>()
.AddScoped<ModerationRendererService>()
.AddScoped<ModerationService>()
.AddScoped<AuthService>() .AddScoped<AuthService>()
.AddScoped<KeyCacheService>() .AddScoped<KeyCacheService>()
.AddScoped<RemoteAuthService>() .AddScoped<RemoteAuthService>()
@ -139,11 +141,13 @@ public static class WebApplicationExtensions
services services
.AddScoped<ErrorHandlerMiddleware>() .AddScoped<ErrorHandlerMiddleware>()
.AddScoped<AuthenticationMiddleware>() .AddScoped<AuthenticationMiddleware>()
.AddScoped<LimitMiddleware>()
.AddScoped<AuthorizationMiddleware>(); .AddScoped<AuthorizationMiddleware>();
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) =>
app.UseMiddleware<ErrorHandlerMiddleware>() app.UseMiddleware<ErrorHandlerMiddleware>()
.UseMiddleware<AuthenticationMiddleware>() .UseMiddleware<AuthenticationMiddleware>()
.UseMiddleware<LimitMiddleware>()
.UseMiddleware<AuthorizationMiddleware>(); .UseMiddleware<AuthorizationMiddleware>();
public static async Task Initialize(this WebApplication app, string[] args) public static async Task Initialize(this WebApplication app, string[] args)

View file

@ -35,7 +35,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Scalar.AspNetCore" Version="1.2.55" /> <PackageReference Include="Scalar.AspNetCore" Version="1.2.55"/>
<PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/> <PackageReference Include="Sentry.AspNetCore" Version="4.13.0"/>
<PackageReference Include="Serilog" Version="4.2.0"/> <PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>

View file

@ -22,17 +22,16 @@ public class AuthorizationMiddleware : IMiddleware
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
Endpoint? endpoint = ctx.GetEndpoint(); Endpoint? endpoint = ctx.GetEndpoint();
AuthorizeAttribute? authorizeAttribute = AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>();
if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0) if (attribute == null || attribute.Scopes.Length == 0)
{ {
await next(ctx); await next(ctx);
return; return;
} }
Token? token = ctx.GetToken(); Token? token = ctx.GetToken();
if (token == null) if (token == null)
{ {
throw new ApiError.Unauthorized( throw new ApiError.Unauthorized(
@ -41,40 +40,15 @@ public class AuthorizationMiddleware : IMiddleware
); );
} }
// Users who got suspended by a moderator can still access *some* endpoints. if (attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any())
if (
token.User.Deleted
&& (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null)
)
{
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
}
if (
authorizeAttribute.Scopes.Length > 0
&& authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()
)
{ {
throw new ApiError.Forbidden( throw new ApiError.Forbidden(
"This endpoint requires ungranted scopes.", "This endpoint requires ungranted scopes.",
authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()), attribute.Scopes.Except(token.Scopes.ExpandScopes()),
ErrorCode.MissingScopes ErrorCode.MissingScopes
); );
} }
if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin)
{
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
}
if (
limitAttribute?.RequireModerator == true
&& token.User.Role is not (UserRole.Admin or UserRole.Moderator)
)
{
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
}
await next(ctx); await next(ctx);
} }
} }
@ -84,11 +58,3 @@ public class AuthorizeAttribute(params string[] scopes) : Attribute
{ {
public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray(); public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
} }
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class LimitAttribute : Attribute
{
public bool UsableBySuspendedUsers { get; init; }
public bool RequireAdmin { get; init; }
public bool RequireModerator { get; init; }
}

View file

@ -0,0 +1,64 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database.Models;
namespace Foxnouns.Backend.Middleware;
public class LimitMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
Endpoint? endpoint = ctx.GetEndpoint();
LimitAttribute? attribute = endpoint?.Metadata.GetMetadata<LimitAttribute>();
if (attribute == null)
{
await next(ctx);
return;
}
Token? token = ctx.GetToken();
if (
token?.User.Deleted == true
&& (!attribute.UsableBySuspendedUsers || token.User.DeletedBy == null)
)
{
throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
}
if (attribute.RequireAdmin && token?.User.Role != UserRole.Admin)
{
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
}
if (
attribute.RequireModerator
&& token?.User.Role is not (UserRole.Admin or UserRole.Moderator)
)
{
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
}
await next(ctx);
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class LimitAttribute : Attribute
{
public bool UsableBySuspendedUsers { get; init; }
public bool RequireAdmin { get; init; }
public bool RequireModerator { get; init; }
}

View file

@ -0,0 +1,73 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Foxnouns.Backend.Services;
public class ModerationRendererService(
DatabaseContext db,
UserRendererService userRenderer,
MemberRendererService memberRenderer
)
{
public ReportResponse RenderReport(Report report)
{
return new ReportResponse(
report.Id,
userRenderer.RenderPartialUser(report.Reporter),
userRenderer.RenderPartialUser(report.TargetUser),
report.TargetMemberId != null
? memberRenderer.RenderPartialMember(report.TargetMember!)
: null,
report.Status,
report.Reason,
report.TargetType,
report.TargetSnapshot != null
? JsonConvert.DeserializeObject<JObject>(report.TargetSnapshot)
: null
);
}
public AuditLogResponse RenderAuditLogEntry(AuditLogEntry entry)
{
return new AuditLogResponse(
Id: entry.Id,
Moderator: ToEntity(entry.ModeratorId, entry.ModeratorUsername)!,
TargetUser: ToEntity(entry.TargetUserId, entry.TargetUsername),
TargetMember: ToEntity(entry.TargetMemberId, entry.TargetMemberName),
ReportId: entry.ReportId,
Type: entry.Type,
Reason: entry.Reason,
ClearedFields: entry.ClearedFields
);
}
public NotificationResponse RenderNotification(Notification notification) =>
new(
notification.Id,
notification.Type,
notification.Message,
notification.LocalizationKey,
notification.LocalizationParams,
notification.AcknowledgedAt != null
);
private static AuditLogEntity? ToEntity(Snowflake? id, string? username) =>
id != null && username != null ? new AuditLogEntity(id.Value, username) : null;
}

View file

@ -0,0 +1,292 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Jobs;
using Humanizer;
using NodaTime;
namespace Foxnouns.Backend.Services;
public class ModerationService(
ILogger logger,
DatabaseContext db,
ISnowflakeGenerator snowflakeGenerator,
IQueue queue,
IClock clock
)
{
private readonly ILogger _logger = logger.ForContext<ModerationService>();
public async Task<AuditLogEntry> IgnoreReportAsync(
User moderator,
Report report,
string? reason = null
)
{
_logger.Information(
"Moderator {ModeratorId} is ignoring report {ReportId} on user {TargetId}",
moderator.Id,
report.Id,
report.TargetUserId
);
var entry = new AuditLogEntry
{
Id = snowflakeGenerator.GenerateSnowflake(),
ModeratorId = moderator.Id,
ModeratorUsername = moderator.Username,
ReportId = report.Id,
Type = AuditLogEntryType.IgnoreReport,
Reason = reason,
};
db.AuditLog.Add(entry);
report.Status = ReportStatus.Closed;
db.Update(report);
await db.SaveChangesAsync();
return entry;
}
public async Task<AuditLogEntry> ExecuteSuspensionAsync(
User moderator,
User target,
Report? report,
string reason,
bool clearProfile
)
{
_logger.Information(
"Moderator {ModeratorId} is suspending user {TargetId}",
moderator.Id,
target.Id
);
var entry = new AuditLogEntry
{
Id = snowflakeGenerator.GenerateSnowflake(),
ModeratorId = moderator.Id,
ModeratorUsername = moderator.Username,
TargetUserId = target.Id,
TargetUsername = target.Username,
ReportId = report?.Id,
Type = AuditLogEntryType.SuspendUser,
Reason = reason,
};
db.AuditLog.Add(entry);
db.Notifications.Add(
new Notification
{
Id = snowflakeGenerator.GenerateSnowflake(),
TargetId = target.Id,
Type = NotificationType.Warning,
Message = null,
LocalizationKey = "notification.suspension",
LocalizationParams = { { "reason", reason } },
}
);
target.Deleted = true;
target.DeletedAt = clock.GetCurrentInstant();
target.DeletedBy = moderator.Id;
if (!clearProfile)
{
db.Update(target);
await db.SaveChangesAsync();
return entry;
}
_logger.Information("Clearing profile of user {TargetId}", target.Id);
target.Username = $"deleted-user-{target.Id}";
target.DisplayName = null;
target.Bio = null;
target.MemberTitle = null;
target.Links = [];
target.Timezone = null;
target.Names = [];
target.Pronouns = [];
target.Fields = [];
target.CustomPreferences = [];
target.ProfileFlags = [];
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(target.Id, null)
);
// TODO: also clear member profiles?
db.Update(target);
await db.SaveChangesAsync();
return entry;
}
public async Task<AuditLogEntry> ExecuteWarningAsync(
User moderator,
User targetUser,
Member? targetMember,
Report? report,
string reason,
FieldsToClear[]? fieldsToClear
)
{
_logger.Information(
"Moderator {ModeratorId} is warning user {TargetId} (member {TargetMemberId})",
moderator.Id,
targetUser.Id,
targetMember?.Id
);
string[]? fields = fieldsToClear?.Select(f => f.Humanize(LetterCasing.LowerCase)).ToArray();
var entry = new AuditLogEntry
{
Id = snowflakeGenerator.GenerateSnowflake(),
ModeratorId = moderator.Id,
ModeratorUsername = moderator.Username,
TargetUserId = targetUser.Id,
TargetUsername = targetUser.Username,
TargetMemberId = targetMember?.Id,
TargetMemberName = targetMember?.Name,
ReportId = report?.Id,
Type =
fields != null
? AuditLogEntryType.WarnUserAndClearProfile
: AuditLogEntryType.WarnUser,
Reason = reason,
ClearedFields = fields,
};
db.AuditLog.Add(entry);
db.Notifications.Add(
new Notification
{
Id = snowflakeGenerator.GenerateSnowflake(),
TargetId = targetUser.Id,
Type = NotificationType.Warning,
Message = null,
LocalizationKey =
fieldsToClear != null
? "notification.warning-cleared-fields"
: "notification.warning",
LocalizationParams =
{
{ "reason", reason },
{
"clearedFields",
string.Join(
"\n",
fieldsToClear?.Select(f => f.Humanize(LetterCasing.LowerCase)) ?? []
)
},
},
}
);
if (targetMember != null && fieldsToClear != null)
{
foreach (FieldsToClear field in fieldsToClear)
{
switch (field)
{
case FieldsToClear.DisplayName:
targetMember.DisplayName = null;
break;
case FieldsToClear.Avatar:
queue.QueueInvocableWithPayload<
MemberAvatarUpdateInvocable,
AvatarUpdatePayload
>(new AvatarUpdatePayload(targetMember.Id, null));
break;
case FieldsToClear.Bio:
targetMember.Bio = null;
break;
case FieldsToClear.Links:
targetMember.Links = [];
break;
case FieldsToClear.Names:
targetMember.Names = [];
break;
case FieldsToClear.Pronouns:
targetMember.Pronouns = [];
break;
case FieldsToClear.Fields:
targetMember.Fields = [];
break;
case FieldsToClear.Flags:
targetMember.ProfileFlags = [];
break;
// custom preferences can't be cleared on member-scoped warnings
case FieldsToClear.CustomPreferences:
default:
break;
}
}
db.Update(targetMember);
}
else if (fieldsToClear != null)
{
foreach (FieldsToClear field in fieldsToClear)
{
switch (field)
{
case FieldsToClear.DisplayName:
targetUser.DisplayName = null;
break;
case FieldsToClear.Avatar:
queue.QueueInvocableWithPayload<
UserAvatarUpdateInvocable,
AvatarUpdatePayload
>(new AvatarUpdatePayload(targetUser.Id, null));
break;
case FieldsToClear.Bio:
targetUser.Bio = null;
break;
case FieldsToClear.Links:
targetUser.Links = [];
break;
case FieldsToClear.Names:
targetUser.Names = [];
break;
case FieldsToClear.Pronouns:
targetUser.Pronouns = [];
break;
case FieldsToClear.Fields:
targetUser.Fields = [];
break;
case FieldsToClear.Flags:
targetUser.ProfileFlags = [];
break;
case FieldsToClear.CustomPreferences:
targetUser.CustomPreferences = [];
break;
default:
break;
}
}
db.Update(targetUser);
}
await db.SaveChangesAsync();
return entry;
}
}

View file

@ -114,7 +114,8 @@ public class UserRendererService(
tokenHidden ? user.ListHidden : null, tokenHidden ? user.ListHidden : null,
tokenHidden ? user.LastActive : null, tokenHidden ? user.LastActive : null,
tokenHidden ? user.LastSidReroll : null, tokenHidden ? user.LastSidReroll : null,
tokenHidden ? user.Timezone ?? "<none>" : null tokenHidden ? user.Timezone ?? "<none>" : null,
tokenHidden ? user.Deleted : null
); );
} }

View file

@ -38,6 +38,7 @@ public static class AuthUtils
"user.read_flags", "user.read_flags",
"user.create_flags", "user.create_flags",
"user.update_flags", "user.update_flags",
"user.moderation",
]; ];
public static readonly string[] MemberScopes = public static readonly string[] MemberScopes =

View file

@ -26,6 +26,7 @@ export type MeUser = UserWithMembers & {
last_active: string; last_active: string;
last_sid_reroll: string; last_sid_reroll: string;
timezone: string; timezone: string;
deleted: boolean;
}; };
export type UserWithMembers = User & { members: PartialMember[] | null }; export type UserWithMembers = User & { members: PartialMember[] | null };

View file

@ -9,17 +9,25 @@
NavItem, NavItem,
} from "@sveltestrap/sveltestrap"; } from "@sveltestrap/sveltestrap";
import { page } from "$app/stores"; import { page } from "$app/stores";
import type { User, Meta } from "$api/models/index"; import type { Meta, MeUser } from "$api/models/index";
import Logo from "$components/Logo.svelte"; import Logo from "$components/Logo.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
type Props = { user: User | null; meta: Meta }; type Props = { user: MeUser | null; meta: Meta };
let { user, meta }: Props = $props(); let { user, meta }: Props = $props();
let isOpen = $state(true); let isOpen = $state(true);
const toggleMenu = () => (isOpen = !isOpen); const toggleMenu = () => (isOpen = !isOpen);
</script> </script>
{#if user && user.deleted}
<div class="suspended-alert text-center py-3 mb-2 px-2">
<strong>{$t("nav.suspended-account-hint")}</strong>
<br />
<a href="/contact">{$t("nav.appeal-suspension-link")}</a>
</div>
{/if}
<Navbar expand="lg" class="mb-4 mx-2"> <Navbar expand="lg" class="mb-4 mx-2">
<NavbarBrand href="/"> <NavbarBrand href="/">
<Logo /> <Logo />
@ -58,6 +66,11 @@
</Navbar> </Navbar>
<style> <style>
.suspended-alert {
color: var(--bs-danger-text-emphasis);
background-color: var(--bs-danger-bg-subtle);
}
/* These exact values make it look almost identical to the SVG version, which is what we want */ /* These exact values make it look almost identical to the SVG version, which is what we want */
#beta-text { #beta-text {
font-size: 0.7em; font-size: 0.7em;

View file

@ -2,7 +2,9 @@
"hello": "Hello, {{name}}!", "hello": "Hello, {{name}}!",
"nav": { "nav": {
"log-in": "Log in or sign up", "log-in": "Log in or sign up",
"settings": "Settings" "settings": "Settings",
"suspended-account-hint": "Your account has been suspended. Your profile has been hidden and you will not be able to change any settings.",
"appeal-suspension-link": "I want to appeal"
}, },
"avatar-tooltip": "Avatar for {{name}}", "avatar-tooltip": "Avatar for {{name}}",
"profile": { "profile": {