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