diff --git a/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.Designer.cs b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.Designer.cs
new file mode 100644
index 0000000000..2f06e5ff98
--- /dev/null
+++ b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.Designer.cs
@@ -0,0 +1,1913 @@
+//
+using System;
+using System.Net;
+using System.Text.Json;
+using Content.Server.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using NpgsqlTypes;
+
+#nullable disable
+
+namespace Content.Server.Database.Migrations.Postgres
+{
+ [DbContext(typeof(PostgresServerDbContext))]
+ [Migration("20240606121555_ban_notify_trigger")]
+ partial class ban_notify_trigger
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Content.Server.Database.Admin", b =>
+ {
+ b.Property("UserId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("AdminRankId")
+ .HasColumnType("integer")
+ .HasColumnName("admin_rank_id");
+
+ b.Property("Title")
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("UserId")
+ .HasName("PK_admin");
+
+ b.HasIndex("AdminRankId")
+ .HasDatabaseName("IX_admin_admin_rank_id");
+
+ b.ToTable("admin", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminFlag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("admin_flag_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AdminId")
+ .HasColumnType("uuid")
+ .HasColumnName("admin_id");
+
+ b.Property("Flag")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("flag");
+
+ b.Property("Negative")
+ .HasColumnType("boolean")
+ .HasColumnName("negative");
+
+ b.HasKey("Id")
+ .HasName("PK_admin_flag");
+
+ b.HasIndex("AdminId")
+ .HasDatabaseName("IX_admin_flag_admin_id");
+
+ b.HasIndex("Flag", "AdminId")
+ .IsUnique();
+
+ b.ToTable("admin_flag", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminLog", b =>
+ {
+ b.Property("RoundId")
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ b.Property("Id")
+ .HasColumnType("integer")
+ .HasColumnName("admin_log_id");
+
+ b.Property("Date")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("date");
+
+ b.Property("Impact")
+ .HasColumnType("smallint")
+ .HasColumnName("impact");
+
+ b.Property("Json")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("json");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("Type")
+ .HasColumnType("integer")
+ .HasColumnName("type");
+
+ b.HasKey("RoundId", "Id")
+ .HasName("PK_admin_log");
+
+ b.HasIndex("Date");
+
+ b.HasIndex("Message")
+ .HasAnnotation("Npgsql:TsVectorConfig", "english");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Message"), "GIN");
+
+ b.HasIndex("Type")
+ .HasDatabaseName("IX_admin_log_type");
+
+ b.ToTable("admin_log", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b =>
+ {
+ b.Property("RoundId")
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ b.Property("LogId")
+ .HasColumnType("integer")
+ .HasColumnName("log_id");
+
+ b.Property("PlayerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_user_id");
+
+ b.HasKey("RoundId", "LogId", "PlayerUserId")
+ .HasName("PK_admin_log_player");
+
+ b.HasIndex("PlayerUserId")
+ .HasDatabaseName("IX_admin_log_player_player_user_id");
+
+ b.ToTable("admin_log_player", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("admin_messages_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property("Dismissed")
+ .HasColumnType("boolean")
+ .HasColumnName("dismissed");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiration_time");
+
+ b.Property("LastEditedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_edited_at");
+
+ b.Property("LastEditedById")
+ .HasColumnType("uuid")
+ .HasColumnName("last_edited_by_id");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("character varying(4096)")
+ .HasColumnName("message");
+
+ b.Property("PlayerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_user_id");
+
+ b.Property("PlaytimeAtNote")
+ .HasColumnType("interval")
+ .HasColumnName("playtime_at_note");
+
+ b.Property("RoundId")
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ b.Property("Seen")
+ .HasColumnType("boolean")
+ .HasColumnName("seen");
+
+ b.HasKey("Id")
+ .HasName("PK_admin_messages");
+
+ b.HasIndex("CreatedById");
+
+ b.HasIndex("DeletedById");
+
+ b.HasIndex("LastEditedById");
+
+ b.HasIndex("PlayerUserId")
+ .HasDatabaseName("IX_admin_messages_player_user_id");
+
+ b.HasIndex("RoundId")
+ .HasDatabaseName("IX_admin_messages_round_id");
+
+ b.ToTable("admin_messages", null, t =>
+ {
+ t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen");
+ });
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminNote", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("admin_notes_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiration_time");
+
+ b.Property("LastEditedAt")
+ .IsRequired()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_edited_at");
+
+ b.Property("LastEditedById")
+ .HasColumnType("uuid")
+ .HasColumnName("last_edited_by_id");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("character varying(4096)")
+ .HasColumnName("message");
+
+ b.Property("PlayerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_user_id");
+
+ b.Property("PlaytimeAtNote")
+ .HasColumnType("interval")
+ .HasColumnName("playtime_at_note");
+
+ b.Property("RoundId")
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ b.Property("Secret")
+ .HasColumnType("boolean")
+ .HasColumnName("secret");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasColumnName("severity");
+
+ b.HasKey("Id")
+ .HasName("PK_admin_notes");
+
+ b.HasIndex("CreatedById");
+
+ b.HasIndex("DeletedById");
+
+ b.HasIndex("LastEditedById");
+
+ b.HasIndex("PlayerUserId")
+ .HasDatabaseName("IX_admin_notes_player_user_id");
+
+ b.HasIndex("RoundId")
+ .HasDatabaseName("IX_admin_notes_round_id");
+
+ b.ToTable("admin_notes", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminRank", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("admin_rank_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("PK_admin_rank");
+
+ b.ToTable("admin_rank", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("admin_rank_flag_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AdminRankId")
+ .HasColumnType("integer")
+ .HasColumnName("admin_rank_id");
+
+ b.Property("Flag")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("flag");
+
+ b.HasKey("Id")
+ .HasName("PK_admin_rank_flag");
+
+ b.HasIndex("AdminRankId");
+
+ b.HasIndex("Flag", "AdminRankId")
+ .IsUnique();
+
+ b.ToTable("admin_rank_flag", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("admin_watchlists_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiration_time");
+
+ b.Property("LastEditedAt")
+ .IsRequired()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_edited_at");
+
+ b.Property("LastEditedById")
+ .HasColumnType("uuid")
+ .HasColumnName("last_edited_by_id");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("character varying(4096)")
+ .HasColumnName("message");
+
+ b.Property("PlayerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_user_id");
+
+ b.Property("PlaytimeAtNote")
+ .HasColumnType("interval")
+ .HasColumnName("playtime_at_note");
+
+ b.Property("RoundId")
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ b.HasKey("Id")
+ .HasName("PK_admin_watchlists");
+
+ b.HasIndex("CreatedById");
+
+ b.HasIndex("DeletedById");
+
+ b.HasIndex("LastEditedById");
+
+ b.HasIndex("PlayerUserId")
+ .HasDatabaseName("IX_admin_watchlists_player_user_id");
+
+ b.HasIndex("RoundId")
+ .HasDatabaseName("IX_admin_watchlists_round_id");
+
+ b.ToTable("admin_watchlists", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Antag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("antag_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AntagName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("antag_name");
+
+ b.Property("ProfileId")
+ .HasColumnType("integer")
+ .HasColumnName("profile_id");
+
+ b.HasKey("Id")
+ .HasName("PK_antag");
+
+ b.HasIndex("ProfileId", "AntagName")
+ .IsUnique();
+
+ b.ToTable("antag", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AssignedUserId", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("assigned_user_id_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("user_name");
+
+ b.HasKey("Id")
+ .HasName("PK_assigned_user_id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.HasIndex("UserName")
+ .IsUnique();
+
+ b.ToTable("assigned_user_id", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("connection_log_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .IsRequired()
+ .HasColumnType("inet")
+ .HasColumnName("address");
+
+ b.Property("Denied")
+ .HasColumnType("smallint")
+ .HasColumnName("denied");
+
+ b.Property("HWId")
+ .HasColumnType("bytea")
+ .HasColumnName("hwid");
+
+ b.Property("ServerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("server_id");
+
+ b.Property("Time")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("time");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("user_name");
+
+ b.HasKey("Id")
+ .HasName("PK_connection_log");
+
+ b.HasIndex("ServerId")
+ .HasDatabaseName("IX_connection_log_server_id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("connection_log", null, t =>
+ {
+ t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address");
+ });
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Job", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("job_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("JobName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("job_name");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("priority");
+
+ b.Property("ProfileId")
+ .HasColumnType("integer")
+ .HasColumnName("profile_id");
+
+ b.HasKey("Id")
+ .HasName("PK_job");
+
+ b.HasIndex("ProfileId");
+
+ b.HasIndex("ProfileId", "JobName")
+ .IsUnique();
+
+ b.HasIndex(new[] { "ProfileId" }, "IX_job_one_high_priority")
+ .IsUnique()
+ .HasFilter("priority = 3");
+
+ b.ToTable("job", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.PlayTime", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("play_time_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("PlayerId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_id");
+
+ b.Property("TimeSpent")
+ .HasColumnType("interval")
+ .HasColumnName("time_spent");
+
+ b.Property("Tracker")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("tracker");
+
+ b.HasKey("Id")
+ .HasName("PK_play_time");
+
+ b.HasIndex("PlayerId", "Tracker")
+ .IsUnique();
+
+ b.ToTable("play_time", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Player", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("player_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FirstSeenTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_time");
+
+ b.Property("LastReadRules")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_read_rules");
+
+ b.Property("LastSeenAddress")
+ .IsRequired()
+ .HasColumnType("inet")
+ .HasColumnName("last_seen_address");
+
+ b.Property("LastSeenHWId")
+ .HasColumnType("bytea")
+ .HasColumnName("last_seen_hwid");
+
+ b.Property("LastSeenTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_time");
+
+ b.Property("LastSeenUserName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("last_seen_user_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("PK_player");
+
+ b.HasAlternateKey("UserId")
+ .HasName("ak_player_user_id");
+
+ b.HasIndex("LastSeenUserName");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("player", null, t =>
+ {
+ t.HasCheckConstraint("LastSeenAddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= last_seen_address");
+ });
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Preference", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("preference_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AdminOOCColor")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("admin_ooc_color");
+
+ b.Property("SelectedCharacterSlot")
+ .HasColumnType("integer")
+ .HasColumnName("selected_character_slot");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("PK_preference");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("preference", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Profile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("profile_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Age")
+ .HasColumnType("integer")
+ .HasColumnName("age");
+
+ b.Property("CharacterName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("char_name");
+
+ b.Property("EyeColor")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("eye_color");
+
+ b.Property("FacialHairColor")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("facial_hair_color");
+
+ b.Property("FacialHairName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("facial_hair_name");
+
+ b.Property("FlavorText")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("flavor_text");
+
+ b.Property("Gender")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("gender");
+
+ b.Property("HairColor")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("hair_color");
+
+ b.Property("HairName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("hair_name");
+
+ b.Property("Markings")
+ .HasColumnType("jsonb")
+ .HasColumnName("markings");
+
+ b.Property("PreferenceId")
+ .HasColumnType("integer")
+ .HasColumnName("preference_id");
+
+ b.Property("PreferenceUnavailable")
+ .HasColumnType("integer")
+ .HasColumnName("pref_unavailable");
+
+ b.Property("Sex")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("sex");
+
+ b.Property("SkinColor")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("skin_color");
+
+ b.Property("Slot")
+ .HasColumnType("integer")
+ .HasColumnName("slot");
+
+ b.Property("SpawnPriority")
+ .HasColumnType("integer")
+ .HasColumnName("spawn_priority");
+
+ b.Property("Species")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("species");
+
+ b.HasKey("Id")
+ .HasName("PK_profile");
+
+ b.HasIndex("PreferenceId")
+ .HasDatabaseName("IX_profile_preference_id");
+
+ b.HasIndex("Slot", "PreferenceId")
+ .IsUnique();
+
+ b.ToTable("profile", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("profile_loadout_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("LoadoutName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("loadout_name");
+
+ b.Property("ProfileLoadoutGroupId")
+ .HasColumnType("integer")
+ .HasColumnName("profile_loadout_group_id");
+
+ b.HasKey("Id")
+ .HasName("PK_profile_loadout");
+
+ b.HasIndex("ProfileLoadoutGroupId");
+
+ b.ToTable("profile_loadout", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("profile_loadout_group_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("GroupName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("group_name");
+
+ b.Property("ProfileRoleLoadoutId")
+ .HasColumnType("integer")
+ .HasColumnName("profile_role_loadout_id");
+
+ b.HasKey("Id")
+ .HasName("PK_profile_loadout_group");
+
+ b.HasIndex("ProfileRoleLoadoutId");
+
+ b.ToTable("profile_loadout_group", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("profile_role_loadout_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ProfileId")
+ .HasColumnType("integer")
+ .HasColumnName("profile_id");
+
+ b.Property("RoleName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("role_name");
+
+ b.HasKey("Id")
+ .HasName("PK_profile_role_loadout");
+
+ b.HasIndex("ProfileId");
+
+ b.ToTable("profile_role_loadout", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b =>
+ {
+ b.Property("PlayerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_user_id");
+
+ b.Property("RoleId")
+ .HasColumnType("text")
+ .HasColumnName("role_id");
+
+ b.HasKey("PlayerUserId", "RoleId")
+ .HasName("PK_role_whitelists");
+
+ b.ToTable("role_whitelists", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Round", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ServerId")
+ .HasColumnType("integer")
+ .HasColumnName("server_id");
+
+ b.Property("StartDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_date");
+
+ b.HasKey("Id")
+ .HasName("PK_round");
+
+ b.HasIndex("ServerId")
+ .HasDatabaseName("IX_round_server_id");
+
+ b.HasIndex("StartDate");
+
+ b.ToTable("round", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Server", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("server_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("PK_server");
+
+ b.ToTable("server", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerBan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("server_ban_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .HasColumnType("inet")
+ .HasColumnName("address");
+
+ b.Property("AutoDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("auto_delete");
+
+ b.Property("BanTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ban_time");
+
+ b.Property("BanningAdmin")
+ .HasColumnType("uuid")
+ .HasColumnName("banning_admin");
+
+ b.Property("ExemptFlags")
+ .HasColumnType("integer")
+ .HasColumnName("exempt_flags");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiration_time");
+
+ b.Property("HWId")
+ .HasColumnType("bytea")
+ .HasColumnName("hwid");
+
+ b.Property("Hidden")
+ .HasColumnType("boolean")
+ .HasColumnName("hidden");
+
+ b.Property("LastEditedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_edited_at");
+
+ b.Property("LastEditedById")
+ .HasColumnType("uuid")
+ .HasColumnName("last_edited_by_id");
+
+ b.Property("PlayerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_user_id");
+
+ b.Property("PlaytimeAtNote")
+ .HasColumnType("interval")
+ .HasColumnName("playtime_at_note");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("RoundId")
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasColumnName("severity");
+
+ b.HasKey("Id")
+ .HasName("PK_server_ban");
+
+ b.HasIndex("Address");
+
+ b.HasIndex("BanningAdmin");
+
+ b.HasIndex("LastEditedById");
+
+ b.HasIndex("PlayerUserId")
+ .HasDatabaseName("IX_server_ban_player_user_id");
+
+ b.HasIndex("RoundId")
+ .HasDatabaseName("IX_server_ban_round_id");
+
+ b.ToTable("server_ban", null, t =>
+ {
+ t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address");
+
+ t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL");
+ });
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerBanExemption", b =>
+ {
+ b.Property("UserId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Flags")
+ .HasColumnType("integer")
+ .HasColumnName("flags");
+
+ b.HasKey("UserId")
+ .HasName("PK_server_ban_exemption");
+
+ b.ToTable("server_ban_exemption", null, t =>
+ {
+ t.HasCheckConstraint("FlagsNotZero", "flags != 0");
+ });
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerBanHit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("server_ban_hit_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BanId")
+ .HasColumnType("integer")
+ .HasColumnName("ban_id");
+
+ b.Property("ConnectionId")
+ .HasColumnType("integer")
+ .HasColumnName("connection_id");
+
+ b.HasKey("Id")
+ .HasName("PK_server_ban_hit");
+
+ b.HasIndex("BanId")
+ .HasDatabaseName("IX_server_ban_hit_ban_id");
+
+ b.HasIndex("ConnectionId")
+ .HasDatabaseName("IX_server_ban_hit_connection_id");
+
+ b.ToTable("server_ban_hit", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("server_role_ban_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .HasColumnType("inet")
+ .HasColumnName("address");
+
+ b.Property("BanTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ban_time");
+
+ b.Property("BanningAdmin")
+ .HasColumnType("uuid")
+ .HasColumnName("banning_admin");
+
+ b.Property("ExpirationTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiration_time");
+
+ b.Property("HWId")
+ .HasColumnType("bytea")
+ .HasColumnName("hwid");
+
+ b.Property("Hidden")
+ .HasColumnType("boolean")
+ .HasColumnName("hidden");
+
+ b.Property("LastEditedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_edited_at");
+
+ b.Property("LastEditedById")
+ .HasColumnType("uuid")
+ .HasColumnName("last_edited_by_id");
+
+ b.Property("PlayerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("player_user_id");
+
+ b.Property("PlaytimeAtNote")
+ .HasColumnType("interval")
+ .HasColumnName("playtime_at_note");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("role_id");
+
+ b.Property("RoundId")
+ .HasColumnType("integer")
+ .HasColumnName("round_id");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasColumnName("severity");
+
+ b.HasKey("Id")
+ .HasName("PK_server_role_ban");
+
+ b.HasIndex("Address");
+
+ b.HasIndex("BanningAdmin");
+
+ b.HasIndex("LastEditedById");
+
+ b.HasIndex("PlayerUserId")
+ .HasDatabaseName("IX_server_role_ban_player_user_id");
+
+ b.HasIndex("RoundId")
+ .HasDatabaseName("IX_server_role_ban_round_id");
+
+ b.ToTable("server_role_ban", null, t =>
+ {
+ t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address");
+
+ t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL");
+ });
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("role_unban_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BanId")
+ .HasColumnType("integer")
+ .HasColumnName("ban_id");
+
+ b.Property("UnbanTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("unban_time");
+
+ b.Property("UnbanningAdmin")
+ .HasColumnType("uuid")
+ .HasColumnName("unbanning_admin");
+
+ b.HasKey("Id")
+ .HasName("PK_server_role_unban");
+
+ b.HasIndex("BanId")
+ .IsUnique();
+
+ b.ToTable("server_role_unban", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerUnban", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("unban_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BanId")
+ .HasColumnType("integer")
+ .HasColumnName("ban_id");
+
+ b.Property("UnbanTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("unban_time");
+
+ b.Property("UnbanningAdmin")
+ .HasColumnType("uuid")
+ .HasColumnName("unbanning_admin");
+
+ b.HasKey("Id")
+ .HasName("PK_server_unban");
+
+ b.HasIndex("BanId")
+ .IsUnique();
+
+ b.ToTable("server_unban", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Trait", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("trait_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ProfileId")
+ .HasColumnType("integer")
+ .HasColumnName("profile_id");
+
+ b.Property("TraitName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trait_name");
+
+ b.HasKey("Id")
+ .HasName("PK_trait");
+
+ b.HasIndex("ProfileId", "TraitName")
+ .IsUnique();
+
+ b.ToTable("trait", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("uploaded_resource_log_id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Data")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("data");
+
+ b.Property("Date")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("date");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("path");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("PK_uploaded_resource_log");
+
+ b.ToTable("uploaded_resource_log", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Whitelist", b =>
+ {
+ b.Property("UserId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("UserId")
+ .HasName("PK_whitelist");
+
+ b.ToTable("whitelist", (string)null);
+ });
+
+ modelBuilder.Entity("PlayerRound", b =>
+ {
+ b.Property("PlayersId")
+ .HasColumnType("integer")
+ .HasColumnName("players_id");
+
+ b.Property("RoundsId")
+ .HasColumnType("integer")
+ .HasColumnName("rounds_id");
+
+ b.HasKey("PlayersId", "RoundsId")
+ .HasName("PK_player_round");
+
+ b.HasIndex("RoundsId")
+ .HasDatabaseName("IX_player_round_rounds_id");
+
+ b.ToTable("player_round", (string)null);
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Admin", b =>
+ {
+ b.HasOne("Content.Server.Database.AdminRank", "AdminRank")
+ .WithMany("Admins")
+ .HasForeignKey("AdminRankId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_admin_rank_admin_rank_id");
+
+ b.Navigation("AdminRank");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminFlag", b =>
+ {
+ b.HasOne("Content.Server.Database.Admin", "Admin")
+ .WithMany("Flags")
+ .HasForeignKey("AdminId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_admin_flag_admin_admin_id");
+
+ b.Navigation("Admin");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminLog", b =>
+ {
+ b.HasOne("Content.Server.Database.Round", "Round")
+ .WithMany("AdminLogs")
+ .HasForeignKey("RoundId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_admin_log_round_round_id");
+
+ b.Navigation("Round");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", "Player")
+ .WithMany("AdminLogs")
+ .HasForeignKey("PlayerUserId")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_admin_log_player_player_player_user_id");
+
+ b.HasOne("Content.Server.Database.AdminLog", "Log")
+ .WithMany("Players")
+ .HasForeignKey("RoundId", "LogId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_admin_log_player_admin_log_round_id_log_id");
+
+ b.Navigation("Log");
+
+ b.Navigation("Player");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminMessage", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", "CreatedBy")
+ .WithMany("AdminMessagesCreated")
+ .HasForeignKey("CreatedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_messages_player_created_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "DeletedBy")
+ .WithMany("AdminMessagesDeleted")
+ .HasForeignKey("DeletedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_messages_player_deleted_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "LastEditedBy")
+ .WithMany("AdminMessagesLastEdited")
+ .HasForeignKey("LastEditedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_messages_player_last_edited_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "Player")
+ .WithMany("AdminMessagesReceived")
+ .HasForeignKey("PlayerUserId")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("FK_admin_messages_player_player_user_id");
+
+ b.HasOne("Content.Server.Database.Round", "Round")
+ .WithMany()
+ .HasForeignKey("RoundId")
+ .HasConstraintName("FK_admin_messages_round_round_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("LastEditedBy");
+
+ b.Navigation("Player");
+
+ b.Navigation("Round");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminNote", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", "CreatedBy")
+ .WithMany("AdminNotesCreated")
+ .HasForeignKey("CreatedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_notes_player_created_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "DeletedBy")
+ .WithMany("AdminNotesDeleted")
+ .HasForeignKey("DeletedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_notes_player_deleted_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "LastEditedBy")
+ .WithMany("AdminNotesLastEdited")
+ .HasForeignKey("LastEditedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_notes_player_last_edited_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "Player")
+ .WithMany("AdminNotesReceived")
+ .HasForeignKey("PlayerUserId")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("FK_admin_notes_player_player_user_id");
+
+ b.HasOne("Content.Server.Database.Round", "Round")
+ .WithMany()
+ .HasForeignKey("RoundId")
+ .HasConstraintName("FK_admin_notes_round_round_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("LastEditedBy");
+
+ b.Navigation("Player");
+
+ b.Navigation("Round");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b =>
+ {
+ b.HasOne("Content.Server.Database.AdminRank", "Rank")
+ .WithMany("Flags")
+ .HasForeignKey("AdminRankId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_admin_rank_flag_admin_rank_admin_rank_id");
+
+ b.Navigation("Rank");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", "CreatedBy")
+ .WithMany("AdminWatchlistsCreated")
+ .HasForeignKey("CreatedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_watchlists_player_created_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "DeletedBy")
+ .WithMany("AdminWatchlistsDeleted")
+ .HasForeignKey("DeletedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_watchlists_player_deleted_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "LastEditedBy")
+ .WithMany("AdminWatchlistsLastEdited")
+ .HasForeignKey("LastEditedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_admin_watchlists_player_last_edited_by_id");
+
+ b.HasOne("Content.Server.Database.Player", "Player")
+ .WithMany("AdminWatchlistsReceived")
+ .HasForeignKey("PlayerUserId")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .HasConstraintName("FK_admin_watchlists_player_player_user_id");
+
+ b.HasOne("Content.Server.Database.Round", "Round")
+ .WithMany()
+ .HasForeignKey("RoundId")
+ .HasConstraintName("FK_admin_watchlists_round_round_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("LastEditedBy");
+
+ b.Navigation("Player");
+
+ b.Navigation("Round");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Antag", b =>
+ {
+ b.HasOne("Content.Server.Database.Profile", "Profile")
+ .WithMany("Antags")
+ .HasForeignKey("ProfileId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_antag_profile_profile_id");
+
+ b.Navigation("Profile");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
+ {
+ b.HasOne("Content.Server.Database.Server", "Server")
+ .WithMany("ConnectionLogs")
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .IsRequired()
+ .HasConstraintName("FK_connection_log_server_server_id");
+
+ b.Navigation("Server");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Job", b =>
+ {
+ b.HasOne("Content.Server.Database.Profile", "Profile")
+ .WithMany("Jobs")
+ .HasForeignKey("ProfileId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_job_profile_profile_id");
+
+ b.Navigation("Profile");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Profile", b =>
+ {
+ b.HasOne("Content.Server.Database.Preference", "Preference")
+ .WithMany("Profiles")
+ .HasForeignKey("PreferenceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_profile_preference_preference_id");
+
+ b.Navigation("Preference");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b =>
+ {
+ b.HasOne("Content.Server.Database.ProfileLoadoutGroup", "ProfileLoadoutGroup")
+ .WithMany("Loadouts")
+ .HasForeignKey("ProfileLoadoutGroupId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_profile_loadout_profile_loadout_group_profile_loadout_group~");
+
+ b.Navigation("ProfileLoadoutGroup");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b =>
+ {
+ b.HasOne("Content.Server.Database.ProfileRoleLoadout", "ProfileRoleLoadout")
+ .WithMany("Groups")
+ .HasForeignKey("ProfileRoleLoadoutId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_profile_loadout_group_profile_role_loadout_profile_role_loa~");
+
+ b.Navigation("ProfileRoleLoadout");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b =>
+ {
+ b.HasOne("Content.Server.Database.Profile", "Profile")
+ .WithMany("Loadouts")
+ .HasForeignKey("ProfileId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_profile_role_loadout_profile_profile_id");
+
+ b.Navigation("Profile");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", "Player")
+ .WithMany("JobWhitelists")
+ .HasForeignKey("PlayerUserId")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_role_whitelists_player_player_user_id");
+
+ b.Navigation("Player");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Round", b =>
+ {
+ b.HasOne("Content.Server.Database.Server", "Server")
+ .WithMany("Rounds")
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_round_server_server_id");
+
+ b.Navigation("Server");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerBan", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", "CreatedBy")
+ .WithMany("AdminServerBansCreated")
+ .HasForeignKey("BanningAdmin")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_server_ban_player_banning_admin");
+
+ b.HasOne("Content.Server.Database.Player", "LastEditedBy")
+ .WithMany("AdminServerBansLastEdited")
+ .HasForeignKey("LastEditedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_server_ban_player_last_edited_by_id");
+
+ b.HasOne("Content.Server.Database.Round", "Round")
+ .WithMany()
+ .HasForeignKey("RoundId")
+ .HasConstraintName("FK_server_ban_round_round_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("LastEditedBy");
+
+ b.Navigation("Round");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerBanHit", b =>
+ {
+ b.HasOne("Content.Server.Database.ServerBan", "Ban")
+ .WithMany("BanHits")
+ .HasForeignKey("BanId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_server_ban_hit_server_ban_ban_id");
+
+ b.HasOne("Content.Server.Database.ConnectionLog", "Connection")
+ .WithMany("BanHits")
+ .HasForeignKey("ConnectionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_server_ban_hit_connection_log_connection_id");
+
+ b.Navigation("Ban");
+
+ b.Navigation("Connection");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", "CreatedBy")
+ .WithMany("AdminServerRoleBansCreated")
+ .HasForeignKey("BanningAdmin")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_server_role_ban_player_banning_admin");
+
+ b.HasOne("Content.Server.Database.Player", "LastEditedBy")
+ .WithMany("AdminServerRoleBansLastEdited")
+ .HasForeignKey("LastEditedById")
+ .HasPrincipalKey("UserId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("FK_server_role_ban_player_last_edited_by_id");
+
+ b.HasOne("Content.Server.Database.Round", "Round")
+ .WithMany()
+ .HasForeignKey("RoundId")
+ .HasConstraintName("FK_server_role_ban_round_round_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("LastEditedBy");
+
+ b.Navigation("Round");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b =>
+ {
+ b.HasOne("Content.Server.Database.ServerRoleBan", "Ban")
+ .WithOne("Unban")
+ .HasForeignKey("Content.Server.Database.ServerRoleUnban", "BanId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_server_role_unban_server_role_ban_ban_id");
+
+ b.Navigation("Ban");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerUnban", b =>
+ {
+ b.HasOne("Content.Server.Database.ServerBan", "Ban")
+ .WithOne("Unban")
+ .HasForeignKey("Content.Server.Database.ServerUnban", "BanId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_server_unban_server_ban_ban_id");
+
+ b.Navigation("Ban");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Trait", b =>
+ {
+ b.HasOne("Content.Server.Database.Profile", "Profile")
+ .WithMany("Traits")
+ .HasForeignKey("ProfileId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_trait_profile_profile_id");
+
+ b.Navigation("Profile");
+ });
+
+ modelBuilder.Entity("PlayerRound", b =>
+ {
+ b.HasOne("Content.Server.Database.Player", null)
+ .WithMany()
+ .HasForeignKey("PlayersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_player_round_player_players_id");
+
+ b.HasOne("Content.Server.Database.Round", null)
+ .WithMany()
+ .HasForeignKey("RoundsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_player_round_round_rounds_id");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Admin", b =>
+ {
+ b.Navigation("Flags");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminLog", b =>
+ {
+ b.Navigation("Players");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.AdminRank", b =>
+ {
+ b.Navigation("Admins");
+
+ b.Navigation("Flags");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ConnectionLog", b =>
+ {
+ b.Navigation("BanHits");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Player", b =>
+ {
+ b.Navigation("AdminLogs");
+
+ b.Navigation("AdminMessagesCreated");
+
+ b.Navigation("AdminMessagesDeleted");
+
+ b.Navigation("AdminMessagesLastEdited");
+
+ b.Navigation("AdminMessagesReceived");
+
+ b.Navigation("AdminNotesCreated");
+
+ b.Navigation("AdminNotesDeleted");
+
+ b.Navigation("AdminNotesLastEdited");
+
+ b.Navigation("AdminNotesReceived");
+
+ b.Navigation("AdminServerBansCreated");
+
+ b.Navigation("AdminServerBansLastEdited");
+
+ b.Navigation("AdminServerRoleBansCreated");
+
+ b.Navigation("AdminServerRoleBansLastEdited");
+
+ b.Navigation("AdminWatchlistsCreated");
+
+ b.Navigation("AdminWatchlistsDeleted");
+
+ b.Navigation("AdminWatchlistsLastEdited");
+
+ b.Navigation("AdminWatchlistsReceived");
+
+ b.Navigation("JobWhitelists");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Preference", b =>
+ {
+ b.Navigation("Profiles");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Profile", b =>
+ {
+ b.Navigation("Antags");
+
+ b.Navigation("Jobs");
+
+ b.Navigation("Loadouts");
+
+ b.Navigation("Traits");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b =>
+ {
+ b.Navigation("Loadouts");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b =>
+ {
+ b.Navigation("Groups");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Round", b =>
+ {
+ b.Navigation("AdminLogs");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.Server", b =>
+ {
+ b.Navigation("ConnectionLogs");
+
+ b.Navigation("Rounds");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerBan", b =>
+ {
+ b.Navigation("BanHits");
+
+ b.Navigation("Unban");
+ });
+
+ modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b =>
+ {
+ b.Navigation("Unban");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.cs b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.cs
new file mode 100644
index 0000000000..b84d230b45
--- /dev/null
+++ b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.cs
@@ -0,0 +1,44 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Content.Server.Database.Migrations.Postgres
+{
+ ///
+ public partial class ban_notify_trigger : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ create or replace function send_server_ban_notification()
+ returns trigger as $$
+ declare
+ x_server_id integer;
+ begin
+ select round.server_id into x_server_id from round where round.round_id = NEW.round_id;
+
+ perform pg_notify('ban_notification', json_build_object('ban_id', NEW.server_ban_id, 'server_id', x_server_id)::text);
+ return NEW;
+ end;
+ $$ LANGUAGE plpgsql;
+ """);
+
+ migrationBuilder.Sql("""
+ create or replace trigger notify_on_server_ban_insert
+ after insert on server_ban
+ for each row
+ execute function send_server_ban_notification();
+ """);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("""
+ drop trigger notify_on_server_ban_insert on server_ban;
+ drop function send_server_ban_notification;
+ """);
+ }
+ }
+}
diff --git a/Content.Server.Database/Model.cs b/Content.Server.Database/Model.cs
index d195201c29..ea63c41fc2 100644
--- a/Content.Server.Database/Model.cs
+++ b/Content.Server.Database/Model.cs
@@ -705,6 +705,11 @@ namespace Content.Server.Database
/// Intended for use with residential IP ranges that are often used maliciously.
///
BlacklistedRange = 1 << 2,
+
+ ///
+ /// Represents having all possible exemption flags.
+ ///
+ All = int.MaxValue,
// @formatter:on
}
@@ -903,7 +908,7 @@ namespace Content.Server.Database
Panic = 3,
/*
* TODO: Remove baby jail code once a more mature gateway process is established. This code is only being issued as a stopgap to help with potential tiding in the immediate future.
- *
+ *
* If baby jail is removed, please reserve this value for as long as can reasonably be done to prevent causing ambiguity in connection denial reasons.
* Reservation by commenting out the value is likely sufficient for this purpose, but may impact projects which depend on SS14 like SS14.Admin.
*/
diff --git a/Content.Server/Administration/Managers/BanManager.Notification.cs b/Content.Server/Administration/Managers/BanManager.Notification.cs
new file mode 100644
index 0000000000..e9bfa62884
--- /dev/null
+++ b/Content.Server/Administration/Managers/BanManager.Notification.cs
@@ -0,0 +1,123 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Content.Server.Database;
+
+namespace Content.Server.Administration.Managers;
+
+public sealed partial class BanManager
+{
+ // Responsible for ban notification handling.
+ // Ban notifications are sent through the database to notify the entire server group that a new ban has been added,
+ // so that people will get kicked if they are banned on a different server than the one that placed the ban.
+ //
+ // Ban notifications are currently sent by a trigger in the database, automatically.
+
+ ///
+ /// The notification channel used to broadcast information about new bans.
+ ///
+ public const string BanNotificationChannel = "ban_notification";
+
+ // Rate limit to avoid undue load from mass-ban imports.
+ // Only process 10 bans per 30 second interval.
+ //
+ // I had the idea of maybe binning this by postgres transaction ID,
+ // to avoid any possibility of dropping a normal ban by coincidence.
+ // Didn't bother implementing this though.
+ private static readonly TimeSpan BanNotificationRateLimitTime = TimeSpan.FromSeconds(30);
+ private const int BanNotificationRateLimitCount = 10;
+
+ private readonly object _banNotificationRateLimitStateLock = new();
+ private TimeSpan _banNotificationRateLimitStart;
+ private int _banNotificationRateLimitCount;
+
+ private void OnDatabaseNotification(DatabaseNotification notification)
+ {
+ if (notification.Channel != BanNotificationChannel)
+ return;
+
+ if (notification.Payload == null)
+ {
+ _sawmill.Error("Got ban notification with null payload!");
+ return;
+ }
+
+ BanNotificationData data;
+ try
+ {
+ data = JsonSerializer.Deserialize(notification.Payload)
+ ?? throw new JsonException("Content is null");
+ }
+ catch (JsonException e)
+ {
+ _sawmill.Error($"Got invalid JSON in ban notification: {e}");
+ return;
+ }
+
+ if (!CheckBanRateLimit())
+ {
+ _sawmill.Verbose("Not processing ban notification due to rate limit");
+ return;
+ }
+
+ _taskManager.RunOnMainThread(() => ProcessBanNotification(data));
+ }
+
+ private async void ProcessBanNotification(BanNotificationData data)
+ {
+ if ((await _entryManager.ServerEntity).Id == data.ServerId)
+ {
+ _sawmill.Verbose("Not processing ban notification: came from this server");
+ return;
+ }
+
+ _sawmill.Verbose($"Processing ban notification for ban {data.BanId}");
+ var ban = await _db.GetServerBanAsync(data.BanId);
+ if (ban == null)
+ {
+ _sawmill.Warning($"Ban in notification ({data.BanId}) didn't exist?");
+ return;
+ }
+
+ KickMatchingConnectedPlayers(ban, "ban notification");
+ }
+
+ private bool CheckBanRateLimit()
+ {
+ lock (_banNotificationRateLimitStateLock)
+ {
+ var now = _gameTiming.RealTime;
+ if (_banNotificationRateLimitStart + BanNotificationRateLimitTime < now)
+ {
+ // Rate limit period expired, restart it.
+ _banNotificationRateLimitCount = 1;
+ _banNotificationRateLimitStart = now;
+ return true;
+ }
+
+ _banNotificationRateLimitCount += 1;
+ return _banNotificationRateLimitCount <= BanNotificationRateLimitCount;
+ }
+ }
+
+ ///
+ /// Data sent along the notification channel for a single ban notification.
+ ///
+ private sealed class BanNotificationData
+ {
+ ///
+ /// The ID of the new ban object in the database to check.
+ ///
+ [JsonRequired, JsonPropertyName("ban_id")]
+ public int BanId { get; init; }
+
+ ///
+ /// The id of the server the ban was made on.
+ /// This is used to avoid double work checking the ban on the originating server.
+ ///
+ ///
+ /// This is optional in case the ban was made outside a server (SS14.Admin)
+ ///
+ [JsonPropertyName("server_id")]
+ public int? ServerId { get; init; }
+ }
+}
diff --git a/Content.Server/Administration/Managers/BanManager.cs b/Content.Server/Administration/Managers/BanManager.cs
index 68bd817026..946770d6aa 100644
--- a/Content.Server/Administration/Managers/BanManager.cs
+++ b/Content.Server/Administration/Managers/BanManager.cs
@@ -2,6 +2,7 @@ using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using Content.Server.Chat.Managers;
using Content.Server.Database;
@@ -12,16 +13,18 @@ using Content.Shared.Players;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Roles;
using Robust.Server.Player;
+using Robust.Shared.Asynchronous;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Administration.Managers;
-public sealed class BanManager : IBanManager, IPostInjectInit
+public sealed partial class BanManager : IBanManager, IPostInjectInit
{
[Dependency] private readonly IServerDbManager _db = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
@@ -29,9 +32,13 @@ public sealed class BanManager : IBanManager, IPostInjectInit
[Dependency] private readonly IEntitySystemManager _systems = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly ILocalizationManager _localizationManager = default!;
+ [Dependency] private readonly ServerDbEntryManager _entryManager = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly INetManager _netManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly ITaskManager _taskManager = default!;
+ [Dependency] private readonly UserDbDataManager _userDbData = default!;
private ISawmill _sawmill = default!;
@@ -39,12 +46,34 @@ public sealed class BanManager : IBanManager, IPostInjectInit
public const string JobPrefix = "Job:";
private readonly Dictionary> _cachedRoleBans = new();
+ // Cached ban exemption flags are used to handle
+ private readonly Dictionary _cachedBanExemptions = new();
public void Initialize()
{
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
_netManager.RegisterNetMessage();
+
+ _db.SubscribeToNotifications(OnDatabaseNotification);
+
+ _userDbData.AddOnLoadPlayer(CachePlayerData);
+ _userDbData.AddOnPlayerDisconnect(ClearPlayerData);
+ }
+
+ private async Task CachePlayerData(ICommonSession player, CancellationToken cancel)
+ {
+ // Yeah so role ban loading code isn't integrated with exempt flag loading code.
+ // Have you seen how garbage role ban code code is? I don't feel like refactoring it right now.
+
+ var flags = await _db.GetBanExemption(player.UserId, cancel);
+ cancel.ThrowIfCancellationRequested();
+ _cachedBanExemptions[player] = flags;
+ }
+
+ private void ClearPlayerData(ICommonSession player)
+ {
+ _cachedBanExemptions.Remove(player);
}
private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
@@ -168,17 +197,43 @@ public sealed class BanManager : IBanManager, IPostInjectInit
_sawmill.Info(logMessage);
_chat.SendAdminAlert(logMessage);
- // If we're not banning a player we don't care about disconnecting people
- if (target == null)
- return;
-
- // Is the player connected?
- if (!_playerManager.TryGetSessionById(target.Value, out var targetPlayer))
- return;
- // If they are, kick them
- var message = banDef.FormatBanMessage(_cfg, _localizationManager);
- targetPlayer.Channel.Disconnect(message);
+ KickMatchingConnectedPlayers(banDef, "newly placed ban");
}
+
+ private void KickMatchingConnectedPlayers(ServerBanDef def, string source)
+ {
+ foreach (var player in _playerManager.Sessions)
+ {
+ if (BanMatchesPlayer(player, def))
+ {
+ KickForBanDef(player, def);
+ _sawmill.Info($"Kicked player {player.Name} ({player.UserId}) through {source}");
+ }
+ }
+ }
+
+ private bool BanMatchesPlayer(ICommonSession player, ServerBanDef ban)
+ {
+ var playerInfo = new BanMatcher.PlayerInfo
+ {
+ UserId = player.UserId,
+ Address = player.Channel.RemoteEndPoint.Address,
+ HWId = player.Channel.UserData.HWId,
+ // It's possible for the player to not have cached data loading yet due to coincidental timing.
+ // If this is the case, we assume they have all flags to avoid false-positives.
+ ExemptFlags = _cachedBanExemptions.GetValueOrDefault(player, ServerBanExemptFlags.All),
+ IsNewPlayer = false,
+ };
+
+ return BanMatcher.BanMatches(ban, playerInfo);
+ }
+
+ private void KickForBanDef(ICommonSession player, ServerBanDef def)
+ {
+ var message = def.FormatBanMessage(_cfg, _localizationManager);
+ player.Channel.Disconnect(message);
+ }
+
#endregion
#region Job Bans
diff --git a/Content.Server/Database/BanMatcher.cs b/Content.Server/Database/BanMatcher.cs
new file mode 100644
index 0000000000..e58e5b0b5f
--- /dev/null
+++ b/Content.Server/Database/BanMatcher.cs
@@ -0,0 +1,90 @@
+using System.Collections.Immutable;
+using System.Net;
+using Content.Server.IP;
+using Robust.Shared.Network;
+
+namespace Content.Server.Database;
+
+///
+/// Implements logic to match a against a player query.
+///
+///
+///
+/// This implementation is used by in-game ban matching code, and partially by the SQLite database layer.
+/// Some logic is duplicated into both the SQLite and PostgreSQL database layers to provide more optimal SQL queries.
+/// Both should be kept in sync, please!
+///
+///
+public static class BanMatcher
+{
+ ///
+ /// Check whether a ban matches the specified player info.
+ ///
+ ///
+ ///
+ /// This function does not check whether the ban itself is expired or manually unbanned.
+ ///
+ ///
+ /// The ban information.
+ /// Information about the player to match against.
+ /// True if the ban matches the provided player info.
+ public static bool BanMatches(ServerBanDef ban, in PlayerInfo player)
+ {
+ var exemptFlags = player.ExemptFlags;
+ // Any flag to bypass BlacklistedRange bans.
+ if (exemptFlags != ServerBanExemptFlags.None)
+ exemptFlags |= ServerBanExemptFlags.BlacklistedRange;
+
+ if ((ban.ExemptFlags & exemptFlags) != 0)
+ return false;
+
+ if (!player.ExemptFlags.HasFlag(ServerBanExemptFlags.IP)
+ && player.Address != null
+ && ban.Address is not null
+ && player.Address.IsInSubnet(ban.Address.Value)
+ && (!ban.ExemptFlags.HasFlag(ServerBanExemptFlags.BlacklistedRange) || player.IsNewPlayer))
+ {
+ return true;
+ }
+
+ if (player.UserId is { } id && ban.UserId == id.UserId)
+ {
+ return true;
+ }
+
+ return player.HWId is { Length: > 0 } hwIdVar
+ && ban.HWId != null
+ && hwIdVar.AsSpan().SequenceEqual(ban.HWId.Value.AsSpan());
+ }
+
+ ///
+ /// A simple struct containing player info used to match bans against.
+ ///
+ public struct PlayerInfo
+ {
+ ///
+ /// The user ID of the player.
+ ///
+ public NetUserId? UserId;
+
+ ///
+ /// The IP address of the player.
+ ///
+ public IPAddress? Address;
+
+ ///
+ /// The hardware ID of the player.
+ ///
+ public ImmutableArray? HWId;
+
+ ///
+ /// Exemption flags the player has been granted.
+ ///
+ public ServerBanExemptFlags ExemptFlags;
+
+ ///
+ /// True if this player is new and is thus eligible for more bans.
+ ///
+ public bool IsNewPlayer;
+ }
+}
diff --git a/Content.Server/Database/ServerBanDef.cs b/Content.Server/Database/ServerBanDef.cs
index 9d67537bd2..09a960e9a6 100644
--- a/Content.Server/Database/ServerBanDef.cs
+++ b/Content.Server/Database/ServerBanDef.cs
@@ -23,9 +23,9 @@ namespace Content.Server.Database
public NoteSeverity Severity { get; set; }
public NetUserId? BanningAdmin { get; }
public ServerUnbanDef? Unban { get; }
+ public ServerBanExemptFlags ExemptFlags { get; }
- public ServerBanDef(
- int? id,
+ public ServerBanDef(int? id,
NetUserId? userId,
(IPAddress, int)? address,
ImmutableArray? hwId,
@@ -36,7 +36,8 @@ namespace Content.Server.Database
string reason,
NoteSeverity severity,
NetUserId? banningAdmin,
- ServerUnbanDef? unban)
+ ServerUnbanDef? unban,
+ ServerBanExemptFlags exemptFlags = default)
{
if (userId == null && address == null && hwId == null)
{
@@ -62,6 +63,7 @@ namespace Content.Server.Database
Severity = severity;
BanningAdmin = banningAdmin;
Unban = unban;
+ ExemptFlags = exemptFlags;
}
public string FormatBanMessage(IConfigurationManager cfg, ILocalizationManager loc)
diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs
index d91ef39198..72e3c50daf 100644
--- a/Content.Server/Database/ServerDbBase.cs
+++ b/Content.Server/Database/ServerDbBase.cs
@@ -28,6 +28,8 @@ namespace Content.Server.Database
{
private readonly ISawmill _opsLog;
+ public event Action? OnNotificationReceived;
+
/// Sawmill to trace log database operations to.
public ServerDbBase(ISawmill opsLog)
{
@@ -425,13 +427,16 @@ namespace Content.Server.Database
await db.DbContext.SaveChangesAsync();
}
- protected static async Task GetBanExemptionCore(DbGuard db, NetUserId? userId)
+ protected static async Task GetBanExemptionCore(
+ DbGuard db,
+ NetUserId? userId,
+ CancellationToken cancel = default)
{
if (userId == null)
return null;
var exemption = await db.DbContext.BanExemption
- .SingleOrDefaultAsync(e => e.UserId == userId.Value.UserId);
+ .SingleOrDefaultAsync(e => e.UserId == userId.Value.UserId, cancellationToken: cancel);
return exemption?.Flags;
}
@@ -462,11 +467,11 @@ namespace Content.Server.Database
await db.DbContext.SaveChangesAsync();
}
- public async Task GetBanExemption(NetUserId userId)
+ public async Task GetBanExemption(NetUserId userId, CancellationToken cancel)
{
- await using var db = await GetDb();
+ await using var db = await GetDb(cancel);
- var flags = await GetBanExemptionCore(db, userId);
+ var flags = await GetBanExemptionCore(db, userId, cancel);
return flags ?? ServerBanExemptFlags.None;
}
@@ -1677,5 +1682,15 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
public abstract ValueTask DisposeAsync();
}
+
+ protected void NotificationReceived(DatabaseNotification notification)
+ {
+ OnNotificationReceived?.Invoke(notification);
+ }
+
+ public virtual void Shutdown()
+ {
+
+ }
}
}
diff --git a/Content.Server/Database/ServerDbManager.cs b/Content.Server/Database/ServerDbManager.cs
index 1983fe43d2..8b6ac5fed6 100644
--- a/Content.Server/Database/ServerDbManager.cs
+++ b/Content.Server/Database/ServerDbManager.cs
@@ -116,7 +116,7 @@ namespace Content.Server.Database
/// Get current ban exemption flags for a user
///
/// if the user is not exempt from any bans.
- Task GetBanExemption(NetUserId userId);
+ Task GetBanExemption(NetUserId userId, CancellationToken cancel = default);
#endregion
@@ -304,6 +304,43 @@ namespace Content.Server.Database
Task RemoveJobWhitelist(Guid player, ProtoId job);
#endregion
+
+ #region DB Notifications
+
+ void SubscribeToNotifications(Action handler);
+
+ ///
+ /// Inject a notification as if it was created by the database. This is intended for testing.
+ ///
+ /// The notification to trigger
+ void InjectTestNotification(DatabaseNotification notification);
+
+ #endregion
+ }
+
+ ///
+ /// Represents a notification sent between servers via the database layer.
+ ///
+ ///
+ ///
+ /// Database notifications are a simple system to broadcast messages to an entire server group
+ /// backed by the same database. For example, this is used to notify all servers of new ban records.
+ ///
+ ///
+ /// They are currently implemented by the PostgreSQL NOTIFY and LISTEN commands.
+ ///
+ ///
+ public struct DatabaseNotification
+ {
+ ///
+ /// The channel for the notification. This can be used to differentiate notifications for different purposes.
+ ///
+ public required string Channel { get; set; }
+
+ ///
+ /// The actual contents of the notification. Optional.
+ ///
+ public string? Payload { get; set; }
}
public sealed class ServerDbManager : IServerDbManager
@@ -333,6 +370,8 @@ namespace Content.Server.Database
// This is that connection, close it when we shut down.
private SqliteConnection? _sqliteInMemoryConnection;
+ private readonly List> _notificationHandlers = [];
+
public void Init()
{
_msLogProvider = new LoggingProvider(_logMgr);
@@ -345,6 +384,7 @@ namespace Content.Server.Database
var engine = _cfg.GetCVar(CCVars.DatabaseEngine).ToLower();
var opsLog = _logMgr.GetSawmill("db.op");
+ var notifyLog = _logMgr.GetSawmill("db.notify");
switch (engine)
{
case "sqlite":
@@ -352,17 +392,22 @@ namespace Content.Server.Database
_db = new ServerDbSqlite(contextFunc, inMemory, _cfg, _synchronous, opsLog);
break;
case "postgres":
- var pgOptions = CreatePostgresOptions();
- _db = new ServerDbPostgres(pgOptions, _cfg, opsLog);
+ var (pgOptions, conString) = CreatePostgresOptions();
+ _db = new ServerDbPostgres(pgOptions, conString, _cfg, opsLog, notifyLog);
break;
default:
throw new InvalidDataException($"Unknown database engine {engine}.");
}
+
+ _db.OnNotificationReceived += HandleDatabaseNotification;
}
public void Shutdown()
{
+ _db.OnNotificationReceived -= HandleDatabaseNotification;
+
_sqliteInMemoryConnection?.Dispose();
+ _db.Shutdown();
}
public Task InitPrefsAsync(
@@ -465,10 +510,10 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.UpdateBanExemption(userId, flags));
}
- public Task GetBanExemption(NetUserId userId)
+ public Task GetBanExemption(NetUserId userId, CancellationToken cancel = default)
{
DbReadOpsMetric.Inc();
- return RunDbCommand(() => _db.GetBanExemption(userId));
+ return RunDbCommand(() => _db.GetBanExemption(userId, cancel));
}
#region Role Ban
@@ -806,7 +851,7 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.GetServerRoleBanAsNoteAsync(id));
}
- public Task> GetAllAdminRemarks(Guid player)
+ public Task> GetAllAdminRemarks(Guid player)
{
DbReadOpsMetric.Inc();
return RunDbCommand(() => _db.GetAllAdminRemarks(player));
@@ -907,6 +952,30 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.RemoveJobWhitelist(player, job));
}
+ public void SubscribeToNotifications(Action handler)
+ {
+ lock (_notificationHandlers)
+ {
+ _notificationHandlers.Add(handler);
+ }
+ }
+
+ public void InjectTestNotification(DatabaseNotification notification)
+ {
+ HandleDatabaseNotification(notification);
+ }
+
+ private async void HandleDatabaseNotification(DatabaseNotification notification)
+ {
+ lock (_notificationHandlers)
+ {
+ foreach (var handler in _notificationHandlers)
+ {
+ handler(notification);
+ }
+ }
+ }
+
// Wrapper functions to run DB commands from the thread pool.
// This will avoid SynchronizationContext capturing and avoid running CPU work on the main thread.
// For SQLite, this will also enable read parallelization (within limits).
@@ -962,7 +1031,7 @@ namespace Content.Server.Database
return enumerable;
}
- private DbContextOptions CreatePostgresOptions()
+ private (DbContextOptions options, string connectionString) CreatePostgresOptions()
{
var host = _cfg.GetCVar(CCVars.DatabasePgHost);
var port = _cfg.GetCVar(CCVars.DatabasePgPort);
@@ -984,7 +1053,7 @@ namespace Content.Server.Database
builder.UseNpgsql(connectionString);
SetupLogging(builder);
- return builder.Options;
+ return (builder.Options, connectionString);
}
private void SetupSqlite(out Func> contextFunc, out bool inMemory)
diff --git a/Content.Server/Database/ServerDbPostgres.Notifications.cs b/Content.Server/Database/ServerDbPostgres.Notifications.cs
new file mode 100644
index 0000000000..fe358923bf
--- /dev/null
+++ b/Content.Server/Database/ServerDbPostgres.Notifications.cs
@@ -0,0 +1,121 @@
+using System.Data;
+using System.Threading;
+using System.Threading.Tasks;
+using Content.Server.Administration.Managers;
+using Npgsql;
+
+namespace Content.Server.Database;
+
+/// Listens for ban_notification containing the player id and the banning server id using postgres listen/notify.
+/// Players a ban_notification got received for get banned, except when the current server id and the one in the notification payload match.
+
+public sealed partial class ServerDbPostgres
+{
+ ///
+ /// The list of notify channels to subscribe to.
+ ///
+ private static readonly string[] NotificationChannels =
+ [
+ BanManager.BanNotificationChannel,
+ ];
+
+ private static readonly TimeSpan ReconnectWaitIncrease = TimeSpan.FromSeconds(10);
+
+ private readonly CancellationTokenSource _notificationTokenSource = new();
+
+ private NpgsqlConnection? _notificationConnection;
+ private TimeSpan _reconnectWaitTime = TimeSpan.Zero;
+
+ ///
+ /// Sets up the database connection and the notification handler
+ ///
+ private void InitNotificationListener(string connectionString)
+ {
+ _notificationConnection = new NpgsqlConnection(connectionString);
+ _notificationConnection.Notification += OnNotification;
+
+ var cancellationToken = _notificationTokenSource.Token;
+ Task.Run(() => NotificationListener(cancellationToken), cancellationToken);
+ }
+
+ ///
+ /// Listens to the notification channel with basic error handling and reopens the connection if it got closed
+ ///
+ private async Task NotificationListener(CancellationToken cancellationToken)
+ {
+ if (_notificationConnection == null)
+ return;
+
+ _notifyLog.Verbose("Starting notification listener");
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ if (_notificationConnection.State == ConnectionState.Broken)
+ {
+ _notifyLog.Debug("Notification listener entered broken state, closing...");
+ await _notificationConnection.CloseAsync();
+ }
+
+ if (_notificationConnection.State == ConnectionState.Closed)
+ {
+ _notifyLog.Debug("Opening notification listener connection...");
+ if (_reconnectWaitTime != TimeSpan.Zero)
+ {
+ _notifyLog.Verbose($"_reconnectWaitTime is {_reconnectWaitTime}");
+ await Task.Delay(_reconnectWaitTime, cancellationToken);
+ }
+
+ await _notificationConnection.OpenAsync(cancellationToken);
+ _reconnectWaitTime = TimeSpan.Zero;
+ _notifyLog.Verbose($"Notification connection opened...");
+ }
+
+ foreach (var channel in NotificationChannels)
+ {
+ _notifyLog.Verbose($"Listening on channel {channel}");
+ await using var cmd = new NpgsqlCommand($"LISTEN {channel}", _notificationConnection);
+ await cmd.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ _notifyLog.Verbose("Waiting on notifications...");
+ await _notificationConnection.WaitAsync(cancellationToken);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Abort loop on cancel.
+ _notifyLog.Verbose($"Shutting down notification listener due to cancellation");
+ return;
+ }
+ catch (Exception e)
+ {
+ _reconnectWaitTime += ReconnectWaitIncrease;
+ _notifyLog.Error($"Error in notification listener: {e}");
+ }
+ }
+ }
+
+ private void OnNotification(object _, NpgsqlNotificationEventArgs notification)
+ {
+ _notifyLog.Verbose($"Received notification on channel {notification.Channel}");
+ NotificationReceived(new DatabaseNotification
+ {
+ Channel = notification.Channel,
+ Payload = notification.Payload,
+ });
+ }
+
+ public override void Shutdown()
+ {
+ _notificationTokenSource.Cancel();
+ if (_notificationConnection == null)
+ return;
+
+ _notificationConnection.Notification -= OnNotification;
+ _notificationConnection.Dispose();
+ }
+}
diff --git a/Content.Server/Database/ServerDbPostgres.cs b/Content.Server/Database/ServerDbPostgres.cs
index f8eef1a554..7d131f70dc 100644
--- a/Content.Server/Database/ServerDbPostgres.cs
+++ b/Content.Server/Database/ServerDbPostgres.cs
@@ -16,23 +16,26 @@ using Robust.Shared.Utility;
namespace Content.Server.Database
{
- public sealed class ServerDbPostgres : ServerDbBase
+ public sealed partial class ServerDbPostgres : ServerDbBase
{
private readonly DbContextOptions _options;
+ private readonly ISawmill _notifyLog;
private readonly SemaphoreSlim _prefsSemaphore;
private readonly Task _dbReadyTask;
private int _msLag;
- public ServerDbPostgres(
- DbContextOptions options,
+ public ServerDbPostgres(DbContextOptions options,
+ string connectionString,
IConfigurationManager cfg,
- ISawmill opsLog)
+ ISawmill opsLog,
+ ISawmill notifyLog)
: base(opsLog)
{
var concurrency = cfg.GetCVar(CCVars.DatabasePgConcurrency);
_options = options;
+ _notifyLog = notifyLog;
_prefsSemaphore = new SemaphoreSlim(concurrency, concurrency);
_dbReadyTask = Task.Run(async () =>
@@ -49,6 +52,8 @@ namespace Content.Server.Database
});
cfg.OnValueChanged(CCVars.DatabasePgFakeLag, v => _msLag = v, true);
+
+ InitNotificationListener(connectionString);
}
#region Ban
@@ -214,7 +219,8 @@ namespace Content.Server.Database
ban.Reason,
ban.Severity,
aUid,
- unbanDef);
+ unbanDef,
+ ban.ExemptFlags);
}
private static ServerUnbanDef? ConvertUnban(ServerUnban? unban)
@@ -251,7 +257,8 @@ namespace Content.Server.Database
ExpirationTime = serverBan.ExpirationTime?.UtcDateTime,
RoundId = serverBan.RoundId,
PlaytimeAtNote = serverBan.PlaytimeAtNote,
- PlayerUserId = serverBan.UserId?.UserId
+ PlayerUserId = serverBan.UserId?.UserId,
+ ExemptFlags = serverBan.ExemptFlags
});
await db.PgDbContext.SaveChangesAsync();
diff --git a/Content.Server/Database/ServerDbSqlite.cs b/Content.Server/Database/ServerDbSqlite.cs
index 204d9fca4f..af4bc2cf8d 100644
--- a/Content.Server/Database/ServerDbSqlite.cs
+++ b/Content.Server/Database/ServerDbSqlite.cs
@@ -84,25 +84,27 @@ namespace Content.Server.Database
{
await using var db = await GetDbImpl();
- var exempt = await GetBanExemptionCore(db, userId);
-
- var newPlayer = userId == null || !await PlayerRecordExists(db, userId.Value);
-
- // SQLite can't do the net masking stuff we need to match IP address ranges.
- // So just pull down the whole list into memory.
- var bans = await GetAllBans(db.SqliteDbContext, includeUnbanned: false, exempt);
-
- return bans.FirstOrDefault(b => BanMatches(b, address, userId, hwId, exempt, newPlayer)) is { } foundBan
- ? ConvertBan(foundBan)
- : null;
+ return (await GetServerBanQueryAsync(db, address, userId, hwId, includeUnbanned: false)).FirstOrDefault();
}
- public override async Task> GetServerBansAsync(IPAddress? address,
+ public override async Task> GetServerBansAsync(
+ IPAddress? address,
NetUserId? userId,
- ImmutableArray? hwId, bool includeUnbanned)
+ ImmutableArray? hwId,
+ bool includeUnbanned)
{
await using var db = await GetDbImpl();
+ return (await GetServerBanQueryAsync(db, address, userId, hwId, includeUnbanned)).ToList();
+ }
+
+ private async Task> GetServerBanQueryAsync(
+ DbGuardImpl db,
+ IPAddress? address,
+ NetUserId? userId,
+ ImmutableArray? hwId,
+ bool includeUnbanned)
+ {
var exempt = await GetBanExemptionCore(db, userId);
var newPlayer = !await db.SqliteDbContext.Player.AnyAsync(p => p.UserId == userId);
@@ -111,10 +113,18 @@ namespace Content.Server.Database
// So just pull down the whole list into memory.
var queryBans = await GetAllBans(db.SqliteDbContext, includeUnbanned, exempt);
+ var playerInfo = new BanMatcher.PlayerInfo
+ {
+ Address = address,
+ UserId = userId,
+ ExemptFlags = exempt ?? default,
+ HWId = hwId,
+ IsNewPlayer = newPlayer,
+ };
+
return queryBans
- .Where(b => BanMatches(b, address, userId, hwId, exempt, newPlayer))
.Select(ConvertBan)
- .ToList()!;
+ .Where(b => BanMatcher.BanMatches(b!, playerInfo))!;
}
private static async Task> GetAllBans(
@@ -141,31 +151,6 @@ namespace Content.Server.Database
return await query.ToListAsync();
}
- private static bool BanMatches(ServerBan ban,
- IPAddress? address,
- NetUserId? userId,
- ImmutableArray? hwId,
- ServerBanExemptFlags? exemptFlags,
- bool newPlayer)
- {
- if (!exemptFlags.GetValueOrDefault(ServerBanExemptFlags.None).HasFlag(ServerBanExemptFlags.IP)
- && address != null
- && ban.Address is not null
- && address.IsInSubnet(ban.Address.ToTuple().Value)
- && (!ban.ExemptFlags.HasFlag(ServerBanExemptFlags.BlacklistedRange) ||
- newPlayer))
- {
- return true;
- }
-
- if (userId is { } id && ban.PlayerUserId == id.UserId)
- {
- return true;
- }
-
- return hwId is { Length: > 0 } hwIdVar && hwIdVar.AsSpan().SequenceEqual(ban.HWId);
- }
-
public override async Task AddServerBanAsync(ServerBanDef serverBan)
{
await using var db = await GetDbImpl();
@@ -181,7 +166,8 @@ namespace Content.Server.Database
ExpirationTime = serverBan.ExpirationTime?.UtcDateTime,
RoundId = serverBan.RoundId,
PlaytimeAtNote = serverBan.PlaytimeAtNote,
- PlayerUserId = serverBan.UserId?.UserId
+ PlayerUserId = serverBan.UserId?.UserId,
+ ExemptFlags = serverBan.ExemptFlags
});
await db.SqliteDbContext.SaveChangesAsync();
@@ -364,6 +350,7 @@ namespace Content.Server.Database
}
#endregion
+ [return: NotNullIfNotNull(nameof(ban))]
private static ServerBanDef? ConvertBan(ServerBan? ban)
{
if (ban == null)
diff --git a/Resources/Locale/en-US/info/ban.ftl b/Resources/Locale/en-US/info/ban.ftl
index 463dd1f566..cc3a34c28b 100644
--- a/Resources/Locale/en-US/info/ban.ftl
+++ b/Resources/Locale/en-US/info/ban.ftl
@@ -82,3 +82,6 @@ ban-panel-erase = Erase chat messages and player from round
server-ban-string = {$admin} created a {$severity} severity server ban that expires {$expires} for [{$name}, {$ip}, {$hwid}], with reason: {$reason}
server-ban-string-no-pii = {$admin} created a {$severity} severity server ban that expires {$expires} for {$name} with reason: {$reason}
server-ban-string-never = never
+
+# Kick on ban
+ban-kick-reason = You have been banned