diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 06f02cc..55aac44 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -31,6 +31,7 @@ using System.Net.WebSockets; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using ZstdSharp.Unsafe; @@ -102,16 +103,197 @@ public enum ELobbyState COMPLETE } + [Flags] public enum ERoomFlags : int { - ROOM_FLAGS_DEFAULT = 0, - ROOM_FLAGS_SHOW_ALL_MATCHES = 1 + ROOM_FLAGS_NONE = 0, + ROOM_FLAGS_SHOW_ALL_MATCHES = 1 << 0 } public class RoomData { public int id { get; set; } = -1; public string name { get; set; } = ""; - public ERoomFlags flags { get; set; } = ERoomFlags.ROOM_FLAGS_DEFAULT; + public int? parent_id { get; set; } + public ERoomFlags flags { get; set; } = ERoomFlags.ROOM_FLAGS_NONE; + } + + internal sealed class RoomConfigData + { + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("default")] + public bool IsDefault { get; init; } + + [JsonPropertyName("rooms")] + public List Rooms { get; init; } = []; + } + + internal sealed class RoomCatalogEntry + { + public required int ID { get; init; } + public required string Name { get; init; } + public int? ParentID { get; init; } + public required Int16 TargetRoomID { get; set; } + } + + internal sealed class RoomCatalogData + { + public required IReadOnlyList Rooms { get; init; } + public required IReadOnlyDictionary RoomsByID { get; init; } + public required IReadOnlyDictionary> DescendantRoomIDs { get; init; } + + public bool TryResolveTargetRoomID(int selectedRoomID, out Int16 targetRoomID) + { + if (selectedRoomID == -1) + { + targetRoomID = -1; + return true; + } + + if (!RoomsByID.TryGetValue(selectedRoomID, out RoomCatalogEntry? room)) + { + targetRoomID = -1; + return false; + } + + targetRoomID = room.TargetRoomID; + return true; + } + + public bool CanViewLobby(int selectedRoomID, int lobbyRoomID) + { + if (!RoomsByID.ContainsKey(selectedRoomID) || !RoomsByID.ContainsKey(lobbyRoomID)) + { + return false; + } + + return selectedRoomID == Rooms[0].ID + || selectedRoomID == lobbyRoomID + || DescendantRoomIDs[selectedRoomID].Contains(lobbyRoomID); + } + } + + public static class RoomCatalog + { + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + RespectNullableAnnotations = true + }; + + private static RoomCatalogData? s_catalog; + + internal static IReadOnlyList Rooms => Current.Rooms; + private static RoomCatalogData Current => s_catalog + ?? throw new InvalidOperationException("The room catalog has not been initialized."); + + public static void Initialize(string catalogPath) + { + try + { + s_catalog = CreateFromJson(File.ReadAllText(catalogPath)); + } + catch (Exception ex) when (ex is IOException or JsonException or InvalidDataException) + { + throw new InvalidDataException($"Failed to load room catalog '{catalogPath}': {ex.Message}", ex); + } + } + + public static bool TryResolveTargetRoomID(int selectedRoomID, out Int16 targetRoomID) + { + return Current.TryResolveTargetRoomID(selectedRoomID, out targetRoomID); + } + + public static bool CanViewLobby(int selectedRoomID, int lobbyRoomID) + { + return Current.CanViewLobby(selectedRoomID, lobbyRoomID); + } + + private static RoomCatalogData CreateFromJson(string json) + { + List configuredRooms = JsonSerializer.Deserialize>(json, s_jsonOptions) + ?? throw new InvalidDataException("The room catalog must contain a JSON array."); + + if (configuredRooms.Count == 0) + { + throw new InvalidDataException("The room catalog must contain at least one room."); + } + if (configuredRooms.Any(room => room is null)) + { + throw new InvalidDataException("The room catalog cannot contain null room entries."); + } + + List rooms = []; + Dictionary> descendantRoomIDs = []; + + RoomCatalogEntry FlattenRoom(RoomConfigData configuredRoom, int? parentID) + { + if (string.IsNullOrWhiteSpace(configuredRoom.Name)) + { + throw new InvalidDataException("Room names cannot be empty."); + } + + if (parentID is null && configuredRoom.IsDefault) + { + throw new InvalidDataException($"Top-level room '{configuredRoom.Name}' cannot be marked as default."); + } + + if (configuredRoom.Rooms.Any(room => room is null)) + { + throw new InvalidDataException("The room catalog cannot contain null room entries."); + } + + int defaultRoomCount = configuredRoom.Rooms.Count(room => room!.IsDefault); + if (configuredRoom.Rooms.Count > 0 && defaultRoomCount != 1) + { + throw new InvalidDataException($"Room '{configuredRoom.Name}' must contain exactly one default child room."); + } + + if (rooms.Count > Int16.MaxValue) + { + throw new InvalidDataException($"The room catalog cannot contain more than {Int16.MaxValue + 1} rooms."); + } + + RoomCatalogEntry room = new() + { + ID = rooms.Count, + Name = configuredRoom.Name, + ParentID = parentID, + TargetRoomID = (Int16)rooms.Count + }; + rooms.Add(room); + + HashSet descendants = []; + foreach (RoomConfigData? childConfig in configuredRoom.Rooms) + { + RoomConfigData childRoomConfig = childConfig!; + RoomCatalogEntry child = FlattenRoom(childRoomConfig, room.ID); + descendants.Add(child.ID); + descendants.UnionWith(descendantRoomIDs[child.ID]); + if (childRoomConfig.IsDefault) + { + room.TargetRoomID = child.TargetRoomID; + } + } + + descendantRoomIDs.Add(room.ID, descendants); + return room; + } + + foreach (RoomConfigData? configuredRoom in configuredRooms) + { + FlattenRoom(configuredRoom!, null); + } + + return new RoomCatalogData + { + Rooms = rooms, + RoomsByID = rooms.ToDictionary(room => room.ID), + DescendantRoomIDs = descendantRoomIDs + }; + } } public class UserSocialContainer @@ -356,6 +538,8 @@ public static async Task CreateSession(AppDbContext _db, public static async Task Tick() { + FlushLobbyListUpdates(); + // Give the entire tick a 20 ms deadline. All users drain concurrently via // Task.WhenAll, so a slow/stuck client cannot delay others. If the deadline // fires, the CancellationToken propagates into each in-flight SendAsync and @@ -658,6 +842,30 @@ public static List GetAllDataFromUser(Int64 userID) return lstRet; } + public static async Task DisconnectUser(Int64 userID, byte[] finalMessage) + { + List userSessions = GetAllDataFromUser(userID); + + foreach (UserSession userSession in userSessions) + { + try + { + UserWebSocketInstance? oldWS = GetWebSocketForSession(userSession); + if (oldWS != null) + { + await oldWS.SendAsync(finalMessage, WebSocketMessageType.Text); + } + + await DeleteSession(userID, userSession.GetSessionType(), oldWS, true); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] DisconnectUser failed for user {userID}, session {userSession.GetSessionType()}: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + } + public static async Task ClearDataFromUser(Int64 userID, EUserSessionType sessionType) { // NOTE: This is when a player is truly disconnected and we can destroy session, remove form lobby etc, websocket disconnect doesnt mean that because the clietn reconnects @@ -695,133 +903,181 @@ public static async Task ClearDataFromUser(Int64 userID, EUserSessionType // helpers - public static async Task SendNewOrDeletedLobbyToAllNetworkRoomMembers(int networkRoomID) + private static readonly object g_dirtyLobbyRoomsLock = new(); + private static readonly HashSet g_dirtyLobbyRooms = new(); + private static readonly HashSet g_dirtyLobbySessions = new(); + private static readonly object g_dirtyMemberRoomsLock = new(); + private static readonly HashSet g_dirtyMemberRooms = new(); + + public static void QueueLobbyListUpdateForViewers(int networkRoomID) + { + if (networkRoomID < 0) + { + return; + } + + lock (g_dirtyLobbyRoomsLock) + { + g_dirtyLobbyRooms.Add(networkRoomID); + } + } + + public static void QueueLobbyListUpdateForSession(UserSession session) { - if (networkRoomID != -1) + lock (g_dirtyLobbyRoomsLock) { - // need a member list update - WebSocketMessage_CurrentNetworkRoomLobbyListUpdate lobbyListUpdate = new WebSocketMessage_CurrentNetworkRoomLobbyListUpdate(); - lobbyListUpdate.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_LOBBY_LIST_UPDATE; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyListUpdate)); + g_dirtyLobbySessions.Add(session); + } + } - // populate list of everyone in the room - foreach (var sessionDataByClient in m_dictUserSessions) + internal static void FlushLobbyListUpdates() + { + int[] dirtyRooms; + HashSet directlyDirtySessions; + lock (g_dirtyLobbyRoomsLock) + { + dirtyRooms = g_dirtyLobbyRooms.ToArray(); + directlyDirtySessions = new HashSet(g_dirtyLobbySessions); + g_dirtyLobbyRooms.Clear(); + g_dirtyLobbySessions.Clear(); + } + + if (dirtyRooms.Length == 0 && directlyDirtySessions.Count == 0) + { + return; + } + + byte[] bytesJSON = CreateLobbyListUpdateBytes(); + HashSet sessionsToCheck = new(m_dictUserSessions.Values.SelectMany(sessions => sessions.Values)); + sessionsToCheck.UnionWith(directlyDirtySessions); + foreach (UserSession session in sessionsToCheck) + { + if (directlyDirtySessions.Contains(session) + || dirtyRooms.Any(roomID => RoomCatalog.CanViewLobby(session.selectedNetworkRoomID, roomID))) { - foreach (var sessionData in sessionDataByClient.Value) - { - if (sessionData.Value != null) - { - if (sessionData.Value.networkRoomID == networkRoomID || sessionData.Value.networkRoomID == 0) - { - sessionData.Value.QueueWebsocketSend(bytesJSON); - } - } - } + session.QueueWebsocketSend(bytesJSON); } } } - private static ConcurrentList g_lstDirtyNetworkRooms = new(); - public static async Task TickRoomMemberList() + private static byte[] CreateLobbyListUpdateBytes() { - foreach (int roomID in g_lstDirtyNetworkRooms) + WebSocketMessage_CurrentNetworkRoomLobbyListUpdate lobbyListUpdate = new() { - - // need a member list update - WebSocketMessage_NetworkRoomMemberListUpdate memberListUpdate = new WebSocketMessage_NetworkRoomMemberListUpdate(); - memberListUpdate.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_MEMBER_LIST_UPDATE; - memberListUpdate.members = new(); - - Dictionary> usersAlreadyProcessed = new(); - // create base - foreach (EUserSessionType sessionType in Enum.GetValues()) + msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_LOBBY_LIST_UPDATE + }; + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyListUpdate)); + } + + public static void QueueRoomSelectionAccepted(UserSession session, UInt64? requestID) + { + QueueRoomSelectionResult(session, requestID, null, null); + } + + public static void QueueRoomSelectionRejected(UserSession session, UInt64? requestID, int? rejectedRoomID, string error) + { + QueueRoomSelectionResult(session, requestID, rejectedRoomID, error); + } + + private static void QueueRoomSelectionResult(UserSession session, UInt64? requestID, int? rejectedRoomID, string? error) + { + WebSocketMessage_NetworkRoomMemberListUpdate memberListUpdate = CreateRoomMemberListUpdate(session.networkRoomID); + memberListUpdate.request_id = requestID; + memberListUpdate.selected_room_id = session.selectedNetworkRoomID; + memberListUpdate.effective_room_id = session.networkRoomID; + memberListUpdate.rejected_room_id = rejectedRoomID; + memberListUpdate.room_selection_error = error; + session.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(memberListUpdate))); + } + + private static WebSocketMessage_NetworkRoomMemberListUpdate CreateRoomMemberListUpdate(int roomID) + { + WebSocketMessage_NetworkRoomMemberListUpdate memberListUpdate = new() + { + msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_MEMBER_LIST_UPDATE + }; + if (roomID < 0) + { + return memberListUpdate; + } + + foreach (UserSession session in m_dictUserSessions.Values.SelectMany(byType => byType.Values)) + { + EUserSessionType sessionType = session.GetSessionType(); + if (session.networkRoomID != roomID) { - usersAlreadyProcessed[sessionType] = new SortedDictionary(); + continue; } - List lstUsersToSend = new(); + SharedUserData? sharedUserData = GetSharedDataForUser(session.m_UserID); + if (sharedUserData == null) + { + continue; + } - // populate list of everyone in the room - foreach (var sessionDataByClient in m_dictUserSessions) + string displayName = sharedUserData.IsAdmin() + ? $"[★★GO STAFF★★] {sharedUserData.m_strDisplayName}" + : sharedUserData.m_strDisplayName; + + if (sessionType == EUserSessionType.GameLauncher) { - foreach (var sessionData in sessionDataByClient.Value) - { - UserSession sess = sessionData.Value; - if (sess.networkRoomID == roomID) - { - EUserSessionType sessType = sessionData.Value.GetSessionType(); - if (!usersAlreadyProcessed[sessType].ContainsKey(sess.m_UserID)) - { - usersAlreadyProcessed[sessType][sess.m_UserID] = true; + displayName += session.m_client_id == KnownClients.EKnownClients.genhub ? " [GENHUB]" : " [LAUNCHER]"; + } + else if (sessionType == EUserSessionType.ChatClient) + { + displayName += " [WEBCHAT]"; + } - SharedUserData? sharedUserData = WebSocketManager.GetSharedDataForUser(sess.m_UserID); - if (sharedUserData != null) - { - // add to member list - string strDisplayName = sharedUserData.IsAdmin() ? String.Format("[\u2605\u2605GO STAFF\u2605\u2605] {0}", sharedUserData.m_strDisplayName) : sharedUserData.m_strDisplayName; - - // append client, if not game - if (sessType != EUserSessionType.GameClient) - { - if (sessType == EUserSessionType.GameLauncher) - { - if (sessionData.Value.m_client_id == KnownClients.EKnownClients.genhub) - { - strDisplayName += " [GENHUB]"; - } - else - { - strDisplayName += " [LAUNCHER]"; - } - } - else if (sessType == EUserSessionType.ChatClient) - { - strDisplayName += " [WEBCHAT]"; - } - } - + memberListUpdate.members.Add(new RoomMember(session.m_UserID, displayName, sharedUserData.IsAdmin())); + } - memberListUpdate.members.Add(new RoomMember(sess.m_UserID, strDisplayName, sharedUserData.IsAdmin())); + return memberListUpdate; + } - // also add to list of users who need this update, since they were in there - lstUsersToSend.Add(sess.m_UserID); - } - } - } - } - } + public static void TickRoomMemberList() + { + int[] dirtyRooms; + lock (g_dirtyMemberRoomsLock) + { + dirtyRooms = g_dirtyMemberRooms.ToArray(); + g_dirtyMemberRooms.Clear(); + } + foreach (int roomID in dirtyRooms) + { + WebSocketMessage_NetworkRoomMemberListUpdate memberListUpdate = CreateRoomMemberListUpdate(roomID); byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(memberListUpdate)); - // what if they have clients in different net rooms? - - // now send to everyone in the room - foreach (Int64 user_id in lstUsersToSend) + foreach (UserSession session in m_dictUserSessions.Values.SelectMany(byType => byType.Values)) { - // find all of their websockets, and send it to any who are in this network room - foreach (UserSession sess in WebSocketManager.GetAllDataFromUser(user_id)) + if (session.networkRoomID == roomID) { - if (sess.networkRoomID == roomID) - { - sess.QueueWebsocketSend(bytesJSON); - } + session.QueueWebsocketSend(bytesJSON); } } } - - g_lstDirtyNetworkRooms.Clear(); } - public static async Task MarkRoomMemberListAsDirty(int roomID) + public static void MarkRoomMemberListAsDirty(int roomID) { - g_lstDirtyNetworkRooms.Add(roomID); + if (roomID < 0) + { + return; + } + + lock (g_dirtyMemberRoomsLock) + { + g_dirtyMemberRooms.Add(roomID); + } } } // NOTE: only one instance for ALL websockets/sessions, and is destroyed when the last one of the former is destroyed - public class SharedUserData - { - private int m_RefCount = 0; + public class SharedUserData + { + private int m_RefCount = 0; + private readonly Queue m_chatMessageTimestamps = new(); + private Int64? m_lastChatRateLimitNoticeTimestamp; public void IncrementRefCount() { @@ -849,7 +1105,49 @@ public bool NeedsGC() public UserSocialContainer GetSocialContainer() { return m_socialContainer; } - public bool IsAdmin() { return m_bIsAdmin; } + public bool IsAdmin() { return m_bIsAdmin; } + + public bool TryConsumeChatMessage() + { + const int maxMessages = 3; + const Int64 windowMilliseconds = 9000; + Int64 now = Environment.TickCount64; + + lock (m_chatMessageTimestamps) + { + while (m_chatMessageTimestamps.TryPeek(out Int64 timestamp) + && now - timestamp >= windowMilliseconds) + { + m_chatMessageTimestamps.Dequeue(); + } + + if (m_chatMessageTimestamps.Count >= maxMessages) + { + return false; + } + + m_chatMessageTimestamps.Enqueue(now); + return true; + } + } + + public bool TryConsumeChatRateLimitNotice() + { + const Int64 intervalMilliseconds = 9000; + Int64 now = Environment.TickCount64; + + lock (m_chatMessageTimestamps) + { + if (m_lastChatRateLimitNoticeTimestamp.HasValue + && now - m_lastChatRateLimitNoticeTimestamp.Value < intervalMilliseconds) + { + return false; + } + + m_lastChatRateLimitNoticeTimestamp = now; + return true; + } + } public SharedUserData(Int64 ownerID, UserSocialContainer socialContainer, string strDisplayName, bool bIsAdmin, PlayerStats userStats) { @@ -1121,25 +1419,25 @@ public bool WasPlayerInMatch(UInt64 matchID, out int slotIndexInLobby, out int a return bWasInMatch; } - public async Task UpdateSessionNetworkRoom(Int16 newRoomID) + public bool TryUpdateSessionNetworkRoom(Int16 newRoomID) { - Int16 oldRoom = networkRoomID; - networkRoomID = newRoomID; - - // update the room roster they left - if (oldRoom >= 0) // only if they werent in the dummy room before + if (!RoomCatalog.TryResolveTargetRoomID(newRoomID, out Int16 targetRoomID)) { - await WebSocketManager.MarkRoomMemberListAsDirty(oldRoom); + Console.WriteLine($"Rejected invalid network room selection {newRoomID} from user {m_UserID}; keeping selected room {selectedNetworkRoomID}."); + return false; } - // send update to joiner + everyone in new room already - if (newRoomID >= 0) // only if they actually joined a room and weren't going to the dummy room + Int16 oldRoom = networkRoomID; + selectedNetworkRoomID = newRoomID; + networkRoomID = targetRoomID; + + if (oldRoom != networkRoomID) { - await WebSocketManager.MarkRoomMemberListAsDirty(newRoomID); + WebSocketManager.MarkRoomMemberListAsDirty(oldRoom); + WebSocketManager.MarkRoomMemberListAsDirty(networkRoomID); } - // make the client force refresh list too - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(this.networkRoomID); + return true; } public void UpdateSessionLobbyID(Int64 newLobbyID) @@ -1150,7 +1448,10 @@ public void UpdateSessionLobbyID(Int64 newLobbyID) } } - // network room + // Client-facing room used for lobby filtering. + public Int16 selectedNetworkRoomID = -1; + + // Concrete room used for presence, chat, and lobby creation. public Int16 networkRoomID = -1; @@ -2545,7 +2846,10 @@ public enum EWebSocketMessageID AC_REGISTER_PLAYER = 40, AC_DEREGISTER_PLAYER = 41, WS_KEEPALIVE = 42, - WS_KEEPALIVE_CLIENT = 43 + WS_KEEPALIVE_CLIENT = 43, + MODERATION_NOTICE = 46, + MODERATION_COMMAND = 47, + MODERATION_COMMAND_RESULT = 48 }; public static class UserPresence @@ -2643,6 +2947,34 @@ public class WebSocketMessage_StartMatch : WebSocketMessage public string screenshot_url { get; set; } = String.Empty; } + public class WebSocketMessage_ModerationNotice : WebSocketMessage + { + public string action_type { get; set; } = String.Empty; + public string reason { get; set; } = String.Empty; + + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public string? scope_type { get; set; } + } + + public sealed class WebSocketMessage_ModerationCommand : WebSocketMessage + { + public UInt64 request_id { get; set; } + public string action_type { get; set; } = String.Empty; + public Int64? target_user_id { get; set; } + public string reason { get; set; } = String.Empty; + } + + public sealed class WebSocketMessage_ModerationCommandResult : WebSocketMessage + { + public UInt64 request_id { get; set; } + public bool success { get; set; } + + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public string? error_code { get; set; } + + public string message { get; set; } = String.Empty; + } + public abstract class WebSocketMessage { public int msg_id { get; set; } @@ -2805,6 +3137,21 @@ public class WebSocketMessage_RelayUpgradeInbound : WebSocketMessage public class WebSocketMessage_NetworkRoomMemberListUpdate : WebSocketMessage { public List members { get; set; } = new(); + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UInt64? request_id { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Int16? selected_room_id { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Int16? effective_room_id { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? rejected_room_id { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? room_selection_error { get; set; } } public class WebSocketMessage_CurrentLobbyUpdate : WebSocketMessage @@ -2824,9 +3171,9 @@ public Int64 lobby_id } } - public class WebSocketMessage_MatchmakerStartGame : WebSocketMessage - { - - } - -} \ No newline at end of file + public class WebSocketMessage_MatchmakerStartGame : WebSocketMessage + { + + } + +} diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index f4768aa..950e481 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -41,6 +41,7 @@ public override Type GetReturnType() public string refresh_token { get; set; } = ""; public Int64 user_id { get; set; } = -1; public string display_name { get; set; } = ""; + public string ban_reason { get; set; } = ""; public string ws_uri { get; set; } = ""; } @@ -176,12 +177,14 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr Int64 user_id = loginEntry.user_id; // ban check - bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); - if (bIsBanned) + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, user_id); + if (banStatus?.IsBanned == true) { await TokenRevocationManager.RevokeAllTokensForUser(user_id, "user is banned"); + await ModerationManager.DisconnectUser(user_id, EModerationAction.Ban, banStatus.BanReason); result.result = EPendingLoginState.LoginFailed; + result.ban_reason = banStatus.BanReason; Response.StatusCode = (int)HttpStatusCode.Locked; return result; } diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 60b165b..167eb72 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -61,8 +61,6 @@ public override Type GetReturnType() public class LobbiesController : ControllerBase { private readonly ILogger _logger; - private static List? s_cachedRooms = null; - private static readonly object s_roomsLock = new object(); private readonly LobbyManager _lobbyManager; private readonly IDbContextFactory _dbFactory; @@ -75,23 +73,6 @@ public LobbiesController(LobbyManager lobbyManager, IDbContextFactory?> GetCachedRooms(JsonSerializerOptions options) - { - if (s_cachedRooms == null) - { - lock (s_roomsLock) - { - if (s_cachedRooms == null) - { - string strFileData = System.IO.File.ReadAllText(Path.Combine("data", "rooms.json")); - s_cachedRooms = JsonSerializer.Deserialize>(strFileData, options); - } - } - } - return await Task.FromResult(s_cachedRooms); - } - // FOR LATENCY ESTIMATIONS // Convert degrees to radians public static double ToRadians(double angleInDegrees) @@ -139,22 +120,15 @@ public async Task Get() using (var reader = new StreamReader(HttpContext.Request.Body)) { string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }; - try { - // find our network room ID - Int16 networkRoomID = -1; + Int16 selectedRoomID = -1; bool bIncludeAllNetworkRooms = false; List? lstLobbies = null; List lstLatencies = new(); List lstPlayerLatencies = new(); - - + Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.ServerListReadOnly)) @@ -163,34 +137,14 @@ public async Task Get() if (sourceData != null) { - // Use cached rooms data - List? lstRooms = await GetCachedRooms(options); - if (lstRooms != null) - { - foreach (RoomData room in lstRooms) - { - if (room.id == sourceData.networkRoomID) - { - if (room.flags == ERoomFlags.ROOM_FLAGS_SHOW_ALL_MATCHES) - { - bIncludeAllNetworkRooms = true; - } - - break; - } - } - } - else - { - Response.StatusCode = (int)HttpStatusCode.InternalServerError; - } + selectedRoomID = sourceData.selectedNetworkRoomID; } else { bIncludeAllNetworkRooms = true; } - lstLobbies = _lobbyManager.GetAllLobbies(networkRoomID, true, true, false, false, bIncludeAllNetworkRooms); + lstLobbies = _lobbyManager.GetAllLobbies(selectedRoomID, true, true, false, false, bIncludeAllNetworkRooms); List lstLobbiesToRemove = new(); @@ -257,17 +211,17 @@ public async Task Get() } else if (this.User.IsInRole("Monitor")) { - networkRoomID = 0; + selectedRoomID = -1; bIncludeAllNetworkRooms = true; - lstLobbies = _lobbyManager.GetAllLobbies(networkRoomID, true, true, true, true, bIncludeAllNetworkRooms); + lstLobbies = _lobbyManager.GetAllLobbies(selectedRoomID, true, true, true, true, bIncludeAllNetworkRooms); } else { Response.StatusCode = (int)HttpStatusCode.InternalServerError; } - + result.lobbies = lstLobbies; result.latencies = lstLatencies; result.playerlatencies = lstPlayerLatencies; @@ -357,7 +311,7 @@ public async Task Put() return result; } - + // get requesting user data from session token Int64 user_id = TokenHelper.GetUserID(this); @@ -388,9 +342,6 @@ public async Task Put() result.result = 1; result.lobby_id = newLobbyID; - // mark lobby list as dirty - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(playerSession.networkRoomID); - // TODO: What if this fails? just let them proceed? just means only direct connect people will be able to play // get some turn credentials TURNCredentialContainer? turnCredentials = await TURNCredentialManager.CreateCredentialsForUser(user_id); diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index e20d2c1..8c58ed2 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -41,6 +41,7 @@ public override Type GetReturnType() public string refresh_token { get; set; } = ""; public Int64 user_id { get; set; } = -1; public string display_name { get; set; } = ""; + public string ban_reason { get; set; } = ""; public string ws_uri { get; set; } = ""; } @@ -129,13 +130,15 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr } // ban check - bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); - if (bIsBanned) + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, user_id); + if (banStatus?.IsBanned == true) { // kill every token they hold, not just this request await TokenRevocationManager.RevokeAllTokensForUser(user_id, "user is banned"); + await ModerationManager.DisconnectUser(user_id, EModerationAction.Ban, banStatus.BanReason); result.result = EPendingLoginState.LoginFailed; + result.ban_reason = banStatus.BanReason; Response.StatusCode = (int)HttpStatusCode.Locked; return result; } diff --git a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs index 8ce557f..1c65a3a 100644 --- a/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs +++ b/GenOnlineService/Controllers/RefreshToken/RefreshTokenController.cs @@ -35,6 +35,7 @@ public override Type GetReturnType() public string refresh_token { get; set; } = ""; public Int64 user_id { get; set; } = -1; public string display_name { get; set; } = ""; + public string ban_reason { get; set; } = ""; } // Pure token rotation. Unlike LoginWithToken this does NOT establish a session - the caller's @@ -97,13 +98,15 @@ public async Task Post_InternalHandler(string ipAddr) // re-check the ban on every rotation so a ban applied since the last refresh takes // effect immediately rather than waiting for the periodic reconcile - bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); - if (bIsBanned) + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, user_id); + if (banStatus?.IsBanned == true) { // kill every token they hold, not just this request await TokenRevocationManager.RevokeAllTokensForUser(user_id, "user is banned"); + await ModerationManager.DisconnectUser(user_id, EModerationAction.Ban, banStatus.BanReason); result.result = EPendingLoginState.LoginFailed; + result.ban_reason = banStatus.BanReason; Response.StatusCode = (int)HttpStatusCode.Locked; return result; } diff --git a/GenOnlineService/Controllers/Rooms/RoomsController.cs b/GenOnlineService/Controllers/Rooms/RoomsController.cs index 21c1be9..86b2e0e 100644 --- a/GenOnlineService/Controllers/Rooms/RoomsController.cs +++ b/GenOnlineService/Controllers/Rooms/RoomsController.cs @@ -19,60 +19,37 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System; -using System.Net.WebSockets; -using System.Text; -using System.Text.Json; namespace GenOnlineService.Controllers { public class RouteHandler_GET_Rooms_Result : APIResult { - public override Type GetReturnType() - { - return this.GetType(); - } + public override Type GetReturnType() => GetType(); - public List? rooms { get; set; } = null; - } + public List rooms { get; set; } = []; + public bool supports_moderation_commands { get; set; } = true; + public bool supports_room_selection_results { get; set; } = true; + } [ApiController] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class RoomsController : ControllerBase { - private readonly ILogger _logger; - - public RoomsController(ILogger logger) - { - _logger = logger; - } - [HttpGet(Name = "GetRooms")] [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] - public async Task Get() + public APIResult Get() { - RouteHandler_GET_Rooms_Result result = new RouteHandler_GET_Rooms_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) + List rooms = RoomCatalog.Rooms.Select((room, index) => new RoomData { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - string strFileData = await System.IO.File.ReadAllTextAsync(Path.Combine("data", "rooms.json")); - List? lstRooms = JsonSerializer.Deserialize>(strFileData, options); - result.rooms = lstRooms; - } - catch - { - return result; - } - - return result; - } + id = room.ID, + name = room.Name, + parent_id = room.ParentID, + flags = index == 0 + ? ERoomFlags.ROOM_FLAGS_SHOW_ALL_MATCHES + : ERoomFlags.ROOM_FLAGS_NONE + }).ToList(); + + return new RouteHandler_GET_Rooms_Result { rooms = rooms }; } } } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 21ab9e0..5d292df 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -41,11 +41,97 @@ public WebSocketController(LobbyManager lobbyManager, IDbContextFactory(payload, JsonOpts); + if (command == null) + { + return; + } + + if (!userData.IsAdmin()) + { + QueueModerationCommandResult(session, command.request_id, false, + "You are not allowed to perform moderation actions.", "forbidden"); + return; + } + + if (command.action_type == "kick") + { + if (!command.target_user_id.HasValue || command.target_user_id.Value == session.m_UserID) + { + QueueModerationCommandResult(session, command.request_id, false, + "Select another online player to kick.", "invalid_target"); + return; + } + + ModerationResult kickResult = await ModerationManager.KickUser(command.target_user_id.Value, command.reason); + switch (kickResult.Result) + { + case EModerationResult.Success: + QueueModerationCommandResult(session, command.request_id, true, + $"{kickResult.TargetDisplayName} was kicked."); + break; + case EModerationResult.TargetNotOnline: + QueueModerationCommandResult(session, command.request_id, false, + "That player is no longer online.", "target_not_online"); + break; + case EModerationResult.ReasonTooLong: + QueueModerationCommandResult(session, command.request_id, false, + $"The reason must be {ModerationManager.MaximumReasonLength} characters or fewer.", "reason_too_long"); + break; + } + return; + } + + QueueModerationCommandResult(session, command.request_id, false, + "That moderation action is not supported.", "unsupported_action"); + } // GeoIP DB is designed to be reused; opening per request is expensive. // It is gitignored and absent in fresh clones, so a failure to open must @@ -292,6 +378,49 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) } } + private static UInt64? GetRoomChangeRequestID(Dictionary? data) + { + if (data != null + && data.TryGetValue("request_id", out JsonElement requestIDValue) + && requestIDValue.ValueKind == JsonValueKind.Number + && requestIDValue.TryGetUInt64(out UInt64 requestID)) + { + return requestID; + } + + return null; + } + + private static void ProcessNetworkRoomChange(UserSession session, Dictionary? data) + { + UInt64? requestID = GetRoomChangeRequestID(data); + if (data == null || !data.TryGetValue("room", out JsonElement roomValue)) + { + Console.WriteLine($"Rejected network room selection without a room ID from user {session.m_UserID}."); + WebSocketManager.QueueRoomSelectionRejected(session, requestID, null, "A room must be selected."); + return; + } + + if (roomValue.ValueKind != JsonValueKind.Number || !roomValue.TryGetInt16(out Int16 roomID)) + { + Console.WriteLine($"Rejected invalid network room selection from user {session.m_UserID}; value must be an Int16."); + WebSocketManager.QueueRoomSelectionRejected(session, requestID, null, "That room is unavailable."); + return; + } + + if (!session.TryUpdateSessionNetworkRoom(roomID)) + { + WebSocketManager.QueueRoomSelectionRejected(session, requestID, roomID, "That room is unavailable."); + return; + } + + if (!requestID.HasValue) + { + WebSocketManager.QueueLobbyListUpdateForSession(session); + } + WebSocketManager.QueueRoomSelectionAccepted(session, requestID); + } + private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession sourceUserSession, WebSocketReceiveResult receiveResult, ArraySegment buffer) { SharedUserData sourceUserData = WebSocketManager.GetSharedDataForUser(sourceUserSession.m_UserID); @@ -366,18 +495,38 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession { sourceUserSession.SetSubscribedToRealtimeSocialUpdates(true); } - else if (msgID == EWebSocketMessageID.SOCIAL_UNSUBSCRIBE_REALTIME_UPDATES) - { - sourceUserSession.SetSubscribedToRealtimeSocialUpdates(false); - } - else if (msgID == EWebSocketMessageID.SOCIAL_FRIEND_CHAT_MESSAGE_CLIENT_TO_SERVER) + else if (msgID == EWebSocketMessageID.SOCIAL_UNSUBSCRIBE_REALTIME_UPDATES) + { + sourceUserSession.SetSubscribedToRealtimeSocialUpdates(false); + } + else if (msgID == EWebSocketMessageID.MODERATION_COMMAND) + { + await ProcessModerationCommand(payload.ToArray(), sourceUserSession, sourceUserData); + } + else if (msgID == EWebSocketMessageID.SOCIAL_FRIEND_CHAT_MESSAGE_CLIENT_TO_SERVER) { WebSocketMessage_Social_FriendChatMessage_Inbound? chatMessage = JsonSerializer.Deserialize(payload, JsonOpts); - if (chatMessage != null) - { - // must be online & friends + if (chatMessage != null) + { + if (!sourceUserData.TryConsumeChatMessage()) + { + if (sourceUserData.TryConsumeChatRateLimitNotice()) + { + WebSocketMessage_Social_FriendChatMessage_Outbound rateLimitMessage = new() + { + msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_CHAT_MESSAGE_SERVER_TO_CLIENT, + source_user_id = sourceUserSession.m_UserID, + target_user_id = chatMessage.target_user_id, + message = "Rate limit: Please wait before sending another message." + }; + sourceUserSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(rateLimitMessage))); + } + return; + } + + // must be online & friends SharedUserData? targetUserData = WebSocketManager.GetSharedDataForUser(chatMessage.target_user_id); @@ -426,9 +575,15 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession WebSocketMessage_NetworkRoomChatMessageInbound? chatMessage = JsonSerializer.Deserialize(payload, JsonOpts); - if (chatMessage != null) - { - // response + if (chatMessage != null) + { + if (!sourceUserData.TryConsumeChatMessage()) + { + QueueChatRateLimited(sourceUserSession, sourceUserData, "room"); + return; + } + + // response WebSocketMessage_NetworkRoomChatMessageOutbound outboundMsg = new WebSocketMessage_NetworkRoomChatMessageOutbound(); outboundMsg.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_CHAT_FROM_SERVER; @@ -442,7 +597,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession { if (sourceUserData.IsAdmin()) { - outboundMsg.message = String.Format("[\u2605\u2605GO STAFF\u2605\u2605] [{0}] {1}", sourceUserData.m_strDisplayName, chatMessage.message); + outboundMsg.message = String.Format("[\u2605\u2605GO STAFF\u2605\u2605] [{0}] {1}", sourceUserData.m_strDisplayName, chatMessage.message); outboundMsg.admin = true; outboundMsg.name_change = false; } @@ -493,11 +648,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } else if (msgID == EWebSocketMessageID.NETWORK_ROOM_CHANGE_ROOM) { - if (data != null && data.ContainsKey("room")) - { - Int16 roomID = data["room"].GetInt16(); - await sourceUserSession.UpdateSessionNetworkRoom(roomID); - } + ProcessNetworkRoomChange(sourceUserSession, data); } else if (msgID == EWebSocketMessageID.NETWORK_ROOM_MARK_READY) { @@ -643,7 +794,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } sourceUserData.m_strDisplayName = nameChangeRequest.name; - await WebSocketManager.MarkRoomMemberListAsDirty(sourceUserSession.networkRoomID); + WebSocketManager.MarkRoomMemberListAsDirty(sourceUserSession.networkRoomID); } else { @@ -703,9 +854,15 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession WebSocketMessage_LobbyChatMessageInbound? chatMessage = JsonSerializer.Deserialize(payload, JsonOpts); - if (chatMessage != null) - { - // get lobby + if (chatMessage != null) + { + if (!sourceUserData.TryConsumeChatMessage()) + { + QueueChatRateLimited(sourceUserSession, sourceUserData, "lobby"); + return; + } + + // get lobby Lobby? playerLobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (playerLobby != null) diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 9b06932..450653f 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -98,6 +98,12 @@ public class UserLobbyPreferences public bool favorite_limit_superweapons = false; } +public sealed class UserBanStatus +{ + public bool IsBanned { get; set; } + public string BanReason { get; set; } = String.Empty; +} + // TODO_EFCORE: add index for code public class PendingLoginConfiguration : IEntityTypeConfiguration { @@ -279,6 +285,18 @@ public static class Users .Select(u => u.IsBanned) .FirstOrDefault()); + private static readonly Func> _getUserBanStatusQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => new UserBanStatus + { + IsBanned = u.IsBanned, + BanReason = u.BanReason ?? String.Empty + }) + .FirstOrDefault()); + private static readonly Func> _getDisplayNameQuery = EF.CompileAsyncQuery((AppDbContext db, long userId) => db.Users @@ -446,6 +464,20 @@ public static async Task IsUserBanned(AppDbContext db, long userId) } } + public static async Task GetUserBanStatus(AppDbContext db, long userId) + { + try + { + return await _getUserBanStatusQuery(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserBanStatus failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + public static async Task GetDisplayName(AppDbContext db, long userId) { diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index 6e9c598..ffca30f 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -460,10 +460,9 @@ private async Task OnMessageReceived(SocketMessage message) } } } - else if (message.Content.ToLower().StartsWith("!kick")) + else if (message.Content.Equals("!kick", StringComparison.OrdinalIgnoreCase) + || message.Content.StartsWith("!kick ", StringComparison.OrdinalIgnoreCase)) { - // TODO: In future we should validate users not just channels - // is it in the admin channel? if (message.Channel.Id == g_dictChannelIDs[EDiscordChannelIDs.AdminCommands]) { if (Program.g_Config == null) @@ -471,60 +470,47 @@ private async Task OnMessageReceived(SocketMessage message) return; } - // is it an admin? - IConfiguration? discordSettings = Program.g_Config.GetSection("Discord"); - - if (discordSettings == null) - { - return; - } - - List? discord_admins = discordSettings.GetSection("discord_admins").Get>(); - if (discord_admins == null) + List? discordAdmins = Program.g_Config + .GetSection("Discord") + .GetSection("discord_admins") + .Get>(); + if (discordAdmins?.Contains(message.Author.Id) == true) { - return; - } - - if (discord_admins.Contains(message.Author.Id)) - { - string[] strComponents = message.Content.Split(' '); - //var clients = message.Author.ActiveClients; - - //var user = message.Author as IGuildUser; // Get the user from the command context - if (strComponents.Length == 2) + string[] strComponents = message.Content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (strComponents.Length >= 2) { - string strUser = string.Join(' ', strComponents.Skip(1)); + string strUser = strComponents[1]; if (Int64.TryParse(strUser, out Int64 TargetUserID)) { - SharedUserData? targetData = GenOnlineService.WebSocketManager.GetSharedDataForUser(TargetUserID); - - if (targetData != null) - { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {TargetUserID} ({targetData.m_strDisplayName}) has been kicked from the server."); + string strReason = string.Join(' ', strComponents.Skip(2)); + ModerationResult kickResult = await ModerationManager.KickUser(TargetUserID, strReason); - // we need to kill all websockets they have - List lstUserSessions = GenOnlineService.WebSocketManager.GetAllDataFromUser(TargetUserID); - foreach (UserSession userSession in lstUserSessions) - { - UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(userSession); - await GenOnlineService.WebSocketManager.DeleteSession(TargetUserID, userSession.GetSessionType(), oldWS, true); - } - - - } - else + switch (kickResult.Result) { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {TargetUserID} is not active on the server."); + case EModerationResult.ReasonTooLong: + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"Kick reason must be {ModerationManager.MaximumReasonLength} characters or fewer."); + break; + case EModerationResult.TargetNotOnline: + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {TargetUserID} is not active on the server."); + break; + case EModerationResult.Success: + string confirmation = $"User {TargetUserID} ({kickResult.TargetDisplayName}) has been kicked from the server."; + if (!String.IsNullOrWhiteSpace(strReason)) + { + confirmation += $" Reason: {strReason}"; + } + PushChannelMessage(EDiscordChannelIDs.AdminCommands, confirmation); + break; } } else { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick (e.g. !kick 123)"); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick [reason] (e.g. !kick 123 reconnect abuse)"); } } else { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick (e.g. !kick 123)"); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick [reason] (e.g. !kick 123 reconnect abuse)"); } } else diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index f32b4e5..bc0df6d 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -220,7 +220,7 @@ public async Task ProcessPendingFullMeshConnectivityChecks() } } - + // inform host that we are done // start full mesh connectivity checks @@ -257,14 +257,26 @@ public async Task ProcessPendingFullMeshConnectivityChecks() public void AddPassword(string password) { + if (IsPassworded && Password == password) + { + return; + } + Password = password; IsPassworded = true; + DirtyRetransmitLobbyList(); } public void RemovePassword() { + if (!IsPassworded && Password.Length == 0) + { + return; + } + Password = String.Empty; IsPassworded = false; + DirtyRetransmitLobbyList(); } public double GetLatitude() { return m_dHostLatitude; } @@ -445,7 +457,9 @@ public void RecordPlayerOutcome(Int64 userId, bool bWon) } } - private bool m_bIsDirty = false; + // Initial sync retries only resend lobby state. + private int m_lobbyUpdatePending = 0; + private int m_lobbyListUpdatePending = 0; [JsonIgnore] private Int64 m_LastInitialSync = Environment.TickCount64; @@ -615,24 +629,16 @@ public void DoHostMigration() { if (member.UserID != oldOwner) { - // found a viable host UInt16 oldSlot = member.SlotIndex; - // update owner Owner = member.UserID; - // move them to slot 0 (host) member.UpdateSlotIndex(0); Members[0] = member; Members[oldSlot] = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, oldSlot, true); - // mark as ready member.SetReadyState(true); - - // mark as dirty - DirtyRetransmit(); - - // we are done + DirtyRetransmitLobbyList(); break; } } @@ -707,11 +713,11 @@ public async Task Tick() if (m_InitialSyncs < 5 && Environment.TickCount64 - m_LastInitialSync > 200) { m_LastInitialSync = Environment.TickCount64; - m_bIsDirty = true; + Interlocked.Exchange(ref m_lobbyUpdatePending, 1); ++m_InitialSyncs; } - if (m_bIsDirty) + if (Interlocked.Exchange(ref m_lobbyUpdatePending, 0) != 0) { WebSocketMessage_CurrentLobbyUpdate lobbyUpdate = new WebSocketMessage_CurrentLobbyUpdate(); lobbyUpdate.msg_id = (int)EWebSocketMessageID.LOBBY_CURRENT_LOBBY_UPDATE; @@ -730,11 +736,11 @@ public async Task Tick() } } } + } - // transmit to those in network room - //WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(NetworkRoomID); - - m_bIsDirty = false; + if (Interlocked.Exchange(ref m_lobbyListUpdatePending, 0) != 0) + { + WebSocketManager.QueueLobbyListUpdateForViewers(NetworkRoomID); } } @@ -871,10 +877,9 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa Members[slotIndex] = newMember; TimeMemberLeft[playerSession.m_UserID] = DateTime.UnixEpoch; - // leave network room we were in - playerSession.UpdateSessionNetworkRoom(-1); + // Lobby members leave public-room presence. + playerSession.TryUpdateSessionNetworkRoom(-1); - // store our lobby ID playerSession.UpdateSessionLobbyID(LobbyID); // START NETWORK SIGNALLING @@ -927,8 +932,8 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa } // END NETWORK SIGNALLING - // also update the lobby for everyone inside of it - DirtyRetransmit(); + // Notify lobby members and room browsers. + DirtyRetransmitLobbyList(); Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, slotIndex); return true; @@ -942,7 +947,7 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa public async Task RemoveMember(LobbyMember member) { // TODO_LOBBY: Optimize this - Int64 UserID = member.UserID; + Int64 UserID = member.UserID; Console.WriteLine("User {0} left lobby {1}", UserID, LobbyID); @@ -992,7 +997,7 @@ public async Task RemoveMember(LobbyMember member) await OnAfterPlayerLeft(UserID); - DirtyRetransmit(); + DirtyRetransmitLobbyList(); } public int GetNumberOfHumans() @@ -1039,7 +1044,13 @@ public void GetParticipantBreakdown(out int numHumans, out int numAI, out int nu public void DirtyRetransmit() { - m_bIsDirty = true; + Interlocked.Exchange(ref m_lobbyUpdatePending, 1); + } + + public void DirtyRetransmitLobbyList() + { + DirtyRetransmit(); + Interlocked.Exchange(ref m_lobbyListUpdatePending, 1); } public async Task DirtyRetransmitToSingleMember(Int64 targetUserID) @@ -1075,7 +1086,7 @@ public async Task DirtyRetransmitToSingleMember(Int64 targetUserID) { return Members[slotIndex]; } - + return null; } @@ -1118,7 +1129,7 @@ public async Task UpdateMap(AppDbContext _db, string strMap, string strMapPath, await Database.Users.SetFavorite_Map(_db, Owner, strMapPath); } - DirtyRetransmit(); + DirtyRetransmitLobbyList(); } public async Task UpdateStartingCash(AppDbContext _db, UInt32 newStartingCash) @@ -1127,7 +1138,7 @@ public async Task UpdateStartingCash(AppDbContext _db, UInt32 newStartingCash) await Database.Users.SetFavorite_StartingMoney(_db, Owner, (int)newStartingCash); - DirtyRetransmit(); + DirtyRetransmitLobbyList(); } public async Task UpdateLimitSuperweapons(AppDbContext _db, bool bLimitSuperweapons) @@ -1136,7 +1147,7 @@ public async Task UpdateLimitSuperweapons(AppDbContext _db, bool bLimitSuperweap await Database.Users.SetFavorite_LimitSuperweapons(_db, Owner, bLimitSuperweapons); - DirtyRetransmit(); + DirtyRetransmitLobbyList(); } public void ForceReady() @@ -1218,19 +1229,19 @@ public async Task UpdateState(ELobbyState state) m_NextProbe = 0; } - + } - DirtyRetransmit(); + DirtyRetransmitLobbyList(); } public void UpdateJoinability(ELobbyJoinability newJoinability) { - // must be a custom match - if (LobbyType == ELobbyType.CustomGame) + if (LobbyType == ELobbyType.CustomGame && LobbyJoinability != newJoinability) { - LobbyJoinability = newJoinability; - } + LobbyJoinability = newJoinability; + DirtyRetransmitLobbyList(); + } } public void UpdateMaxCameraHeight(UInt16 maxCamHeight) @@ -1350,6 +1361,12 @@ private void DirtyRetransmit() lobby?.DirtyRetransmit(); } + private void DirtyRetransmitLobbyList() + { + CurrentLobby.TryGetTarget(out Lobby? lobby); + lobby?.DirtyRetransmitLobbyList(); + } + public void SetReadyState(bool bReady) { IsReady = bReady; @@ -1367,7 +1384,7 @@ public void SetPlayerSlotState(EPlayerType newState) IsReady = true; } - DirtyRetransmit(); + DirtyRetransmitLobbyList(); } public async Task UpdateSide(AppDbContext _db, int newSide, int start_pos) @@ -1531,21 +1548,15 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession Lobby newLobby = new Lobby(newLobbyID, owningSession, strName, ELobbyState.GAME_SETUP, strMapName, strMapPath, bVanillaTeams, starting_cash, bLimitSuperweapons, bTrackStats, bPassworded, strPassword, bMapOfficial, rng_seed, parentNetworkRoom, bAllowObservers, maxCamHeight, exe_crc, ini_crc, maxPlayers, lobbyType, anticheatID); m_dictLobbies[newLobbyID] = newLobby; - - - // subscribe for self-destruct event newLobby.OnLobbyNeedsDestroyed += HandleLobbyNeedsDestroyed; - // and join - if (lobbyType != ELobbyType.QuickMatch) // quickmatch requires a manual join, because the service creates the lobby for them, so the client knows nothing about it without a manual join + if (lobbyType != ELobbyType.QuickMatch) // QuickMatch clients join after allocation. { - bool bJoined = await JoinLobby(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); + await JoinLobby(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); } - newLobby.DirtyRetransmit(); - - // inform - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(parentNetworkRoom); + // Send initial lobby state and invalidate room listings. + newLobby.DirtyRetransmitLobbyList(); return newLobbyID; } @@ -1588,13 +1599,13 @@ public async Task CleanupUserLobbiesNotStarted(Int64 UserID) } } - public List GetAllLobbies(Int16 networkRoomID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted, bool bIncludeAllNetworkRooms) + public List GetAllLobbies(Int16 selectedRoomID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted, bool bIncludeAllNetworkRooms) { List listLobbies = new List(); foreach (var kvp in m_dictLobbies) { Lobby lobby = kvp.Value; - if (!bIncludeAllNetworkRooms && lobby.NetworkRoomID != networkRoomID) + if (!bIncludeAllNetworkRooms && !RoomCatalog.CanViewLobby(selectedRoomID, lobby.NetworkRoomID)) { continue; } @@ -1746,28 +1757,21 @@ public async Task DeleteLobby(Lobby lobby) { if (lobby.State != ELobbyState.COMPLETE) { - // make done await lobby.UpdateState(ELobbyState.COMPLETE); - - // attempt to commit it await Database.MatchHistory.CommitLobbyToMatchHistory(_db, lobby); } - // delete bool bRemoved = m_dictLobbies.Remove(lobby.LobbyID, out _); - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); - // only do this once if (bRemoved) { - // unsubscribe from self-destruct event + WebSocketManager.QueueLobbyListUpdateForViewers(lobby.NetworkRoomID); + lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; - // make sure we have a winner await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(_db, lobby); - // Post match result to external leaderboard API for every lobby type. - // Only QuickMatch responses are expected to carry a ratings body. + // All lobby types are published; only QuickMatch returns ratings. await ExternalLeaderboardsClient.PostMatchResultAsync(_db, lobby); } @@ -1787,4 +1791,4 @@ public bool IsUserInLobby(Lobby lobby, Int64 user_id) return member != null; } } -} \ No newline at end of file +} diff --git a/GenOnlineService/Moderation.cs b/GenOnlineService/Moderation.cs new file mode 100644 index 0000000..7d0b9b7 --- /dev/null +++ b/GenOnlineService/Moderation.cs @@ -0,0 +1,78 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +*/ + +using System.Text; +using System.Text.Json; + +namespace GenOnlineService +{ + public enum EModerationResult + { + Success, + TargetNotOnline, + ReasonTooLong + } + + public enum EModerationAction + { + Ban, + Kick + } + + public sealed class ModerationResult + { + public EModerationResult Result { get; init; } + public string TargetDisplayName { get; init; } = String.Empty; + } + + public static class ModerationManager + { + public const int MaximumReasonLength = 256; + + public static async Task KickUser(Int64 targetUserID, string reason) + { + if (reason.Length > MaximumReasonLength) + { + return new ModerationResult { Result = EModerationResult.ReasonTooLong }; + } + + SharedUserData? target = WebSocketManager.GetSharedDataForUser(targetUserID); + if (target == null) + { + return new ModerationResult { Result = EModerationResult.TargetNotOnline }; + } + + await DisconnectUser(targetUserID, EModerationAction.Kick, reason); + return new ModerationResult + { + Result = EModerationResult.Success, + TargetDisplayName = target.m_strDisplayName + }; + } + + public static async Task DisconnectUser(Int64 userID, EModerationAction action, string? reason) + { + WebSocketMessage_ModerationNotice notice = new WebSocketMessage_ModerationNotice + { + msg_id = (int)EWebSocketMessageID.MODERATION_NOTICE, + action_type = action switch + { + EModerationAction.Ban => "ban", + EModerationAction.Kick => "kick", + _ => throw new ArgumentOutOfRangeException(nameof(action)) + }, + reason = reason ?? String.Empty + }; + byte[] noticeJson = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(notice)); + + await WebSocketManager.DisconnectUser(userID, noticeJson); + } + } +} diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index e2d54e4..b6a6b14 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -409,6 +409,8 @@ public static string GetIPAddress(ControllerBase controller) public class Program { + private const string BannedUserContextKey = "GenOnlineService.BannedUserID"; + public static IConfiguration? g_Config = null; public static DiscordBot? g_Discord = null; @@ -632,6 +634,7 @@ private static Task AdditionalValidation(TokenValidatedContext context) // Revocation checks. All in-memory, no database access per request. if (TokenRevocationManager.IsUserBanned(userID)) { + context.HttpContext.Items[BannedUserContextKey] = userID; context.Fail("Failed Validation #12 - User is banned"); return Task.CompletedTask; } @@ -694,6 +697,28 @@ private static Task AdditionalValidation(TokenValidatedContext context) return Task.CompletedTask; } + private static async Task HandleJwtChallenge(JwtBearerChallengeContext context) + { + if (!context.HttpContext.Items.TryGetValue(BannedUserContextKey, out object? value) + || value is not Int64 userID) + { + return; + } + + IDbContextFactory dbFactory = context.HttpContext.RequestServices.GetRequiredService>(); + await using var db = await dbFactory.CreateDbContextAsync(); + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, userID); + + if (banStatus?.IsBanned != true) + { + return; + } + + context.HandleResponse(); + context.Response.StatusCode = StatusCodes.Status423Locked; + await context.Response.WriteAsJsonAsync(new { ban_reason = banStatus.BanReason }); + } + public class JwtTokenGenerator { private readonly IConfiguration _configuration; @@ -826,6 +851,7 @@ public static async Task Main(string[] args) ThreadPool.SetMinThreads(200, 200); var builder = WebApplication.CreateBuilder(args); + RoomCatalog.Initialize(Path.Combine(builder.Environment.ContentRootPath, "data", "rooms.json")); // Add services to the container. @@ -988,7 +1014,8 @@ public static async Task Main(string[] args) options.Events = new JwtBearerEvents { - OnTokenValidated = AdditionalValidation + OnTokenValidated = AdditionalValidation, + OnChallenge = HandleJwtChallenge }; }).AddScheme("Basic", null); @@ -1359,11 +1386,11 @@ public static async Task Main(string[] args) { System.Timers.Timer timerTick = new System.Timers.Timer(1000); // 1s tick timerTick.AutoReset = false; - timerTick.Elapsed += async (sender, e) => + timerTick.Elapsed += (sender, e) => { try { - await WebSocketManager.TickRoomMemberList(); + WebSocketManager.TickRoomMemberList(); } catch (Exception ex) { @@ -1404,9 +1431,9 @@ public static async Task Main(string[] args) timerTick.Start(); } - // keep token revocation state in sync with bans applied directly in the database + // Pick up bans applied directly in the database. { - System.Timers.Timer timerTick = new System.Timers.Timer(60000); // 60s tick + System.Timers.Timer timerTick = new System.Timers.Timer(5000); // 5s tick timerTick.AutoReset = false; timerTick.Elapsed += async (sender, e) => { diff --git a/GenOnlineService/TokenRevocation.cs b/GenOnlineService/TokenRevocation.cs index cdade4b..7370808 100644 --- a/GenOnlineService/TokenRevocation.cs +++ b/GenOnlineService/TokenRevocation.cs @@ -111,7 +111,7 @@ public static async Task> GetBannedUserIDs(AppDbContext db) { Console.WriteLine($"[ERROR] UserTokens.GetBannedUserIDs failed: {ex.Message}"); SentrySdk.CaptureException(ex); - return new List(); + throw; } } } @@ -244,8 +244,7 @@ public static async Task OnTokensIssued(Int64 userID, EUserSessionType sessionTy await Persist(userID, sessionType, newState); } - // Invalidates every token previously issued to this user, across all session types, and drops - // any live websockets they hold. + // Invalidates every token previously issued to this user across all session types. public static async Task RevokeAllTokensForUser(Int64 userID, string reason) { Console.WriteLine($"[TokenRevocation] Revoking all tokens for user {userID} ({reason})."); @@ -260,7 +259,6 @@ public static async Task RevokeAllTokensForUser(Int64 userID, string reason) await Persist(userID, sessionType, newState); } - await DisconnectUser(userID); } // Picks up bans applied directly in the database (there is no in-process ban API). @@ -291,7 +289,9 @@ public static async Task ReconcileBans(AppDbContext db) foreach (Int64 userID in newlyBanned) { + UserBanStatus? banStatus = await Database.Users.GetUserBanStatus(db, userID); await RevokeAllTokensForUser(userID, "user was banned"); + await ModerationManager.DisconnectUser(userID, EModerationAction.Ban, banStatus?.BanReason); } } @@ -314,22 +314,5 @@ private static async Task Persist(Int64 userID, EUserSessionType sessionType, Ca } } - private static async Task DisconnectUser(Int64 userID) - { - try - { - List lstUserSessions = WebSocketManager.GetAllDataFromUser(userID); - foreach (UserSession userSession in lstUserSessions) - { - UserWebSocketInstance? oldWS = WebSocketManager.GetWebSocketForSession(userSession); - await WebSocketManager.DeleteSession(userID, userSession.GetSessionType(), oldWS, true); - } - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] TokenRevocation.DisconnectUser failed: {ex.Message}"); - SentrySdk.CaptureException(ex); - } - } } } diff --git a/GenOnlineService/data/rooms.json b/GenOnlineService/data/rooms.json index 4876aa1..a6ea539 100644 --- a/GenOnlineService/data/rooms.json +++ b/GenOnlineService/data/rooms.json @@ -1,37 +1,14 @@ -[ - { - "id": 0, - "name": "ALL GAMES", - "flags": 1 - }, - { - "id": 1, - "name": "General", - "flags": 0 - }, - { - "id": 2, - "name": "1v1", - "flags": 0 - }, - { - "id": 3, - "name": "2v2", - "flags": 0 - }, - { - "id": 4, - "name": "No Rules", - "flags": 0 - }, - { - "id": 5, - "name": "Pro Rules", - "flags": 0 - }, - { - "id": 6, - "name": "Rise of the Reds (MOD)", - "flags": 0 - } -] \ No newline at end of file +[ + { + "name": "All Games", + "rooms": [ + { + "name": "General", + "default": true + }, + { + "name": "Tournament" + } + ] + } +]