Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion GenOnlineService/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,30 @@ public static List<UserSession> GetAllDataFromUser(Int64 userID)
return lstRet;
}

public static async Task DisconnectUser(Int64 userID, byte[] finalMessage)
{
List<UserSession> 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<bool> 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
Expand Down Expand Up @@ -2545,7 +2569,8 @@ public enum EWebSocketMessageID
AC_REGISTER_PLAYER = 40,
AC_DEREGISTER_PLAYER = 41,
WS_KEEPALIVE = 42,
WS_KEEPALIVE_CLIENT = 43
WS_KEEPALIVE_CLIENT = 43,
MODERATION_ACTION = 46
};

public static class UserPresence
Expand Down Expand Up @@ -2643,6 +2668,12 @@ public class WebSocketMessage_StartMatch : WebSocketMessage
public string screenshot_url { get; set; } = String.Empty;
}

public class WebSocketMessage_ModerationAction : WebSocketMessage
{
public string action_type { get; set; } = String.Empty;
public string reason { get; set; } = String.Empty;
}

public abstract class WebSocketMessage
{
public int msg_id { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; } = "";
}
Expand Down Expand Up @@ -180,12 +181,14 @@ public async Task<APIResult> Post_InternalHandler(string jsonData, string ipAddr
if (clientID != null && Program.g_tokenGenerator != null)
{
// 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; } = "";
}
Expand Down Expand Up @@ -129,13 +130,15 @@ public async Task<APIResult> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,13 +98,15 @@ public async Task<APIResult> 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;
}
Expand Down
32 changes: 32 additions & 0 deletions GenOnlineService/Database/Database.User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PendingLogin>
{
Expand Down Expand Up @@ -372,6 +378,18 @@ public static class Users
.Select(u => u.IsBanned)
.FirstOrDefault());

private static readonly Func<AppDbContext, long, Task<UserBanStatus?>> _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<AppDbContext, long, Task<string?>> _getDisplayNameQuery =
EF.CompileAsyncQuery((AppDbContext db, long userId) =>
db.Users
Expand Down Expand Up @@ -539,6 +557,20 @@ public static async Task<bool> IsUserBanned(AppDbContext db, long userId)
}
}

public static async Task<UserBanStatus?> 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<string> GetDisplayName(AppDbContext db, long userId)
{
Expand Down
72 changes: 29 additions & 43 deletions GenOnlineService/Discord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -460,71 +460,57 @@ 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)
{
return;
}

// is it an admin?
IConfiguration? discordSettings = Program.g_Config.GetSection("Discord");

if (discordSettings == null)
{
return;
}

List<UInt64>? discord_admins = discordSettings.GetSection("discord_admins").Get<List<UInt64>>();
if (discord_admins == null)
List<UInt64>? discordAdmins = Program.g_Config
.GetSection("Discord")
.GetSection("discord_admins")
.Get<List<UInt64>>();
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<UserSession> 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 <user_id> (e.g. !kick 123)");
PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick <user_id> [reason] (e.g. !kick 123 reconnect abuse)");
}
}
else
{
PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick <user_id> (e.g. !kick 123)");
PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick <user_id> [reason] (e.g. !kick 123 reconnect abuse)");
}
}
else
Expand Down
78 changes: 78 additions & 0 deletions GenOnlineService/Moderation.cs
Original file line number Diff line number Diff line change
@@ -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<ModerationResult> 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_ModerationAction notice = new WebSocketMessage_ModerationAction
{
msg_id = (int)EWebSocketMessageID.MODERATION_ACTION,
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);
}
}
}
Loading
Loading