diff --git a/Core/GameEngine/Include/GameNetwork/GameSpyOverlay.h b/Core/GameEngine/Include/GameNetwork/GameSpyOverlay.h index 9c717f7040a..e89581f286a 100644 --- a/Core/GameEngine/Include/GameNetwork/GameSpyOverlay.h +++ b/Core/GameEngine/Include/GameNetwork/GameSpyOverlay.h @@ -38,6 +38,13 @@ void ClearGSMessageBoxes(); ///< Tear down any GS message boxes (e.g. in case we have a new one to put up) void GSMessageBoxOk(UnicodeString titleString,UnicodeString bodyString, GameWinMsgBoxFunc okFunc = nullptr); ///< Display a Message box with Ok button and track it void GSMessageBoxOkCancel(UnicodeString title, UnicodeString message, GameWinMsgBoxFunc okFunc, GameWinMsgBoxFunc cancelFunc); ///< Display a Message box with Ok/Cancel buttons and track it +void GSMessageBoxOkCancelWithLabels( + UnicodeString title, + UnicodeString message, + UnicodeString okLabel, + UnicodeString cancelLabel, + GameWinMsgBoxFunc okFunc, + GameWinMsgBoxFunc cancelFunc); ///< Display a tracked Ok/Cancel message box with caller-provided button labels void GSMessageBoxYesNo(UnicodeString title, UnicodeString message, GameWinMsgBoxFunc yesFunc, GameWinMsgBoxFunc noFunc); ///< Display a Message box with Yes/No buttons and track it void RaiseGSMessageBox(); ///< Bring GS message box to the foreground (if we transition screens while a message box is up) diff --git a/Core/GameEngine/Source/GameNetwork/GameSpyOverlay.cpp b/Core/GameEngine/Source/GameNetwork/GameSpyOverlay.cpp index 7dcfb814062..e0dc3edc154 100644 --- a/Core/GameEngine/Source/GameNetwork/GameSpyOverlay.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameSpyOverlay.cpp @@ -30,6 +30,7 @@ #include "Common/AudioEventRTS.h" #include "GameClient/GadgetListBox.h" +#include "GameClient/GadgetPushButton.h" #include "GameClient/GameText.h" #include "GameClient/MessageBox.h" #include "GameClient/ShellHooks.h" @@ -125,6 +126,33 @@ void GSMessageBoxOkCancel(UnicodeString title, UnicodeString message, GameWinMsg cancelFunc = newCancelFunc; } +void GSMessageBoxOkCancelWithLabels( + UnicodeString title, + UnicodeString message, + UnicodeString okLabel, + UnicodeString cancelLabel, + GameWinMsgBoxFunc newOkFunc, + GameWinMsgBoxFunc newCancelFunc) +{ + GSMessageBoxOkCancel(title, message, newOkFunc, newCancelFunc); + if (messageBoxWindow == nullptr) + { + return; + } + + GameWindow* buttonOk = TheWindowManager->winGetWindowFromId(messageBoxWindow, TheNameKeyGenerator->nameToKey("MessageBox.wnd:ButtonOk")); + GameWindow* buttonCancel = TheWindowManager->winGetWindowFromId(messageBoxWindow, TheNameKeyGenerator->nameToKey("MessageBox.wnd:ButtonCancel")); + if (buttonOk != nullptr) + { + GadgetButtonSetText(buttonOk, okLabel); + } + + if (buttonCancel != nullptr) + { + GadgetButtonSetText(buttonCancel, cancelLabel); + } +} + /** * GSMessageBoxYesNo puts up a Yes/No dialog box and saves the * pointers to it and its callbacks. diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 7cb8b0cba9f..f49819dc3f4 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1150,6 +1150,7 @@ set(GAMEENGINE_SRC Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h + Include/GameNetwork/GeneralsOnline/OnlineServices_Moderation.h Include/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.h Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h Include/GameNetwork/GeneralsOnline/OnlineServices_SocialInterface.h @@ -1193,6 +1194,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp + Source/GameNetwork/GeneralsOnline/OnlineServices_Moderation.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_SocialInterface.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h index 8c0c3474788..07d590e64f5 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h @@ -27,6 +27,7 @@ void NetworkLog(ELogVerbosity logVerbosity, const char* fmt, ...); std::string to_utf8(const std::wstring& wstr); std::wstring from_utf8(const std::string& utf8_str); +std::wstring NormalizeSingleLineText(const std::wstring& text); int RoundUpLatencyToFrameInterval(int latency, int frameInterval); int ConvertMSLatencyToFrames(int ms); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 7b2e31001f8..f833e41f00c 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -94,7 +94,8 @@ enum EWebSocketMessageID AC_REGISTER_PLAYER = 40, AC_DEREGISTER_PLAYER = 41, WS_KEEPALIVE = 42, - WS_KEEPALIVE_CLIENT = 43 + WS_KEEPALIVE_CLIENT = 43, + MODERATION_ACTION = 46 }; enum class EQoSRegions @@ -124,9 +125,16 @@ enum class EGOTearDownReason LOST_CONNECTION = 0, USER_LOGOUT = 1, USER_REQUESTED_SILENT = 2, - AUTH_FAILED = 3 + AUTH_FAILED = 3, + MODERATION_BAN = 4, + MODERATION_KICK = 5 }; +constexpr bool IsModerationTeardownReason(EGOTearDownReason reason) noexcept +{ + return reason == EGOTearDownReason::MODERATION_BAN || reason == EGOTearDownReason::MODERATION_KICK; +} + class WebSocket { public: @@ -500,7 +508,16 @@ class NGMP_OnlineServicesManager std::string& GetMOTD() { return m_strMOTD; } - void SetPendingFullTeardown(EGOTearDownReason reason) { m_bPendingFullTeardown = true; m_teardownReason = reason; } + void SetPendingFullTeardown(EGOTearDownReason reason) + { + if (IsModerationTeardownReason(m_teardownReason) && !IsModerationTeardownReason(reason)) + { + return; + } + + m_bPendingFullTeardown = true; + m_teardownReason = reason; + } bool IsPendingFullTeardown() const { return m_bPendingFullTeardown; } EGOTearDownReason GetTeardownReason() const { return m_teardownReason; } void ConsumePendingFullTeardown() { m_bPendingFullTeardown = false; } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Moderation.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Moderation.h new file mode 100644 index 00000000000..5b7b3c55f14 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Moderation.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +enum class EOnlineModerationAction +{ + BAN, + KICK +}; + +void ShowLoginBanDialog(const std::string& reason); +void HandleModerationDisconnect(EOnlineModerationAction action, const std::string& reason); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index a5d5ca2e3f8..d39ea9bee76 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -128,7 +128,7 @@ void TearDownGeneralsOnline() EGOTearDownReason teardownReason = NGMP_OnlineServicesManager::GetInstance()->GetTeardownReason(); - if (teardownReason != EGOTearDownReason::USER_REQUESTED_SILENT) + if (teardownReason != EGOTearDownReason::USER_REQUESTED_SILENT && !IsModerationTeardownReason(teardownReason)) { UnicodeString title, body; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp index 38e771c323f..271b6e23805 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp @@ -1,5 +1,6 @@ #include "GameNetwork/GeneralsOnline/NGMP_include.h" #include +#include #include #include #include @@ -25,6 +26,31 @@ std::wstring from_utf8(const std::string& utf8_str) return converter.from_bytes(utf8_str); } +std::wstring NormalizeSingleLineText(const std::wstring& text) +{ + std::wstring normalizedText; + normalizedText.reserve(text.size()); + bool pendingSpace = false; + + for (wchar_t character : text) + { + if (std::iswspace(character) || std::iswcntrl(character)) + { + pendingSpace = !normalizedText.empty(); + continue; + } + + if (pendingSpace) + { + normalizedText += L' '; + pendingSpace = false; + } + normalizedText += character; + } + + return normalizedText; +} + void NetworkLog(ELogVerbosity logVerbosity, const char* fmt, ...) { if (!NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp index ec24a32654d..388c120b9ed 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp @@ -2,6 +2,7 @@ #include "GameNetwork/GeneralsOnline/HTTP/HTTPManager.h" #include "GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Moderation.h" #include "GameNetwork/GeneralsOnline/json.hpp" #include #include @@ -44,6 +45,24 @@ struct AuthResponse NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(AuthResponse, result, session_token, refresh_token, user_id, display_name, ws_uri) }; +namespace +{ + std::string GetBanReason(const std::string& responseBody) + { + nlohmann::json jsonObject = nlohmann::json::parse(responseBody, nullptr, false, true); + if (jsonObject.is_object()) + { + const auto banReason = jsonObject.find("ban_reason"); + if (banReason != jsonObject.end() && banReason->is_string()) + { + return banReason->get_ref(); + } + } + + return std::string(); + } +} + struct MOTDResponse { std::string MOTD; @@ -219,6 +238,16 @@ void NGMP_OnlineServices_AuthInterface::RefreshToken() { if (statusCode >= 400 && statusCode < 500) { + if (statusCode == 423) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[AUTH]: Account ban detected during token refresh, tearing down"); + m_tokenCreationTime = -1; + m_nextRefreshRetryTime = -1; + m_currentRefreshAttempt = 0; + HandleModerationDisconnect(EOnlineModerationAction::BAN, GetBanReason(strBody)); + return; + } + OnRefreshTokenFailed(std::format("HTTP {}", statusCode).c_str(), strBody); } else @@ -352,11 +381,7 @@ void NGMP_OnlineServices_AuthInterface::BeginLogin() { if (statusCode == 423) { - ClearGSMessageBoxes(); - GSMessageBoxOk(UnicodeString(L"Account Banned"), UnicodeString(L"You are banned. You can file an appeal in Discord."), []() - { - TheShell->pop(); - }); + ShowLoginBanDialog(GetBanReason(strBody)); return; } else @@ -530,11 +555,7 @@ void NGMP_OnlineServices_AuthInterface::Tick() if (statusCode == 423) { m_bWaitingLogin = false; - ClearGSMessageBoxes(); - GSMessageBoxOk(UnicodeString(L"Account Banned"), UnicodeString(L"You are banned. You can file an appeal in Discord."), []() - { - TheShell->pop(); - }); + ShowLoginBanDialog(GetBanReason(strBody)); return; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Moderation.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Moderation.cpp new file mode 100644 index 00000000000..c60659e110b --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Moderation.cpp @@ -0,0 +1,128 @@ +#include "GameNetwork/GeneralsOnline/OnlineServices_Moderation.h" + +#include "GameNetwork/GeneralsOnline/NGMP_include.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" +#include "GameNetwork/GameSpyOverlay.h" + +#include +#include +#include + +namespace +{ + enum class EModerationDialogContext + { + LOGIN, + ACTIVE_SESSION + }; + + void LeaveLoginScreen() + { + TheShell->pop(); + } + + void OpenDiscord() + { + ShellExecuteA(nullptr, "open", "https://discord.playgenerals.online", nullptr, nullptr, SW_SHOWNORMAL); + } + + void OpenDiscordFromLogin() + { + OpenDiscord(); + LeaveLoginScreen(); + } + + std::wstring GetNormalizedReason(const std::string& reason) + { + try + { + return NormalizeSingleLineText(from_utf8(reason)); + } + catch (const std::range_error&) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[MODERATION]: Ignoring an invalid UTF-8 reason"); + return std::wstring(); + } + } + + void AppendReason(std::wstring& message, const std::string& reason) + { + const std::wstring normalizedReason = GetNormalizedReason(reason); + if (!normalizedReason.empty()) + { + message += L"\n\nReason: "; + message += normalizedReason; + } + } + + void ShowModerationDialog( + UnicodeString title, + std::wstring message, + const std::string& reason, + EModerationDialogContext context) + { + AppendReason(message, reason); + message += L"\n\nVisit Discord for more information or support."; + + GameWinMsgBoxFunc discordCallback = OpenDiscord; + GameWinMsgBoxFunc closeCallback = nullptr; + if (context == EModerationDialogContext::LOGIN) + { + discordCallback = OpenDiscordFromLogin; + closeCallback = LeaveLoginScreen; + } + + GSMessageBoxOkCancelWithLabels( + title, + UnicodeString(message.c_str()), + UnicodeString(L"Discord"), + UnicodeString(L"Close"), + discordCallback, + closeCallback); + } + + void ShowBanDialog(const std::string& reason, EModerationDialogContext context) + { + ShowModerationDialog( + UnicodeString(L"Banned"), + L"You have been banned from Generals Online.", + reason, + context); + } + + void ShowKickDialog(const std::string& reason) + { + ShowModerationDialog( + UnicodeString(L"Kicked"), + L"You have been kicked from Generals Online.", + reason, + EModerationDialogContext::ACTIVE_SESSION); + } +} + +void ShowLoginBanDialog(const std::string& reason) +{ + ShowBanDialog(reason, EModerationDialogContext::LOGIN); +} + +void HandleModerationDisconnect(EOnlineModerationAction action, const std::string& reason) +{ + NGMP_OnlineServicesManager* manager = NGMP_OnlineServicesManager::GetInstance(); + if (manager == nullptr || IsModerationTeardownReason(manager->GetTeardownReason())) + { + return; + } + + switch (action) + { + case EOnlineModerationAction::BAN: + manager->SetPendingFullTeardown(EGOTearDownReason::MODERATION_BAN); + ShowBanDialog(reason, EModerationDialogContext::ACTIVE_SESSION); + break; + + case EOnlineModerationAction::KICK: + manager->SetPendingFullTeardown(EGOTearDownReason::MODERATION_KICK); + ShowKickDialog(reason); + break; + } +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 2c2e1d394e5..e7c2deeefe2 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -2,6 +2,7 @@ #include "GameNetwork/GeneralsOnline/NGMP_include.h" #include "GameNetwork/GeneralsOnline/NetworkPacket.h" #include "GameNetwork/GeneralsOnline/NetworkBitstream.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Moderation.h" #include "GameNetwork/GeneralsOnline/json.hpp" #include "../OnlineServices_Init.h" #include "../HTTP/HTTPManager.h" @@ -352,6 +353,15 @@ class WebSocketMessageBase NLOHMANN_DEFINE_TYPE_INTRUSIVE(WebSocketMessageBase, msg_id) }; +class WebSocketMessage_ModerationAction : public WebSocketMessageBase +{ +public: + std::string action_type; + std::string reason; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE(WebSocketMessage_ModerationAction, msg_id, action_type, reason) +}; + class WebSocketMessage_NetworkStartSignalling : public WebSocketMessageBase { public: @@ -832,6 +842,26 @@ void WebSocket::Tick() switch (msgID) { + case EWebSocketMessageID::MODERATION_ACTION: + { + WebSocketMessage_ModerationAction moderationAction; + if (JSONGetAsObject(jsonObject, &moderationAction)) + { + if (moderationAction.action_type == "ban") + { + HandleModerationDisconnect(EOnlineModerationAction::BAN, moderationAction.reason); + } + else if (moderationAction.action_type == "kick") + { + HandleModerationDisconnect(EOnlineModerationAction::KICK, moderationAction.reason); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Unknown moderation action: %s", moderationAction.action_type.c_str()); + } + } + } + break; case EWebSocketMessageID::PONG: {