From 6236026571af1a1c29b87d3541598d96541a5375 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:04:40 +0200 Subject: [PATCH 1/4] feat(moderation): add websocket protocol messages --- GenOnlineService/Constants.cs | 59 +++++++++++++- .../CheckLogin/CheckLoginController.cs | 7 +- .../LoginWithTokenController.cs | 7 +- .../RefreshToken/RefreshTokenController.cs | 7 +- GenOnlineService/Database/Database.User.cs | 32 ++++++++ GenOnlineService/Discord.cs | 72 +++++++---------- GenOnlineService/Moderation.cs | 78 +++++++++++++++++++ GenOnlineService/Program.cs | 32 +++++++- GenOnlineService/TokenRevocation.cs | 25 +----- 9 files changed, 244 insertions(+), 75 deletions(-) create mode 100644 GenOnlineService/Moderation.cs diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 06f02cc..77567f3 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -658,6 +658,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 @@ -2545,7 +2569,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 +2670,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; } @@ -2829,4 +2884,4 @@ public class WebSocketMessage_MatchmakerStartGame : WebSocketMessage } -} \ No newline at end of file +} 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/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/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/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..8e35801 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; @@ -988,7 +1013,8 @@ public static async Task Main(string[] args) options.Events = new JwtBearerEvents { - OnTokenValidated = AdditionalValidation + OnTokenValidated = AdditionalValidation, + OnChallenge = HandleJwtChallenge }; }).AddScheme("Basic", null); @@ -1404,9 +1430,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); - } - } } } From 038c78ca24240a8f107aad800cd40bf63c6c44a4 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:04:49 +0200 Subject: [PATCH 2/4] feat(moderation): enforce chat rate limits --- GenOnlineService/Constants.cs | 52 ++++++++++++-- .../WebSocket/WebSocketController.cs | 67 ++++++++++++++++--- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 77567f3..f28bd10 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -843,9 +843,11 @@ public static async Task MarkRoomMemberListAsDirty(int 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() { @@ -873,7 +875,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) { diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 21ab9e0..c5616ae 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -41,11 +41,28 @@ public WebSocketController(LobbyManager lobbyManager, IDbContextFactory(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 +459,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; @@ -703,9 +742,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) From c5472905154936ce30ed82f0d23500cbbed43cbb Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:05:14 +0200 Subject: [PATCH 3/4] fix(chat): normalize staff message spacing --- GenOnlineService/Controllers/WebSocket/WebSocketController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index c5616ae..40450fb 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -481,7 +481,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; } From bc8f7b6ee5a7d88d4c13e619f40150dde126cdde Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:05:21 +0200 Subject: [PATCH 4/4] feat(moderation): accept client commands --- .../Controllers/Rooms/RoomsController.cs | 5 +- .../WebSocket/WebSocketController.cs | 83 +++++++++++++++++-- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/GenOnlineService/Controllers/Rooms/RoomsController.cs b/GenOnlineService/Controllers/Rooms/RoomsController.cs index 21c1be9..9760016 100644 --- a/GenOnlineService/Controllers/Rooms/RoomsController.cs +++ b/GenOnlineService/Controllers/Rooms/RoomsController.cs @@ -32,8 +32,9 @@ public override Type GetReturnType() return this.GetType(); } - public List? rooms { get; set; } = null; - } + public List? rooms { get; set; } = null; + public bool supports_moderation_commands { get; set; } = true; + } [ApiController] [Route("env/{environment}/contract/{contract_version}/[controller]")] diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 40450fb..b487c88 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -63,6 +63,75 @@ private static void QueueChatRateLimited(UserSession session, SharedUserData use }; session.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message))); } + + private static void QueueModerationCommandResult( + UserSession session, + UInt64 requestID, + bool success, + string message, + string? errorCode = null) + { + WebSocketMessage_ModerationCommandResult result = new() + { + msg_id = (int)EWebSocketMessageID.MODERATION_COMMAND_RESULT, + request_id = requestID, + success = success, + error_code = errorCode, + message = message + }; + session.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result))); + } + + private async Task ProcessModerationCommand( + byte[] payload, + UserSession session, + SharedUserData userData) + { + WebSocketMessage_ModerationCommand? command = + JsonSerializer.Deserialize(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 @@ -383,11 +452,15 @@ 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);