diff --git a/.gitmodules b/.gitmodules index f79819a95..b7f41e5f6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -42,4 +42,7 @@ url = https://github.com/zeromq/libzmq [submodule "externals/cppzmq"] path = externals/cppzmq - url = https://github.com/zeromq/cppzmq \ No newline at end of file + url = https://github.com/zeromq/cppzmq +[submodule "cpp-jwt"] + path = externals/cpp-jwt + url = https://github.com/arun11299/cpp-jwt.git diff --git a/dist/license.md b/dist/license.md index c469e21f1..3300388a7 100644 --- a/dist/license.md +++ b/dist/license.md @@ -4,6 +4,7 @@ Icon Name | License | Origin/Author --- | --- | --- qt_themes/default/icons/16x16/checked.png | Free for non-commercial use qt_themes/default/icons/16x16/connected.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/default/icons/16x16/connected_notification.png | CC BY-ND 3.0 | https://icons8.com qt_themes/default/icons/16x16/disconnected.png | CC BY-ND 3.0 | https://icons8.com qt_themes/default/icons/16x16/failed.png | Free for non-commercial use qt_themes/default/icons/16x16/lock.png | CC BY-ND 3.0 | https://icons8.com @@ -11,10 +12,12 @@ qt_themes/default/icons/256x256/plus_folder.png | CC BY-ND 3.0 | https://icons8. qt_themes/default/icons/48x48/bad_folder.png | CC BY-ND 3.0 | https://icons8.com qt_themes/default/icons/48x48/chip.png | CC BY-ND 3.0 | https://icons8.com qt_themes/default/icons/48x48/folder.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/default/icons/48x48/no_avatar.png | CC BY-ND 3.0 | https://icons8.com qt_themes/default/icons/48x48/plus.png | CC0 1.0 | Designed by BreadFish64 from the Citra team qt_themes/default/icons/48x48/sd_card.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/16x16/checked.png | Free for non-commercial use qt_themes/qdarkstyle/icons/16x16/connected.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/qdarkstyle/icons/16x16/connected_notification.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/16x16/disconnected.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/16x16/failed.png | Free for non-commercial use qt_themes/qdarkstyle/icons/16x16/lock.png | CC BY-ND 3.0 | https://icons8.com @@ -22,9 +25,11 @@ qt_themes/qdarkstyle/icons/256x256/plus_folder.png | CC BY-ND 3.0 | https://icon qt_themes/qdarkstyle/icons/48x48/bad_folder.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/48x48/chip.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/48x48/folder.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/qdarkstyle/icons/48x48/no_avatar.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/48x48/plus.png | CC0 1.0 | Designed by BreadFish64 from the Citra team qt_themes/qdarkstyle/icons/48x48/sd_card.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/16x16/connected.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/colorful/icons/16x16/connected_notification.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/16x16/disconnected.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/16x16/lock.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/256x256/plus_folder.png | CC BY-ND 3.0 | https://icons8.com diff --git a/dist/qt_themes/colorful/icons/16x16/connected_notification.png b/dist/qt_themes/colorful/icons/16x16/connected_notification.png new file mode 100644 index 000000000..0dfe032d5 Binary files /dev/null and b/dist/qt_themes/colorful/icons/16x16/connected_notification.png differ diff --git a/dist/qt_themes/colorful/style.qrc b/dist/qt_themes/colorful/style.qrc index dcf8b06e8..b8d47460f 100644 --- a/dist/qt_themes/colorful/style.qrc +++ b/dist/qt_themes/colorful/style.qrc @@ -2,6 +2,7 @@ icons/index.theme icons/16x16/connected.png + icons/16x16/connected_notification.png icons/16x16/disconnected.png icons/16x16/lock.png icons/48x48/bad_folder.png diff --git a/dist/qt_themes/colorful_dark/style.qrc b/dist/qt_themes/colorful_dark/style.qrc index 4b955998e..9c531fe1b 100644 --- a/dist/qt_themes/colorful_dark/style.qrc +++ b/dist/qt_themes/colorful_dark/style.qrc @@ -2,11 +2,13 @@ icons/index.theme ../colorful/icons/16x16/connected.png + ../colorful/icons/16x16/connected_notification.png ../colorful/icons/16x16/disconnected.png icons/16x16/lock.png ../colorful/icons/48x48/bad_folder.png ../colorful/icons/48x48/chip.png ../colorful/icons/48x48/folder.png + ../qdarkstyle/icons/48x48/no_avatar.png ../colorful/icons/48x48/plus.png ../colorful/icons/48x48/sd_card.png ../colorful/icons/256x256/plus_folder.png diff --git a/dist/qt_themes/default/default.qrc b/dist/qt_themes/default/default.qrc index 974079e78..cf011680f 100644 --- a/dist/qt_themes/default/default.qrc +++ b/dist/qt_themes/default/default.qrc @@ -10,6 +10,8 @@ icons/16x16/disconnected.png + icons/16x16/connected_notification.png + icons/16x16/lock.png icons/48x48/bad_folder.png @@ -18,6 +20,8 @@ icons/48x48/folder.png + icons/48x48/no_avatar.png + icons/48x48/plus.png icons/48x48/sd_card.png diff --git a/dist/qt_themes/default/icons/16x16/connected_notification.png b/dist/qt_themes/default/icons/16x16/connected_notification.png new file mode 100644 index 000000000..e64901378 Binary files /dev/null and b/dist/qt_themes/default/icons/16x16/connected_notification.png differ diff --git a/dist/qt_themes/default/icons/48x48/no_avatar.png b/dist/qt_themes/default/icons/48x48/no_avatar.png new file mode 100644 index 000000000..d4bf82026 Binary files /dev/null and b/dist/qt_themes/default/icons/48x48/no_avatar.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/16x16/connected_notification.png b/dist/qt_themes/qdarkstyle/icons/16x16/connected_notification.png new file mode 100644 index 000000000..7cd8b9d29 Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/16x16/connected_notification.png differ diff --git a/dist/qt_themes/qdarkstyle/icons/48x48/no_avatar.png b/dist/qt_themes/qdarkstyle/icons/48x48/no_avatar.png new file mode 100644 index 000000000..43e0dd267 Binary files /dev/null and b/dist/qt_themes/qdarkstyle/icons/48x48/no_avatar.png differ diff --git a/dist/qt_themes/qdarkstyle/style.qrc b/dist/qt_themes/qdarkstyle/style.qrc index bcb121d4b..0a4424b7f 100644 --- a/dist/qt_themes/qdarkstyle/style.qrc +++ b/dist/qt_themes/qdarkstyle/style.qrc @@ -3,10 +3,12 @@ icons/index.theme icons/16x16/connected.png icons/16x16/disconnected.png + icons/16x16/connected_notification.png icons/16x16/lock.png icons/48x48/bad_folder.png icons/48x48/chip.png icons/48x48/folder.png + icons/48x48/no_avatar.png icons/48x48/plus.png icons/48x48/sd_card.png icons/256x256/plus_folder.png diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index 13b347d89..b96bebb7f 100644 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -82,6 +82,10 @@ if (ENABLE_WEB_SERVICE) target_include_directories(ssl INTERFACE ./libressl/include) target_compile_definitions(ssl PRIVATE -DHAVE_INET_NTOP) + # JSON + add_library(json-headers INTERFACE) + target_include_directories(json-headers INTERFACE ./json) + # lurlparser add_subdirectory(lurlparser EXCLUDE_FROM_ALL) @@ -89,9 +93,9 @@ if (ENABLE_WEB_SERVICE) add_library(httplib INTERFACE) target_include_directories(httplib INTERFACE ./httplib) - # JSON - add_library(json-headers INTERFACE) - target_include_directories(json-headers INTERFACE ./json) + # cpp-jwt + add_library(cpp-jwt INTERFACE) + target_include_directories(cpp-jwt INTERFACE ./cpp-jwt/include) endif() if (ENABLE_SCRIPTING) diff --git a/externals/cpp-jwt b/externals/cpp-jwt new file mode 160000 index 000000000..6e27aa4c8 --- /dev/null +++ b/externals/cpp-jwt @@ -0,0 +1 @@ +Subproject commit 6e27aa4c8671e183f11e327a2e1f556c64fdc4a9 diff --git a/license.txt b/license.txt index fe47594db..93078088c 100644 --- a/license.txt +++ b/license.txt @@ -345,6 +345,7 @@ Icon Name | License | Origin/Author --- | --- | --- checked.png | Free for non-commercial use connected.png | CC BY-ND 3.0 | https://icons8.com +connected_notification.png | CC BY-ND 3.0 | https://icons8.com disconnected.png | CC BY-ND 3.0 | https://icons8.com failed.png | Free for non-commercial use lock.png | CC BY-ND 3.0 | https://icons8.com diff --git a/src/citra/citra.cpp b/src/citra/citra.cpp index b64510957..2fabfbfc7 100644 --- a/src/citra/citra.cpp +++ b/src/citra/citra.cpp @@ -39,6 +39,7 @@ #include "core/frontend/applets/default_applets.h" #include "core/gdbstub/gdbstub.h" #include "core/hle/service/am/am.h" +#include "core/hle/service/cfg/cfg.h" #include "core/loader/loader.h" #include "core/movie.h" #include "core/settings.h" @@ -80,42 +81,87 @@ static void OnStateChanged(const Network::RoomMember::State& state) { case Network::RoomMember::State::Joined: LOG_DEBUG(Network, "Successfully joined to the room"); break; - case Network::RoomMember::State::LostConnection: - LOG_DEBUG(Network, "Lost connection to the room"); - break; - case Network::RoomMember::State::CouldNotConnect: - LOG_ERROR(Network, "State: CouldNotConnect"); - exit(1); - break; - case Network::RoomMember::State::NameCollision: - LOG_ERROR( - Network, - "You tried to use the same nickname as another user that is connected to the Room"); - exit(1); - break; - case Network::RoomMember::State::MacCollision: - LOG_ERROR(Network, "You tried to use the same MAC-Address as another user that is " - "connected to the Room"); - exit(1); - break; - case Network::RoomMember::State::WrongPassword: - LOG_ERROR(Network, "Room replied with: Wrong password"); - exit(1); - break; - case Network::RoomMember::State::WrongVersion: - LOG_ERROR(Network, - "You are using a different version than the room you are trying to connect to"); - exit(1); + case Network::RoomMember::State::Moderator: + LOG_DEBUG(Network, "Successfully joined the room as a moderator"); break; default: break; } } +static void OnNetworkError(const Network::RoomMember::Error& error) { + switch (error) { + case Network::RoomMember::Error::LostConnection: + LOG_DEBUG(Network, "Lost connection to the room"); + break; + case Network::RoomMember::Error::CouldNotConnect: + LOG_ERROR(Network, "Error: Could not connect"); + exit(1); + break; + case Network::RoomMember::Error::NameCollision: + LOG_ERROR( + Network, + "You tried to use the same nickname as another user that is connected to the Room"); + exit(1); + break; + case Network::RoomMember::Error::MacCollision: + LOG_ERROR(Network, "You tried to use the same MAC-Address as another user that is " + "connected to the Room"); + exit(1); + break; + case Network::RoomMember::Error::ConsoleIdCollision: + LOG_ERROR(Network, "Your Console ID conflicted with someone else in the Room"); + exit(1); + break; + case Network::RoomMember::Error::WrongPassword: + LOG_ERROR(Network, "Room replied with: Wrong password"); + exit(1); + break; + case Network::RoomMember::Error::WrongVersion: + LOG_ERROR(Network, + "You are using a different version than the room you are trying to connect to"); + exit(1); + break; + case Network::RoomMember::Error::RoomIsFull: + LOG_ERROR(Network, "The room is full"); + exit(1); + break; + case Network::RoomMember::Error::HostKicked: + LOG_ERROR(Network, "You have been kicked by the host"); + break; + case Network::RoomMember::Error::HostBanned: + LOG_ERROR(Network, "You have been banned by the host"); + break; + } +} + static void OnMessageReceived(const Network::ChatEntry& msg) { std::cout << std::endl << msg.nickname << ": " << msg.message << std::endl << std::endl; } +static void OnStatusMessageReceived(const Network::StatusMessageEntry& msg) { + std::string message; + switch (msg.type) { + case Network::IdMemberJoin: + message = fmt::format("{} has joined", msg.nickname); + break; + case Network::IdMemberLeave: + message = fmt::format("{} has left", msg.nickname); + break; + case Network::IdMemberKicked: + message = fmt::format("{} has been kicked", msg.nickname); + break; + case Network::IdMemberBanned: + message = fmt::format("{} has been banned", msg.nickname); + break; + case Network::IdAddressUnbanned: + message = fmt::format("{} has been unbanned", msg.nickname); + break; + } + if (!message.empty()) + std::cout << std::endl << "* " << message << std::endl << std::endl; +} + static void InitializeLogging() { Log::Filter log_filter(Log::Level::Debug); log_filter.ParseFilterString(Settings::values.log_filter); @@ -333,10 +379,13 @@ int main(int argc, char** argv) { if (use_multiplayer) { if (auto member = Network::GetRoomMember().lock()) { member->BindOnChatMessageRecieved(OnMessageReceived); + member->BindOnStatusMessageReceived(OnStatusMessageReceived); member->BindOnStateChanged(OnStateChanged); + member->BindOnError(OnNetworkError); LOG_DEBUG(Network, "Start connection to {}:{} with nickname {}", address, port, nickname); - member->Join(nickname, address.c_str(), port, 0, Network::NoPreferredMac, password); + member->Join(nickname, Service::CFG::GetConsoleIdHash(system), address.c_str(), port, 0, + Network::NoPreferredMac, password); } else { LOG_ERROR(Network, "Could not access RoomMember"); return 0; diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index 4bb95a7f2..9eab29b3e 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -99,6 +99,8 @@ add_executable(citra-qt multiplayer/lobby.cpp multiplayer/message.h multiplayer/message.cpp + multiplayer/moderation_dialog.cpp + multiplayer/moderation_dialog.h multiplayer/state.cpp multiplayer/state.h multiplayer/validation.h @@ -135,6 +137,7 @@ set(UIS multiplayer/chat_room.ui multiplayer/client_room.ui multiplayer/host_room.ui + multiplayer/moderation_dialog.ui aboutdialog.ui cheats.ui hotkeys.ui @@ -228,6 +231,10 @@ if (USE_DISCORD_PRESENCE) target_compile_definitions(citra-qt PRIVATE -DUSE_DISCORD_PRESENCE) endif() +if (ENABLE_WEB_SERVICE) + target_compile_definitions(citra-qt PRIVATE -DENABLE_WEB_SERVICE) +endif() + if(UNIX AND NOT APPLE) install(TARGETS citra-qt RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin") endif() diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index d696ed98d..dcbe18122 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -329,6 +329,22 @@ void Config::ReadValues() { } UISettings::values.max_player = ReadSetting("max_player", 8).toUInt(); UISettings::values.game_id = ReadSetting("game_id", 0).toULongLong(); + UISettings::values.room_description = ReadSetting("room_description", "").toString(); + // Read ban list back + size = qt_config->beginReadArray("username_ban_list"); + UISettings::values.ban_list.first.resize(size); + for (int i = 0; i < size; ++i) { + qt_config->setArrayIndex(i); + UISettings::values.ban_list.first[i] = ReadSetting("username").toString().toStdString(); + } + qt_config->endArray(); + size = qt_config->beginReadArray("ip_ban_list"); + UISettings::values.ban_list.second.resize(size); + for (int i = 0; i < size; ++i) { + qt_config->setArrayIndex(i); + UISettings::values.ban_list.second[i] = ReadSetting("ip").toString().toStdString(); + } + qt_config->endArray(); qt_config->endGroup(); qt_config->endGroup(); @@ -533,6 +549,20 @@ void Config::SaveValues() { WriteSetting("host_type", UISettings::values.host_type, 0); WriteSetting("max_player", UISettings::values.max_player, 8); WriteSetting("game_id", UISettings::values.game_id, 0); + WriteSetting("room_description", UISettings::values.room_description, ""); + // Write ban list + qt_config->beginWriteArray("username_ban_list"); + for (std::size_t i = 0; i < UISettings::values.ban_list.first.size(); ++i) { + qt_config->setArrayIndex(i); + WriteSetting("username", QString::fromStdString(UISettings::values.ban_list.first[i])); + } + qt_config->endArray(); + qt_config->beginWriteArray("ip_ban_list"); + for (std::size_t i = 0; i < UISettings::values.ban_list.second.size(); ++i) { + qt_config->setArrayIndex(i); + WriteSetting("ip", QString::fromStdString(UISettings::values.ban_list.second[i])); + } + qt_config->endArray(); qt_config->endGroup(); qt_config->endGroup(); diff --git a/src/citra_qt/multiplayer/chat_room.cpp b/src/citra_qt/multiplayer/chat_room.cpp index 50bc5eb03..47eb2da2c 100644 --- a/src/citra_qt/multiplayer/chat_room.cpp +++ b/src/citra_qt/multiplayer/chat_room.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -12,6 +14,7 @@ #include #include #include +#include #include #include "citra_qt/game_list_p.h" #include "citra_qt/multiplayer/chat_room.h" @@ -19,6 +22,9 @@ #include "common/logging/log.h" #include "core/announce_multiplayer_session.h" #include "ui_chat_room.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/web_backend.h" +#endif class ChatMessage { public: @@ -27,24 +33,60 @@ public: QLocale locale; timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); nickname = QString::fromStdString(chat.nickname); + username = QString::fromStdString(chat.username); message = QString::fromStdString(chat.message); + + // Check for user pings + QString cur_nickname, cur_username; + if (auto room = Network::GetRoomMember().lock()) { + cur_nickname = QString::fromStdString(room->GetNickname()); + cur_username = QString::fromStdString(room->GetUsername()); + } + if (message.contains(QString("@").append(cur_nickname)) || + (!cur_username.isEmpty() && message.contains(QString("@").append(cur_username)))) { + + contains_ping = true; + } else { + contains_ping = false; + } + } + + bool ContainsPing() const { + return contains_ping; } /// Format the message using the players color QString GetPlayerChatMessage(u16 player) const { auto color = player_color[player % 16]; - return QString("[%1] <%3> %4") - .arg(timestamp, color, nickname.toHtmlEscaped(), message.toHtmlEscaped()); + QString name; + if (username.isEmpty() || username == nickname) { + name = nickname; + } else { + name = QString("%1 (%2)").arg(nickname, username); + } + + QString style; + if (ContainsPing()) { + // Add a background color to these messages + style = QString("background-color: %1").arg(ping_color); + } + + return QString("[%1] <%3> %5") + .arg(timestamp, color, name.toHtmlEscaped(), style, message.toHtmlEscaped()); } private: static constexpr std::array player_color = { {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "FFFF00"}}; + static constexpr char ping_color[] = "#FFFF00"; QString timestamp; QString nickname; + QString username; QString message; + bool contains_ping; }; class StatusMessage { @@ -57,37 +99,69 @@ public: } QString GetSystemChatMessage() const { - return QString("[%1] %3") - .arg(timestamp, system_color, message); + return QString("[%1] * %3").arg(timestamp, system_color, message); } private: - static constexpr const char system_color[] = "#888888"; + static constexpr const char system_color[] = "#FF8C00"; QString timestamp; QString message; }; +class PlayerListItem : public QStandardItem { +public: + static const int NicknameRole = Qt::UserRole + 1; + static const int UsernameRole = Qt::UserRole + 2; + static const int AvatarUrlRole = Qt::UserRole + 3; + static const int GameNameRole = Qt::UserRole + 4; + + PlayerListItem() = default; + explicit PlayerListItem(const std::string& nickname, const std::string& username, + const std::string& avatar_url, const std::string& game_name) { + setEditable(false); + setData(QString::fromStdString(nickname), NicknameRole); + setData(QString::fromStdString(username), UsernameRole); + setData(QString::fromStdString(avatar_url), AvatarUrlRole); + if (game_name.empty()) { + setData(QObject::tr("Not playing a game"), GameNameRole); + } else { + setData(QString::fromStdString(game_name), GameNameRole); + } + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return QStandardItem::data(role); + } + QString name; + const QString nickname = data(NicknameRole).toString(); + const QString username = data(UsernameRole).toString(); + if (username.isEmpty() || username == nickname) { + name = nickname; + } else { + name = QString("%1 (%2)").arg(nickname, username); + } + return QString("%1\n %2").arg(name, data(GameNameRole).toString()); + } +}; + ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique()) { ui->setupUi(this); // set the item_model for player_view - enum { - COLUMN_NAME, - COLUMN_GAME, - COLUMN_COUNT, // Number of columns - }; player_list = new QStandardItemModel(ui->player_view); ui->player_view->setModel(player_list); ui->player_view->setContextMenuPolicy(Qt::CustomContextMenu); - player_list->insertColumns(0, COLUMN_COUNT); - player_list->setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name")); - player_list->setHeaderData(COLUMN_GAME, Qt::Horizontal, tr("Game")); + // set a header to make it look better though there is only one column + player_list->insertColumns(0, 1); + player_list->setHeaderData(0, Qt::Horizontal, tr("Members")); ui->chat_history->document()->setMaximumBlockCount(max_chat_lines); // register the network structs to use in slots and signals qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); @@ -95,7 +169,12 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_uniqueBindOnChatMessageRecieved( [this](const Network::ChatEntry& chat) { emit ChatReceived(chat); }); + member->BindOnStatusMessageReceived( + [this](const Network::StatusMessageEntry& status_message) { + emit StatusMessageReceived(status_message); + }); connect(this, &ChatRoom::ChatReceived, this, &ChatRoom::OnChatReceive); + connect(this, &ChatRoom::StatusMessageReceived, this, &ChatRoom::OnStatusMessageReceive); } else { // TODO (jroweboy) network was not initialized? } @@ -110,6 +189,10 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_uniqueretranslateUi(this); } @@ -127,6 +210,21 @@ void ChatRoom::AppendChatMessage(const QString& msg) { ui->chat_history->append(msg); } +void ChatRoom::SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname) { + if (auto room = Network::GetRoomMember().lock()) { + auto members = room->GetMemberInformation(); + auto it = std::find_if(members.begin(), members.end(), + [&nickname](const Network::RoomMember::MemberInformation& member) { + return member.nickname == nickname; + }); + if (it == members.end()) { + NetworkMessage::ShowError(NetworkMessage::NO_SUCH_USER); + return; + } + room->SendModerationRequest(type, nickname); + } +} + bool ChatRoom::ValidateMessage(const std::string& msg) { return !msg.empty(); } @@ -157,7 +255,8 @@ void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { auto members = room->GetMemberInformation(); auto it = std::find_if(members.begin(), members.end(), [&chat](const Network::RoomMember::MemberInformation& member) { - return member.nickname == chat.nickname; + return member.nickname == chat.nickname && + member.username == chat.username; }); if (it == members.end()) { LOG_INFO(Network, "Chat message received from unknown player. Ignoring it."); @@ -170,13 +269,48 @@ void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { } auto player = std::distance(members.begin(), it); ChatMessage m(chat); + if (m.ContainsPing()) { + emit UserPinged(); + } AppendChatMessage(m.GetPlayerChatMessage(player)); } } +void ChatRoom::OnStatusMessageReceive(const Network::StatusMessageEntry& status_message) { + QString name; + if (status_message.username.empty() || status_message.username == status_message.nickname) { + name = QString::fromStdString(status_message.nickname); + } else { + name = QString("%1 (%2)").arg(QString::fromStdString(status_message.nickname), + QString::fromStdString(status_message.username)); + } + QString message; + switch (status_message.type) { + case Network::IdMemberJoin: + message = tr("%1 has joined").arg(name); + break; + case Network::IdMemberLeave: + message = tr("%1 has left").arg(name); + break; + case Network::IdMemberKicked: + message = tr("%1 has been kicked").arg(name); + break; + case Network::IdMemberBanned: + message = tr("%1 has been banned").arg(name); + break; + case Network::IdAddressUnbanned: + message = tr("%1 has been unbanned").arg(name); + break; + } + if (!message.isEmpty()) + AppendStatusMessage(message); +} + void ChatRoom::OnSendChat() { if (auto room = Network::GetRoomMember().lock()) { - if (room->GetState() != Network::RoomMember::State::Joined) { + if (room->GetState() != Network::RoomMember::State::Joined && + room->GetState() != Network::RoomMember::State::Moderator) { + return; } auto message = ui->chat_message->text().toStdString(); @@ -184,12 +318,14 @@ void ChatRoom::OnSendChat() { return; } auto nick = room->GetNickname(); - Network::ChatEntry chat{nick, message}; + auto username = room->GetUsername(); + Network::ChatEntry chat{nick, username, message}; auto members = room->GetMemberInformation(); auto it = std::find_if(members.begin(), members.end(), [&chat](const Network::RoomMember::MemberInformation& member) { - return member.nickname == chat.nickname; + return member.nickname == chat.nickname && + member.username == chat.username; }); if (it == members.end()) { LOG_INFO(Network, "Cannot find self in the player list when sending a message."); @@ -202,20 +338,64 @@ void ChatRoom::OnSendChat() { } } +void ChatRoom::UpdateIconDisplay() { + for (int row = 0; row < player_list->invisibleRootItem()->rowCount(); ++row) { + QStandardItem* item = player_list->invisibleRootItem()->child(row); + const std::string avatar_url = + item->data(PlayerListItem::AvatarUrlRole).toString().toStdString(); + if (icon_cache.count(avatar_url)) { + item->setData(icon_cache.at(avatar_url), Qt::DecorationRole); + } + } +} + void ChatRoom::SetPlayerList(const Network::RoomMember::MemberList& member_list) { // TODO(B3N30): Remember which row is selected player_list->removeRows(0, player_list->rowCount()); for (const auto& member : member_list) { if (member.nickname.empty()) continue; - QList l; - std::vector elements = {member.nickname, member.game_info.name}; - for (const auto& item : elements) { - QStandardItem* child = new QStandardItem(QString::fromStdString(item)); - child->setEditable(false); - l.append(child); + QStandardItem* name_item = new PlayerListItem(member.nickname, member.username, + member.avatar_url, member.game_info.name); + + if (!icon_cache.count(member.avatar_url)) { + // Emplace a default question mark icon as avatar + icon_cache.emplace(member.avatar_url, QIcon::fromTheme("no_avatar").pixmap(48)); + if (!member.avatar_url.empty()) { +#ifdef ENABLE_WEB_SERVICE + // Start a request to get the member's avatar + const QUrl url(QString::fromStdString(member.avatar_url)); + QFuture future = QtConcurrent::run([url] { + WebService::Client client( + QString("%1://%2").arg(url.scheme(), url.host()).toStdString(), "", ""); + auto result = client.GetImage(url.path().toStdString(), true); + if (result.returned_data.empty()) { + LOG_ERROR(WebService, "Failed to get avatar"); + } + return result.returned_data; + }); + auto* future_watcher = new QFutureWatcher(this); + connect(future_watcher, &QFutureWatcher::finished, this, + [this, future_watcher, avatar_url = member.avatar_url] { + const std::string result = future_watcher->result(); + if (result.empty()) + return; + QPixmap pixmap; + if (!pixmap.loadFromData(reinterpret_cast(result.data()), + result.size())) + return; + icon_cache[avatar_url] = pixmap.scaled(48, 48, Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + // Update all the displayed icons with the new icon_cache + UpdateIconDisplay(); + }); + future_watcher->setFuture(future); +#endif + } } - player_list->invisibleRootItem()->appendRow(l); + name_item->setData(icon_cache.at(member.avatar_url), Qt::DecorationRole); + + player_list->invisibleRootItem()->appendRow(name_item); } // TODO(B3N30): Restore row selection } @@ -230,33 +410,73 @@ void ChatRoom::PopupContextMenu(const QPoint& menu_location) { if (!item.isValid()) return; - std::string nickname = player_list->item(item.row())->text().toStdString(); - if (auto room = Network::GetRoomMember().lock()) { - // You can't block yourself - if (nickname == room->GetNickname()) - return; - } + std::string nickname = + player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString(); QMenu context_menu; - QAction* block_action = context_menu.addAction(tr("Block Player")); - block_action->setCheckable(true); - block_action->setChecked(block_list.count(nickname) > 0); + QString username = player_list->item(item.row())->data(PlayerListItem::UsernameRole).toString(); + if (!username.isEmpty()) { + QAction* view_profile_action = context_menu.addAction(tr("View Profile")); + connect(view_profile_action, &QAction::triggered, [username] { + QDesktopServices::openUrl( + QString("https://community.citra-emu.org/u/%1").arg(username)); + }); + } - connect(block_action, &QAction::triggered, [this, nickname] { - if (block_list.count(nickname)) { - block_list.erase(nickname); - } else { + std::string cur_nickname; + if (auto room = Network::GetRoomMember().lock()) { + cur_nickname = room->GetNickname(); + } + + if (nickname != cur_nickname) { // You can't block yourself + QAction* block_action = context_menu.addAction(tr("Block Player")); + + block_action->setCheckable(true); + block_action->setChecked(block_list.count(nickname) > 0); + + connect(block_action, &QAction::triggered, [this, nickname] { + if (block_list.count(nickname)) { + block_list.erase(nickname); + } else { + QMessageBox::StandardButton result = QMessageBox::question( + this, tr("Block Player"), + tr("When you block a player, you will no longer receive chat messages from " + "them.

Are you sure you would like to block %1?") + .arg(QString::fromStdString(nickname)), + QMessageBox::Yes | QMessageBox::No); + if (result == QMessageBox::Yes) + block_list.emplace(nickname); + } + }); + } + + if (has_mod_perms && nickname != cur_nickname) { // You can't kick or ban yourself + context_menu.addSeparator(); + + QAction* kick_action = context_menu.addAction(tr("Kick")); + QAction* ban_action = context_menu.addAction(tr("Ban")); + + connect(kick_action, &QAction::triggered, [this, nickname] { + QMessageBox::StandardButton result = + QMessageBox::question(this, tr("Kick Player"), + tr("Are you sure you would like to kick %1?") + .arg(QString::fromStdString(nickname)), + QMessageBox::Yes | QMessageBox::No); + if (result == QMessageBox::Yes) + SendModerationRequest(Network::IdModKick, nickname); + }); + connect(ban_action, &QAction::triggered, [this, nickname] { QMessageBox::StandardButton result = QMessageBox::question( - this, tr("Block Player"), - tr("When you block a player, you will no longer receive chat messages from " - "them.

Are you sure you would like to block %1?") + this, tr("Ban Player"), + tr("Are you sure you would like to kick and ban %1?\n\nThis would " + "ban both their forum username and their IP address.") .arg(QString::fromStdString(nickname)), QMessageBox::Yes | QMessageBox::No); if (result == QMessageBox::Yes) - block_list.emplace(nickname); - } - }); + SendModerationRequest(Network::IdModBan, nickname); + }); + } context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location)); } diff --git a/src/citra_qt/multiplayer/chat_room.h b/src/citra_qt/multiplayer/chat_room.h index f541af819..70f786dbe 100644 --- a/src/citra_qt/multiplayer/chat_room.h +++ b/src/citra_qt/multiplayer/chat_room.h @@ -36,9 +36,12 @@ public: void AppendStatusMessage(const QString& msg); ~ChatRoom(); + void SetModPerms(bool is_mod); + public slots: void OnRoomUpdate(const Network::RoomInformation& info); void OnChatReceive(const Network::ChatEntry&); + void OnStatusMessageReceive(const Network::StatusMessageEntry&); void OnSendChat(); void OnChatTextChanged(); void PopupContextMenu(const QPoint& menu_location); @@ -47,16 +50,25 @@ public slots: signals: void ChatReceived(const Network::ChatEntry&); + void StatusMessageReceived(const Network::StatusMessageEntry&); + void UserPinged(); private: static constexpr u32 max_chat_lines = 1000; void AppendChatMessage(const QString&); bool ValidateMessage(const std::string&); + void SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname); + void UpdateIconDisplay(); + + bool has_mod_perms = false; QStandardItemModel* player_list; std::unique_ptr ui; std::unordered_set block_list; + std::unordered_map icon_cache; }; Q_DECLARE_METATYPE(Network::ChatEntry); +Q_DECLARE_METATYPE(Network::StatusMessageEntry); Q_DECLARE_METATYPE(Network::RoomInformation); Q_DECLARE_METATYPE(Network::RoomMember::State); +Q_DECLARE_METATYPE(Network::RoomMember::Error); diff --git a/src/citra_qt/multiplayer/chat_room.ui b/src/citra_qt/multiplayer/chat_room.ui index 8bb1899c0..f2b31b5da 100644 --- a/src/citra_qt/multiplayer/chat_room.ui +++ b/src/citra_qt/multiplayer/chat_room.ui @@ -6,7 +6,7 @@ 0 0 - 607 + 807 432 diff --git a/src/citra_qt/multiplayer/client_room.cpp b/src/citra_qt/multiplayer/client_room.cpp index 63227f6e0..458f8c864 100644 --- a/src/citra_qt/multiplayer/client_room.cpp +++ b/src/citra_qt/multiplayer/client_room.cpp @@ -13,6 +13,7 @@ #include "citra_qt/game_list_p.h" #include "citra_qt/multiplayer/client_room.h" #include "citra_qt/multiplayer/message.h" +#include "citra_qt/multiplayer/moderation_dialog.h" #include "citra_qt/multiplayer/state.h" #include "common/logging/log.h" #include "core/announce_multiplayer_session.h" @@ -33,6 +34,8 @@ ClientRoomWindow::ClientRoomWindow(QWidget* parent) connect(this, &ClientRoomWindow::RoomInformationChanged, this, &ClientRoomWindow::OnRoomUpdate); connect(this, &ClientRoomWindow::StateChanged, this, &::ClientRoomWindow::OnStateChange); + // Update the state + OnStateChange(member->GetState()); } else { // TODO (jroweboy) network was not initialized? } @@ -40,11 +43,25 @@ ClientRoomWindow::ClientRoomWindow(QWidget* parent) connect(ui->disconnect, &QPushButton::pressed, [this] { Disconnect(); }); ui->disconnect->setDefault(false); ui->disconnect->setAutoDefault(false); + connect(ui->moderation, &QPushButton::clicked, [this] { + ModerationDialog dialog(this); + dialog.exec(); + }); + ui->moderation->setDefault(false); + ui->moderation->setAutoDefault(false); + connect(ui->chat, &ChatRoom::UserPinged, this, &ClientRoomWindow::ShowNotification); UpdateView(); } ClientRoomWindow::~ClientRoomWindow() = default; +void ClientRoomWindow::SetModPerms(bool is_mod) { + ui->chat->SetModPerms(is_mod); + ui->moderation->setVisible(is_mod); + ui->moderation->setDefault(false); + ui->moderation->setAutoDefault(false); +} + void ClientRoomWindow::RetranslateUi() { ui->retranslateUi(this); ui->chat->RetranslateUi(); @@ -55,9 +72,12 @@ void ClientRoomWindow::OnRoomUpdate(const Network::RoomInformation& info) { } void ClientRoomWindow::OnStateChange(const Network::RoomMember::State& state) { - if (state == Network::RoomMember::State::Joined) { + if (state == Network::RoomMember::State::Joined || + state == Network::RoomMember::State::Moderator) { + ui->chat->Clear(); ui->chat->AppendStatusMessage(tr("Connected")); + SetModPerms(state == Network::RoomMember::State::Moderator); } UpdateView(); } @@ -82,6 +102,7 @@ void ClientRoomWindow::UpdateView() { .arg(QString::fromStdString(information.name)) .arg(memberlist.size()) .arg(information.member_slots)); + ui->description->setText(QString::fromStdString(information.description)); return; } } diff --git a/src/citra_qt/multiplayer/client_room.h b/src/citra_qt/multiplayer/client_room.h index 47add6f51..584a51642 100644 --- a/src/citra_qt/multiplayer/client_room.h +++ b/src/citra_qt/multiplayer/client_room.h @@ -26,10 +26,12 @@ public slots: signals: void RoomInformationChanged(const Network::RoomInformation&); void StateChanged(const Network::RoomMember::State&); + void ShowNotification(); private: void Disconnect(); void UpdateView(); + void SetModPerms(bool is_mod); QStandardItemModel* player_list; std::unique_ptr ui; diff --git a/src/citra_qt/multiplayer/client_room.ui b/src/citra_qt/multiplayer/client_room.ui index d83c088c2..97e88b502 100644 --- a/src/citra_qt/multiplayer/client_room.ui +++ b/src/citra_qt/multiplayer/client_room.ui @@ -6,7 +6,7 @@ 0 0 - 607 + 807 432 @@ -21,6 +21,13 @@ 0 + + + + Room Description + + + @@ -34,6 +41,16 @@ + + + + Moderation... + + + false + + + diff --git a/src/citra_qt/multiplayer/direct_connect.cpp b/src/citra_qt/multiplayer/direct_connect.cpp index 2d6e28c91..c14edde76 100644 --- a/src/citra_qt/multiplayer/direct_connect.cpp +++ b/src/citra_qt/multiplayer/direct_connect.cpp @@ -15,6 +15,7 @@ #include "citra_qt/multiplayer/state.h" #include "citra_qt/multiplayer/validation.h" #include "citra_qt/ui_settings.h" +#include "core/hle/service/cfg/cfg.h" #include "core/settings.h" #include "network/network.h" #include "ui_direct_connect.h" @@ -62,7 +63,7 @@ void DirectConnectWindow::Connect() { // Prevent the user from trying to join a room while they are already joining. if (member->GetState() == Network::RoomMember::State::Joining) { return; - } else if (member->GetState() == Network::RoomMember::State::Joined) { + } else if (member->IsConnected()) { // And ask if they want to leave the room if they are already in one. if (!NetworkMessage::WarnDisconnect()) { return; @@ -97,6 +98,7 @@ void DirectConnectWindow::Connect() { if (auto room_member = Network::GetRoomMember().lock()) { auto port = UISettings::values.port.toUInt(); room_member->Join(ui->nickname->text().toStdString(), + Service::CFG::GetConsoleIdHash(Core::System::GetInstance()), ui->ip->text().toStdString().c_str(), port, 0, Network::NoPreferredMac, ui->password->text().toStdString().c_str()); } @@ -120,7 +122,9 @@ void DirectConnectWindow::OnConnection() { EndConnecting(); if (auto room_member = Network::GetRoomMember().lock()) { - if (room_member->GetState() == Network::RoomMember::State::Joined) { + if (room_member->GetState() == Network::RoomMember::State::Joined || + room_member->GetState() == Network::RoomMember::State::Moderator) { + close(); } } diff --git a/src/citra_qt/multiplayer/host_room.cpp b/src/citra_qt/multiplayer/host_room.cpp index 1d389539d..2dbc74d6c 100644 --- a/src/citra_qt/multiplayer/host_room.cpp +++ b/src/citra_qt/multiplayer/host_room.cpp @@ -19,8 +19,12 @@ #include "citra_qt/ui_settings.h" #include "common/logging/log.h" #include "core/announce_multiplayer_session.h" +#include "core/hle/service/cfg/cfg.h" #include "core/settings.h" #include "ui_host_room.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/verify_user_jwt.h" +#endif HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list, std::shared_ptr session) @@ -69,6 +73,7 @@ HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list, if (index != -1) { ui->game_list->setCurrentIndex(index); } + ui->room_description->setText(UISettings::values.room_description); } HostRoomWindow::~HostRoomWindow() = default; @@ -77,6 +82,21 @@ void HostRoomWindow::RetranslateUi() { ui->retranslateUi(this); } +std::unique_ptr HostRoomWindow::CreateVerifyBackend( + bool use_validation) const { + std::unique_ptr verify_backend; + if (use_validation) { +#ifdef ENABLE_WEB_SERVICE + verify_backend = std::make_unique(Settings::values.web_api_url); +#else + verify_backend = std::make_unique(); +#endif + } else { + verify_backend = std::make_unique(); + } + return verify_backend; +} + void HostRoomWindow::Host() { if (!ui->username->hasAcceptableInput()) { NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID); @@ -93,7 +113,7 @@ void HostRoomWindow::Host() { if (auto member = Network::GetRoomMember().lock()) { if (member->GetState() == Network::RoomMember::State::Joining) { return; - } else if (member->GetState() == Network::RoomMember::State::Joined) { + } else if (member->IsConnected()) { auto parent = static_cast(parentWidget()); if (!parent->OnCloseRoom()) { close(); @@ -106,9 +126,17 @@ void HostRoomWindow::Host() { auto game_id = ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong(); auto port = ui->port->isModified() ? ui->port->text().toInt() : Network::DefaultRoomPort; auto password = ui->password->text().toStdString(); + const bool is_public = ui->host_type->currentIndex() == 0; + Network::Room::BanList ban_list{}; + if (ui->load_ban_list->isChecked()) { + ban_list = UISettings::values.ban_list; + } if (auto room = Network::GetRoom().lock()) { - bool created = room->Create(ui->room_name->text().toStdString(), "", port, password, - ui->max_player->value(), game_name.toStdString(), game_id); + bool created = room->Create(ui->room_name->text().toStdString(), + ui->room_description->toPlainText().toStdString(), "", port, + password, ui->max_player->value(), + Settings::values.citra_username, game_name.toStdString(), + game_id, CreateVerifyBackend(is_public), ban_list); if (!created) { NetworkMessage::ShowError(NetworkMessage::COULD_NOT_CREATE_ROOM); LOG_ERROR(Network, "Could not create room!"); @@ -116,8 +144,34 @@ void HostRoomWindow::Host() { return; } } - member->Join(ui->username->text().toStdString(), "127.0.0.1", port, 0, - Network::NoPreferredMac, password); + // Start the announce session if they chose Public + if (is_public) { + if (auto session = announce_multiplayer_session.lock()) { + // Register the room first to ensure verify_UID is present when we connect + session->Register(); + session->Start(); + } else { + LOG_ERROR(Network, "Starting announce session failed"); + } + } + std::string token; +#ifdef ENABLE_WEB_SERVICE + if (is_public) { + WebService::Client client(Settings::values.web_api_url, Settings::values.citra_username, + Settings::values.citra_token); + if (auto room = Network::GetRoom().lock()) { + token = client.GetExternalJWT(room->GetVerifyUID()).returned_data; + } + if (token.empty()) { + LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); + } else { + LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + } + } +#endif + member->Join(ui->username->text().toStdString(), + Service::CFG::GetConsoleIdHash(Core::System::GetInstance()), "127.0.0.1", port, + 0, Network::NoPreferredMac, password, token); // Store settings UISettings::values.room_nickname = ui->username->text(); @@ -130,25 +184,10 @@ void HostRoomWindow::Host() { UISettings::values.room_port = (ui->port->isModified() && !ui->port->text().isEmpty()) ? ui->port->text() : QString::number(Network::DefaultRoomPort); + UISettings::values.room_description = ui->room_description->toPlainText(); Settings::Apply(); - OnConnection(); - } -} - -void HostRoomWindow::OnConnection() { - ui->host->setEnabled(true); - if (auto room_member = Network::GetRoomMember().lock()) { - if (room_member->GetState() == Network::RoomMember::State::Joining) { - // Start the announce session if they chose Public - if (ui->host_type->currentIndex() == 0) { - if (auto session = announce_multiplayer_session.lock()) { - session->Start(); - } else { - LOG_ERROR(Network, "Starting announce session failed"); - } - } - close(); - } + ui->host->setEnabled(true); + close(); } } diff --git a/src/citra_qt/multiplayer/host_room.h b/src/citra_qt/multiplayer/host_room.h index 87ad96b9d..620574bd6 100644 --- a/src/citra_qt/multiplayer/host_room.h +++ b/src/citra_qt/multiplayer/host_room.h @@ -26,6 +26,10 @@ class ComboBoxProxyModel; class ChatMessage; +namespace Network::VerifyUser { +class Backend; +}; + class HostRoomWindow : public QDialog { Q_OBJECT @@ -36,15 +40,9 @@ public: void RetranslateUi(); -private slots: - /** - * Handler for connection status changes. Launches the chat window if successful or - * displays an error - */ - void OnConnection(); - private: void Host(); + std::unique_ptr CreateVerifyBackend(bool use_validation) const; std::weak_ptr announce_multiplayer_session; QStandardItemModel* game_list; diff --git a/src/citra_qt/multiplayer/host_room.ui b/src/citra_qt/multiplayer/host_room.ui index 68bfdb0da..d54cf49c6 100644 --- a/src/citra_qt/multiplayer/host_room.ui +++ b/src/citra_qt/multiplayer/host_room.ui @@ -7,7 +7,7 @@ 0 0 607 - 165 + 211 @@ -131,6 +131,34 @@ + + + + + + Room Description + + + + + + + + + + + + + + Load Previous Ban List + + + true + + + + + diff --git a/src/citra_qt/multiplayer/lobby.cpp b/src/citra_qt/multiplayer/lobby.cpp index 8a5630289..3b48b3a95 100644 --- a/src/citra_qt/multiplayer/lobby.cpp +++ b/src/citra_qt/multiplayer/lobby.cpp @@ -15,8 +15,12 @@ #include "citra_qt/multiplayer/validation.h" #include "citra_qt/ui_settings.h" #include "common/logging/log.h" +#include "core/hle/service/cfg/cfg.h" #include "core/settings.h" #include "network/network.h" +#ifdef ENABLE_WEB_SERVICE +#include "web_service/web_backend.h" +#endif Lobby::Lobby(QWidget* parent, QStandardItemModel* list, std::shared_ptr session) @@ -105,7 +109,7 @@ void Lobby::OnJoinRoom(const QModelIndex& source) { // Prevent the user from trying to join a room while they are already joining. if (member->GetState() == Network::RoomMember::State::Joining) { return; - } else if (member->GetState() == Network::RoomMember::State::Joined) { + } else if (member->IsConnected()) { // And ask if they want to leave the room if they are already in one. if (!NetworkMessage::WarnDisconnect()) { return; @@ -135,11 +139,27 @@ void Lobby::OnJoinRoom(const QModelIndex& source) { const std::string ip = proxy->data(connection_index, LobbyItemHost::HostIPRole).toString().toStdString(); int port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); + const std::string verify_UID = + proxy->data(connection_index, LobbyItemHost::HostVerifyUIDRole).toString().toStdString(); // attempt to connect in a different thread - QFuture f = QtConcurrent::run([nickname, ip, port, password] { + QFuture f = QtConcurrent::run([nickname, ip, port, password, verify_UID] { + std::string token; +#ifdef ENABLE_WEB_SERVICE + if (!Settings::values.citra_username.empty() && !Settings::values.citra_token.empty()) { + WebService::Client client(Settings::values.web_api_url, Settings::values.citra_username, + Settings::values.citra_token); + token = client.GetExternalJWT(verify_UID).returned_data; + if (token.empty()) { + LOG_ERROR(WebService, "Could not get external JWT, verification may fail"); + } else { + LOG_INFO(WebService, "Successfully requested external JWT: size={}", token.size()); + } + } +#endif if (auto room_member = Network::GetRoomMember().lock()) { - room_member->Join(nickname, ip.c_str(), port, 0, Network::NoPreferredMac, password); + room_member->Join(nickname, Service::CFG::GetConsoleIdHash(Core::System::GetInstance()), + ip.c_str(), port, 0, Network::NoPreferredMac, password, token); } }); watcher->setFuture(f); @@ -191,7 +211,8 @@ void Lobby::OnRefreshLobby() { QList members; for (auto member : room.members) { QVariant var; - var.setValue(LobbyMember{QString::fromStdString(member.name), member.game_id, + var.setValue(LobbyMember{QString::fromStdString(member.username), + QString::fromStdString(member.nickname), member.game_id, QString::fromStdString(member.game_name)}); members.append(var); } @@ -203,14 +224,18 @@ void Lobby::OnRefreshLobby() { new LobbyItemGame(room.preferred_game_id, QString::fromStdString(room.preferred_game), smdh_icon), new LobbyItemHost(QString::fromStdString(room.owner), QString::fromStdString(room.ip), - room.port), + room.port, QString::fromStdString(room.verify_UID)), new LobbyItemMemberList(members, room.max_player), }); model->appendRow(row); // To make the rows expandable, add the member data as a child of the first column of the // rows with people in them and have qt set them to colspan after the model is finished // resetting - if (room.members.size() > 0) { + if (!room.description.empty()) { + first_item->appendRow( + new LobbyItemDescription(QString::fromStdString(room.description))); + } + if (!room.members.empty()) { first_item->appendRow(new LobbyItemExpandedMemberList(members)); } } @@ -226,8 +251,8 @@ void Lobby::OnRefreshLobby() { // Set the member list child items to span all columns for (int i = 0; i < proxy->rowCount(); i++) { auto parent = model->item(i, 0); - if (parent->hasChildren()) { - ui->room_list->setFirstColumnSpanned(0, proxy->index(i, 0), true); + for (int j = 0; j < parent->rowCount(); j++) { + ui->room_list->setFirstColumnSpanned(j, proxy->index(i, 0), true); } } } diff --git a/src/citra_qt/multiplayer/lobby_p.h b/src/citra_qt/multiplayer/lobby_p.h index 3773f99de..e684fc816 100644 --- a/src/citra_qt/multiplayer/lobby_p.h +++ b/src/citra_qt/multiplayer/lobby_p.h @@ -55,6 +55,31 @@ public: } }; +class LobbyItemDescription : public LobbyItem { +public: + static const int DescriptionRole = Qt::UserRole + 1; + + LobbyItemDescription() = default; + explicit LobbyItemDescription(QString description) { + setData(description, DescriptionRole); + } + + QVariant data(int role) const override { + if (role != Qt::DisplayRole) { + return LobbyItem::data(role); + } + auto description = data(DescriptionRole).toString(); + description.prepend("Description: "); + return description; + } + + bool operator<(const QStandardItem& other) const override { + return data(DescriptionRole) + .toString() + .localeAwareCompare(other.data(DescriptionRole).toString()) < 0; + } +}; + class LobbyItemGame : public LobbyItem { public: static const int TitleIDRole = Qt::UserRole + 1; @@ -95,12 +120,14 @@ public: static const int HostUsernameRole = Qt::UserRole + 1; static const int HostIPRole = Qt::UserRole + 2; static const int HostPortRole = Qt::UserRole + 3; + static const int HostVerifyUIDRole = Qt::UserRole + 4; LobbyItemHost() = default; - explicit LobbyItemHost(QString username, QString ip, u16 port) { + explicit LobbyItemHost(QString username, QString ip, u16 port, QString verify_UID) { setData(username, HostUsernameRole); setData(ip, HostIPRole); setData(port, HostPortRole); + setData(verify_UID, HostVerifyUIDRole); } QVariant data(int role) const override { @@ -121,12 +148,17 @@ class LobbyMember { public: LobbyMember() = default; LobbyMember(const LobbyMember& other) = default; - explicit LobbyMember(QString username, u64 title_id, QString game_name) - : username(std::move(username)), title_id(title_id), game_name(std::move(game_name)) {} + explicit LobbyMember(QString username, QString nickname, u64 title_id, QString game_name) + : username(std::move(username)), nickname(std::move(nickname)), title_id(title_id), + game_name(std::move(game_name)) {} ~LobbyMember() = default; - QString GetUsername() const { - return username; + QString GetName() const { + if (username.isEmpty() || username == nickname) { + return nickname; + } else { + return QString("%1 (%2)").arg(nickname, username); + } } u64 GetTitleId() const { return title_id; @@ -137,6 +169,7 @@ public: private: QString username; + QString nickname; u64 title_id; QString game_name; }; @@ -195,10 +228,9 @@ public: out += '\n'; const auto& m = member.value(); if (m.GetGameName().isEmpty()) { - out += QString(QObject::tr("%1 is not playing a game")).arg(m.GetUsername()); + out += QString(QObject::tr("%1 is not playing a game")).arg(m.GetName()); } else { - out += - QString(QObject::tr("%1 is playing %2")).arg(m.GetUsername(), m.GetGameName()); + out += QString(QObject::tr("%1 is playing %2")).arg(m.GetName(), m.GetGameName()); } first = false; } diff --git a/src/citra_qt/multiplayer/message.cpp b/src/citra_qt/multiplayer/message.cpp index d2275e0e0..36e465acb 100644 --- a/src/citra_qt/multiplayer/message.cpp +++ b/src/citra_qt/multiplayer/message.cpp @@ -12,8 +12,8 @@ const ConnectionError USERNAME_NOT_VALID( QT_TR_NOOP("Username is not valid. Must be 4 to 20 alphanumeric characters.")); const ConnectionError ROOMNAME_NOT_VALID( QT_TR_NOOP("Room name is not valid. Must be 4 to 20 alphanumeric characters.")); -const ConnectionError USERNAME_IN_USE( - QT_TR_NOOP("Username is already in use. Please choose another.")); +const ConnectionError USERNAME_NOT_VALID_SERVER( + QT_TR_NOOP("Username is already in use or not valid. Please choose another.")); const ConnectionError IP_ADDRESS_NOT_VALID(QT_TR_NOOP("IP is not a valid IPv4 address.")); const ConnectionError PORT_NOT_VALID(QT_TR_NOOP("Port must be a number between 0 to 65535.")); const ConnectionError NO_INTERNET( @@ -22,6 +22,8 @@ const ConnectionError UNABLE_TO_CONNECT( QT_TR_NOOP("Unable to connect to the host. Verify that the connection settings are correct. If " "you still cannot connect, contact the room host and verify that the host is " "properly configured with the external port forwarded.")); +const ConnectionError ROOM_IS_FULL( + QT_TR_NOOP("Unable to connect to the room because it is already full.")); const ConnectionError COULD_NOT_CREATE_ROOM( QT_TR_NOOP("Creating a room failed. Please retry. Restarting Citra might be necessary.")); const ConnectionError HOST_BANNED( @@ -34,8 +36,16 @@ const ConnectionError WRONG_PASSWORD(QT_TR_NOOP("Incorrect password.")); const ConnectionError GENERIC_ERROR( QT_TR_NOOP("An unknown error occured. If this error continues to occur, please open an issue")); const ConnectionError LOST_CONNECTION(QT_TR_NOOP("Connection to room lost. Try to reconnect.")); +const ConnectionError HOST_KICKED(QT_TR_NOOP("You have been kicked by the room host.")); const ConnectionError MAC_COLLISION( QT_TR_NOOP("MAC address is already in use. Please choose another.")); +const ConnectionError CONSOLE_ID_COLLISION(QT_TR_NOOP( + "Your Console ID conflicted with someone else's in the room.\n\nPlease go to Emulation " + "> Configure > System to regenerate your Console ID.")); +const ConnectionError PERMISSION_DENIED( + QT_TR_NOOP("You do not have enough permission to perform this action.")); +const ConnectionError NO_SUCH_USER(QT_TR_NOOP( + "The user you are trying to kick/ban could not be found.\nThey may have left the room.")); static bool WarnMessage(const std::string& title, const std::string& text) { return QMessageBox::Ok == QMessageBox::warning(nullptr, QObject::tr(title.c_str()), diff --git a/src/citra_qt/multiplayer/message.h b/src/citra_qt/multiplayer/message.h index 3b8613199..955b90847 100644 --- a/src/citra_qt/multiplayer/message.h +++ b/src/citra_qt/multiplayer/message.h @@ -20,20 +20,27 @@ private: std::string err; }; +/// When the nickname is considered invalid by the client extern const ConnectionError USERNAME_NOT_VALID; extern const ConnectionError ROOMNAME_NOT_VALID; -extern const ConnectionError USERNAME_IN_USE; +/// When the nickname is considered invalid by the room server +extern const ConnectionError USERNAME_NOT_VALID_SERVER; extern const ConnectionError IP_ADDRESS_NOT_VALID; extern const ConnectionError PORT_NOT_VALID; extern const ConnectionError NO_INTERNET; extern const ConnectionError UNABLE_TO_CONNECT; +extern const ConnectionError ROOM_IS_FULL; extern const ConnectionError COULD_NOT_CREATE_ROOM; extern const ConnectionError HOST_BANNED; extern const ConnectionError WRONG_VERSION; extern const ConnectionError WRONG_PASSWORD; extern const ConnectionError GENERIC_ERROR; extern const ConnectionError LOST_CONNECTION; +extern const ConnectionError HOST_KICKED; extern const ConnectionError MAC_COLLISION; +extern const ConnectionError CONSOLE_ID_COLLISION; +extern const ConnectionError PERMISSION_DENIED; +extern const ConnectionError NO_SUCH_USER; /** * Shows a standard QMessageBox with a error message diff --git a/src/citra_qt/multiplayer/moderation_dialog.cpp b/src/citra_qt/multiplayer/moderation_dialog.cpp new file mode 100644 index 000000000..def084666 --- /dev/null +++ b/src/citra_qt/multiplayer/moderation_dialog.cpp @@ -0,0 +1,113 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include "citra_qt/multiplayer/moderation_dialog.h" +#include "network/network.h" +#include "network/room_member.h" +#include "ui_moderation_dialog.h" + +namespace Column { +enum { + SUBJECT, + TYPE, + COUNT, +}; +} + +ModerationDialog::ModerationDialog(QWidget* parent) + : QDialog(parent), ui(std::make_unique()) { + ui->setupUi(this); + + qRegisterMetaType(); + + if (auto member = Network::GetRoomMember().lock()) { + callback_handle_status_message = member->BindOnStatusMessageReceived( + [this](const Network::StatusMessageEntry& status_message) { + emit StatusMessageReceived(status_message); + }); + connect(this, &ModerationDialog::StatusMessageReceived, this, + &ModerationDialog::OnStatusMessageReceived); + callback_handle_ban_list = member->BindOnBanListReceived( + [this](const Network::Room::BanList& ban_list) { emit BanListReceived(ban_list); }); + connect(this, &ModerationDialog::BanListReceived, this, &ModerationDialog::PopulateBanList); + } + + // Initialize the UI + model = new QStandardItemModel(ui->ban_list_view); + model->insertColumns(0, Column::COUNT); + model->setHeaderData(Column::SUBJECT, Qt::Horizontal, tr("Subject")); + model->setHeaderData(Column::TYPE, Qt::Horizontal, tr("Type")); + + ui->ban_list_view->setModel(model); + + // Load the ban list in background + LoadBanList(); + + connect(ui->refresh, &QPushButton::clicked, this, [this] { LoadBanList(); }); + connect(ui->unban, &QPushButton::clicked, this, [this] { + auto index = ui->ban_list_view->currentIndex(); + SendUnbanRequest(model->item(index.row(), 0)->text()); + }); + connect(ui->ban_list_view, &QTreeView::clicked, [this] { ui->unban->setEnabled(true); }); +} + +ModerationDialog::~ModerationDialog() { + if (callback_handle_status_message) { + if (auto room = Network::GetRoomMember().lock()) { + room->Unbind(callback_handle_status_message); + } + } + + if (callback_handle_ban_list) { + if (auto room = Network::GetRoomMember().lock()) { + room->Unbind(callback_handle_ban_list); + } + } +} + +void ModerationDialog::LoadBanList() { + if (auto room = Network::GetRoomMember().lock()) { + ui->refresh->setEnabled(false); + ui->refresh->setText(tr("Refreshing")); + ui->unban->setEnabled(false); + room->RequestBanList(); + } +} + +void ModerationDialog::PopulateBanList(const Network::Room::BanList& ban_list) { + model->removeRows(0, model->rowCount()); + for (const auto& username : ban_list.first) { + QStandardItem* subject_item = new QStandardItem(QString::fromStdString(username)); + QStandardItem* type_item = new QStandardItem(tr("Forum Username")); + model->invisibleRootItem()->appendRow({subject_item, type_item}); + } + for (const auto& ip : ban_list.second) { + QStandardItem* subject_item = new QStandardItem(QString::fromStdString(ip)); + QStandardItem* type_item = new QStandardItem(tr("IP Address")); + model->invisibleRootItem()->appendRow({subject_item, type_item}); + } + for (int i = 0; i < Column::COUNT - 1; ++i) { + ui->ban_list_view->resizeColumnToContents(i); + } + ui->refresh->setEnabled(true); + ui->refresh->setText(tr("Refresh")); + ui->unban->setEnabled(false); +} + +void ModerationDialog::SendUnbanRequest(const QString& subject) { + if (auto room = Network::GetRoomMember().lock()) { + room->SendModerationRequest(Network::IdModUnban, subject.toStdString()); + } +} + +void ModerationDialog::OnStatusMessageReceived(const Network::StatusMessageEntry& status_message) { + if (status_message.type != Network::IdMemberBanned && + status_message.type != Network::IdAddressUnbanned) + return; + + // Update the ban list for ban/unban + LoadBanList(); +} diff --git a/src/citra_qt/multiplayer/moderation_dialog.h b/src/citra_qt/multiplayer/moderation_dialog.h new file mode 100644 index 000000000..d10083d5b --- /dev/null +++ b/src/citra_qt/multiplayer/moderation_dialog.h @@ -0,0 +1,42 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include "network/room.h" +#include "network/room_member.h" + +namespace Ui { +class ModerationDialog; +} + +class QStandardItemModel; + +class ModerationDialog : public QDialog { + Q_OBJECT + +public: + explicit ModerationDialog(QWidget* parent = nullptr); + ~ModerationDialog(); + +signals: + void StatusMessageReceived(const Network::StatusMessageEntry&); + void BanListReceived(const Network::Room::BanList&); + +private: + void LoadBanList(); + void PopulateBanList(const Network::Room::BanList& ban_list); + void SendUnbanRequest(const QString& subject); + void OnStatusMessageReceived(const Network::StatusMessageEntry& status_message); + + std::unique_ptr ui; + QStandardItemModel* model; + Network::RoomMember::CallbackHandle callback_handle_status_message; + Network::RoomMember::CallbackHandle callback_handle_ban_list; +}; + +Q_DECLARE_METATYPE(Network::Room::BanList); diff --git a/src/citra_qt/multiplayer/moderation_dialog.ui b/src/citra_qt/multiplayer/moderation_dialog.ui new file mode 100644 index 000000000..808d99414 --- /dev/null +++ b/src/citra_qt/multiplayer/moderation_dialog.ui @@ -0,0 +1,84 @@ + + + ModerationDialog + + + Moderation + + + + 0 + 0 + 500 + 300 + + + + + + + Ban List + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refreshing + + + false + + + + + + + Unban + + + false + + + + + + + + + + + + + + + QDialogButtonBox::Ok + + + + + + + + buttonBox + accepted() + ModerationDialog + accept() + + + + diff --git a/src/citra_qt/multiplayer/state.cpp b/src/citra_qt/multiplayer/state.cpp index f1dec2ba3..75d74e189 100644 --- a/src/citra_qt/multiplayer/state.cpp +++ b/src/citra_qt/multiplayer/state.cpp @@ -3,6 +3,7 @@ // Refer to the license.txt file included. #include +#include #include #include #include @@ -13,6 +14,7 @@ #include "citra_qt/multiplayer/lobby.h" #include "citra_qt/multiplayer/message.h" #include "citra_qt/multiplayer/state.h" +#include "citra_qt/ui_settings.h" #include "citra_qt/util/clickable_label.h" #include "common/announce_multiplayer_room.h" #include "common/logging/log.h" @@ -27,9 +29,13 @@ MultiplayerState::MultiplayerState(QWidget* parent, QStandardItemModel* game_lis [this](const Network::RoomMember::State& state) { emit NetworkStateChanged(state); }); connect(this, &MultiplayerState::NetworkStateChanged, this, &MultiplayerState::OnNetworkStateChanged); + error_callback_handle = member->BindOnError( + [this](const Network::RoomMember::Error& error) { emit NetworkError(error); }); + connect(this, &MultiplayerState::NetworkError, this, &MultiplayerState::OnNetworkError); } qRegisterMetaType(); + qRegisterMetaType(); qRegisterMetaType(); announce_multiplayer_session = std::make_shared(); announce_multiplayer_session->BindErrorCallback( @@ -44,6 +50,13 @@ MultiplayerState::MultiplayerState(QWidget* parent, QStandardItemModel* game_lis connect(status_text, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); connect(status_icon, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); + + connect(static_cast(QApplication::instance()), &QApplication::focusChanged, this, + [this](QWidget* /*old*/, QWidget* now) { + if (client_room && client_room->isAncestorOf(now)) { + HideNotification(); + } + }); } MultiplayerState::~MultiplayerState() { @@ -52,6 +65,12 @@ MultiplayerState::~MultiplayerState() { member->Unbind(state_callback_handle); } } + + if (error_callback_handle) { + if (auto member = Network::GetRoomMember().lock()) { + member->Unbind(error_callback_handle); + } + } } void MultiplayerState::Close() { @@ -70,7 +89,9 @@ void MultiplayerState::retranslateUi() { if (current_state == Network::RoomMember::State::Uninitialized) { status_text->setText(tr("Not Connected. Click here to find a room!")); - } else if (current_state == Network::RoomMember::State::Joined) { + } else if (current_state == Network::RoomMember::State::Joined || + current_state == Network::RoomMember::State::Moderator) { + status_text->setText(tr("Connected")); } else { status_text->setText(tr("Not Connected")); @@ -88,35 +109,10 @@ void MultiplayerState::retranslateUi() { void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& state) { LOG_DEBUG(Frontend, "Network State: {}", Network::GetStateStr(state)); - bool is_connected = false; - switch (state) { - case Network::RoomMember::State::LostConnection: - NetworkMessage::ShowError(NetworkMessage::LOST_CONNECTION); - break; - case Network::RoomMember::State::CouldNotConnect: - NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT); - break; - case Network::RoomMember::State::NameCollision: - NetworkMessage::ShowError(NetworkMessage::USERNAME_IN_USE); - break; - case Network::RoomMember::State::MacCollision: - NetworkMessage::ShowError(NetworkMessage::MAC_COLLISION); - break; - case Network::RoomMember::State::WrongPassword: - NetworkMessage::ShowError(NetworkMessage::WRONG_PASSWORD); - break; - case Network::RoomMember::State::WrongVersion: - NetworkMessage::ShowError(NetworkMessage::WRONG_VERSION); - break; - case Network::RoomMember::State::Error: - NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT); - break; - case Network::RoomMember::State::Joined: - is_connected = true; + if (state == Network::RoomMember::State::Joined || + state == Network::RoomMember::State::Moderator) { + OnOpenNetworkRoom(); - break; - } - if (is_connected) { status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16)); status_text->setText(tr("Connected")); leave_room->setEnabled(true); @@ -131,6 +127,51 @@ void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& s current_state = state; } +void MultiplayerState::OnNetworkError(const Network::RoomMember::Error& error) { + LOG_DEBUG(Frontend, "Network Error: {}", Network::GetErrorStr(error)); + switch (error) { + case Network::RoomMember::Error::LostConnection: + NetworkMessage::ShowError(NetworkMessage::LOST_CONNECTION); + break; + case Network::RoomMember::Error::HostKicked: + NetworkMessage::ShowError(NetworkMessage::HOST_KICKED); + break; + case Network::RoomMember::Error::CouldNotConnect: + NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::Error::NameCollision: + NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID_SERVER); + break; + case Network::RoomMember::Error::MacCollision: + NetworkMessage::ShowError(NetworkMessage::MAC_COLLISION); + break; + case Network::RoomMember::Error::ConsoleIdCollision: + NetworkMessage::ShowError(NetworkMessage::CONSOLE_ID_COLLISION); + break; + case Network::RoomMember::Error::RoomIsFull: + NetworkMessage::ShowError(NetworkMessage::ROOM_IS_FULL); + break; + case Network::RoomMember::Error::WrongPassword: + NetworkMessage::ShowError(NetworkMessage::WRONG_PASSWORD); + break; + case Network::RoomMember::Error::WrongVersion: + NetworkMessage::ShowError(NetworkMessage::WRONG_VERSION); + break; + case Network::RoomMember::Error::HostBanned: + NetworkMessage::ShowError(NetworkMessage::HOST_BANNED); + break; + case Network::RoomMember::Error::UnknownError: + NetworkMessage::ShowError(NetworkMessage::UNABLE_TO_CONNECT); + break; + case Network::RoomMember::Error::PermissionDenied: + NetworkMessage::ShowError(NetworkMessage::PERMISSION_DENIED); + break; + case Network::RoomMember::Error::NoSuchUser: + NetworkMessage::ShowError(NetworkMessage::NO_SUCH_USER); + break; + } +} + void MultiplayerState::OnAnnounceFailed(const Common::WebResult& result) { announce_multiplayer_session->Stop(); QMessageBox::warning( @@ -144,7 +185,11 @@ void MultiplayerState::OnAnnounceFailed(const Common::WebResult& result) { } void MultiplayerState::UpdateThemedIcons() { - if (current_state == Network::RoomMember::State::Joined) { + if (show_notification) { + status_icon->setPixmap(QIcon::fromTheme("connected_notification").pixmap(16)); + } else if (current_state == Network::RoomMember::State::Joined || + current_state == Network::RoomMember::State::Moderator) { + status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16)); } else { status_icon->setPixmap(QIcon::fromTheme("disconnected").pixmap(16)); @@ -185,6 +230,10 @@ bool MultiplayerState::OnCloseRoom() { if (room->GetState() != Network::Room::State::Open) { return true; } + // Save ban list + if (auto room = Network::GetRoom().lock()) { + UISettings::values.ban_list = std::move(room->GetBanList()); + } room->Destroy(); announce_multiplayer_session->Stop(); LOG_DEBUG(Frontend, "Closed the room (as a server)"); @@ -192,11 +241,28 @@ bool MultiplayerState::OnCloseRoom() { return true; } +void MultiplayerState::ShowNotification() { + if (client_room && client_room->isAncestorOf(QApplication::focusWidget())) + return; // Do not show notification if the chat window currently has focus + show_notification = true; + QApplication::alert(nullptr); + status_icon->setPixmap(QIcon::fromTheme("connected_notification").pixmap(16)); + status_text->setText(tr("New Messages Received")); +} + +void MultiplayerState::HideNotification() { + show_notification = false; + status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16)); + status_text->setText(tr("Connected")); +} + void MultiplayerState::OnOpenNetworkRoom() { if (auto member = Network::GetRoomMember().lock()) { if (member->IsConnected()) { if (client_room == nullptr) { client_room = new ClientRoomWindow(this); + connect(client_room, &ClientRoomWindow::ShowNotification, this, + &MultiplayerState::ShowNotification); } BringWidgetToFront(client_room); return; diff --git a/src/citra_qt/multiplayer/state.h b/src/citra_qt/multiplayer/state.h index b375a81a6..8061d18cf 100644 --- a/src/citra_qt/multiplayer/state.h +++ b/src/citra_qt/multiplayer/state.h @@ -40,6 +40,7 @@ public: public slots: void OnNetworkStateChanged(const Network::RoomMember::State& state); + void OnNetworkError(const Network::RoomMember::Error& error); void OnViewLobby(); void OnCreateRoom(); bool OnCloseRoom(); @@ -47,9 +48,12 @@ public slots: void OnDirectConnectToRoom(); void OnAnnounceFailed(const Common::WebResult&); void UpdateThemedIcons(); + void ShowNotification(); + void HideNotification(); signals: void NetworkStateChanged(const Network::RoomMember::State&); + void NetworkError(const Network::RoomMember::Error&); void AnnounceFailed(const Common::WebResult&); private: @@ -64,7 +68,11 @@ private: QAction* show_room; std::shared_ptr announce_multiplayer_session; Network::RoomMember::State current_state = Network::RoomMember::State::Uninitialized; + bool has_mod_perms = false; Network::RoomMember::CallbackHandle state_callback_handle; + Network::RoomMember::CallbackHandle error_callback_handle; + + bool show_notification = false; }; Q_DECLARE_METATYPE(Common::WebResult); diff --git a/src/citra_qt/ui_settings.h b/src/citra_qt/ui_settings.h index 1e09dd7e8..d623b14da 100644 --- a/src/citra_qt/ui_settings.h +++ b/src/citra_qt/ui_settings.h @@ -5,6 +5,8 @@ #pragma once #include +#include +#include #include #include #include @@ -109,6 +111,8 @@ struct Values { QString room_port; uint host_type; qulonglong game_id; + QString room_description; + std::pair, std::vector> ban_list; // logging bool show_console; diff --git a/src/common/announce_multiplayer_room.h b/src/common/announce_multiplayer_room.h index 5f9fc8ec2..f0cf79386 100644 --- a/src/common/announce_multiplayer_room.h +++ b/src/common/announce_multiplayer_room.h @@ -17,13 +17,17 @@ using MacAddress = std::array; struct Room { struct Member { - std::string name; + std::string username; + std::string nickname; + std::string avatar_url; MacAddress mac_address; std::string game_name; u64 game_id; }; + std::string id; + std::string verify_UID; ///< UID used for verification std::string name; - std::string UID; + std::string description; std::string owner; std::string ip; u16 port; @@ -49,14 +53,15 @@ public: * Sets the Information that gets used for the announce * @param uid The Id of the room * @param name The name of the room + * @param description The room description * @param port The port of the room * @param net_version The version of the libNetwork that gets used * @param has_password True if the room is passowrd protected * @param preferred_game The preferred game of the room * @param preferred_game_id The title id of the preferred game */ - virtual void SetRoomInformation(const std::string& uid, const std::string& name, const u16 port, - const u32 max_player, const u32 net_version, + virtual void SetRoomInformation(const std::string& name, const std::string& description, + const u16 port, const u32 max_player, const u32 net_version, const bool has_password, const std::string& preferred_game, const u64 preferred_game_id) = 0; /** @@ -66,14 +71,21 @@ public: * @param game_id The title id of the game the player plays * @param game_name The name of the game the player plays */ - virtual void AddPlayer(const std::string& nickname, const MacAddress& mac_address, + virtual void AddPlayer(const std::string& username, const std::string& nickname, + const std::string& avatar_url, const MacAddress& mac_address, const u64 game_id, const std::string& game_name) = 0; /** - * Send the data to the announce service - * @result The result of the announce attempt + * Updates the data in the announce service. Re-register the room when required. + * @result The result of the update attempt */ - virtual Common::WebResult Announce() = 0; + virtual Common::WebResult Update() = 0; + + /** + * Registers the data in the announce service + * @result A global Guid of the room which may be used for verification + */ + virtual std::string Register() = 0; /** * Empties the stored players @@ -99,15 +111,19 @@ public: class NullBackend : public Backend { public: ~NullBackend() = default; - void SetRoomInformation(const std::string& /*uid*/, const std::string& /*name*/, + void SetRoomInformation(const std::string& /*name*/, const std::string& /*description*/, const u16 /*port*/, const u32 /*max_player*/, const u32 /*net_version*/, const bool /*has_password*/, const std::string& /*preferred_game*/, const u64 /*preferred_game_id*/) override {} - void AddPlayer(const std::string& /*nickname*/, const MacAddress& /*mac_address*/, + void AddPlayer(const std::string& /*username*/, const std::string& /*nickname*/, + const std::string& /*avatar_url*/, const MacAddress& /*mac_address*/, const u64 /*game_id*/, const std::string& /*game_name*/) override {} - Common::WebResult Announce() override { + Common::WebResult Update() override { return Common::WebResult{Common::WebResult::Code::NoWebservice, "WebService is missing"}; } + std::string Register() override { + return ""; + } void ClearPlayers() override {} RoomList GetRoomList() override { return RoomList{}; diff --git a/src/core/announce_multiplayer_session.cpp b/src/core/announce_multiplayer_session.cpp index 69dbf6fce..e255581f9 100644 --- a/src/core/announce_multiplayer_session.cpp +++ b/src/core/announce_multiplayer_session.cpp @@ -29,6 +29,21 @@ AnnounceMultiplayerSession::AnnounceMultiplayerSession() { #endif } +void AnnounceMultiplayerSession::Register() { + std::shared_ptr room = Network::GetRoom().lock(); + if (!room) { + return; + } + if (room->GetState() != Network::Room::State::Open) { + return; + } + UpdateBackendData(room); + std::string result = backend->Register(); + LOG_INFO(WebService, "Room has been registered"); + room->SetVerifyUID(result); + registered = true; +} + void AnnounceMultiplayerSession::Start() { if (announce_multiplayer_thread) { Stop(); @@ -44,6 +59,7 @@ void AnnounceMultiplayerSession::Stop() { announce_multiplayer_thread->join(); announce_multiplayer_thread.reset(); backend->Delete(); + registered = false; } } @@ -64,7 +80,24 @@ AnnounceMultiplayerSession::~AnnounceMultiplayerSession() { Stop(); } +void AnnounceMultiplayerSession::UpdateBackendData(std::shared_ptr room) { + Network::RoomInformation room_information = room->GetRoomInformation(); + std::vector memberlist = room->GetRoomMemberList(); + backend->SetRoomInformation( + room_information.name, room_information.description, room_information.port, + room_information.member_slots, Network::network_version, room->HasPassword(), + room_information.preferred_game, room_information.preferred_game_id); + backend->ClearPlayers(); + for (const auto& member : memberlist) { + backend->AddPlayer(member.username, member.nickname, member.avatar_url, member.mac_address, + member.game_info.id, member.game_info.name); + } +} + void AnnounceMultiplayerSession::AnnounceMultiplayerLoop() { + if (!registered) { + Register(); + } auto update_time = std::chrono::steady_clock::now(); std::future future; while (!shutdown_event.WaitUntil(update_time)) { @@ -76,24 +109,19 @@ void AnnounceMultiplayerSession::AnnounceMultiplayerLoop() { if (room->GetState() != Network::Room::State::Open) { break; } - Network::RoomInformation room_information = room->GetRoomInformation(); - std::vector memberlist = room->GetRoomMemberList(); - backend->SetRoomInformation( - room_information.uid, room_information.name, room_information.port, - room_information.member_slots, Network::network_version, room->HasPassword(), - room_information.preferred_game, room_information.preferred_game_id); - backend->ClearPlayers(); - for (const auto& member : memberlist) { - backend->AddPlayer(member.nickname, member.mac_address, member.game_info.id, - member.game_info.name); - } - Common::WebResult result = backend->Announce(); + UpdateBackendData(room); + Common::WebResult result = backend->Update(); if (result.result_code != Common::WebResult::Code::Success) { std::lock_guard lock(callback_mutex); for (auto callback : error_callbacks) { (*callback)(result); } } + if (result.result_string == "404") { + registered = false; + // Needs to register the room again + Register(); + } } } diff --git a/src/core/announce_multiplayer_session.h b/src/core/announce_multiplayer_session.h index b9ba4c48a..79b30fb24 100644 --- a/src/core/announce_multiplayer_session.h +++ b/src/core/announce_multiplayer_session.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include @@ -13,6 +14,10 @@ #include "common/common_types.h" #include "common/thread.h" +namespace Network { +class Room; +} + namespace Core { /** @@ -39,6 +44,9 @@ public: */ void UnbindErrorCallback(CallbackHandle handle); + /// Registers a room to web services + void Register(); + /** * Starts the announce of a room to web services */ @@ -65,6 +73,9 @@ private: /// Backend interface that logs fields std::unique_ptr backend; + std::atomic_bool registered = false; ///< Whether the room has been registered + + void UpdateBackendData(std::shared_ptr room); void AnnounceMultiplayerLoop(); }; diff --git a/src/core/hle/service/cfg/cfg.cpp b/src/core/hle/service/cfg/cfg.cpp index d5c08a5a7..8c6859bb3 100644 --- a/src/core/hle/service/cfg/cfg.cpp +++ b/src/core/hle/service/cfg/cfg.cpp @@ -734,4 +734,21 @@ void InstallInterfaces(Core::System& system) { std::make_shared()->InstallAsService(service_manager); } +std::string GetConsoleIdHash(Core::System& system) { + u64_le console_id{}; + std::array buffer; + if (system.IsPoweredOn()) { + auto cfg = GetModule(system); + ASSERT_MSG(cfg, "CFG Module missing!"); + console_id = cfg->GetConsoleUniqueId(); + } else { + console_id = std::make_unique()->GetConsoleUniqueId(); + } + std::memcpy(buffer.data(), &console_id, sizeof(console_id)); + + std::array hash; + CryptoPP::SHA256().CalculateDigest(hash.data(), buffer.data(), sizeof(buffer)); + return fmt::format("{:02x}", fmt::join(hash.begin(), hash.end(), "")); +} + } // namespace Service::CFG diff --git a/src/core/hle/service/cfg/cfg.h b/src/core/hle/service/cfg/cfg.h index b069ad728..af63c6f91 100644 --- a/src/core/hle/service/cfg/cfg.h +++ b/src/core/hle/service/cfg/cfg.h @@ -415,4 +415,7 @@ std::shared_ptr GetModule(Core::System& system); void InstallInterfaces(Core::System& system); +/// Convenience function for getting a SHA256 hash of the Console ID +std::string GetConsoleIdHash(Core::System& system); + } // namespace Service::CFG diff --git a/src/core/hle/service/nwm/nwm_uds.cpp b/src/core/hle/service/nwm/nwm_uds.cpp index 755da4f1d..bf2d408c1 100644 --- a/src/core/hle/service/nwm/nwm_uds.cpp +++ b/src/core/hle/service/nwm/nwm_uds.cpp @@ -140,7 +140,9 @@ std::list GetReceivedBeacons(const MacAddress& sender) { /// Sends a WifiPacket to the room we're currently connected to. void SendPacket(Network::WifiPacket& packet) { if (auto room_member = Network::GetRoomMember().lock()) { - if (room_member->GetState() == Network::RoomMember::State::Joined) { + if (room_member->GetState() == Network::RoomMember::State::Joined || + room_member->GetState() == Network::RoomMember::State::Moderator) { + packet.transmitter_address = room_member->GetMacAddress(); room_member->SendWifiPacket(packet); } diff --git a/src/dedicated_room/CMakeLists.txt b/src/dedicated_room/CMakeLists.txt index cbd5d9726..2492bc523 100644 --- a/src/dedicated_room/CMakeLists.txt +++ b/src/dedicated_room/CMakeLists.txt @@ -8,6 +8,11 @@ add_executable(citra-room create_target_directory_groups(citra-room) target_link_libraries(citra-room PRIVATE common core network) +if (ENABLE_WEB_SERVICE) + target_compile_definitions(citra-room PRIVATE -DENABLE_WEB_SERVICE) + target_link_libraries(citra-room PRIVATE web_service) +endif() + target_link_libraries(citra-room PRIVATE glad) if (MSVC) target_link_libraries(citra-room PRIVATE getopt) diff --git a/src/dedicated_room/citra-room.cpp b/src/dedicated_room/citra-room.cpp index 7d23c93f2..8a8c2ae0f 100644 --- a/src/dedicated_room/citra-room.cpp +++ b/src/dedicated_room/citra-room.cpp @@ -3,6 +3,7 @@ // Refer to the license.txt file included. #include +#include #include #include #include @@ -27,15 +28,23 @@ #include "common/common_types.h" #include "common/detached_tasks.h" #include "common/scm_rev.h" +#include "common/string_util.h" #include "core/announce_multiplayer_session.h" #include "core/core.h" #include "core/settings.h" #include "network/network.h" +#include "network/room.h" +#include "network/verify_user.h" + +#ifdef ENABLE_WEB_SERVICE +#include "web_service/verify_user_jwt.h" +#endif static void PrintHelp(const char* argv0) { std::cout << "Usage: " << argv0 << " [options] \n" "--room-name The name of the room\n" + "--room-description The room description\n" "--port The port used for the room\n" "--max_members The maximum number of players for this room\n" "--password The password for the room\n" @@ -44,6 +53,8 @@ static void PrintHelp(const char* argv0) { "--username The username used for announce\n" "--token The token used for announce\n" "--web-api-url Citra Web API url\n" + "--ban-list-file The file for storing the room ban list\n" + "--enable-citra-mods Allow Citra Community Moderators to moderate on your room\n" "-h, --help Display this help and exit\n" "-v, --version Output version information and exit\n"; } @@ -53,6 +64,71 @@ static void PrintVersion() { << " Libnetwork: " << Network::network_version << std::endl; } +/// The magic text at the beginning of a citra-room ban list file. +static constexpr char BanListMagic[] = "CitraRoom-BanList-1"; + +static Network::Room::BanList LoadBanList(const std::string& path) { + std::ifstream file; + OpenFStream(file, path, std::ios_base::in); + if (!file || file.eof()) { + std::cout << "Could not open ban list!\n\n"; + return {}; + } + std::string magic; + std::getline(file, magic); + if (magic != BanListMagic) { + std::cout << "Ban list is not valid!\n\n"; + return {}; + } + + // false = username ban list, true = ip ban list + bool ban_list_type = false; + Network::Room::UsernameBanList username_ban_list; + Network::Room::IPBanList ip_ban_list; + while (!file.eof()) { + std::string line; + std::getline(file, line); + line.erase(std::remove(line.begin(), line.end(), '\0'), line.end()); + line = Common::StripSpaces(line); + if (line.empty()) { + // An empty line marks start of the IP ban list + ban_list_type = true; + continue; + } + if (ban_list_type) { + ip_ban_list.emplace_back(line); + } else { + username_ban_list.emplace_back(line); + } + } + + return {username_ban_list, ip_ban_list}; +} + +static void SaveBanList(const Network::Room::BanList& ban_list, const std::string& path) { + std::ofstream file; + OpenFStream(file, path, std::ios_base::out); + if (!file) { + std::cout << "Could not save ban list!\n\n"; + return; + } + + file << BanListMagic << "\n"; + + // Username ban list + for (const auto& username : ban_list.first) { + file << username << "\n"; + } + file << "\n"; + + // IP ban list + for (const auto& ip : ban_list.second) { + file << ip << "\n"; + } + + file.flush(); +} + /// Application entry point int main(int argc, char** argv) { Common::DetachedTasks detached_tasks; @@ -63,17 +139,21 @@ int main(int argc, char** argv) { gladLoadGL(); std::string room_name; + std::string room_description; std::string password; std::string preferred_game; std::string username; std::string token; std::string web_api_url; + std::string ban_list_file; u64 preferred_game_id = 0; u32 port = Network::DefaultRoomPort; u32 max_members = 16; + bool enable_citra_mods = false; static struct option long_options[] = { {"room-name", required_argument, 0, 'n'}, + {"room-description", required_argument, 0, 'd'}, {"port", required_argument, 0, 'p'}, {"max_members", required_argument, 0, 'm'}, {"password", required_argument, 0, 'w'}, @@ -82,18 +162,23 @@ int main(int argc, char** argv) { {"username", required_argument, 0, 'u'}, {"token", required_argument, 0, 't'}, {"web-api-url", required_argument, 0, 'a'}, + {"ban-list-file", required_argument, 0, 'b'}, + {"enable-citra-mods", no_argument, 0, 'e'}, {"help", no_argument, 0, 'h'}, {"version", no_argument, 0, 'v'}, {0, 0, 0, 0}, }; while (optind < argc) { - char arg = getopt_long(argc, argv, "n:p:m:w:g:u:t:a:i:hv", long_options, &option_index); + char arg = getopt_long(argc, argv, "n:d:p:m:w:g:u:t:a:i:hv", long_options, &option_index); if (arg != -1) { switch (arg) { case 'n': room_name.assign(optarg); break; + case 'd': + room_description.assign(optarg); + break; case 'p': port = strtoul(optarg, &endarg, 0); break; @@ -118,6 +203,12 @@ int main(int argc, char** argv) { case 'a': web_api_url.assign(optarg); break; + case 'b': + ban_list_file.assign(optarg); + break; + case 'e': + enable_citra_mods = true; + break; case 'h': PrintHelp(argv[0]); return 0; @@ -153,6 +244,10 @@ int main(int argc, char** argv) { PrintHelp(argv[0]); return -1; } + if (ban_list_file.empty()) { + std::cout << "Ban list file not set!\nThis should get set to load and save room ban " + "list.\nSet with --ban-list-file \n\n"; + } bool announce = true; if (username.empty()) { announce = false; @@ -172,11 +267,35 @@ int main(int argc, char** argv) { Settings::values.citra_username = username; Settings::values.citra_token = token; } + if (!announce && enable_citra_mods) { + enable_citra_mods = false; + std::cout << "Can not enable Citra Moderators for private rooms\n\n"; + } + + // Load the ban list + Network::Room::BanList ban_list; + if (!ban_list_file.empty()) { + ban_list = LoadBanList(ban_list_file); + } + + std::unique_ptr verify_backend; + if (announce) { +#ifdef ENABLE_WEB_SERVICE + verify_backend = std::make_unique(Settings::values.web_api_url); +#else + std::cout + << "Citra Web Services is not available with this build: validation is disabled.\n\n"; + verify_backend = std::make_unique(); +#endif + } else { + verify_backend = std::make_unique(); + } Network::Init(); if (std::shared_ptr room = Network::GetRoom().lock()) { - if (!room->Create(room_name, "", port, password, max_members, preferred_game, - preferred_game_id)) { + if (!room->Create(room_name, room_description, "", port, password, max_members, username, + preferred_game, preferred_game_id, std::move(verify_backend), ban_list, + enable_citra_mods)) { std::cout << "Failed to create room: \n\n"; return -1; } @@ -193,6 +312,10 @@ int main(int argc, char** argv) { announce_session->Stop(); } announce_session.reset(); + // Save the ban list + if (!ban_list_file.empty()) { + SaveBanList(room->GetBanList(), ban_list_file); + } room->Destroy(); Network::Shutdown(); return 0; @@ -203,6 +326,10 @@ int main(int argc, char** argv) { announce_session->Stop(); } announce_session.reset(); + // Save the ban list + if (!ban_list_file.empty()) { + SaveBanList(room->GetBanList(), ban_list_file); + } room->Destroy(); } Network::Shutdown(); diff --git a/src/network/CMakeLists.txt b/src/network/CMakeLists.txt index f72da9439..b98cf1654 100644 --- a/src/network/CMakeLists.txt +++ b/src/network/CMakeLists.txt @@ -7,8 +7,10 @@ add_library(network STATIC room.h room_member.cpp room_member.h + verify_user.cpp + verify_user.h ) create_target_directory_groups(network) -target_link_libraries(network PRIVATE common enet) +target_link_libraries(network PRIVATE common cpp-jwt enet) diff --git a/src/network/packet.h b/src/network/packet.h index 5a2e58dc2..7bdc3da95 100644 --- a/src/network/packet.h +++ b/src/network/packet.h @@ -126,7 +126,7 @@ Packet& Packet::operator>>(std::vector& out_data) { // Then extract the data for (std::size_t i = 0; i < out_data.size(); ++i) { - T character = 0; + T character; *this >> character; out_data[i] = character; } @@ -136,7 +136,7 @@ Packet& Packet::operator>>(std::vector& out_data) { template Packet& Packet::operator>>(std::array& out_data) { for (std::size_t i = 0; i < out_data.size(); ++i) { - T character = 0; + T character; *this >> character; out_data[i] = character; } diff --git a/src/network/room.cpp b/src/network/room.cpp index f0d229d40..6014b4360 100644 --- a/src/network/room.cpp +++ b/src/network/room.cpp @@ -7,12 +7,14 @@ #include #include #include +#include #include #include #include "common/logging/log.h" #include "enet/enet.h" #include "network/packet.h" #include "network/room.h" +#include "network/verify_user.h" namespace Network { @@ -27,25 +29,38 @@ public: std::atomic state{State::Closed}; ///< Current state of the room. RoomInformation room_information; ///< Information about this room. + std::string verify_UID; ///< A GUID which may be used for verfication. + mutable std::mutex verify_UID_mutex; ///< Mutex for verify_UID + std::string password; ///< The password required to connect to this room. struct Member { - std::string nickname; ///< The nickname of the member. - GameInfo game_info; ///< The current game of the member - MacAddress mac_address; ///< The assigned mac address of the member. - ENetPeer* peer; ///< The remote peer. + std::string nickname; ///< The nickname of the member. + std::string console_id_hash; ///< A hash of the console ID of the member. + GameInfo game_info; ///< The current game of the member + MacAddress mac_address; ///< The assigned mac address of the member. + /// Data of the user, often including authenticated forum username. + VerifyUser::UserData user_data; + ENetPeer* peer; ///< The remote peer. }; using MemberList = std::vector; MemberList members; ///< Information about the members of this room mutable std::mutex member_mutex; ///< Mutex for locking the members list /// This should be a std::shared_mutex as soon as C++17 is supported + UsernameBanList username_ban_list; ///< List of banned usernames + IPBanList ip_ban_list; ///< List of banned IP addresses + mutable std::mutex ban_list_mutex; ///< Mutex for the ban lists + RoomImpl() : random_gen(std::random_device()()), NintendoOUI{0x00, 0x1F, 0x32, 0x00, 0x00, 0x00} {} /// Thread that receives and dispatches network packets std::unique_ptr room_thread; + /// Verification backend of the room + std::unique_ptr verify_backend; + /// Thread function that will receive and dispatch messages until the room is destroyed. void ServerLoop(); void StartLoop(); @@ -57,6 +72,30 @@ public: */ void HandleJoinRequest(const ENetEvent* event); + /** + * Parses and answers a kick request from a client. + * Validates the permissions and that the given user exists and then kicks the member. + */ + void HandleModKickPacket(const ENetEvent* event); + + /** + * Parses and answers a ban request from a client. + * Validates the permissions and bans the user (by forum username or IP). + */ + void HandleModBanPacket(const ENetEvent* event); + + /** + * Parses and answers a unban request from a client. + * Validates the permissions and unbans the address. + */ + void HandleModUnbanPacket(const ENetEvent* event); + + /** + * Parses and answers a get ban list request from a client. + * Validates the permissions and returns the ban list. + */ + void HandleModGetBanListPacket(const ENetEvent* event); + /** * Returns whether the nickname is valid, ie. isn't already taken by someone else in the room. */ @@ -68,6 +107,22 @@ public: */ bool IsValidMacAddress(const MacAddress& address) const; + /** + * Returns whether the console ID (hash) is valid, ie. isn't already taken by someone else in + * the room. + */ + bool IsValidConsoleId(const std::string& console_id_hash) const; + + /** + * Returns whether a user has mod permissions. + */ + bool HasModPermission(const ENetPeer* client) const; + + /** + * Sends a ID_ROOM_IS_FULL message telling the client that the room is full. + */ + void SendRoomIsFull(ENetPeer* client); + /** * Sends a ID_ROOM_NAME_COLLISION message telling the client that the name is invalid. */ @@ -78,6 +133,12 @@ public: */ void SendMacCollision(ENetPeer* client); + /** + * Sends a IdConsoleIdCollison message telling the client that another member with the same + * console ID exists. + */ + void SendConsoleIdCollision(ENetPeer* client); + /** * Sends a ID_ROOM_VERSION_MISMATCH message telling the client that the version is invalid. */ @@ -94,17 +155,56 @@ public: */ void SendJoinSuccess(ENetPeer* client, MacAddress mac_address); + /** + * Notifies the member that its connection attempt was successful, + * and it is now part of the room, and it has been granted mod permissions. + */ + void SendJoinSuccessAsMod(ENetPeer* client, MacAddress mac_address); + + /** + * Sends a IdHostKicked message telling the client that they have been kicked. + */ + void SendUserKicked(ENetPeer* client); + + /** + * Sends a IdHostBanned message telling the client that they have been banned. + */ + void SendUserBanned(ENetPeer* client); + + /** + * Sends a IdModPermissionDenied message telling the client that they do not have mod + * permission. + */ + void SendModPermissionDenied(ENetPeer* client); + + /** + * Sends a IdModNoSuchUser message telling the client that the given user could not be found. + */ + void SendModNoSuchUser(ENetPeer* client); + + /** + * Sends the ban list in response to a client's request for getting ban list. + */ + void SendModBanListResponse(ENetPeer* client); + /** * Notifies the members that the room is closed, */ void SendCloseMessage(); + /** + * Sends a system message to all the connected clients. + */ + void SendStatusMessage(StatusMessageTypes type, const std::string& nickname, + const std::string& username); + /** * Sends the information about the room, along with the list of members * to every connected client in the room. * The packet has the structure: * ID_ROOM_INFORMATION * room_name + * room_description * member_slots: The max number of clients allowed in this room * uid * port @@ -145,11 +245,6 @@ public: * to all other clients. */ void HandleClientDisconnection(ENetPeer* client); - - /** - * Creates a random ID in the form 12345678-1234-1234-1234-123456789012 - */ - void CreateUniqueID(); }; // RoomImpl @@ -172,6 +267,19 @@ void Room::RoomImpl::ServerLoop() { case IdChatMessage: HandleChatPacket(&event); break; + // Moderation + case IdModKick: + HandleModKickPacket(&event); + break; + case IdModBan: + HandleModBanPacket(&event); + break; + case IdModUnban: + HandleModUnbanPacket(&event); + break; + case IdModGetBanList: + HandleModGetBanListPacket(&event); + break; } enet_packet_destroy(event.packet); break; @@ -193,12 +301,22 @@ void Room::RoomImpl::StartLoop() { } void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) { + { + std::lock_guard lock(member_mutex); + if (members.size() >= room_information.member_slots) { + SendRoomIsFull(event->peer); + return; + } + } Packet packet; packet.Append(event->packet->data, event->packet->dataLength); packet.IgnoreBytes(sizeof(u8)); // Ignore the message type std::string nickname; packet >> nickname; + std::string console_id_hash; + packet >> console_id_hash; + MacAddress preferred_mac; packet >> preferred_mac; @@ -208,6 +326,9 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) { std::string pass; packet >> pass; + std::string token; + packet >> token; + if (pass != password) { SendWrongPassword(event->peer); return; @@ -229,6 +350,11 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) { preferred_mac = GenerateMacAddress(); } + if (!IsValidConsoleId(console_id_hash)) { + SendConsoleIdCollision(event->peer); + return; + } + if (client_version != network_version) { SendVersionMismatch(event->peer); return; @@ -237,9 +363,43 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) { // At this point the client is ready to be added to the room. Member member{}; member.mac_address = preferred_mac; + member.console_id_hash = console_id_hash; member.nickname = nickname; member.peer = event->peer; + std::string uid; + { + std::lock_guard lock(verify_UID_mutex); + uid = verify_UID; + } + member.user_data = verify_backend->LoadUserData(uid, token); + + { + std::lock_guard lock(ban_list_mutex); + + // Check username ban + if (!member.user_data.username.empty() && + std::find(username_ban_list.begin(), username_ban_list.end(), + member.user_data.username) != username_ban_list.end()) { + + SendUserBanned(event->peer); + return; + } + + // Check IP ban + char ip_raw[256]; + enet_address_get_host_ip(&event->peer->address, ip_raw, sizeof(ip_raw) - 1); + std::string ip = ip_raw; + + if (std::find(ip_ban_list.begin(), ip_ban_list.end(), ip) != ip_ban_list.end()) { + SendUserBanned(event->peer); + return; + } + } + + // Notify everyone that the user has joined. + SendStatusMessage(IdMemberJoin, member.nickname, member.user_data.username); + { std::lock_guard lock(member_mutex); members.push_back(std::move(member)); @@ -247,12 +407,167 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) { // Notify everyone that the room information has changed. BroadcastRoomInformation(); - SendJoinSuccess(event->peer, preferred_mac); + if (HasModPermission(event->peer)) { + SendJoinSuccessAsMod(event->peer, preferred_mac); + } else { + SendJoinSuccess(event->peer, preferred_mac); + } +} + +void Room::RoomImpl::HandleModKickPacket(const ENetEvent* event) { + if (!HasModPermission(event->peer)) { + SendModPermissionDenied(event->peer); + return; + } + + Packet packet; + packet.Append(event->packet->data, event->packet->dataLength); + packet.IgnoreBytes(sizeof(u8)); // Ignore the message type + + std::string nickname; + packet >> nickname; + + std::string username; + { + std::lock_guard lock(member_mutex); + const auto target_member = + std::find_if(members.begin(), members.end(), + [&nickname](const auto& member) { return member.nickname == nickname; }); + if (target_member == members.end()) { + SendModNoSuchUser(event->peer); + return; + } + + // Notify the kicked member + SendUserKicked(target_member->peer); + + username = target_member->user_data.username; + + enet_peer_disconnect(target_member->peer, 0); + members.erase(target_member); + } + + // Announce the change to all clients. + SendStatusMessage(IdMemberKicked, nickname, username); + BroadcastRoomInformation(); +} + +void Room::RoomImpl::HandleModBanPacket(const ENetEvent* event) { + if (!HasModPermission(event->peer)) { + SendModPermissionDenied(event->peer); + return; + } + + Packet packet; + packet.Append(event->packet->data, event->packet->dataLength); + packet.IgnoreBytes(sizeof(u8)); // Ignore the message type + + std::string nickname; + packet >> nickname; + + std::string username; + std::string ip; + + { + std::lock_guard lock(member_mutex); + const auto target_member = + std::find_if(members.begin(), members.end(), + [&nickname](const auto& member) { return member.nickname == nickname; }); + if (target_member == members.end()) { + SendModNoSuchUser(event->peer); + return; + } + + // Notify the banned member + SendUserBanned(target_member->peer); + + nickname = target_member->nickname; + username = target_member->user_data.username; + + char ip_raw[256]; + enet_address_get_host_ip(&target_member->peer->address, ip_raw, sizeof(ip_raw) - 1); + ip = ip_raw; + + enet_peer_disconnect(target_member->peer, 0); + members.erase(target_member); + } + + { + std::lock_guard lock(ban_list_mutex); + + if (!username.empty()) { + // Ban the forum username + if (std::find(username_ban_list.begin(), username_ban_list.end(), username) == + username_ban_list.end()) { + + username_ban_list.emplace_back(username); + } + } + + // Ban the member's IP as well + if (std::find(ip_ban_list.begin(), ip_ban_list.end(), ip) == ip_ban_list.end()) { + ip_ban_list.emplace_back(ip); + } + } + + // Announce the change to all clients. + SendStatusMessage(IdMemberBanned, nickname, username); + BroadcastRoomInformation(); +} + +void Room::RoomImpl::HandleModUnbanPacket(const ENetEvent* event) { + if (!HasModPermission(event->peer)) { + SendModPermissionDenied(event->peer); + return; + } + + Packet packet; + packet.Append(event->packet->data, event->packet->dataLength); + packet.IgnoreBytes(sizeof(u8)); // Ignore the message type + + std::string address; + packet >> address; + + bool unbanned = false; + { + std::lock_guard lock(ban_list_mutex); + + auto it = std::find(username_ban_list.begin(), username_ban_list.end(), address); + if (it != username_ban_list.end()) { + unbanned = true; + username_ban_list.erase(it); + } + + it = std::find(ip_ban_list.begin(), ip_ban_list.end(), address); + if (it != ip_ban_list.end()) { + unbanned = true; + ip_ban_list.erase(it); + } + } + + if (unbanned) { + SendStatusMessage(IdAddressUnbanned, address, ""); + } else { + SendModNoSuchUser(event->peer); + } +} + +void Room::RoomImpl::HandleModGetBanListPacket(const ENetEvent* event) { + if (!HasModPermission(event->peer)) { + SendModPermissionDenied(event->peer); + return; + } + + SendModBanListResponse(event->peer); } bool Room::RoomImpl::IsValidNickname(const std::string& nickname) const { - // A nickname is valid if it is not already taken by anybody else in the room. - // TODO(B3N30): Check for empty names, spaces, etc. + // A nickname is valid if it matches the regex and is not already taken by anybody else in the + // room. + const std::regex nickname_regex("^[ a-zA-Z0-9._-]{4,20}$"); + if (!std::regex_match(nickname, nickname_regex)) + return false; + std::lock_guard lock(member_mutex); return std::all_of(members.begin(), members.end(), [&nickname](const auto& member) { return member.nickname != nickname; }); @@ -265,6 +580,35 @@ bool Room::RoomImpl::IsValidMacAddress(const MacAddress& address) const { [&address](const auto& member) { return member.mac_address != address; }); } +bool Room::RoomImpl::IsValidConsoleId(const std::string& console_id_hash) const { + // A Console ID is valid if it is not already taken by anybody else in the room. + std::lock_guard lock(member_mutex); + return std::all_of(members.begin(), members.end(), [&console_id_hash](const auto& member) { + return member.console_id_hash != console_id_hash; + }); +} + +bool Room::RoomImpl::HasModPermission(const ENetPeer* client) const { + std::lock_guard lock(member_mutex); + const auto sending_member = + std::find_if(members.begin(), members.end(), + [client](const auto& member) { return member.peer == client; }); + if (sending_member == members.end()) { + return false; + } + if (room_information.enable_citra_mods && + sending_member->user_data.moderator) { // Community moderator + + return true; + } + if (!room_information.host_username.empty() && + sending_member->user_data.username == room_information.host_username) { // Room host + + return true; + } + return false; +} + void Room::RoomImpl::SendNameCollision(ENetPeer* client) { Packet packet; packet << static_cast(IdNameCollision); @@ -285,6 +629,16 @@ void Room::RoomImpl::SendMacCollision(ENetPeer* client) { enet_host_flush(server); } +void Room::RoomImpl::SendConsoleIdCollision(ENetPeer* client) { + Packet packet; + packet << static_cast(IdConsoleIdCollision); + + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + void Room::RoomImpl::SendWrongPassword(ENetPeer* client) { Packet packet; packet << static_cast(IdWrongPassword); @@ -295,6 +649,16 @@ void Room::RoomImpl::SendWrongPassword(ENetPeer* client) { enet_host_flush(server); } +void Room::RoomImpl::SendRoomIsFull(ENetPeer* client) { + Packet packet; + packet << static_cast(IdRoomIsFull); + + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + void Room::RoomImpl::SendVersionMismatch(ENetPeer* client) { Packet packet; packet << static_cast(IdVersionMismatch); @@ -316,6 +680,71 @@ void Room::RoomImpl::SendJoinSuccess(ENetPeer* client, MacAddress mac_address) { enet_host_flush(server); } +void Room::RoomImpl::SendJoinSuccessAsMod(ENetPeer* client, MacAddress mac_address) { + Packet packet; + packet << static_cast(IdJoinSuccessAsMod); + packet << mac_address; + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + +void Room::RoomImpl::SendUserKicked(ENetPeer* client) { + Packet packet; + packet << static_cast(IdHostKicked); + + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + +void Room::RoomImpl::SendUserBanned(ENetPeer* client) { + Packet packet; + packet << static_cast(IdHostBanned); + + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + +void Room::RoomImpl::SendModPermissionDenied(ENetPeer* client) { + Packet packet; + packet << static_cast(IdModPermissionDenied); + + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + +void Room::RoomImpl::SendModNoSuchUser(ENetPeer* client) { + Packet packet; + packet << static_cast(IdModNoSuchUser); + + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + +void Room::RoomImpl::SendModBanListResponse(ENetPeer* client) { + Packet packet; + packet << static_cast(IdModBanListResponse); + { + std::lock_guard lock(ban_list_mutex); + packet << username_ban_list; + packet << ip_ban_list; + } + + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + enet_peer_send(client, 0, enet_packet); + enet_host_flush(server); +} + void Room::RoomImpl::SendCloseMessage() { Packet packet; packet << static_cast(IdCloseRoom); @@ -333,14 +762,33 @@ void Room::RoomImpl::SendCloseMessage() { } } +void Room::RoomImpl::SendStatusMessage(StatusMessageTypes type, const std::string& nickname, + const std::string& username) { + Packet packet; + packet << static_cast(IdStatusMessage); + packet << static_cast(type); + packet << nickname; + packet << username; + std::lock_guard lock(member_mutex); + if (!members.empty()) { + ENetPacket* enet_packet = + enet_packet_create(packet.GetData(), packet.GetDataSize(), ENET_PACKET_FLAG_RELIABLE); + for (auto& member : members) { + enet_peer_send(member.peer, 0, enet_packet); + } + } + enet_host_flush(server); +} + void Room::RoomImpl::BroadcastRoomInformation() { Packet packet; packet << static_cast(IdRoomInformation); packet << room_information.name; + packet << room_information.description; packet << room_information.member_slots; - packet << room_information.uid; packet << room_information.port; packet << room_information.preferred_game; + packet << room_information.host_username; packet << static_cast(members.size()); { @@ -350,6 +798,9 @@ void Room::RoomImpl::BroadcastRoomInformation() { packet << member.mac_address; packet << member.game_info.name; packet << member.game_info.id; + packet << member.user_data.username; + packet << member.user_data.display_name; + packet << member.user_data.avatar_url; } } @@ -442,6 +893,7 @@ void Room::RoomImpl::HandleChatPacket(const ENetEvent* event) { Packet out_packet; out_packet << static_cast(IdChatMessage); out_packet << sending_member->nickname; + out_packet << sending_member->user_data.username; out_packet << message; ENetPacket* enet_packet = enet_packet_create(out_packet.GetData(), out_packet.GetDataSize(), @@ -485,41 +937,37 @@ void Room::RoomImpl::HandleGameNamePacket(const ENetEvent* event) { void Room::RoomImpl::HandleClientDisconnection(ENetPeer* client) { // Remove the client from the members list. + std::string nickname, username; { std::lock_guard lock(member_mutex); - members.erase( - std::remove_if(members.begin(), members.end(), - [client](const Member& member) { return member.peer == client; }), - members.end()); + auto member = std::find_if(members.begin(), members.end(), [client](const Member& member) { + return member.peer == client; + }); + if (member != members.end()) { + nickname = member->nickname; + username = member->user_data.username; + members.erase(member); + } } // Announce the change to all clients. enet_peer_disconnect(client, 0); + if (!nickname.empty()) + SendStatusMessage(IdMemberLeave, nickname, username); BroadcastRoomInformation(); } -void Room::RoomImpl::CreateUniqueID() { - std::uniform_int_distribution<> dis(0, 9999); - std::ostringstream stream; - stream << std::setfill('0') << std::setw(4) << dis(random_gen); - stream << std::setfill('0') << std::setw(4) << dis(random_gen) << "-"; - stream << std::setfill('0') << std::setw(4) << dis(random_gen) << "-"; - stream << std::setfill('0') << std::setw(4) << dis(random_gen) << "-"; - stream << std::setfill('0') << std::setw(4) << dis(random_gen) << "-"; - stream << std::setfill('0') << std::setw(4) << dis(random_gen); - stream << std::setfill('0') << std::setw(4) << dis(random_gen); - stream << std::setfill('0') << std::setw(4) << dis(random_gen); - room_information.uid = stream.str(); -} - // Room Room::Room() : room_impl{std::make_unique()} {} Room::~Room() = default; -bool Room::Create(const std::string& name, const std::string& server_address, u16 server_port, - const std::string& password, const u32 max_connections, - const std::string& preferred_game, u64 preferred_game_id) { +bool Room::Create(const std::string& name, const std::string& description, + const std::string& server_address, u16 server_port, const std::string& password, + const u32 max_connections, const std::string& host_username, + const std::string& preferred_game, u64 preferred_game_id, + std::unique_ptr verify_backend, + const Room::BanList& ban_list, bool enable_citra_mods) { ENetAddress address; address.host = ENET_HOST_ANY; if (!server_address.empty()) { @@ -527,19 +975,26 @@ bool Room::Create(const std::string& name, const std::string& server_address, u1 } address.port = server_port; - room_impl->server = enet_host_create(&address, max_connections, NumChannels, 0, 0); + // In order to send the room is full message to the connecting client, we need to leave one + // slot open so enet won't reject the incoming connection without telling us + room_impl->server = enet_host_create(&address, max_connections + 1, NumChannels, 0, 0); if (!room_impl->server) { return false; } room_impl->state = State::Open; room_impl->room_information.name = name; + room_impl->room_information.description = description; room_impl->room_information.member_slots = max_connections; room_impl->room_information.port = server_port; room_impl->room_information.preferred_game = preferred_game; room_impl->room_information.preferred_game_id = preferred_game_id; + room_impl->room_information.host_username = host_username; + room_impl->room_information.enable_citra_mods = enable_citra_mods; room_impl->password = password; - room_impl->CreateUniqueID(); + room_impl->verify_backend = std::move(verify_backend); + room_impl->username_ban_list = ban_list.first; + room_impl->ip_ban_list = ban_list.second; room_impl->StartLoop(); return true; @@ -553,12 +1008,25 @@ const RoomInformation& Room::GetRoomInformation() const { return room_impl->room_information; } +std::string Room::GetVerifyUID() const { + std::lock_guard lock(room_impl->verify_UID_mutex); + return room_impl->verify_UID; +} + +Room::BanList Room::GetBanList() const { + std::lock_guard lock(room_impl->ban_list_mutex); + return {room_impl->username_ban_list, room_impl->ip_ban_list}; +} + std::vector Room::GetRoomMemberList() const { std::vector member_list; std::lock_guard lock(room_impl->member_mutex); for (const auto& member_impl : room_impl->members) { Member member; member.nickname = member_impl.nickname; + member.username = member_impl.user_data.username; + member.display_name = member_impl.user_data.display_name; + member.avatar_url = member_impl.user_data.avatar_url; member.mac_address = member_impl.mac_address; member.game_info = member_impl.game_info; member_list.push_back(member); @@ -570,6 +1038,11 @@ bool Room::HasPassword() const { return !room_impl->password.empty(); } +void Room::SetVerifyUID(const std::string& uid) { + std::lock_guard lock(room_impl->verify_UID_mutex); + room_impl->verify_UID = uid; +} + void Room::Destroy() { room_impl->state = State::Closed; room_impl->room_thread->join(); diff --git a/src/network/room.h b/src/network/room.h index bff190c55..a67984837 100644 --- a/src/network/room.h +++ b/src/network/room.h @@ -9,10 +9,11 @@ #include #include #include "common/common_types.h" +#include "network/verify_user.h" namespace Network { -constexpr u32 network_version = 3; ///< The version of this Room and RoomMember +constexpr u32 network_version = 4; ///< The version of this Room and RoomMember constexpr u16 DefaultRoomPort = 24872; @@ -25,11 +26,13 @@ constexpr std::size_t NumChannels = 1; // Number of channels used for the connec struct RoomInformation { std::string name; ///< Name of the server + std::string description; ///< Server description u32 member_slots; ///< Maximum number of members in this room - std::string uid; ///< The unique ID of the room u16 port; ///< The port of this room std::string preferred_game; ///< Game to advertise that you want to play u64 preferred_game_id; ///< Title ID for the advertised game + std::string host_username; ///< Forum username of the host + bool enable_citra_mods; ///< Allow Citra Moderators to moderate on this room }; struct GameInfo { @@ -57,7 +60,31 @@ enum RoomMessageTypes : u8 { IdMacCollision, IdVersionMismatch, IdWrongPassword, - IdCloseRoom + IdCloseRoom, + IdRoomIsFull, + IdConsoleIdCollision, + IdStatusMessage, + IdHostKicked, + IdHostBanned, + /// Moderation requests + IdModKick, + IdModBan, + IdModUnban, + IdModGetBanList, + // Moderation responses + IdModBanListResponse, + IdModPermissionDenied, + IdModNoSuchUser, + IdJoinSuccessAsMod, +}; + +/// Types of system status messages +enum StatusMessageTypes : u8 { + IdMemberJoin = 1, ///< Member joining + IdMemberLeave, ///< Member leaving + IdMemberKicked, ///< A member is kicked from the room + IdMemberBanned, ///< A member is banned from the room + IdAddressUnbanned, ///< A username / ip address is unbanned from the room }; /// This is what a server [person creating a server] would use. @@ -69,9 +96,12 @@ public: }; struct Member { - std::string nickname; ///< The nickname of the member. - GameInfo game_info; ///< The current game of the member - MacAddress mac_address; ///< The assigned mac address of the member. + std::string nickname; ///< The nickname of the member. + std::string username; ///< The web services username of the member. Can be empty. + std::string display_name; ///< The web services display name of the member. Can be empty. + std::string avatar_url; ///< Url to the member's avatar. Can be empty. + GameInfo game_info; ///< The current game of the member + MacAddress mac_address; ///< The assigned mac address of the member. }; Room(); @@ -87,6 +117,11 @@ public: */ const RoomInformation& GetRoomInformation() const; + /** + * Gets the verify UID of this room. + */ + std::string GetVerifyUID() const; + /** * Gets a list of the mbmers connected to the room. */ @@ -97,14 +132,33 @@ public: */ bool HasPassword() const; + using UsernameBanList = std::vector; + using IPBanList = std::vector; + + using BanList = std::pair; + /** * Creates the socket for this room. Will bind to default address if * server is empty string. */ - bool Create(const std::string& name, const std::string& server = "", - u16 server_port = DefaultRoomPort, const std::string& password = "", + bool Create(const std::string& name, const std::string& description = "", + const std::string& server = "", u16 server_port = DefaultRoomPort, + const std::string& password = "", const u32 max_connections = MaxConcurrentConnections, - const std::string& preferred_game = "", u64 preferred_game_id = 0); + const std::string& host_username = "", const std::string& preferred_game = "", + u64 preferred_game_id = 0, + std::unique_ptr verify_backend = nullptr, + const BanList& ban_list = {}, bool enable_citra_mods = false); + + /** + * Sets the verification GUID of the room. + */ + void SetVerifyUID(const std::string& uid); + + /** + * Gets the ban list (including banned forum usernames and IPs) of the room. + */ + BanList GetBanList() const; /** * Destroys the socket diff --git a/src/network/room_member.cpp b/src/network/room_member.cpp index 601c67b0e..8a846ee04 100644 --- a/src/network/room_member.cpp +++ b/src/network/room_member.cpp @@ -31,9 +31,14 @@ public: std::atomic state{State::Idle}; ///< Current state of the RoomMember. void SetState(const State new_state); + void SetError(const Error new_error); bool IsConnected() const; - std::string nickname; ///< The nickname of this member. + std::string nickname; ///< The nickname of this member. + + std::string username; ///< The username of this member. + mutable std::mutex username_mutex; ///< Mutex for locking username. + MacAddress mac_address; ///< The mac_address of this member. std::mutex network_mutex; ///< Mutex that controls access to the `client` variable. @@ -54,8 +59,11 @@ public: private: CallbackSet callback_set_wifi_packet; CallbackSet callback_set_chat_messages; + CallbackSet callback_set_status_messages; CallbackSet callback_set_room_information; CallbackSet callback_set_state; + CallbackSet callback_set_error; + CallbackSet callback_set_ban_list; }; Callbacks callbacks; ///< All CallbackSets to all events @@ -73,13 +81,14 @@ public: * Sends a request to the server, asking for permission to join a room with the specified * nickname and preferred mac. * @params nickname The desired nickname. + * @params console_id_hash A hash of the Console ID. * @params preferred_mac The preferred MAC address to use in the room, the NoPreferredMac tells * @params password The password for the room * the server to assign one for us. */ - void SendJoinRequest(const std::string& nickname, + void SendJoinRequest(const std::string& nickname, const std::string& console_id_hash, const MacAddress& preferred_mac = NoPreferredMac, - const std::string& password = ""); + const std::string& password = "", const std::string& token = ""); /** * Extracts a MAC Address from a received ENet packet. @@ -104,6 +113,19 @@ public: */ void HandleChatPacket(const ENetEvent* event); + /** + * Extracts a system message entry from a received ENet packet and adds it to the system message + * queue. + * @param event The ENet event that was received. + */ + void HandleStatusMessagePacket(const ENetEvent* event); + + /** + * Extracts a ban list request response from a received ENet packet. + * @param event The ENet event that was received. + */ + void HandleModBanListResponsePacket(const ENetEvent* event); + /** * Disconnects the RoomMember from the Room */ @@ -124,8 +146,12 @@ void RoomMember::RoomMemberImpl::SetState(const State new_state) { } } +void RoomMember::RoomMemberImpl::SetError(const Error new_error) { + Invoke(new_error); +} + bool RoomMember::RoomMemberImpl::IsConnected() const { - return state == State::Joining || state == State::Joined; + return state == State::Joining || state == State::Joined || state == State::Moderator; } void RoomMember::RoomMemberImpl::MemberLoop() { @@ -143,37 +169,78 @@ void RoomMember::RoomMemberImpl::MemberLoop() { case IdChatMessage: HandleChatPacket(&event); break; + case IdStatusMessage: + HandleStatusMessagePacket(&event); + break; case IdRoomInformation: HandleRoomInformationPacket(&event); break; case IdJoinSuccess: + case IdJoinSuccessAsMod: // The join request was successful, we are now in the room. // If we joined successfully, there must be at least one client in the room: us. ASSERT_MSG(member_information.size() > 0, "We have not yet received member information."); HandleJoinPacket(&event); // Get the MAC Address for the client - SetState(State::Joined); + if (event.packet->data[0] == IdJoinSuccessAsMod) { + SetState(State::Moderator); + } else { + SetState(State::Joined); + } + break; + case IdModBanListResponse: + HandleModBanListResponsePacket(&event); + break; + case IdRoomIsFull: + SetState(State::Idle); + SetError(Error::RoomIsFull); break; case IdNameCollision: - SetState(State::NameCollision); + SetState(State::Idle); + SetError(Error::NameCollision); break; case IdMacCollision: - SetState(State::MacCollision); + SetState(State::Idle); + SetError(Error::MacCollision); + break; + case IdConsoleIdCollision: + SetState(State::Idle); + SetError(Error::ConsoleIdCollision); break; case IdVersionMismatch: - SetState(State::WrongVersion); + SetState(State::Idle); + SetError(Error::WrongVersion); break; case IdWrongPassword: - SetState(State::WrongPassword); + SetState(State::Idle); + SetError(Error::WrongPassword); break; case IdCloseRoom: - SetState(State::LostConnection); + SetState(State::Idle); + SetError(Error::LostConnection); + break; + case IdHostKicked: + SetState(State::Idle); + SetError(Error::HostKicked); + break; + case IdHostBanned: + SetState(State::Idle); + SetError(Error::HostBanned); + break; + case IdModPermissionDenied: + SetError(Error::PermissionDenied); + break; + case IdModNoSuchUser: + SetError(Error::NoSuchUser); break; } enet_packet_destroy(event.packet); break; case ENET_EVENT_TYPE_DISCONNECT: - SetState(State::LostConnection); + if (state == State::Joined || state == State::Moderator) { + SetState(State::Idle); + SetError(Error::LostConnection); + } break; } } @@ -201,14 +268,18 @@ void RoomMember::RoomMemberImpl::Send(Packet&& packet) { } void RoomMember::RoomMemberImpl::SendJoinRequest(const std::string& nickname, + const std::string& console_id_hash, const MacAddress& preferred_mac, - const std::string& password) { + const std::string& password, + const std::string& token) { Packet packet; packet << static_cast(IdJoinRequest); packet << nickname; + packet << console_id_hash; packet << preferred_mac; packet << network_version; packet << password; + packet << token; Send(std::move(packet)); } @@ -221,14 +292,17 @@ void RoomMember::RoomMemberImpl::HandleRoomInformationPacket(const ENetEvent* ev RoomInformation info{}; packet >> info.name; + packet >> info.description; packet >> info.member_slots; - packet >> info.uid; packet >> info.port; packet >> info.preferred_game; + packet >> info.host_username; room_information.name = info.name; + room_information.description = info.description; room_information.member_slots = info.member_slots; room_information.port = info.port; room_information.preferred_game = info.preferred_game; + room_information.host_username = info.host_username; u32 num_members; packet >> num_members; @@ -239,6 +313,16 @@ void RoomMember::RoomMemberImpl::HandleRoomInformationPacket(const ENetEvent* ev packet >> member.mac_address; packet >> member.game_info.name; packet >> member.game_info.id; + packet >> member.username; + packet >> member.display_name; + packet >> member.avatar_url; + + { + std::lock_guard lock(username_mutex); + if (member.nickname == nickname) { + username = member.username; + } + } } Invoke(room_information); } @@ -252,7 +336,6 @@ void RoomMember::RoomMemberImpl::HandleJoinPacket(const ENetEvent* event) { // Parse the MAC Address from the packet packet >> mac_address; - SetState(State::Joined); } void RoomMember::RoomMemberImpl::HandleWifiPackets(const ENetEvent* event) { @@ -286,10 +369,40 @@ void RoomMember::RoomMemberImpl::HandleChatPacket(const ENetEvent* event) { ChatEntry chat_entry{}; packet >> chat_entry.nickname; + packet >> chat_entry.username; packet >> chat_entry.message; Invoke(chat_entry); } +void RoomMember::RoomMemberImpl::HandleStatusMessagePacket(const ENetEvent* event) { + Packet packet; + packet.Append(event->packet->data, event->packet->dataLength); + + // Ignore the first byte, which is the message id. + packet.IgnoreBytes(sizeof(u8)); + + StatusMessageEntry status_message_entry{}; + u8 type{}; + packet >> type; + status_message_entry.type = static_cast(type); + packet >> status_message_entry.nickname; + packet >> status_message_entry.username; + Invoke(status_message_entry); +} + +void RoomMember::RoomMemberImpl::HandleModBanListResponsePacket(const ENetEvent* event) { + Packet packet; + packet.Append(event->packet->data, event->packet->dataLength); + + // Ignore the first byte, which is the message id. + packet.IgnoreBytes(sizeof(u8)); + + Room::BanList ban_list = {}; + packet >> ban_list.first; + packet >> ban_list.second; + Invoke(ban_list); +} + void RoomMember::RoomMemberImpl::Disconnect() { member_information.clear(); room_information.member_slots = 0; @@ -329,6 +442,12 @@ RoomMember::RoomMemberImpl::Callbacks::Get() { return callback_set_state; } +template <> +RoomMember::RoomMemberImpl::CallbackSet& +RoomMember::RoomMemberImpl::Callbacks::Get() { + return callback_set_error; +} + template <> RoomMember::RoomMemberImpl::CallbackSet& RoomMember::RoomMemberImpl::Callbacks::Get() { @@ -340,6 +459,18 @@ RoomMember::RoomMemberImpl::CallbackSet& RoomMember::RoomMemberImpl:: return callback_set_chat_messages; } +template <> +RoomMember::RoomMemberImpl::CallbackSet& +RoomMember::RoomMemberImpl::Callbacks::Get() { + return callback_set_status_messages; +} + +template <> +RoomMember::RoomMemberImpl::CallbackSet& +RoomMember::RoomMemberImpl::Callbacks::Get() { + return callback_set_ban_list; +} + template void RoomMember::RoomMemberImpl::Invoke(const T& data) { std::lock_guard lock(callback_mutex); @@ -380,6 +511,11 @@ const std::string& RoomMember::GetNickname() const { return room_member_impl->nickname; } +const std::string& RoomMember::GetUsername() const { + std::lock_guard lock(room_member_impl->username_mutex); + return room_member_impl->username; +} + const MacAddress& RoomMember::GetMacAddress() const { ASSERT_MSG(IsConnected(), "Tried to get MAC address while not connected"); return room_member_impl->mac_address; @@ -389,9 +525,10 @@ RoomInformation RoomMember::GetRoomInformation() const { return room_member_impl->room_information; } -void RoomMember::Join(const std::string& nick, const char* server_addr, u16 server_port, - u16 client_port, const MacAddress& preferred_mac, - const std::string& password) { +void RoomMember::Join(const std::string& nick, const std::string& console_id_hash, + const char* server_addr, u16 server_port, u16 client_port, + const MacAddress& preferred_mac, const std::string& password, + const std::string& token) { // If the member is connected, kill the connection first if (room_member_impl->loop_thread && room_member_impl->loop_thread->joinable()) { Leave(); @@ -415,7 +552,8 @@ void RoomMember::Join(const std::string& nick, const char* server_addr, u16 serv enet_host_connect(room_member_impl->client, &address, NumChannels, 0); if (!room_member_impl->server) { - room_member_impl->SetState(State::Error); + room_member_impl->SetState(State::Idle); + room_member_impl->SetError(Error::UnknownError); return; } @@ -424,11 +562,12 @@ void RoomMember::Join(const std::string& nick, const char* server_addr, u16 serv if (net > 0 && event.type == ENET_EVENT_TYPE_CONNECT) { room_member_impl->nickname = nick; room_member_impl->StartLoop(); - room_member_impl->SendJoinRequest(nick, preferred_mac, password); + room_member_impl->SendJoinRequest(nick, console_id_hash, preferred_mac, password, token); SendGameInfo(room_member_impl->current_game_info); } else { enet_peer_disconnect(room_member_impl->server, 0); - room_member_impl->SetState(State::CouldNotConnect); + room_member_impl->SetState(State::Idle); + room_member_impl->SetError(Error::CouldNotConnect); } } @@ -466,11 +605,37 @@ void RoomMember::SendGameInfo(const GameInfo& game_info) { room_member_impl->Send(std::move(packet)); } +void RoomMember::SendModerationRequest(RoomMessageTypes type, const std::string& nickname) { + ASSERT_MSG(type == IdModKick || type == IdModBan || type == IdModUnban, + "type is not a moderation request"); + if (!IsConnected()) + return; + + Packet packet; + packet << static_cast(type); + packet << nickname; + room_member_impl->Send(std::move(packet)); +} + +void RoomMember::RequestBanList() { + if (!IsConnected()) + return; + + Packet packet; + packet << static_cast(IdModGetBanList); + room_member_impl->Send(std::move(packet)); +} + RoomMember::CallbackHandle RoomMember::BindOnStateChanged( std::function callback) { return room_member_impl->Bind(callback); } +RoomMember::CallbackHandle RoomMember::BindOnError( + std::function callback) { + return room_member_impl->Bind(callback); +} + RoomMember::CallbackHandle RoomMember::BindOnWifiPacketReceived( std::function callback) { return room_member_impl->Bind(callback); @@ -486,6 +651,16 @@ RoomMember::CallbackHandle RoomMember::BindOnChatMessageRecieved( return room_member_impl->Bind(callback); } +RoomMember::CallbackHandle RoomMember::BindOnStatusMessageReceived( + std::function callback) { + return room_member_impl->Bind(callback); +} + +RoomMember::CallbackHandle RoomMember::BindOnBanListReceived( + std::function callback) { + return room_member_impl->Bind(callback); +} + template void RoomMember::Unbind(CallbackHandle handle) { std::lock_guard lock(room_member_impl->callback_mutex); @@ -503,7 +678,10 @@ void RoomMember::Leave() { template void RoomMember::Unbind(CallbackHandle); template void RoomMember::Unbind(CallbackHandle); +template void RoomMember::Unbind(CallbackHandle); template void RoomMember::Unbind(CallbackHandle); template void RoomMember::Unbind(CallbackHandle); +template void RoomMember::Unbind(CallbackHandle); +template void RoomMember::Unbind(CallbackHandle); } // namespace Network diff --git a/src/network/room_member.h b/src/network/room_member.h index 12cff102a..3410abac1 100644 --- a/src/network/room_member.h +++ b/src/network/room_member.h @@ -35,7 +35,17 @@ struct WifiPacket { /// Represents a chat message. struct ChatEntry { std::string nickname; ///< Nickname of the client who sent this message. - std::string message; ///< Body of the message. + /// Web services username of the client who sent this message, can be empty. + std::string username; + std::string message; ///< Body of the message. +}; + +/// Represents a system status message. +struct StatusMessageEntry { + StatusMessageTypes type; ///< Type of the message + /// Subject of the message. i.e. the user who is joining/leaving/being banned, etc. + std::string nickname; + std::string username; }; /** @@ -47,22 +57,38 @@ class RoomMember final { public: enum class State : u8 { Uninitialized, ///< Not initialized - Idle, ///< Default state - Error, ///< Some error [permissions to network device missing or something] + Idle, ///< Default state (i.e. not connected) Joining, ///< The client is attempting to join a room. - Joined, ///< The client is connected to the room and is ready to send/receive packets. + Joined, ///< The client is connected to the room and is ready to send/receive packets. + Moderator, ///< The client is connnected to the room and is granted mod permissions. + }; + + enum class Error : u8 { + // Reasons why connection was closed LostConnection, ///< Connection closed + HostKicked, ///< Kicked by the host // Reasons why connection was rejected - NameCollision, ///< Somebody is already using this name - MacCollision, ///< Somebody is already using that mac-address - WrongVersion, ///< The room version is not the same as for this RoomMember - WrongPassword, ///< The password doesn't match the one from the Room - CouldNotConnect ///< The room is not responding to a connection attempt + UnknownError, ///< Some error [permissions to network device missing or something] + NameCollision, ///< Somebody is already using this name + MacCollision, ///< Somebody is already using that mac-address + ConsoleIdCollision, ///< Somebody in the room has the same Console ID + WrongVersion, ///< The room version is not the same as for this RoomMember + WrongPassword, ///< The password doesn't match the one from the Room + CouldNotConnect, ///< The room is not responding to a connection attempt + RoomIsFull, ///< Room is already at the maximum number of players + HostBanned, ///< The user is banned by the host + + // Reasons why moderation request failed + PermissionDenied, ///< The user does not have mod permissions + NoSuchUser, ///< The nickname the user attempts to kick/ban does not exist }; struct MemberInformation { - std::string nickname; ///< Nickname of the member. + std::string nickname; ///< Nickname of the member. + std::string username; ///< The web services username of the member. Can be empty. + std::string display_name; ///< The web services display name of the member. Can be empty. + std::string avatar_url; ///< Url to the member's avatar. Can be empty. GameInfo game_info; ///< Name of the game they're currently playing, or empty if they're /// not playing anything. MacAddress mac_address; ///< MAC address associated with this member. @@ -98,6 +124,11 @@ public: */ const std::string& GetNickname() const; + /** + * Returns the username of the RoomMember. + */ + const std::string& GetUsername() const; + /** * Returns the MAC address of the RoomMember. */ @@ -115,11 +146,13 @@ public: /** * Attempts to join a room at the specified address and port, using the specified nickname. - * This may fail if the username is already taken. + * A console ID hash is passed in to check console ID conflicts. + * This may fail if the username or console ID is already taken. */ - void Join(const std::string& nickname, const char* server_addr = "127.0.0.1", - const u16 server_port = DefaultRoomPort, const u16 client_port = 0, - const MacAddress& preferred_mac = NoPreferredMac, const std::string& password = ""); + void Join(const std::string& nickname, const std::string& console_id_hash, + const char* server_addr = "127.0.0.1", const u16 server_port = DefaultRoomPort, + const u16 client_port = 0, const MacAddress& preferred_mac = NoPreferredMac, + const std::string& password = "", const std::string& token = ""); /** * Sends a WiFi packet to the room. @@ -139,6 +172,19 @@ public: */ void SendGameInfo(const GameInfo& game_info); + /** + * Sends a moderation request to the room. + * @param type Moderation request type. + * @param nickname The subject of the request. (i.e. the user you want to kick/ban) + */ + void SendModerationRequest(RoomMessageTypes type, const std::string& nickname); + + /** + * Attempts to retrieve ban list from the room. + * If success, the ban list callback would be called. Otherwise an error would be emitted. + */ + void RequestBanList(); + /** * Binds a function to an event that will be triggered every time the State of the member * changed. The function wil be called every time the event is triggered. The callback function @@ -148,6 +194,15 @@ public: */ CallbackHandle BindOnStateChanged(std::function callback); + /** + * Binds a function to an event that will be triggered every time an error happened. The + * function wil be called every time the event is triggered. The callback function must not bind + * or unbind a function. Doing so will cause a deadlock + * @param callback The function to call + * @return A handle used for removing the function from the registered list + */ + CallbackHandle BindOnError(std::function callback); + /** * Binds a function to an event that will be triggered every time a WifiPacket is received. * The function wil be called everytime the event is triggered. @@ -178,6 +233,26 @@ public: CallbackHandle BindOnChatMessageRecieved( std::function callback); + /** + * Binds a function to an event that will be triggered every time a StatusMessage is + * received. The function will be called every time the event is triggered. The callback + * function must not bind or unbind a function. Doing so will cause a deadlock + * @param callback The function to call + * @return A handle used for removing the function from the registered list + */ + CallbackHandle BindOnStatusMessageReceived( + std::function callback); + + /** + * Binds a function to an event that will be triggered every time a requested ban list + * received. The function will be called every time the event is triggered. The callback + * function must not bind or unbind a function. Doing so will cause a deadlock + * @param callback The function to call + * @return A handle used for removing the function from the registered list + */ + CallbackHandle BindOnBanListReceived( + std::function callback); + /** * Leaves the current room. */ @@ -192,24 +267,44 @@ static const char* GetStateStr(const RoomMember::State& s) { switch (s) { case RoomMember::State::Idle: return "Idle"; - case RoomMember::State::Error: - return "Error"; case RoomMember::State::Joining: return "Joining"; case RoomMember::State::Joined: return "Joined"; - case RoomMember::State::LostConnection: + case RoomMember::State::Moderator: + return "Moderator"; + } + return "Unknown"; +} + +static const char* GetErrorStr(const RoomMember::Error& e) { + switch (e) { + case RoomMember::Error::LostConnection: return "LostConnection"; - case RoomMember::State::NameCollision: + case RoomMember::Error::HostKicked: + return "HostKicked"; + case RoomMember::Error::UnknownError: + return "UnknownError"; + case RoomMember::Error::NameCollision: return "NameCollision"; - case RoomMember::State::MacCollision: - return "MacCollision"; - case RoomMember::State::WrongVersion: + case RoomMember::Error::MacCollision: + return "MaxCollision"; + case RoomMember::Error::ConsoleIdCollision: + return "ConsoleIdCollision"; + case RoomMember::Error::WrongVersion: return "WrongVersion"; - case RoomMember::State::WrongPassword: + case RoomMember::Error::WrongPassword: return "WrongPassword"; - case RoomMember::State::CouldNotConnect: + case RoomMember::Error::CouldNotConnect: return "CouldNotConnect"; + case RoomMember::Error::RoomIsFull: + return "RoomIsFull"; + case RoomMember::Error::HostBanned: + return "HostBanned"; + case RoomMember::Error::PermissionDenied: + return "PermissionDenied"; + case RoomMember::Error::NoSuchUser: + return "NoSuchUser"; } return "Unknown"; } diff --git a/src/network/verify_user.cpp b/src/network/verify_user.cpp new file mode 100644 index 000000000..d9d98e495 --- /dev/null +++ b/src/network/verify_user.cpp @@ -0,0 +1,18 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include "network/verify_user.h" + +namespace Network::VerifyUser { + +Backend::~Backend() = default; + +NullBackend::~NullBackend() = default; + +UserData NullBackend::LoadUserData([[maybe_unused]] const std::string& verify_UID, + [[maybe_unused]] const std::string& token) { + return {}; +} + +} // namespace Network::VerifyUser diff --git a/src/network/verify_user.h b/src/network/verify_user.h new file mode 100644 index 000000000..01b9877c8 --- /dev/null +++ b/src/network/verify_user.h @@ -0,0 +1,46 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include "common/logging/log.h" + +namespace Network::VerifyUser { + +struct UserData { + std::string username; + std::string display_name; + std::string avatar_url; + bool moderator = false; ///< Whether the user is a Citra Moderator. +}; + +/** + * A backend used for verifying users and loading user data. + */ +class Backend { +public: + virtual ~Backend(); + + /** + * Verifies the given token and loads the information into a UserData struct. + * @param verify_UID A GUID that may be used for verification. + * @param token A token that contains user data and verification data. The format and content is + * decided by backends. + */ + virtual UserData LoadUserData(const std::string& verify_UID, const std::string& token) = 0; +}; + +/** + * A null backend where the token is ignored. + * No verification is performed here and the function returns an empty UserData. + */ +class NullBackend final : public Backend { +public: + ~NullBackend(); + + UserData LoadUserData(const std::string& verify_UID, const std::string& token) override; +}; + +} // namespace Network::VerifyUser diff --git a/src/web_service/CMakeLists.txt b/src/web_service/CMakeLists.txt index 71bf8977d..c3d42fe8b 100644 --- a/src/web_service/CMakeLists.txt +++ b/src/web_service/CMakeLists.txt @@ -5,6 +5,8 @@ add_library(web_service STATIC telemetry_json.h verify_login.cpp verify_login.h + verify_user_jwt.cpp + verify_user_jwt.h web_backend.cpp web_backend.h ) @@ -15,4 +17,4 @@ get_directory_property(OPENSSL_LIBS DIRECTORY ${PROJECT_SOURCE_DIR}/externals/libressl DEFINITION OPENSSL_LIBS) target_compile_definitions(web_service PRIVATE -DCPPHTTPLIB_OPENSSL_SUPPORT) -target_link_libraries(web_service PRIVATE common json-headers ${OPENSSL_LIBS} httplib lurlparser) +target_link_libraries(web_service PRIVATE common network json-headers ${OPENSSL_LIBS} httplib lurlparser cpp-jwt) diff --git a/src/web_service/announce_room_json.cpp b/src/web_service/announce_room_json.cpp index c0d543e73..2d2601256 100644 --- a/src/web_service/announce_room_json.cpp +++ b/src/web_service/announce_room_json.cpp @@ -12,21 +12,36 @@ namespace AnnounceMultiplayerRoom { void to_json(nlohmann::json& json, const Room::Member& member) { - json["name"] = member.name; + if (!member.username.empty()) { + json["username"] = member.username; + } + json["nickname"] = member.nickname; + if (!member.avatar_url.empty()) { + json["avatarUrl"] = member.avatar_url; + } json["gameName"] = member.game_name; json["gameId"] = member.game_id; } void from_json(const nlohmann::json& json, Room::Member& member) { - member.name = json.at("name").get(); + member.nickname = json.at("nickname").get(); member.game_name = json.at("gameName").get(); member.game_id = json.at("gameId").get(); + try { + member.username = json.at("username").get(); + member.avatar_url = json.at("avatarUrl").get(); + } catch (const nlohmann::detail::out_of_range& e) { + member.username = member.avatar_url = ""; + LOG_DEBUG(Network, "Member \'{}\' isn't authenticated", member.nickname); + } } void to_json(nlohmann::json& json, const Room& room) { - json["id"] = room.UID; json["port"] = room.port; json["name"] = room.name; + if (!room.description.empty()) { + json["description"] = room.description; + } json["preferredGameName"] = room.preferred_game; json["preferredGameId"] = room.preferred_game_id; json["maxPlayers"] = room.max_player; @@ -39,8 +54,15 @@ void to_json(nlohmann::json& json, const Room& room) { } void from_json(const nlohmann::json& json, Room& room) { + room.verify_UID = json.at("externalGuid").get(); room.ip = json.at("address").get(); room.name = json.at("name").get(); + try { + room.description = json.at("description").get(); + } catch (const nlohmann::detail::out_of_range& e) { + room.description = ""; + LOG_DEBUG(Network, "Room \'{}\' doesn't contain a description", room.name); + } room.owner = json.at("owner").get(); room.port = json.at("port").get(); room.preferred_game = json.at("preferredGameName").get(); @@ -59,12 +81,12 @@ void from_json(const nlohmann::json& json, Room& room) { namespace WebService { -void RoomJson::SetRoomInformation(const std::string& uid, const std::string& name, const u16 port, - const u32 max_player, const u32 net_version, +void RoomJson::SetRoomInformation(const std::string& name, const std::string& description, + const u16 port, const u32 max_player, const u32 net_version, const bool has_password, const std::string& preferred_game, const u64 preferred_game_id) { room.name = name; - room.UID = uid; + room.description = description; room.port = port; room.max_player = max_player; room.net_version = net_version; @@ -72,20 +94,39 @@ void RoomJson::SetRoomInformation(const std::string& uid, const std::string& nam room.preferred_game = preferred_game; room.preferred_game_id = preferred_game_id; } -void RoomJson::AddPlayer(const std::string& nickname, +void RoomJson::AddPlayer(const std::string& username, const std::string& nickname, + const std::string& avatar_url, const AnnounceMultiplayerRoom::MacAddress& mac_address, const u64 game_id, const std::string& game_name) { AnnounceMultiplayerRoom::Room::Member member; - member.name = nickname; + member.username = username; + member.nickname = nickname; + member.avatar_url = avatar_url; member.mac_address = mac_address; member.game_id = game_id; member.game_name = game_name; room.members.push_back(member); } -Common::WebResult RoomJson::Announce() { +Common::WebResult RoomJson::Update() { + if (room_id.empty()) { + LOG_ERROR(WebService, "Room must be registered to be updated"); + return Common::WebResult{Common::WebResult::Code::LibError, "Room is not registered"}; + } + nlohmann::json json{{"players", room.members}}; + return client.PostJson(fmt::format("/lobby/{}", room_id), json.dump(), false); +} + +std::string RoomJson::Register() { nlohmann::json json = room; - return client.PostJson("/lobby", json.dump(), false); + auto reply = client.PostJson("/lobby", json.dump(), false).returned_data; + if (reply.empty()) { + return ""; + } + auto reply_json = nlohmann::json::parse(reply); + room = reply_json.get(); + room_id = reply_json.at("id").get(); + return room.verify_UID; } void RoomJson::ClearPlayers() { @@ -101,12 +142,14 @@ AnnounceMultiplayerRoom::RoomList RoomJson::GetRoomList() { } void RoomJson::Delete() { - nlohmann::json json; - json["id"] = room.UID; + if (room_id.empty()) { + LOG_ERROR(WebService, "Room must be registered to be deleted"); + return; + } Common::DetachedTasks::AddTask( - [host{this->host}, username{this->username}, token{this->token}, content{json.dump()}]() { + [host{this->host}, username{this->username}, token{this->token}, room_id{this->room_id}]() { // create a new client here because the this->client might be destroyed. - Client{host, username, token}.DeleteJson("/lobby", content, false); + Client{host, username, token}.DeleteJson(fmt::format("/lobby/{}", room_id), "", false); }); } diff --git a/src/web_service/announce_room_json.h b/src/web_service/announce_room_json.h index 735605213..16d630863 100644 --- a/src/web_service/announce_room_json.h +++ b/src/web_service/announce_room_json.h @@ -20,14 +20,16 @@ public: RoomJson(const std::string& host, const std::string& username, const std::string& token) : client(host, username, token), host(host), username(username), token(token) {} ~RoomJson() = default; - void SetRoomInformation(const std::string& uid, const std::string& name, const u16 port, + void SetRoomInformation(const std::string& name, const std::string& description, const u16 port, const u32 max_player, const u32 net_version, const bool has_password, const std::string& preferred_game, const u64 preferred_game_id) override; - void AddPlayer(const std::string& nickname, + void AddPlayer(const std::string& username, const std::string& nickname, + const std::string& avatar_url, const AnnounceMultiplayerRoom::MacAddress& mac_address, const u64 game_id, const std::string& game_name) override; - Common::WebResult Announce() override; + Common::WebResult Update() override; + std::string Register() override; void ClearPlayers() override; AnnounceMultiplayerRoom::RoomList GetRoomList() override; void Delete() override; @@ -38,6 +40,7 @@ private: std::string host; std::string username; std::string token; + std::string room_id; }; } // namespace WebService diff --git a/src/web_service/verify_user_jwt.cpp b/src/web_service/verify_user_jwt.cpp new file mode 100644 index 000000000..27e08db9e --- /dev/null +++ b/src/web_service/verify_user_jwt.cpp @@ -0,0 +1,60 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include "common/logging/log.h" +#include "common/web_result.h" +#include "web_service/verify_user_jwt.h" +#include "web_service/web_backend.h" + +namespace WebService { + +static std::string public_key; +std::string GetPublicKey(const std::string& host) { + if (public_key.empty()) { + Client client(host, "", ""); // no need for credentials here + public_key = client.GetPlain("/jwt/external/key.pem", true).returned_data; + if (public_key.empty()) { + LOG_ERROR(WebService, "Could not fetch external JWT public key, verification may fail"); + } else { + LOG_INFO(WebService, "Fetched external JWT public key (size={})", public_key.size()); + } + } + return public_key; +} + +VerifyUserJWT::VerifyUserJWT(const std::string& host) : pub_key(GetPublicKey(host)) {} + +Network::VerifyUser::UserData VerifyUserJWT::LoadUserData(const std::string& verify_UID, + const std::string& token) { + const std::string audience = fmt::format("external-{}", verify_UID); + using namespace jwt::params; + std::error_code error; + auto decoded = + jwt::decode(token, algorithms({"rs256"}), error, secret(pub_key), issuer("citra-core"), + aud(audience), validate_iat(true), validate_jti(true)); + if (error) { + LOG_INFO(WebService, "Verification failed: category={}, code={}, message={}", + error.category().name(), error.value(), error.message()); + return {}; + } + Network::VerifyUser::UserData user_data{}; + if (decoded.payload().has_claim("username")) { + user_data.username = decoded.payload().get_claim_value("username"); + } + if (decoded.payload().has_claim("displayName")) { + user_data.display_name = decoded.payload().get_claim_value("displayName"); + } + if (decoded.payload().has_claim("avatarUrl")) { + user_data.avatar_url = decoded.payload().get_claim_value("avatarUrl"); + } + if (decoded.payload().has_claim("roles")) { + auto roles = decoded.payload().get_claim_value>("roles"); + user_data.moderator = std::find(roles.begin(), roles.end(), "moderator") != roles.end(); + } + return user_data; +} + +} // namespace WebService diff --git a/src/web_service/verify_user_jwt.h b/src/web_service/verify_user_jwt.h new file mode 100644 index 000000000..826e01eed --- /dev/null +++ b/src/web_service/verify_user_jwt.h @@ -0,0 +1,25 @@ +// Copyright 2018 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include "network/verify_user.h" +#include "web_service/web_backend.h" + +namespace WebService { + +class VerifyUserJWT final : public Network::VerifyUser::Backend { +public: + VerifyUserJWT(const std::string& host); + ~VerifyUserJWT() = default; + + Network::VerifyUser::UserData LoadUserData(const std::string& verify_UID, + const std::string& token) override; + +private: + std::string pub_key; +}; + +} // namespace WebService diff --git a/src/web_service/web_backend.cpp b/src/web_service/web_backend.cpp index 84d6105d7..453c96574 100644 --- a/src/web_service/web_backend.cpp +++ b/src/web_service/web_backend.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "common/common_types.h" #include "common/logging/log.h" @@ -32,8 +33,9 @@ struct Client::Impl { } /// A generic function handles POST, GET and DELETE request together - Common::WebResult GenericJson(const std::string& method, const std::string& path, - const std::string& data, bool allow_anonymous) { + Common::WebResult GenericRequest(const std::string& method, const std::string& path, + const std::string& data, bool allow_anonymous, + const std::string& accept) { if (jwt.empty()) { UpdateJWT(); } @@ -44,11 +46,11 @@ struct Client::Impl { "Credentials needed"}; } - auto result = GenericJson(method, path, data, jwt); + auto result = GenericRequest(method, path, data, accept, jwt); if (result.result_string == "401") { // Try again with new JWT UpdateJWT(); - result = GenericJson(method, path, data, jwt); + result = GenericRequest(method, path, data, accept, jwt); } return result; @@ -60,9 +62,10 @@ struct Client::Impl { * username + token is used if jwt is empty but username and token are * not empty anonymous if all of jwt, username and token are empty */ - Common::WebResult GenericJson(const std::string& method, const std::string& path, - const std::string& data, const std::string& jwt = "", - const std::string& username = "", const std::string& token = "") { + Common::WebResult GenericRequest(const std::string& method, const std::string& path, + const std::string& data, const std::string& accept, + const std::string& jwt = "", const std::string& username = "", + const std::string& token = "") { if (cli == nullptr) { auto parsedUrl = LUrlParser::clParseURL::ParseURL(host); int port; @@ -133,8 +136,7 @@ struct Client::Impl { return Common::WebResult{Common::WebResult::Code::WrongContent, ""}; } - if (content_type->second.find("application/json") == std::string::npos && - content_type->second.find("text/html; charset=utf-8") == std::string::npos) { + if (content_type->second.find(accept) == std::string::npos) { LOG_ERROR(WebService, "{} to {} returned wrong content: {}", method, host + path, content_type->second); return Common::WebResult{Common::WebResult::Code::WrongContent, "Wrong content"}; @@ -148,7 +150,7 @@ struct Client::Impl { return; } - auto result = GenericJson("POST", "/jwt/internal", "", "", username, token); + auto result = GenericRequest("POST", "/jwt/internal", "", "text/html", "", username, token); if (result.result_code != Common::WebResult::Code::Success) { LOG_ERROR(WebService, "UpdateJWT failed"); } else { @@ -181,16 +183,29 @@ Client::~Client() = default; Common::WebResult Client::PostJson(const std::string& path, const std::string& data, bool allow_anonymous) { - return impl->GenericJson("POST", path, data, allow_anonymous); + return impl->GenericRequest("POST", path, data, allow_anonymous, "application/json"); } Common::WebResult Client::GetJson(const std::string& path, bool allow_anonymous) { - return impl->GenericJson("GET", path, "", allow_anonymous); + return impl->GenericRequest("GET", path, "", allow_anonymous, "application/json"); } Common::WebResult Client::DeleteJson(const std::string& path, const std::string& data, bool allow_anonymous) { - return impl->GenericJson("DELETE", path, data, allow_anonymous); + return impl->GenericRequest("DELETE", path, data, allow_anonymous, "application/json"); +} + +Common::WebResult Client::GetPlain(const std::string& path, bool allow_anonymous) { + return impl->GenericRequest("GET", path, "", allow_anonymous, "text/plain"); +} + +Common::WebResult Client::GetImage(const std::string& path, bool allow_anonymous) { + return impl->GenericRequest("GET", path, "", allow_anonymous, "image/png"); +} + +Common::WebResult Client::GetExternalJWT(const std::string& audience) { + return impl->GenericRequest("POST", fmt::format("/jwt/external/{}", audience), "", false, + "text/html"); } } // namespace WebService diff --git a/src/web_service/web_backend.h b/src/web_service/web_backend.h index c637e09df..04121f17e 100644 --- a/src/web_service/web_backend.h +++ b/src/web_service/web_backend.h @@ -46,6 +46,29 @@ public: Common::WebResult DeleteJson(const std::string& path, const std::string& data, bool allow_anonymous); + /** + * Gets a plain string from the specified path. + * @param path the URL segment after the host address. + * @param allow_anonymous If true, allow anonymous unauthenticated requests. + * @return the result of the request. + */ + Common::WebResult GetPlain(const std::string& path, bool allow_anonymous); + + /** + * Gets an PNG image from the specified path. + * @param path the URL segment after the host address. + * @param allow_anonymous If true, allow anonymous unauthenticated requests. + * @return the result of the request. + */ + Common::WebResult GetImage(const std::string& path, bool allow_anonymous); + + /** + * Requests an external JWT for the specific audience provided. + * @param audience the audience of the JWT requested. + * @return the result of the request. + */ + Common::WebResult GetExternalJWT(const std::string& audience); + private: struct Impl; std::unique_ptr impl;