Merge pull request #4468 from citra-emu/multiplayer-v4/main

Multiplayer version 4
master
Pengfei Zhu 2018-12-22 09:45:28 +07:00 committed by GitHub
commit eabc9727d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 2395 additions and 308 deletions

5
.gitmodules vendored

@ -42,4 +42,7 @@
url = https://github.com/zeromq/libzmq url = https://github.com/zeromq/libzmq
[submodule "externals/cppzmq"] [submodule "externals/cppzmq"]
path = externals/cppzmq path = externals/cppzmq
url = https://github.com/zeromq/cppzmq url = https://github.com/zeromq/cppzmq
[submodule "cpp-jwt"]
path = externals/cpp-jwt
url = https://github.com/arun11299/cpp-jwt.git

5
dist/license.md vendored

@ -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/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.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/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/failed.png | Free for non-commercial use
qt_themes/default/icons/16x16/lock.png | CC BY-ND 3.0 | https://icons8.com 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/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/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/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/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/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/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.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/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/failed.png | Free for non-commercial use
qt_themes/qdarkstyle/icons/16x16/lock.png | CC BY-ND 3.0 | https://icons8.com 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/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/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/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/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/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.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/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/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 qt_themes/colorful/icons/256x256/plus_folder.png | CC BY-ND 3.0 | https://icons8.com

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

@ -2,6 +2,7 @@
<qresource prefix="icons/colorful"> <qresource prefix="icons/colorful">
<file alias="index.theme">icons/index.theme</file> <file alias="index.theme">icons/index.theme</file>
<file alias="16x16/connected.png">icons/16x16/connected.png</file> <file alias="16x16/connected.png">icons/16x16/connected.png</file>
<file alias="16x16/connected_notification.png">icons/16x16/connected_notification.png</file>
<file alias="16x16/disconnected.png">icons/16x16/disconnected.png</file> <file alias="16x16/disconnected.png">icons/16x16/disconnected.png</file>
<file alias="16x16/lock.png">icons/16x16/lock.png</file> <file alias="16x16/lock.png">icons/16x16/lock.png</file>
<file alias="48x48/bad_folder.png">icons/48x48/bad_folder.png</file> <file alias="48x48/bad_folder.png">icons/48x48/bad_folder.png</file>

@ -2,11 +2,13 @@
<qresource prefix="icons/colorful_dark"> <qresource prefix="icons/colorful_dark">
<file alias="index.theme">icons/index.theme</file> <file alias="index.theme">icons/index.theme</file>
<file alias="16x16/connected.png">../colorful/icons/16x16/connected.png</file> <file alias="16x16/connected.png">../colorful/icons/16x16/connected.png</file>
<file alias="16x16/connected_notification.png">../colorful/icons/16x16/connected_notification.png</file>
<file alias="16x16/disconnected.png">../colorful/icons/16x16/disconnected.png</file> <file alias="16x16/disconnected.png">../colorful/icons/16x16/disconnected.png</file>
<file alias="16x16/lock.png">icons/16x16/lock.png</file> <file alias="16x16/lock.png">icons/16x16/lock.png</file>
<file alias="48x48/bad_folder.png">../colorful/icons/48x48/bad_folder.png</file> <file alias="48x48/bad_folder.png">../colorful/icons/48x48/bad_folder.png</file>
<file alias="48x48/chip.png">../colorful/icons/48x48/chip.png</file> <file alias="48x48/chip.png">../colorful/icons/48x48/chip.png</file>
<file alias="48x48/folder.png">../colorful/icons/48x48/folder.png</file> <file alias="48x48/folder.png">../colorful/icons/48x48/folder.png</file>
<file alias="48x48/no_avatar.png">../qdarkstyle/icons/48x48/no_avatar.png</file>
<file alias="48x48/plus.png">../colorful/icons/48x48/plus.png</file> <file alias="48x48/plus.png">../colorful/icons/48x48/plus.png</file>
<file alias="48x48/sd_card.png">../colorful/icons/48x48/sd_card.png</file> <file alias="48x48/sd_card.png">../colorful/icons/48x48/sd_card.png</file>
<file alias="256x256/plus_folder.png">../colorful/icons/256x256/plus_folder.png</file> <file alias="256x256/plus_folder.png">../colorful/icons/256x256/plus_folder.png</file>

@ -10,6 +10,8 @@
<file alias="16x16/disconnected.png">icons/16x16/disconnected.png</file> <file alias="16x16/disconnected.png">icons/16x16/disconnected.png</file>
<file alias="16x16/connected_notification.png">icons/16x16/connected_notification.png</file>
<file alias="16x16/lock.png">icons/16x16/lock.png</file> <file alias="16x16/lock.png">icons/16x16/lock.png</file>
<file alias="48x48/bad_folder.png">icons/48x48/bad_folder.png</file> <file alias="48x48/bad_folder.png">icons/48x48/bad_folder.png</file>
@ -18,6 +20,8 @@
<file alias="48x48/folder.png">icons/48x48/folder.png</file> <file alias="48x48/folder.png">icons/48x48/folder.png</file>
<file alias="48x48/no_avatar.png">icons/48x48/no_avatar.png</file>
<file alias="48x48/plus.png">icons/48x48/plus.png</file> <file alias="48x48/plus.png">icons/48x48/plus.png</file>
<file alias="48x48/sd_card.png">icons/48x48/sd_card.png</file> <file alias="48x48/sd_card.png">icons/48x48/sd_card.png</file>

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 B

@ -3,10 +3,12 @@
<file alias="index.theme">icons/index.theme</file> <file alias="index.theme">icons/index.theme</file>
<file alias="16x16/connected.png">icons/16x16/connected.png</file> <file alias="16x16/connected.png">icons/16x16/connected.png</file>
<file alias="16x16/disconnected.png">icons/16x16/disconnected.png</file> <file alias="16x16/disconnected.png">icons/16x16/disconnected.png</file>
<file alias="16x16/connected_notification.png">icons/16x16/connected_notification.png</file>
<file alias="16x16/lock.png">icons/16x16/lock.png</file> <file alias="16x16/lock.png">icons/16x16/lock.png</file>
<file alias="48x48/bad_folder.png">icons/48x48/bad_folder.png</file> <file alias="48x48/bad_folder.png">icons/48x48/bad_folder.png</file>
<file alias="48x48/chip.png">icons/48x48/chip.png</file> <file alias="48x48/chip.png">icons/48x48/chip.png</file>
<file alias="48x48/folder.png">icons/48x48/folder.png</file> <file alias="48x48/folder.png">icons/48x48/folder.png</file>
<file alias="48x48/no_avatar.png">icons/48x48/no_avatar.png</file>
<file alias="48x48/plus.png">icons/48x48/plus.png</file> <file alias="48x48/plus.png">icons/48x48/plus.png</file>
<file alias="48x48/sd_card.png">icons/48x48/sd_card.png</file> <file alias="48x48/sd_card.png">icons/48x48/sd_card.png</file>
<file alias="256x256/plus_folder.png">icons/256x256/plus_folder.png</file> <file alias="256x256/plus_folder.png">icons/256x256/plus_folder.png</file>

@ -82,6 +82,10 @@ if (ENABLE_WEB_SERVICE)
target_include_directories(ssl INTERFACE ./libressl/include) target_include_directories(ssl INTERFACE ./libressl/include)
target_compile_definitions(ssl PRIVATE -DHAVE_INET_NTOP) target_compile_definitions(ssl PRIVATE -DHAVE_INET_NTOP)
# JSON
add_library(json-headers INTERFACE)
target_include_directories(json-headers INTERFACE ./json)
# lurlparser # lurlparser
add_subdirectory(lurlparser EXCLUDE_FROM_ALL) add_subdirectory(lurlparser EXCLUDE_FROM_ALL)
@ -89,9 +93,9 @@ if (ENABLE_WEB_SERVICE)
add_library(httplib INTERFACE) add_library(httplib INTERFACE)
target_include_directories(httplib INTERFACE ./httplib) target_include_directories(httplib INTERFACE ./httplib)
# JSON # cpp-jwt
add_library(json-headers INTERFACE) add_library(cpp-jwt INTERFACE)
target_include_directories(json-headers INTERFACE ./json) target_include_directories(cpp-jwt INTERFACE ./cpp-jwt/include)
endif() endif()
if (ENABLE_SCRIPTING) if (ENABLE_SCRIPTING)

1
externals/cpp-jwt vendored

@ -0,0 +1 @@
Subproject commit 6e27aa4c8671e183f11e327a2e1f556c64fdc4a9

@ -345,6 +345,7 @@ Icon Name | License | Origin/Author
--- | --- | --- --- | --- | ---
checked.png | Free for non-commercial use checked.png | Free for non-commercial use
connected.png | CC BY-ND 3.0 | https://icons8.com 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 disconnected.png | CC BY-ND 3.0 | https://icons8.com
failed.png | Free for non-commercial use failed.png | Free for non-commercial use
lock.png | CC BY-ND 3.0 | https://icons8.com lock.png | CC BY-ND 3.0 | https://icons8.com

@ -39,6 +39,7 @@
#include "core/frontend/applets/default_applets.h" #include "core/frontend/applets/default_applets.h"
#include "core/gdbstub/gdbstub.h" #include "core/gdbstub/gdbstub.h"
#include "core/hle/service/am/am.h" #include "core/hle/service/am/am.h"
#include "core/hle/service/cfg/cfg.h"
#include "core/loader/loader.h" #include "core/loader/loader.h"
#include "core/movie.h" #include "core/movie.h"
#include "core/settings.h" #include "core/settings.h"
@ -80,42 +81,87 @@ static void OnStateChanged(const Network::RoomMember::State& state) {
case Network::RoomMember::State::Joined: case Network::RoomMember::State::Joined:
LOG_DEBUG(Network, "Successfully joined to the room"); LOG_DEBUG(Network, "Successfully joined to the room");
break; break;
case Network::RoomMember::State::LostConnection: case Network::RoomMember::State::Moderator:
LOG_DEBUG(Network, "Lost connection to the room"); LOG_DEBUG(Network, "Successfully joined the room as a moderator");
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);
break; break;
default: default:
break; 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) { static void OnMessageReceived(const Network::ChatEntry& msg) {
std::cout << std::endl << msg.nickname << ": " << msg.message << std::endl << std::endl; 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() { static void InitializeLogging() {
Log::Filter log_filter(Log::Level::Debug); Log::Filter log_filter(Log::Level::Debug);
log_filter.ParseFilterString(Settings::values.log_filter); log_filter.ParseFilterString(Settings::values.log_filter);
@ -333,10 +379,13 @@ int main(int argc, char** argv) {
if (use_multiplayer) { if (use_multiplayer) {
if (auto member = Network::GetRoomMember().lock()) { if (auto member = Network::GetRoomMember().lock()) {
member->BindOnChatMessageRecieved(OnMessageReceived); member->BindOnChatMessageRecieved(OnMessageReceived);
member->BindOnStatusMessageReceived(OnStatusMessageReceived);
member->BindOnStateChanged(OnStateChanged); member->BindOnStateChanged(OnStateChanged);
member->BindOnError(OnNetworkError);
LOG_DEBUG(Network, "Start connection to {}:{} with nickname {}", address, port, LOG_DEBUG(Network, "Start connection to {}:{} with nickname {}", address, port,
nickname); 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 { } else {
LOG_ERROR(Network, "Could not access RoomMember"); LOG_ERROR(Network, "Could not access RoomMember");
return 0; return 0;

@ -99,6 +99,8 @@ add_executable(citra-qt
multiplayer/lobby.cpp multiplayer/lobby.cpp
multiplayer/message.h multiplayer/message.h
multiplayer/message.cpp multiplayer/message.cpp
multiplayer/moderation_dialog.cpp
multiplayer/moderation_dialog.h
multiplayer/state.cpp multiplayer/state.cpp
multiplayer/state.h multiplayer/state.h
multiplayer/validation.h multiplayer/validation.h
@ -135,6 +137,7 @@ set(UIS
multiplayer/chat_room.ui multiplayer/chat_room.ui
multiplayer/client_room.ui multiplayer/client_room.ui
multiplayer/host_room.ui multiplayer/host_room.ui
multiplayer/moderation_dialog.ui
aboutdialog.ui aboutdialog.ui
cheats.ui cheats.ui
hotkeys.ui hotkeys.ui
@ -228,6 +231,10 @@ if (USE_DISCORD_PRESENCE)
target_compile_definitions(citra-qt PRIVATE -DUSE_DISCORD_PRESENCE) target_compile_definitions(citra-qt PRIVATE -DUSE_DISCORD_PRESENCE)
endif() endif()
if (ENABLE_WEB_SERVICE)
target_compile_definitions(citra-qt PRIVATE -DENABLE_WEB_SERVICE)
endif()
if(UNIX AND NOT APPLE) if(UNIX AND NOT APPLE)
install(TARGETS citra-qt RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin") install(TARGETS citra-qt RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin")
endif() endif()

@ -329,6 +329,22 @@ void Config::ReadValues() {
} }
UISettings::values.max_player = ReadSetting("max_player", 8).toUInt(); UISettings::values.max_player = ReadSetting("max_player", 8).toUInt();
UISettings::values.game_id = ReadSetting("game_id", 0).toULongLong(); 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();
qt_config->endGroup(); qt_config->endGroup();
@ -533,6 +549,20 @@ void Config::SaveValues() {
WriteSetting("host_type", UISettings::values.host_type, 0); WriteSetting("host_type", UISettings::values.host_type, 0);
WriteSetting("max_player", UISettings::values.max_player, 8); WriteSetting("max_player", UISettings::values.max_player, 8);
WriteSetting("game_id", UISettings::values.game_id, 0); 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();
qt_config->endGroup(); qt_config->endGroup();

@ -5,6 +5,8 @@
#include <array> #include <array>
#include <future> #include <future>
#include <QColor> #include <QColor>
#include <QDesktopServices>
#include <QFutureWatcher>
#include <QImage> #include <QImage>
#include <QList> #include <QList>
#include <QLocale> #include <QLocale>
@ -12,6 +14,7 @@
#include <QMessageBox> #include <QMessageBox>
#include <QMetaType> #include <QMetaType>
#include <QTime> #include <QTime>
#include <QUrl>
#include <QtConcurrent/QtConcurrentRun> #include <QtConcurrent/QtConcurrentRun>
#include "citra_qt/game_list_p.h" #include "citra_qt/game_list_p.h"
#include "citra_qt/multiplayer/chat_room.h" #include "citra_qt/multiplayer/chat_room.h"
@ -19,6 +22,9 @@
#include "common/logging/log.h" #include "common/logging/log.h"
#include "core/announce_multiplayer_session.h" #include "core/announce_multiplayer_session.h"
#include "ui_chat_room.h" #include "ui_chat_room.h"
#ifdef ENABLE_WEB_SERVICE
#include "web_service/web_backend.h"
#endif
class ChatMessage { class ChatMessage {
public: public:
@ -27,24 +33,60 @@ public:
QLocale locale; QLocale locale;
timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat); timestamp = locale.toString(ts.isValid() ? ts : QTime::currentTime(), QLocale::ShortFormat);
nickname = QString::fromStdString(chat.nickname); nickname = QString::fromStdString(chat.nickname);
username = QString::fromStdString(chat.username);
message = QString::fromStdString(chat.message); 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 /// Format the message using the players color
QString GetPlayerChatMessage(u16 player) const { QString GetPlayerChatMessage(u16 player) const {
auto color = player_color[player % 16]; auto color = player_color[player % 16];
return QString("[%1] <font color='%2'>&lt;%3&gt;</font> %4") QString name;
.arg(timestamp, color, nickname.toHtmlEscaped(), message.toHtmlEscaped()); 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] <font color='%2'>&lt;%3&gt;</font> <font style='%4' "
"color='#000000'>%5</font>")
.arg(timestamp, color, name.toHtmlEscaped(), style, message.toHtmlEscaped());
} }
private: private:
static constexpr std::array<const char*, 16> player_color = { static constexpr std::array<const char*, 16> player_color = {
{"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", {"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222",
"#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "FFFF00"}}; "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "FFFF00"}};
static constexpr char ping_color[] = "#FFFF00";
QString timestamp; QString timestamp;
QString nickname; QString nickname;
QString username;
QString message; QString message;
bool contains_ping;
}; };
class StatusMessage { class StatusMessage {
@ -57,37 +99,69 @@ public:
} }
QString GetSystemChatMessage() const { QString GetSystemChatMessage() const {
return QString("[%1] <font color='%2'><i>%3</i></font>") return QString("[%1] <font color='%2'>* %3</font>").arg(timestamp, system_color, message);
.arg(timestamp, system_color, message);
} }
private: private:
static constexpr const char system_color[] = "#888888"; static constexpr const char system_color[] = "#FF8C00";
QString timestamp; QString timestamp;
QString message; 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::ChatRoom>()) { ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique<Ui::ChatRoom>()) {
ui->setupUi(this); ui->setupUi(this);
// set the item_model for player_view // set the item_model for player_view
enum {
COLUMN_NAME,
COLUMN_GAME,
COLUMN_COUNT, // Number of columns
};
player_list = new QStandardItemModel(ui->player_view); player_list = new QStandardItemModel(ui->player_view);
ui->player_view->setModel(player_list); ui->player_view->setModel(player_list);
ui->player_view->setContextMenuPolicy(Qt::CustomContextMenu); ui->player_view->setContextMenuPolicy(Qt::CustomContextMenu);
player_list->insertColumns(0, COLUMN_COUNT); // set a header to make it look better though there is only one column
player_list->setHeaderData(COLUMN_NAME, Qt::Horizontal, tr("Name")); player_list->insertColumns(0, 1);
player_list->setHeaderData(COLUMN_GAME, Qt::Horizontal, tr("Game")); player_list->setHeaderData(0, Qt::Horizontal, tr("Members"));
ui->chat_history->document()->setMaximumBlockCount(max_chat_lines); ui->chat_history->document()->setMaximumBlockCount(max_chat_lines);
// register the network structs to use in slots and signals // register the network structs to use in slots and signals
qRegisterMetaType<Network::ChatEntry>(); qRegisterMetaType<Network::ChatEntry>();
qRegisterMetaType<Network::StatusMessageEntry>();
qRegisterMetaType<Network::RoomInformation>(); qRegisterMetaType<Network::RoomInformation>();
qRegisterMetaType<Network::RoomMember::State>(); qRegisterMetaType<Network::RoomMember::State>();
@ -95,7 +169,12 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique<Ui::C
if (auto member = Network::GetRoomMember().lock()) { if (auto member = Network::GetRoomMember().lock()) {
member->BindOnChatMessageRecieved( member->BindOnChatMessageRecieved(
[this](const Network::ChatEntry& chat) { emit ChatReceived(chat); }); [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::ChatReceived, this, &ChatRoom::OnChatReceive);
connect(this, &ChatRoom::StatusMessageReceived, this, &ChatRoom::OnStatusMessageReceive);
} else { } else {
// TODO (jroweboy) network was not initialized? // TODO (jroweboy) network was not initialized?
} }
@ -110,6 +189,10 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique<Ui::C
ChatRoom::~ChatRoom() = default; ChatRoom::~ChatRoom() = default;
void ChatRoom::SetModPerms(bool is_mod) {
has_mod_perms = is_mod;
}
void ChatRoom::RetranslateUi() { void ChatRoom::RetranslateUi() {
ui->retranslateUi(this); ui->retranslateUi(this);
} }
@ -127,6 +210,21 @@ void ChatRoom::AppendChatMessage(const QString& msg) {
ui->chat_history->append(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) { bool ChatRoom::ValidateMessage(const std::string& msg) {
return !msg.empty(); return !msg.empty();
} }
@ -157,7 +255,8 @@ void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) {
auto members = room->GetMemberInformation(); auto members = room->GetMemberInformation();
auto it = std::find_if(members.begin(), members.end(), auto it = std::find_if(members.begin(), members.end(),
[&chat](const Network::RoomMember::MemberInformation& member) { [&chat](const Network::RoomMember::MemberInformation& member) {
return member.nickname == chat.nickname; return member.nickname == chat.nickname &&
member.username == chat.username;
}); });
if (it == members.end()) { if (it == members.end()) {
LOG_INFO(Network, "Chat message received from unknown player. Ignoring it."); 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); auto player = std::distance(members.begin(), it);
ChatMessage m(chat); ChatMessage m(chat);
if (m.ContainsPing()) {
emit UserPinged();
}
AppendChatMessage(m.GetPlayerChatMessage(player)); 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() { void ChatRoom::OnSendChat() {
if (auto room = Network::GetRoomMember().lock()) { 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; return;
} }
auto message = ui->chat_message->text().toStdString(); auto message = ui->chat_message->text().toStdString();
@ -184,12 +318,14 @@ void ChatRoom::OnSendChat() {
return; return;
} }
auto nick = room->GetNickname(); auto nick = room->GetNickname();
Network::ChatEntry chat{nick, message}; auto username = room->GetUsername();
Network::ChatEntry chat{nick, username, message};
auto members = room->GetMemberInformation(); auto members = room->GetMemberInformation();
auto it = std::find_if(members.begin(), members.end(), auto it = std::find_if(members.begin(), members.end(),
[&chat](const Network::RoomMember::MemberInformation& member) { [&chat](const Network::RoomMember::MemberInformation& member) {
return member.nickname == chat.nickname; return member.nickname == chat.nickname &&
member.username == chat.username;
}); });
if (it == members.end()) { if (it == members.end()) {
LOG_INFO(Network, "Cannot find self in the player list when sending a message."); 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) { void ChatRoom::SetPlayerList(const Network::RoomMember::MemberList& member_list) {
// TODO(B3N30): Remember which row is selected // TODO(B3N30): Remember which row is selected
player_list->removeRows(0, player_list->rowCount()); player_list->removeRows(0, player_list->rowCount());
for (const auto& member : member_list) { for (const auto& member : member_list) {
if (member.nickname.empty()) if (member.nickname.empty())
continue; continue;
QList<QStandardItem*> l; QStandardItem* name_item = new PlayerListItem(member.nickname, member.username,
std::vector<std::string> elements = {member.nickname, member.game_info.name}; member.avatar_url, member.game_info.name);
for (const auto& item : elements) {
QStandardItem* child = new QStandardItem(QString::fromStdString(item)); if (!icon_cache.count(member.avatar_url)) {
child->setEditable(false); // Emplace a default question mark icon as avatar
l.append(child); 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<std::string> 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<std::string>(this);
connect(future_watcher, &QFutureWatcher<std::string>::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<const u8*>(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 // TODO(B3N30): Restore row selection
} }
@ -230,33 +410,73 @@ void ChatRoom::PopupContextMenu(const QPoint& menu_location) {
if (!item.isValid()) if (!item.isValid())
return; return;
std::string nickname = player_list->item(item.row())->text().toStdString(); std::string nickname =
if (auto room = Network::GetRoomMember().lock()) { player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString();
// You can't block yourself
if (nickname == room->GetNickname())
return;
}
QMenu context_menu; QMenu context_menu;
QAction* block_action = context_menu.addAction(tr("Block Player"));
block_action->setCheckable(true); QString username = player_list->item(item.row())->data(PlayerListItem::UsernameRole).toString();
block_action->setChecked(block_list.count(nickname) > 0); 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] { std::string cur_nickname;
if (block_list.count(nickname)) { if (auto room = Network::GetRoomMember().lock()) {
block_list.erase(nickname); cur_nickname = room->GetNickname();
} else { }
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.<br><br>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 <b>kick</b> %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( QMessageBox::StandardButton result = QMessageBox::question(
this, tr("Block Player"), this, tr("Ban Player"),
tr("When you block a player, you will no longer receive chat messages from " tr("Are you sure you would like to <b>kick and ban</b> %1?\n\nThis would "
"them.<br><br>Are you sure you would like to block %1?") "ban both their forum username and their IP address.")
.arg(QString::fromStdString(nickname)), .arg(QString::fromStdString(nickname)),
QMessageBox::Yes | QMessageBox::No); QMessageBox::Yes | QMessageBox::No);
if (result == QMessageBox::Yes) if (result == QMessageBox::Yes)
block_list.emplace(nickname); SendModerationRequest(Network::IdModBan, nickname);
} });
}); }
context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location)); context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location));
} }

@ -36,9 +36,12 @@ public:
void AppendStatusMessage(const QString& msg); void AppendStatusMessage(const QString& msg);
~ChatRoom(); ~ChatRoom();
void SetModPerms(bool is_mod);
public slots: public slots:
void OnRoomUpdate(const Network::RoomInformation& info); void OnRoomUpdate(const Network::RoomInformation& info);
void OnChatReceive(const Network::ChatEntry&); void OnChatReceive(const Network::ChatEntry&);
void OnStatusMessageReceive(const Network::StatusMessageEntry&);
void OnSendChat(); void OnSendChat();
void OnChatTextChanged(); void OnChatTextChanged();
void PopupContextMenu(const QPoint& menu_location); void PopupContextMenu(const QPoint& menu_location);
@ -47,16 +50,25 @@ public slots:
signals: signals:
void ChatReceived(const Network::ChatEntry&); void ChatReceived(const Network::ChatEntry&);
void StatusMessageReceived(const Network::StatusMessageEntry&);
void UserPinged();
private: private:
static constexpr u32 max_chat_lines = 1000; static constexpr u32 max_chat_lines = 1000;
void AppendChatMessage(const QString&); void AppendChatMessage(const QString&);
bool ValidateMessage(const std::string&); bool ValidateMessage(const std::string&);
void SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname);
void UpdateIconDisplay();
bool has_mod_perms = false;
QStandardItemModel* player_list; QStandardItemModel* player_list;
std::unique_ptr<Ui::ChatRoom> ui; std::unique_ptr<Ui::ChatRoom> ui;
std::unordered_set<std::string> block_list; std::unordered_set<std::string> block_list;
std::unordered_map<std::string, QPixmap> icon_cache;
}; };
Q_DECLARE_METATYPE(Network::ChatEntry); Q_DECLARE_METATYPE(Network::ChatEntry);
Q_DECLARE_METATYPE(Network::StatusMessageEntry);
Q_DECLARE_METATYPE(Network::RoomInformation); Q_DECLARE_METATYPE(Network::RoomInformation);
Q_DECLARE_METATYPE(Network::RoomMember::State); Q_DECLARE_METATYPE(Network::RoomMember::State);
Q_DECLARE_METATYPE(Network::RoomMember::Error);

@ -6,7 +6,7 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>607</width> <width>807</width>
<height>432</height> <height>432</height>
</rect> </rect>
</property> </property>

@ -13,6 +13,7 @@
#include "citra_qt/game_list_p.h" #include "citra_qt/game_list_p.h"
#include "citra_qt/multiplayer/client_room.h" #include "citra_qt/multiplayer/client_room.h"
#include "citra_qt/multiplayer/message.h" #include "citra_qt/multiplayer/message.h"
#include "citra_qt/multiplayer/moderation_dialog.h"
#include "citra_qt/multiplayer/state.h" #include "citra_qt/multiplayer/state.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "core/announce_multiplayer_session.h" #include "core/announce_multiplayer_session.h"
@ -33,6 +34,8 @@ ClientRoomWindow::ClientRoomWindow(QWidget* parent)
connect(this, &ClientRoomWindow::RoomInformationChanged, this, connect(this, &ClientRoomWindow::RoomInformationChanged, this,
&ClientRoomWindow::OnRoomUpdate); &ClientRoomWindow::OnRoomUpdate);
connect(this, &ClientRoomWindow::StateChanged, this, &::ClientRoomWindow::OnStateChange); connect(this, &ClientRoomWindow::StateChanged, this, &::ClientRoomWindow::OnStateChange);
// Update the state
OnStateChange(member->GetState());
} else { } else {
// TODO (jroweboy) network was not initialized? // TODO (jroweboy) network was not initialized?
} }
@ -40,11 +43,25 @@ ClientRoomWindow::ClientRoomWindow(QWidget* parent)
connect(ui->disconnect, &QPushButton::pressed, [this] { Disconnect(); }); connect(ui->disconnect, &QPushButton::pressed, [this] { Disconnect(); });
ui->disconnect->setDefault(false); ui->disconnect->setDefault(false);
ui->disconnect->setAutoDefault(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(); UpdateView();
} }
ClientRoomWindow::~ClientRoomWindow() = default; 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() { void ClientRoomWindow::RetranslateUi() {
ui->retranslateUi(this); ui->retranslateUi(this);
ui->chat->RetranslateUi(); ui->chat->RetranslateUi();
@ -55,9 +72,12 @@ void ClientRoomWindow::OnRoomUpdate(const Network::RoomInformation& info) {
} }
void ClientRoomWindow::OnStateChange(const Network::RoomMember::State& state) { 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->Clear();
ui->chat->AppendStatusMessage(tr("Connected")); ui->chat->AppendStatusMessage(tr("Connected"));
SetModPerms(state == Network::RoomMember::State::Moderator);
} }
UpdateView(); UpdateView();
} }
@ -82,6 +102,7 @@ void ClientRoomWindow::UpdateView() {
.arg(QString::fromStdString(information.name)) .arg(QString::fromStdString(information.name))
.arg(memberlist.size()) .arg(memberlist.size())
.arg(information.member_slots)); .arg(information.member_slots));
ui->description->setText(QString::fromStdString(information.description));
return; return;
} }
} }

@ -26,10 +26,12 @@ public slots:
signals: signals:
void RoomInformationChanged(const Network::RoomInformation&); void RoomInformationChanged(const Network::RoomInformation&);
void StateChanged(const Network::RoomMember::State&); void StateChanged(const Network::RoomMember::State&);
void ShowNotification();
private: private:
void Disconnect(); void Disconnect();
void UpdateView(); void UpdateView();
void SetModPerms(bool is_mod);
QStandardItemModel* player_list; QStandardItemModel* player_list;
std::unique_ptr<Ui::ClientRoom> ui; std::unique_ptr<Ui::ClientRoom> ui;

@ -6,7 +6,7 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>607</width> <width>807</width>
<height>432</height> <height>432</height>
</rect> </rect>
</property> </property>
@ -21,6 +21,13 @@
<property name="rightMargin"> <property name="rightMargin">
<number>0</number> <number>0</number>
</property> </property>
<item>
<widget class="QLabel" name="description">
<property name="text">
<string>Room Description</string>
</property>
</widget>
</item>
<item> <item>
<spacer name="horizontalSpacer"> <spacer name="horizontalSpacer">
<property name="orientation"> <property name="orientation">
@ -34,6 +41,16 @@
</property> </property>
</spacer> </spacer>
</item> </item>
<item>
<widget class="QPushButton" name="moderation">
<property name="text">
<string>Moderation...</string>
</property>
<property name="visible">
<bool>false</bool>
</property>
</widget>
</item>
<item> <item>
<widget class="QPushButton" name="disconnect"> <widget class="QPushButton" name="disconnect">
<property name="text"> <property name="text">

@ -15,6 +15,7 @@
#include "citra_qt/multiplayer/state.h" #include "citra_qt/multiplayer/state.h"
#include "citra_qt/multiplayer/validation.h" #include "citra_qt/multiplayer/validation.h"
#include "citra_qt/ui_settings.h" #include "citra_qt/ui_settings.h"
#include "core/hle/service/cfg/cfg.h"
#include "core/settings.h" #include "core/settings.h"
#include "network/network.h" #include "network/network.h"
#include "ui_direct_connect.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. // Prevent the user from trying to join a room while they are already joining.
if (member->GetState() == Network::RoomMember::State::Joining) { if (member->GetState() == Network::RoomMember::State::Joining) {
return; 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. // And ask if they want to leave the room if they are already in one.
if (!NetworkMessage::WarnDisconnect()) { if (!NetworkMessage::WarnDisconnect()) {
return; return;
@ -97,6 +98,7 @@ void DirectConnectWindow::Connect() {
if (auto room_member = Network::GetRoomMember().lock()) { if (auto room_member = Network::GetRoomMember().lock()) {
auto port = UISettings::values.port.toUInt(); auto port = UISettings::values.port.toUInt();
room_member->Join(ui->nickname->text().toStdString(), room_member->Join(ui->nickname->text().toStdString(),
Service::CFG::GetConsoleIdHash(Core::System::GetInstance()),
ui->ip->text().toStdString().c_str(), port, 0, ui->ip->text().toStdString().c_str(), port, 0,
Network::NoPreferredMac, ui->password->text().toStdString().c_str()); Network::NoPreferredMac, ui->password->text().toStdString().c_str());
} }
@ -120,7 +122,9 @@ void DirectConnectWindow::OnConnection() {
EndConnecting(); EndConnecting();
if (auto room_member = Network::GetRoomMember().lock()) { 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(); close();
} }
} }

@ -19,8 +19,12 @@
#include "citra_qt/ui_settings.h" #include "citra_qt/ui_settings.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "core/announce_multiplayer_session.h" #include "core/announce_multiplayer_session.h"
#include "core/hle/service/cfg/cfg.h"
#include "core/settings.h" #include "core/settings.h"
#include "ui_host_room.h" #include "ui_host_room.h"
#ifdef ENABLE_WEB_SERVICE
#include "web_service/verify_user_jwt.h"
#endif
HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list, HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list,
std::shared_ptr<Core::AnnounceMultiplayerSession> session) std::shared_ptr<Core::AnnounceMultiplayerSession> session)
@ -69,6 +73,7 @@ HostRoomWindow::HostRoomWindow(QWidget* parent, QStandardItemModel* list,
if (index != -1) { if (index != -1) {
ui->game_list->setCurrentIndex(index); ui->game_list->setCurrentIndex(index);
} }
ui->room_description->setText(UISettings::values.room_description);
} }
HostRoomWindow::~HostRoomWindow() = default; HostRoomWindow::~HostRoomWindow() = default;
@ -77,6 +82,21 @@ void HostRoomWindow::RetranslateUi() {
ui->retranslateUi(this); ui->retranslateUi(this);
} }
std::unique_ptr<Network::VerifyUser::Backend> HostRoomWindow::CreateVerifyBackend(
bool use_validation) const {
std::unique_ptr<Network::VerifyUser::Backend> verify_backend;
if (use_validation) {
#ifdef ENABLE_WEB_SERVICE
verify_backend = std::make_unique<WebService::VerifyUserJWT>(Settings::values.web_api_url);
#else
verify_backend = std::make_unique<Network::VerifyUser::NullBackend>();
#endif
} else {
verify_backend = std::make_unique<Network::VerifyUser::NullBackend>();
}
return verify_backend;
}
void HostRoomWindow::Host() { void HostRoomWindow::Host() {
if (!ui->username->hasAcceptableInput()) { if (!ui->username->hasAcceptableInput()) {
NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID); NetworkMessage::ShowError(NetworkMessage::USERNAME_NOT_VALID);
@ -93,7 +113,7 @@ void HostRoomWindow::Host() {
if (auto member = Network::GetRoomMember().lock()) { if (auto member = Network::GetRoomMember().lock()) {
if (member->GetState() == Network::RoomMember::State::Joining) { if (member->GetState() == Network::RoomMember::State::Joining) {
return; return;
} else if (member->GetState() == Network::RoomMember::State::Joined) { } else if (member->IsConnected()) {
auto parent = static_cast<MultiplayerState*>(parentWidget()); auto parent = static_cast<MultiplayerState*>(parentWidget());
if (!parent->OnCloseRoom()) { if (!parent->OnCloseRoom()) {
close(); close();
@ -106,9 +126,17 @@ void HostRoomWindow::Host() {
auto game_id = ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong(); auto game_id = ui->game_list->currentData(GameListItemPath::ProgramIdRole).toLongLong();
auto port = ui->port->isModified() ? ui->port->text().toInt() : Network::DefaultRoomPort; auto port = ui->port->isModified() ? ui->port->text().toInt() : Network::DefaultRoomPort;
auto password = ui->password->text().toStdString(); 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()) { if (auto room = Network::GetRoom().lock()) {
bool created = room->Create(ui->room_name->text().toStdString(), "", port, password, bool created = room->Create(ui->room_name->text().toStdString(),
ui->max_player->value(), game_name.toStdString(), game_id); 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) { if (!created) {
NetworkMessage::ShowError(NetworkMessage::COULD_NOT_CREATE_ROOM); NetworkMessage::ShowError(NetworkMessage::COULD_NOT_CREATE_ROOM);
LOG_ERROR(Network, "Could not create room!"); LOG_ERROR(Network, "Could not create room!");
@ -116,8 +144,34 @@ void HostRoomWindow::Host() {
return; return;
} }
} }
member->Join(ui->username->text().toStdString(), "127.0.0.1", port, 0, // Start the announce session if they chose Public
Network::NoPreferredMac, password); 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 // Store settings
UISettings::values.room_nickname = ui->username->text(); 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()) UISettings::values.room_port = (ui->port->isModified() && !ui->port->text().isEmpty())
? ui->port->text() ? ui->port->text()
: QString::number(Network::DefaultRoomPort); : QString::number(Network::DefaultRoomPort);
UISettings::values.room_description = ui->room_description->toPlainText();
Settings::Apply(); Settings::Apply();
OnConnection(); ui->host->setEnabled(true);
} close();
}
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();
}
} }
} }

@ -26,6 +26,10 @@ class ComboBoxProxyModel;
class ChatMessage; class ChatMessage;
namespace Network::VerifyUser {
class Backend;
};
class HostRoomWindow : public QDialog { class HostRoomWindow : public QDialog {
Q_OBJECT Q_OBJECT
@ -36,15 +40,9 @@ public:
void RetranslateUi(); void RetranslateUi();
private slots:
/**
* Handler for connection status changes. Launches the chat window if successful or
* displays an error
*/
void OnConnection();
private: private:
void Host(); void Host();
std::unique_ptr<Network::VerifyUser::Backend> CreateVerifyBackend(bool use_validation) const;
std::weak_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session; std::weak_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session;
QStandardItemModel* game_list; QStandardItemModel* game_list;

@ -7,7 +7,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>607</width> <width>607</width>
<height>165</height> <height>211</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
@ -131,6 +131,34 @@
</layout> </layout>
</widget> </widget>
</item> </item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Room Description</string>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="room_description"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout">
<item>
<widget class="QCheckBox" name="load_ban_list">
<property name="text">
<string>Load Previous Ban List</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item> <item>
<layout class="QHBoxLayout" name="horizontalLayout"> <layout class="QHBoxLayout" name="horizontalLayout">
<property name="rightMargin"> <property name="rightMargin">

@ -15,8 +15,12 @@
#include "citra_qt/multiplayer/validation.h" #include "citra_qt/multiplayer/validation.h"
#include "citra_qt/ui_settings.h" #include "citra_qt/ui_settings.h"
#include "common/logging/log.h" #include "common/logging/log.h"
#include "core/hle/service/cfg/cfg.h"
#include "core/settings.h" #include "core/settings.h"
#include "network/network.h" #include "network/network.h"
#ifdef ENABLE_WEB_SERVICE
#include "web_service/web_backend.h"
#endif
Lobby::Lobby(QWidget* parent, QStandardItemModel* list, Lobby::Lobby(QWidget* parent, QStandardItemModel* list,
std::shared_ptr<Core::AnnounceMultiplayerSession> session) std::shared_ptr<Core::AnnounceMultiplayerSession> 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. // Prevent the user from trying to join a room while they are already joining.
if (member->GetState() == Network::RoomMember::State::Joining) { if (member->GetState() == Network::RoomMember::State::Joining) {
return; 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. // And ask if they want to leave the room if they are already in one.
if (!NetworkMessage::WarnDisconnect()) { if (!NetworkMessage::WarnDisconnect()) {
return; return;
@ -135,11 +139,27 @@ void Lobby::OnJoinRoom(const QModelIndex& source) {
const std::string ip = const std::string ip =
proxy->data(connection_index, LobbyItemHost::HostIPRole).toString().toStdString(); proxy->data(connection_index, LobbyItemHost::HostIPRole).toString().toStdString();
int port = proxy->data(connection_index, LobbyItemHost::HostPortRole).toInt(); 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 // attempt to connect in a different thread
QFuture<void> f = QtConcurrent::run([nickname, ip, port, password] { QFuture<void> 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()) { 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); watcher->setFuture(f);
@ -191,7 +211,8 @@ void Lobby::OnRefreshLobby() {
QList<QVariant> members; QList<QVariant> members;
for (auto member : room.members) { for (auto member : room.members) {
QVariant var; 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)}); QString::fromStdString(member.game_name)});
members.append(var); members.append(var);
} }
@ -203,14 +224,18 @@ void Lobby::OnRefreshLobby() {
new LobbyItemGame(room.preferred_game_id, QString::fromStdString(room.preferred_game), new LobbyItemGame(room.preferred_game_id, QString::fromStdString(room.preferred_game),
smdh_icon), smdh_icon),
new LobbyItemHost(QString::fromStdString(room.owner), QString::fromStdString(room.ip), 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), new LobbyItemMemberList(members, room.max_player),
}); });
model->appendRow(row); model->appendRow(row);
// To make the rows expandable, add the member data as a child of the first column of the // 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 // rows with people in them and have qt set them to colspan after the model is finished
// resetting // 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)); first_item->appendRow(new LobbyItemExpandedMemberList(members));
} }
} }
@ -226,8 +251,8 @@ void Lobby::OnRefreshLobby() {
// Set the member list child items to span all columns // Set the member list child items to span all columns
for (int i = 0; i < proxy->rowCount(); i++) { for (int i = 0; i < proxy->rowCount(); i++) {
auto parent = model->item(i, 0); auto parent = model->item(i, 0);
if (parent->hasChildren()) { for (int j = 0; j < parent->rowCount(); j++) {
ui->room_list->setFirstColumnSpanned(0, proxy->index(i, 0), true); ui->room_list->setFirstColumnSpanned(j, proxy->index(i, 0), true);
} }
} }
} }

@ -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 { class LobbyItemGame : public LobbyItem {
public: public:
static const int TitleIDRole = Qt::UserRole + 1; static const int TitleIDRole = Qt::UserRole + 1;
@ -95,12 +120,14 @@ public:
static const int HostUsernameRole = Qt::UserRole + 1; static const int HostUsernameRole = Qt::UserRole + 1;
static const int HostIPRole = Qt::UserRole + 2; static const int HostIPRole = Qt::UserRole + 2;
static const int HostPortRole = Qt::UserRole + 3; static const int HostPortRole = Qt::UserRole + 3;
static const int HostVerifyUIDRole = Qt::UserRole + 4;
LobbyItemHost() = default; 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(username, HostUsernameRole);
setData(ip, HostIPRole); setData(ip, HostIPRole);
setData(port, HostPortRole); setData(port, HostPortRole);
setData(verify_UID, HostVerifyUIDRole);
} }
QVariant data(int role) const override { QVariant data(int role) const override {
@ -121,12 +148,17 @@ class LobbyMember {
public: public:
LobbyMember() = default; LobbyMember() = default;
LobbyMember(const LobbyMember& other) = default; LobbyMember(const LobbyMember& other) = default;
explicit LobbyMember(QString username, u64 title_id, QString game_name) explicit LobbyMember(QString username, QString nickname, u64 title_id, QString game_name)
: username(std::move(username)), title_id(title_id), game_name(std::move(game_name)) {} : username(std::move(username)), nickname(std::move(nickname)), title_id(title_id),
game_name(std::move(game_name)) {}
~LobbyMember() = default; ~LobbyMember() = default;
QString GetUsername() const { QString GetName() const {
return username; if (username.isEmpty() || username == nickname) {
return nickname;
} else {
return QString("%1 (%2)").arg(nickname, username);
}
} }
u64 GetTitleId() const { u64 GetTitleId() const {
return title_id; return title_id;
@ -137,6 +169,7 @@ public:
private: private:
QString username; QString username;
QString nickname;
u64 title_id; u64 title_id;
QString game_name; QString game_name;
}; };
@ -195,10 +228,9 @@ public:
out += '\n'; out += '\n';
const auto& m = member.value<LobbyMember>(); const auto& m = member.value<LobbyMember>();
if (m.GetGameName().isEmpty()) { 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 { } else {
out += out += QString(QObject::tr("%1 is playing %2")).arg(m.GetName(), m.GetGameName());
QString(QObject::tr("%1 is playing %2")).arg(m.GetUsername(), m.GetGameName());
} }
first = false; first = false;
} }

@ -12,8 +12,8 @@ const ConnectionError USERNAME_NOT_VALID(
QT_TR_NOOP("Username is not valid. Must be 4 to 20 alphanumeric characters.")); QT_TR_NOOP("Username is not valid. Must be 4 to 20 alphanumeric characters."));
const ConnectionError ROOMNAME_NOT_VALID( const ConnectionError ROOMNAME_NOT_VALID(
QT_TR_NOOP("Room name is not valid. Must be 4 to 20 alphanumeric characters.")); QT_TR_NOOP("Room name is not valid. Must be 4 to 20 alphanumeric characters."));
const ConnectionError USERNAME_IN_USE( const ConnectionError USERNAME_NOT_VALID_SERVER(
QT_TR_NOOP("Username is already in use. Please choose another.")); 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 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 PORT_NOT_VALID(QT_TR_NOOP("Port must be a number between 0 to 65535."));
const ConnectionError NO_INTERNET( 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 " 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 " "you still cannot connect, contact the room host and verify that the host is "
"properly configured with the external port forwarded.")); "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( const ConnectionError COULD_NOT_CREATE_ROOM(
QT_TR_NOOP("Creating a room failed. Please retry. Restarting Citra might be necessary.")); QT_TR_NOOP("Creating a room failed. Please retry. Restarting Citra might be necessary."));
const ConnectionError HOST_BANNED( const ConnectionError HOST_BANNED(
@ -34,8 +36,16 @@ const ConnectionError WRONG_PASSWORD(QT_TR_NOOP("Incorrect password."));
const ConnectionError GENERIC_ERROR( const ConnectionError GENERIC_ERROR(
QT_TR_NOOP("An unknown error occured. If this error continues to occur, please open an issue")); 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 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( const ConnectionError MAC_COLLISION(
QT_TR_NOOP("MAC address is already in use. Please choose another.")); 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) { static bool WarnMessage(const std::string& title, const std::string& text) {
return QMessageBox::Ok == QMessageBox::warning(nullptr, QObject::tr(title.c_str()), return QMessageBox::Ok == QMessageBox::warning(nullptr, QObject::tr(title.c_str()),

@ -20,20 +20,27 @@ private:
std::string err; std::string err;
}; };
/// When the nickname is considered invalid by the client
extern const ConnectionError USERNAME_NOT_VALID; extern const ConnectionError USERNAME_NOT_VALID;
extern const ConnectionError ROOMNAME_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 IP_ADDRESS_NOT_VALID;
extern const ConnectionError PORT_NOT_VALID; extern const ConnectionError PORT_NOT_VALID;
extern const ConnectionError NO_INTERNET; extern const ConnectionError NO_INTERNET;
extern const ConnectionError UNABLE_TO_CONNECT; extern const ConnectionError UNABLE_TO_CONNECT;
extern const ConnectionError ROOM_IS_FULL;
extern const ConnectionError COULD_NOT_CREATE_ROOM; extern const ConnectionError COULD_NOT_CREATE_ROOM;
extern const ConnectionError HOST_BANNED; extern const ConnectionError HOST_BANNED;
extern const ConnectionError WRONG_VERSION; extern const ConnectionError WRONG_VERSION;
extern const ConnectionError WRONG_PASSWORD; extern const ConnectionError WRONG_PASSWORD;
extern const ConnectionError GENERIC_ERROR; extern const ConnectionError GENERIC_ERROR;
extern const ConnectionError LOST_CONNECTION; extern const ConnectionError LOST_CONNECTION;
extern const ConnectionError HOST_KICKED;
extern const ConnectionError MAC_COLLISION; 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 * Shows a standard QMessageBox with a error message

@ -0,0 +1,113 @@
// Copyright 2018 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <QStandardItem>
#include <QStandardItemModel>
#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::ModerationDialog>()) {
ui->setupUi(this);
qRegisterMetaType<Network::Room::BanList>();
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();
}

@ -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 <memory>
#include <optional>
#include <QDialog>
#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::ModerationDialog> ui;
QStandardItemModel* model;
Network::RoomMember::CallbackHandle<Network::StatusMessageEntry> callback_handle_status_message;
Network::RoomMember::CallbackHandle<Network::Room::BanList> callback_handle_ban_list;
};
Q_DECLARE_METATYPE(Network::Room::BanList);

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ModerationDialog</class>
<widget class="QDialog" name="ModerationDialog">
<property name="windowTitle">
<string>Moderation</string>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>500</width>
<height>300</height>
</rect>
</property>
<layout class="QVBoxLayout">
<item>
<widget class="QGroupBox" name="ban_list_group_box">
<property name="title">
<string>Ban List</string>
</property>
<layout class="QVBoxLayout">
<item>
<layout class="QHBoxLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="refresh">
<property name="text">
<string>Refreshing</string>
</property>
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="unban">
<property name="text">
<string>Unban</string>
</property>
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTreeView" name="ban_list_view"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>ModerationDialog</receiver>
<slot>accept()</slot>
</connection>
</connections>
<resources/>
</ui>

@ -3,6 +3,7 @@
// Refer to the license.txt file included. // Refer to the license.txt file included.
#include <QAction> #include <QAction>
#include <QApplication>
#include <QIcon> #include <QIcon>
#include <QMessageBox> #include <QMessageBox>
#include <QStandardItemModel> #include <QStandardItemModel>
@ -13,6 +14,7 @@
#include "citra_qt/multiplayer/lobby.h" #include "citra_qt/multiplayer/lobby.h"
#include "citra_qt/multiplayer/message.h" #include "citra_qt/multiplayer/message.h"
#include "citra_qt/multiplayer/state.h" #include "citra_qt/multiplayer/state.h"
#include "citra_qt/ui_settings.h"
#include "citra_qt/util/clickable_label.h" #include "citra_qt/util/clickable_label.h"
#include "common/announce_multiplayer_room.h" #include "common/announce_multiplayer_room.h"
#include "common/logging/log.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); }); [this](const Network::RoomMember::State& state) { emit NetworkStateChanged(state); });
connect(this, &MultiplayerState::NetworkStateChanged, this, connect(this, &MultiplayerState::NetworkStateChanged, this,
&MultiplayerState::OnNetworkStateChanged); &MultiplayerState::OnNetworkStateChanged);
error_callback_handle = member->BindOnError(
[this](const Network::RoomMember::Error& error) { emit NetworkError(error); });
connect(this, &MultiplayerState::NetworkError, this, &MultiplayerState::OnNetworkError);
} }
qRegisterMetaType<Network::RoomMember::State>(); qRegisterMetaType<Network::RoomMember::State>();
qRegisterMetaType<Network::RoomMember::Error>();
qRegisterMetaType<Common::WebResult>(); qRegisterMetaType<Common::WebResult>();
announce_multiplayer_session = std::make_shared<Core::AnnounceMultiplayerSession>(); announce_multiplayer_session = std::make_shared<Core::AnnounceMultiplayerSession>();
announce_multiplayer_session->BindErrorCallback( 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_text, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom);
connect(status_icon, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom); connect(status_icon, &ClickableLabel::clicked, this, &MultiplayerState::OnOpenNetworkRoom);
connect(static_cast<QApplication*>(QApplication::instance()), &QApplication::focusChanged, this,
[this](QWidget* /*old*/, QWidget* now) {
if (client_room && client_room->isAncestorOf(now)) {
HideNotification();
}
});
} }
MultiplayerState::~MultiplayerState() { MultiplayerState::~MultiplayerState() {
@ -52,6 +65,12 @@ MultiplayerState::~MultiplayerState() {
member->Unbind(state_callback_handle); member->Unbind(state_callback_handle);
} }
} }
if (error_callback_handle) {
if (auto member = Network::GetRoomMember().lock()) {
member->Unbind(error_callback_handle);
}
}
} }
void MultiplayerState::Close() { void MultiplayerState::Close() {
@ -70,7 +89,9 @@ void MultiplayerState::retranslateUi() {
if (current_state == Network::RoomMember::State::Uninitialized) { if (current_state == Network::RoomMember::State::Uninitialized) {
status_text->setText(tr("Not Connected. Click here to find a room!")); 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")); status_text->setText(tr("Connected"));
} else { } else {
status_text->setText(tr("Not Connected")); status_text->setText(tr("Not Connected"));
@ -88,35 +109,10 @@ void MultiplayerState::retranslateUi() {
void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& state) { void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& state) {
LOG_DEBUG(Frontend, "Network State: {}", Network::GetStateStr(state)); LOG_DEBUG(Frontend, "Network State: {}", Network::GetStateStr(state));
bool is_connected = false; if (state == Network::RoomMember::State::Joined ||
switch (state) { state == Network::RoomMember::State::Moderator) {
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;
OnOpenNetworkRoom(); OnOpenNetworkRoom();
break;
}
if (is_connected) {
status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16)); status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16));
status_text->setText(tr("Connected")); status_text->setText(tr("Connected"));
leave_room->setEnabled(true); leave_room->setEnabled(true);
@ -131,6 +127,51 @@ void MultiplayerState::OnNetworkStateChanged(const Network::RoomMember::State& s
current_state = state; 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) { void MultiplayerState::OnAnnounceFailed(const Common::WebResult& result) {
announce_multiplayer_session->Stop(); announce_multiplayer_session->Stop();
QMessageBox::warning( QMessageBox::warning(
@ -144,7 +185,11 @@ void MultiplayerState::OnAnnounceFailed(const Common::WebResult& result) {
} }
void MultiplayerState::UpdateThemedIcons() { 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)); status_icon->setPixmap(QIcon::fromTheme("connected").pixmap(16));
} else { } else {
status_icon->setPixmap(QIcon::fromTheme("disconnected").pixmap(16)); status_icon->setPixmap(QIcon::fromTheme("disconnected").pixmap(16));
@ -185,6 +230,10 @@ bool MultiplayerState::OnCloseRoom() {
if (room->GetState() != Network::Room::State::Open) { if (room->GetState() != Network::Room::State::Open) {
return true; return true;
} }
// Save ban list
if (auto room = Network::GetRoom().lock()) {
UISettings::values.ban_list = std::move(room->GetBanList());
}
room->Destroy(); room->Destroy();
announce_multiplayer_session->Stop(); announce_multiplayer_session->Stop();
LOG_DEBUG(Frontend, "Closed the room (as a server)"); LOG_DEBUG(Frontend, "Closed the room (as a server)");
@ -192,11 +241,28 @@ bool MultiplayerState::OnCloseRoom() {
return true; 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() { void MultiplayerState::OnOpenNetworkRoom() {
if (auto member = Network::GetRoomMember().lock()) { if (auto member = Network::GetRoomMember().lock()) {
if (member->IsConnected()) { if (member->IsConnected()) {
if (client_room == nullptr) { if (client_room == nullptr) {
client_room = new ClientRoomWindow(this); client_room = new ClientRoomWindow(this);
connect(client_room, &ClientRoomWindow::ShowNotification, this,
&MultiplayerState::ShowNotification);
} }
BringWidgetToFront(client_room); BringWidgetToFront(client_room);
return; return;

@ -40,6 +40,7 @@ public:
public slots: public slots:
void OnNetworkStateChanged(const Network::RoomMember::State& state); void OnNetworkStateChanged(const Network::RoomMember::State& state);
void OnNetworkError(const Network::RoomMember::Error& error);
void OnViewLobby(); void OnViewLobby();
void OnCreateRoom(); void OnCreateRoom();
bool OnCloseRoom(); bool OnCloseRoom();
@ -47,9 +48,12 @@ public slots:
void OnDirectConnectToRoom(); void OnDirectConnectToRoom();
void OnAnnounceFailed(const Common::WebResult&); void OnAnnounceFailed(const Common::WebResult&);
void UpdateThemedIcons(); void UpdateThemedIcons();
void ShowNotification();
void HideNotification();
signals: signals:
void NetworkStateChanged(const Network::RoomMember::State&); void NetworkStateChanged(const Network::RoomMember::State&);
void NetworkError(const Network::RoomMember::Error&);
void AnnounceFailed(const Common::WebResult&); void AnnounceFailed(const Common::WebResult&);
private: private:
@ -64,7 +68,11 @@ private:
QAction* show_room; QAction* show_room;
std::shared_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session; std::shared_ptr<Core::AnnounceMultiplayerSession> announce_multiplayer_session;
Network::RoomMember::State current_state = Network::RoomMember::State::Uninitialized; Network::RoomMember::State current_state = Network::RoomMember::State::Uninitialized;
bool has_mod_perms = false;
Network::RoomMember::CallbackHandle<Network::RoomMember::State> state_callback_handle; Network::RoomMember::CallbackHandle<Network::RoomMember::State> state_callback_handle;
Network::RoomMember::CallbackHandle<Network::RoomMember::Error> error_callback_handle;
bool show_notification = false;
}; };
Q_DECLARE_METATYPE(Common::WebResult); Q_DECLARE_METATYPE(Common::WebResult);

@ -5,6 +5,8 @@
#pragma once #pragma once
#include <array> #include <array>
#include <string>
#include <utility>
#include <vector> #include <vector>
#include <QByteArray> #include <QByteArray>
#include <QMetaType> #include <QMetaType>
@ -109,6 +111,8 @@ struct Values {
QString room_port; QString room_port;
uint host_type; uint host_type;
qulonglong game_id; qulonglong game_id;
QString room_description;
std::pair<std::vector<std::string>, std::vector<std::string>> ban_list;
// logging // logging
bool show_console; bool show_console;

@ -17,13 +17,17 @@ using MacAddress = std::array<u8, 6>;
struct Room { struct Room {
struct Member { struct Member {
std::string name; std::string username;
std::string nickname;
std::string avatar_url;
MacAddress mac_address; MacAddress mac_address;
std::string game_name; std::string game_name;
u64 game_id; u64 game_id;
}; };
std::string id;
std::string verify_UID; ///< UID used for verification
std::string name; std::string name;
std::string UID; std::string description;
std::string owner; std::string owner;
std::string ip; std::string ip;
u16 port; u16 port;
@ -49,14 +53,15 @@ public:
* Sets the Information that gets used for the announce * Sets the Information that gets used for the announce
* @param uid The Id of the room * @param uid The Id of the room
* @param name The name of the room * @param name The name of the room
* @param description The room description
* @param port The port of the room * @param port The port of the room
* @param net_version The version of the libNetwork that gets used * @param net_version The version of the libNetwork that gets used
* @param has_password True if the room is passowrd protected * @param has_password True if the room is passowrd protected
* @param preferred_game The preferred game of the room * @param preferred_game The preferred game of the room
* @param preferred_game_id The title id of the preferred game * @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, virtual void SetRoomInformation(const std::string& name, const std::string& description,
const u32 max_player, const u32 net_version, const u16 port, const u32 max_player, const u32 net_version,
const bool has_password, const std::string& preferred_game, const bool has_password, const std::string& preferred_game,
const u64 preferred_game_id) = 0; 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_id The title id of the game the player plays
* @param game_name The name 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; const u64 game_id, const std::string& game_name) = 0;
/** /**
* Send the data to the announce service * Updates the data in the announce service. Re-register the room when required.
* @result The result of the announce attempt * @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 * Empties the stored players
@ -99,15 +111,19 @@ public:
class NullBackend : public Backend { class NullBackend : public Backend {
public: public:
~NullBackend() = default; ~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 u16 /*port*/, const u32 /*max_player*/, const u32 /*net_version*/,
const bool /*has_password*/, const std::string& /*preferred_game*/, const bool /*has_password*/, const std::string& /*preferred_game*/,
const u64 /*preferred_game_id*/) override {} 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 {} 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"}; return Common::WebResult{Common::WebResult::Code::NoWebservice, "WebService is missing"};
} }
std::string Register() override {
return "";
}
void ClearPlayers() override {} void ClearPlayers() override {}
RoomList GetRoomList() override { RoomList GetRoomList() override {
return RoomList{}; return RoomList{};

@ -29,6 +29,21 @@ AnnounceMultiplayerSession::AnnounceMultiplayerSession() {
#endif #endif
} }
void AnnounceMultiplayerSession::Register() {
std::shared_ptr<Network::Room> 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() { void AnnounceMultiplayerSession::Start() {
if (announce_multiplayer_thread) { if (announce_multiplayer_thread) {
Stop(); Stop();
@ -44,6 +59,7 @@ void AnnounceMultiplayerSession::Stop() {
announce_multiplayer_thread->join(); announce_multiplayer_thread->join();
announce_multiplayer_thread.reset(); announce_multiplayer_thread.reset();
backend->Delete(); backend->Delete();
registered = false;
} }
} }
@ -64,7 +80,24 @@ AnnounceMultiplayerSession::~AnnounceMultiplayerSession() {
Stop(); Stop();
} }
void AnnounceMultiplayerSession::UpdateBackendData(std::shared_ptr<Network::Room> room) {
Network::RoomInformation room_information = room->GetRoomInformation();
std::vector<Network::Room::Member> 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() { void AnnounceMultiplayerSession::AnnounceMultiplayerLoop() {
if (!registered) {
Register();
}
auto update_time = std::chrono::steady_clock::now(); auto update_time = std::chrono::steady_clock::now();
std::future<Common::WebResult> future; std::future<Common::WebResult> future;
while (!shutdown_event.WaitUntil(update_time)) { while (!shutdown_event.WaitUntil(update_time)) {
@ -76,24 +109,19 @@ void AnnounceMultiplayerSession::AnnounceMultiplayerLoop() {
if (room->GetState() != Network::Room::State::Open) { if (room->GetState() != Network::Room::State::Open) {
break; break;
} }
Network::RoomInformation room_information = room->GetRoomInformation(); UpdateBackendData(room);
std::vector<Network::Room::Member> memberlist = room->GetRoomMemberList(); Common::WebResult result = backend->Update();
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();
if (result.result_code != Common::WebResult::Code::Success) { if (result.result_code != Common::WebResult::Code::Success) {
std::lock_guard<std::mutex> lock(callback_mutex); std::lock_guard<std::mutex> lock(callback_mutex);
for (auto callback : error_callbacks) { for (auto callback : error_callbacks) {
(*callback)(result); (*callback)(result);
} }
} }
if (result.result_string == "404") {
registered = false;
// Needs to register the room again
Register();
}
} }
} }

@ -4,6 +4,7 @@
#pragma once #pragma once
#include <atomic>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
@ -13,6 +14,10 @@
#include "common/common_types.h" #include "common/common_types.h"
#include "common/thread.h" #include "common/thread.h"
namespace Network {
class Room;
}
namespace Core { namespace Core {
/** /**
@ -39,6 +44,9 @@ public:
*/ */
void UnbindErrorCallback(CallbackHandle handle); void UnbindErrorCallback(CallbackHandle handle);
/// Registers a room to web services
void Register();
/** /**
* Starts the announce of a room to web services * Starts the announce of a room to web services
*/ */
@ -65,6 +73,9 @@ private:
/// Backend interface that logs fields /// Backend interface that logs fields
std::unique_ptr<AnnounceMultiplayerRoom::Backend> backend; std::unique_ptr<AnnounceMultiplayerRoom::Backend> backend;
std::atomic_bool registered = false; ///< Whether the room has been registered
void UpdateBackendData(std::shared_ptr<Network::Room> room);
void AnnounceMultiplayerLoop(); void AnnounceMultiplayerLoop();
}; };

@ -734,4 +734,21 @@ void InstallInterfaces(Core::System& system) {
std::make_shared<CFG_NOR>()->InstallAsService(service_manager); std::make_shared<CFG_NOR>()->InstallAsService(service_manager);
} }
std::string GetConsoleIdHash(Core::System& system) {
u64_le console_id{};
std::array<u8, sizeof(console_id)> buffer;
if (system.IsPoweredOn()) {
auto cfg = GetModule(system);
ASSERT_MSG(cfg, "CFG Module missing!");
console_id = cfg->GetConsoleUniqueId();
} else {
console_id = std::make_unique<Service::CFG::Module>()->GetConsoleUniqueId();
}
std::memcpy(buffer.data(), &console_id, sizeof(console_id));
std::array<u8, CryptoPP::SHA256::DIGESTSIZE> hash;
CryptoPP::SHA256().CalculateDigest(hash.data(), buffer.data(), sizeof(buffer));
return fmt::format("{:02x}", fmt::join(hash.begin(), hash.end(), ""));
}
} // namespace Service::CFG } // namespace Service::CFG

@ -415,4 +415,7 @@ std::shared_ptr<Module> GetModule(Core::System& system);
void InstallInterfaces(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 } // namespace Service::CFG

@ -140,7 +140,9 @@ std::list<Network::WifiPacket> GetReceivedBeacons(const MacAddress& sender) {
/// Sends a WifiPacket to the room we're currently connected to. /// Sends a WifiPacket to the room we're currently connected to.
void SendPacket(Network::WifiPacket& packet) { void SendPacket(Network::WifiPacket& packet) {
if (auto room_member = Network::GetRoomMember().lock()) { 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(); packet.transmitter_address = room_member->GetMacAddress();
room_member->SendWifiPacket(packet); room_member->SendWifiPacket(packet);
} }

@ -8,6 +8,11 @@ add_executable(citra-room
create_target_directory_groups(citra-room) create_target_directory_groups(citra-room)
target_link_libraries(citra-room PRIVATE common core network) 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) target_link_libraries(citra-room PRIVATE glad)
if (MSVC) if (MSVC)
target_link_libraries(citra-room PRIVATE getopt) target_link_libraries(citra-room PRIVATE getopt)

@ -3,6 +3,7 @@
// Refer to the license.txt file included. // Refer to the license.txt file included.
#include <chrono> #include <chrono>
#include <fstream>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <regex> #include <regex>
@ -27,15 +28,23 @@
#include "common/common_types.h" #include "common/common_types.h"
#include "common/detached_tasks.h" #include "common/detached_tasks.h"
#include "common/scm_rev.h" #include "common/scm_rev.h"
#include "common/string_util.h"
#include "core/announce_multiplayer_session.h" #include "core/announce_multiplayer_session.h"
#include "core/core.h" #include "core/core.h"
#include "core/settings.h" #include "core/settings.h"
#include "network/network.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) { static void PrintHelp(const char* argv0) {
std::cout << "Usage: " << argv0 std::cout << "Usage: " << argv0
<< " [options] <filename>\n" << " [options] <filename>\n"
"--room-name The name of the room\n" "--room-name The name of the room\n"
"--room-description The room description\n"
"--port The port used for the room\n" "--port The port used for the room\n"
"--max_members The maximum number of players for this room\n" "--max_members The maximum number of players for this room\n"
"--password The password for the 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" "--username The username used for announce\n"
"--token The token used for announce\n" "--token The token used for announce\n"
"--web-api-url Citra Web API url\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" "-h, --help Display this help and exit\n"
"-v, --version Output version information and exit\n"; "-v, --version Output version information and exit\n";
} }
@ -53,6 +64,71 @@ static void PrintVersion() {
<< " Libnetwork: " << Network::network_version << std::endl; << " 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 /// Application entry point
int main(int argc, char** argv) { int main(int argc, char** argv) {
Common::DetachedTasks detached_tasks; Common::DetachedTasks detached_tasks;
@ -63,17 +139,21 @@ int main(int argc, char** argv) {
gladLoadGL(); gladLoadGL();
std::string room_name; std::string room_name;
std::string room_description;
std::string password; std::string password;
std::string preferred_game; std::string preferred_game;
std::string username; std::string username;
std::string token; std::string token;
std::string web_api_url; std::string web_api_url;
std::string ban_list_file;
u64 preferred_game_id = 0; u64 preferred_game_id = 0;
u32 port = Network::DefaultRoomPort; u32 port = Network::DefaultRoomPort;
u32 max_members = 16; u32 max_members = 16;
bool enable_citra_mods = false;
static struct option long_options[] = { static struct option long_options[] = {
{"room-name", required_argument, 0, 'n'}, {"room-name", required_argument, 0, 'n'},
{"room-description", required_argument, 0, 'd'},
{"port", required_argument, 0, 'p'}, {"port", required_argument, 0, 'p'},
{"max_members", required_argument, 0, 'm'}, {"max_members", required_argument, 0, 'm'},
{"password", required_argument, 0, 'w'}, {"password", required_argument, 0, 'w'},
@ -82,18 +162,23 @@ int main(int argc, char** argv) {
{"username", required_argument, 0, 'u'}, {"username", required_argument, 0, 'u'},
{"token", required_argument, 0, 't'}, {"token", required_argument, 0, 't'},
{"web-api-url", required_argument, 0, 'a'}, {"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'}, {"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'v'}, {"version", no_argument, 0, 'v'},
{0, 0, 0, 0}, {0, 0, 0, 0},
}; };
while (optind < argc) { 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) { if (arg != -1) {
switch (arg) { switch (arg) {
case 'n': case 'n':
room_name.assign(optarg); room_name.assign(optarg);
break; break;
case 'd':
room_description.assign(optarg);
break;
case 'p': case 'p':
port = strtoul(optarg, &endarg, 0); port = strtoul(optarg, &endarg, 0);
break; break;
@ -118,6 +203,12 @@ int main(int argc, char** argv) {
case 'a': case 'a':
web_api_url.assign(optarg); web_api_url.assign(optarg);
break; break;
case 'b':
ban_list_file.assign(optarg);
break;
case 'e':
enable_citra_mods = true;
break;
case 'h': case 'h':
PrintHelp(argv[0]); PrintHelp(argv[0]);
return 0; return 0;
@ -153,6 +244,10 @@ int main(int argc, char** argv) {
PrintHelp(argv[0]); PrintHelp(argv[0]);
return -1; 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 <file>\n\n";
}
bool announce = true; bool announce = true;
if (username.empty()) { if (username.empty()) {
announce = false; announce = false;
@ -172,11 +267,35 @@ int main(int argc, char** argv) {
Settings::values.citra_username = username; Settings::values.citra_username = username;
Settings::values.citra_token = token; 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<Network::VerifyUser::Backend> verify_backend;
if (announce) {
#ifdef ENABLE_WEB_SERVICE
verify_backend = std::make_unique<WebService::VerifyUserJWT>(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<Network::VerifyUser::NullBackend>();
#endif
} else {
verify_backend = std::make_unique<Network::VerifyUser::NullBackend>();
}
Network::Init(); Network::Init();
if (std::shared_ptr<Network::Room> room = Network::GetRoom().lock()) { if (std::shared_ptr<Network::Room> room = Network::GetRoom().lock()) {
if (!room->Create(room_name, "", port, password, max_members, preferred_game, if (!room->Create(room_name, room_description, "", port, password, max_members, username,
preferred_game_id)) { preferred_game, preferred_game_id, std::move(verify_backend), ban_list,
enable_citra_mods)) {
std::cout << "Failed to create room: \n\n"; std::cout << "Failed to create room: \n\n";
return -1; return -1;
} }
@ -193,6 +312,10 @@ int main(int argc, char** argv) {
announce_session->Stop(); announce_session->Stop();
} }
announce_session.reset(); announce_session.reset();
// Save the ban list
if (!ban_list_file.empty()) {
SaveBanList(room->GetBanList(), ban_list_file);
}
room->Destroy(); room->Destroy();
Network::Shutdown(); Network::Shutdown();
return 0; return 0;
@ -203,6 +326,10 @@ int main(int argc, char** argv) {
announce_session->Stop(); announce_session->Stop();
} }
announce_session.reset(); announce_session.reset();
// Save the ban list
if (!ban_list_file.empty()) {
SaveBanList(room->GetBanList(), ban_list_file);
}
room->Destroy(); room->Destroy();
} }
Network::Shutdown(); Network::Shutdown();

@ -7,8 +7,10 @@ add_library(network STATIC
room.h room.h
room_member.cpp room_member.cpp
room_member.h room_member.h
verify_user.cpp
verify_user.h
) )
create_target_directory_groups(network) create_target_directory_groups(network)
target_link_libraries(network PRIVATE common enet) target_link_libraries(network PRIVATE common cpp-jwt enet)

@ -126,7 +126,7 @@ Packet& Packet::operator>>(std::vector<T>& out_data) {
// Then extract the data // Then extract the data
for (std::size_t i = 0; i < out_data.size(); ++i) { for (std::size_t i = 0; i < out_data.size(); ++i) {
T character = 0; T character;
*this >> character; *this >> character;
out_data[i] = character; out_data[i] = character;
} }
@ -136,7 +136,7 @@ Packet& Packet::operator>>(std::vector<T>& out_data) {
template <typename T, std::size_t S> template <typename T, std::size_t S>
Packet& Packet::operator>>(std::array<T, S>& out_data) { Packet& Packet::operator>>(std::array<T, S>& out_data) {
for (std::size_t i = 0; i < out_data.size(); ++i) { for (std::size_t i = 0; i < out_data.size(); ++i) {
T character = 0; T character;
*this >> character; *this >> character;
out_data[i] = character; out_data[i] = character;
} }

@ -7,12 +7,14 @@
#include <iomanip> #include <iomanip>
#include <mutex> #include <mutex>
#include <random> #include <random>
#include <regex>
#include <sstream> #include <sstream>
#include <thread> #include <thread>
#include "common/logging/log.h" #include "common/logging/log.h"
#include "enet/enet.h" #include "enet/enet.h"
#include "network/packet.h" #include "network/packet.h"
#include "network/room.h" #include "network/room.h"
#include "network/verify_user.h"
namespace Network { namespace Network {
@ -27,25 +29,38 @@ public:
std::atomic<State> state{State::Closed}; ///< Current state of the room. std::atomic<State> state{State::Closed}; ///< Current state of the room.
RoomInformation room_information; ///< Information about this 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. std::string password; ///< The password required to connect to this room.
struct Member { struct Member {
std::string nickname; ///< The nickname of the member. std::string nickname; ///< The nickname of the member.
GameInfo game_info; ///< The current game of the member std::string console_id_hash; ///< A hash of the console ID of the member.
MacAddress mac_address; ///< The assigned mac address of the member. GameInfo game_info; ///< The current game of the member
ENetPeer* peer; ///< The remote peer. 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<Member>; using MemberList = std::vector<Member>;
MemberList members; ///< Information about the members of this room MemberList members; ///< Information about the members of this room
mutable std::mutex member_mutex; ///< Mutex for locking the members list 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 /// 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() RoomImpl()
: random_gen(std::random_device()()), NintendoOUI{0x00, 0x1F, 0x32, 0x00, 0x00, 0x00} {} : random_gen(std::random_device()()), NintendoOUI{0x00, 0x1F, 0x32, 0x00, 0x00, 0x00} {}
/// Thread that receives and dispatches network packets /// Thread that receives and dispatches network packets
std::unique_ptr<std::thread> room_thread; std::unique_ptr<std::thread> room_thread;
/// Verification backend of the room
std::unique_ptr<VerifyUser::Backend> verify_backend;
/// Thread function that will receive and dispatch messages until the room is destroyed. /// Thread function that will receive and dispatch messages until the room is destroyed.
void ServerLoop(); void ServerLoop();
void StartLoop(); void StartLoop();
@ -57,6 +72,30 @@ public:
*/ */
void HandleJoinRequest(const ENetEvent* event); 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. * 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; 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. * Sends a ID_ROOM_NAME_COLLISION message telling the client that the name is invalid.
*/ */
@ -78,6 +133,12 @@ public:
*/ */
void SendMacCollision(ENetPeer* client); 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. * 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); 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, * Notifies the members that the room is closed,
*/ */
void SendCloseMessage(); 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 * Sends the information about the room, along with the list of members
* to every connected client in the room. * to every connected client in the room.
* The packet has the structure: * The packet has the structure:
* <MessageID>ID_ROOM_INFORMATION * <MessageID>ID_ROOM_INFORMATION
* <String> room_name * <String> room_name
* <String> room_description
* <u32> member_slots: The max number of clients allowed in this room * <u32> member_slots: The max number of clients allowed in this room
* <String> uid * <String> uid
* <u16> port * <u16> port
@ -145,11 +245,6 @@ public:
* to all other clients. * to all other clients.
*/ */
void HandleClientDisconnection(ENetPeer* client); void HandleClientDisconnection(ENetPeer* client);
/**
* Creates a random ID in the form 12345678-1234-1234-1234-123456789012
*/
void CreateUniqueID();
}; };
// RoomImpl // RoomImpl
@ -172,6 +267,19 @@ void Room::RoomImpl::ServerLoop() {
case IdChatMessage: case IdChatMessage:
HandleChatPacket(&event); HandleChatPacket(&event);
break; 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); enet_packet_destroy(event.packet);
break; break;
@ -193,12 +301,22 @@ void Room::RoomImpl::StartLoop() {
} }
void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) { void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) {
{
std::lock_guard<std::mutex> lock(member_mutex);
if (members.size() >= room_information.member_slots) {
SendRoomIsFull(event->peer);
return;
}
}
Packet packet; Packet packet;
packet.Append(event->packet->data, event->packet->dataLength); packet.Append(event->packet->data, event->packet->dataLength);
packet.IgnoreBytes(sizeof(u8)); // Ignore the message type packet.IgnoreBytes(sizeof(u8)); // Ignore the message type
std::string nickname; std::string nickname;
packet >> nickname; packet >> nickname;
std::string console_id_hash;
packet >> console_id_hash;
MacAddress preferred_mac; MacAddress preferred_mac;
packet >> preferred_mac; packet >> preferred_mac;
@ -208,6 +326,9 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) {
std::string pass; std::string pass;
packet >> pass; packet >> pass;
std::string token;
packet >> token;
if (pass != password) { if (pass != password) {
SendWrongPassword(event->peer); SendWrongPassword(event->peer);
return; return;
@ -229,6 +350,11 @@ void Room::RoomImpl::HandleJoinRequest(const ENetEvent* event) {
preferred_mac = GenerateMacAddress(); preferred_mac = GenerateMacAddress();
} }
if (!IsValidConsoleId(console_id_hash)) {
SendConsoleIdCollision(event->peer);
return;
}
if (client_version != network_version) { if (client_version != network_version) {
SendVersionMismatch(event->peer); SendVersionMismatch(event->peer);
return; 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. // At this point the client is ready to be added to the room.
Member member{}; Member member{};
member.mac_address = preferred_mac; member.mac_address = preferred_mac;
member.console_id_hash = console_id_hash;
member.nickname = nickname; member.nickname = nickname;
member.peer = event->peer; member.peer = event->peer;
std::string uid;
{
std::lock_guard<std::mutex> lock(verify_UID_mutex);
uid = verify_UID;
}
member.user_data = verify_backend->LoadUserData(uid, token);
{
std::lock_guard<std::mutex> 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<std::mutex> lock(member_mutex); std::lock_guard<std::mutex> lock(member_mutex);
members.push_back(std::move(member)); 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. // Notify everyone that the room information has changed.
BroadcastRoomInformation(); 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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 { 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. // A nickname is valid if it matches the regex and is not already taken by anybody else in the
// TODO(B3N30): Check for empty names, spaces, etc. // room.
const std::regex nickname_regex("^[ a-zA-Z0-9._-]{4,20}$");
if (!std::regex_match(nickname, nickname_regex))
return false;
std::lock_guard<std::mutex> lock(member_mutex); std::lock_guard<std::mutex> lock(member_mutex);
return std::all_of(members.begin(), members.end(), return std::all_of(members.begin(), members.end(),
[&nickname](const auto& member) { return member.nickname != nickname; }); [&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; }); [&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<std::mutex> 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<std::mutex> 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) { void Room::RoomImpl::SendNameCollision(ENetPeer* client) {
Packet packet; Packet packet;
packet << static_cast<u8>(IdNameCollision); packet << static_cast<u8>(IdNameCollision);
@ -285,6 +629,16 @@ void Room::RoomImpl::SendMacCollision(ENetPeer* client) {
enet_host_flush(server); enet_host_flush(server);
} }
void Room::RoomImpl::SendConsoleIdCollision(ENetPeer* client) {
Packet packet;
packet << static_cast<u8>(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) { void Room::RoomImpl::SendWrongPassword(ENetPeer* client) {
Packet packet; Packet packet;
packet << static_cast<u8>(IdWrongPassword); packet << static_cast<u8>(IdWrongPassword);
@ -295,6 +649,16 @@ void Room::RoomImpl::SendWrongPassword(ENetPeer* client) {
enet_host_flush(server); enet_host_flush(server);
} }
void Room::RoomImpl::SendRoomIsFull(ENetPeer* client) {
Packet packet;
packet << static_cast<u8>(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) { void Room::RoomImpl::SendVersionMismatch(ENetPeer* client) {
Packet packet; Packet packet;
packet << static_cast<u8>(IdVersionMismatch); packet << static_cast<u8>(IdVersionMismatch);
@ -316,6 +680,71 @@ void Room::RoomImpl::SendJoinSuccess(ENetPeer* client, MacAddress mac_address) {
enet_host_flush(server); enet_host_flush(server);
} }
void Room::RoomImpl::SendJoinSuccessAsMod(ENetPeer* client, MacAddress mac_address) {
Packet packet;
packet << static_cast<u8>(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<u8>(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<u8>(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<u8>(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<u8>(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<u8>(IdModBanListResponse);
{
std::lock_guard<std::mutex> 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() { void Room::RoomImpl::SendCloseMessage() {
Packet packet; Packet packet;
packet << static_cast<u8>(IdCloseRoom); packet << static_cast<u8>(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<u8>(IdStatusMessage);
packet << static_cast<u8>(type);
packet << nickname;
packet << username;
std::lock_guard<std::mutex> 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() { void Room::RoomImpl::BroadcastRoomInformation() {
Packet packet; Packet packet;
packet << static_cast<u8>(IdRoomInformation); packet << static_cast<u8>(IdRoomInformation);
packet << room_information.name; packet << room_information.name;
packet << room_information.description;
packet << room_information.member_slots; packet << room_information.member_slots;
packet << room_information.uid;
packet << room_information.port; packet << room_information.port;
packet << room_information.preferred_game; packet << room_information.preferred_game;
packet << room_information.host_username;
packet << static_cast<u32>(members.size()); packet << static_cast<u32>(members.size());
{ {
@ -350,6 +798,9 @@ void Room::RoomImpl::BroadcastRoomInformation() {
packet << member.mac_address; packet << member.mac_address;
packet << member.game_info.name; packet << member.game_info.name;
packet << member.game_info.id; 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; Packet out_packet;
out_packet << static_cast<u8>(IdChatMessage); out_packet << static_cast<u8>(IdChatMessage);
out_packet << sending_member->nickname; out_packet << sending_member->nickname;
out_packet << sending_member->user_data.username;
out_packet << message; out_packet << message;
ENetPacket* enet_packet = enet_packet_create(out_packet.GetData(), out_packet.GetDataSize(), 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) { void Room::RoomImpl::HandleClientDisconnection(ENetPeer* client) {
// Remove the client from the members list. // Remove the client from the members list.
std::string nickname, username;
{ {
std::lock_guard<std::mutex> lock(member_mutex); std::lock_guard<std::mutex> lock(member_mutex);
members.erase( auto member = std::find_if(members.begin(), members.end(), [client](const Member& member) {
std::remove_if(members.begin(), members.end(), return member.peer == client;
[client](const Member& member) { return member.peer == client; }), });
members.end()); if (member != members.end()) {
nickname = member->nickname;
username = member->user_data.username;
members.erase(member);
}
} }
// Announce the change to all clients. // Announce the change to all clients.
enet_peer_disconnect(client, 0); enet_peer_disconnect(client, 0);
if (!nickname.empty())
SendStatusMessage(IdMemberLeave, nickname, username);
BroadcastRoomInformation(); 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() : room_impl{std::make_unique<RoomImpl>()} {} Room::Room() : room_impl{std::make_unique<RoomImpl>()} {}
Room::~Room() = default; Room::~Room() = default;
bool Room::Create(const std::string& name, const std::string& server_address, u16 server_port, bool Room::Create(const std::string& name, const std::string& description,
const std::string& password, const u32 max_connections, const std::string& server_address, u16 server_port, const std::string& password,
const std::string& preferred_game, u64 preferred_game_id) { const u32 max_connections, const std::string& host_username,
const std::string& preferred_game, u64 preferred_game_id,
std::unique_ptr<VerifyUser::Backend> verify_backend,
const Room::BanList& ban_list, bool enable_citra_mods) {
ENetAddress address; ENetAddress address;
address.host = ENET_HOST_ANY; address.host = ENET_HOST_ANY;
if (!server_address.empty()) { 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; 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) { if (!room_impl->server) {
return false; return false;
} }
room_impl->state = State::Open; room_impl->state = State::Open;
room_impl->room_information.name = name; room_impl->room_information.name = name;
room_impl->room_information.description = description;
room_impl->room_information.member_slots = max_connections; room_impl->room_information.member_slots = max_connections;
room_impl->room_information.port = server_port; room_impl->room_information.port = server_port;
room_impl->room_information.preferred_game = preferred_game; room_impl->room_information.preferred_game = preferred_game;
room_impl->room_information.preferred_game_id = preferred_game_id; 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->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(); room_impl->StartLoop();
return true; return true;
@ -553,12 +1008,25 @@ const RoomInformation& Room::GetRoomInformation() const {
return room_impl->room_information; return room_impl->room_information;
} }
std::string Room::GetVerifyUID() const {
std::lock_guard<std::mutex> lock(room_impl->verify_UID_mutex);
return room_impl->verify_UID;
}
Room::BanList Room::GetBanList() const {
std::lock_guard<std::mutex> lock(room_impl->ban_list_mutex);
return {room_impl->username_ban_list, room_impl->ip_ban_list};
}
std::vector<Room::Member> Room::GetRoomMemberList() const { std::vector<Room::Member> Room::GetRoomMemberList() const {
std::vector<Room::Member> member_list; std::vector<Room::Member> member_list;
std::lock_guard<std::mutex> lock(room_impl->member_mutex); std::lock_guard<std::mutex> lock(room_impl->member_mutex);
for (const auto& member_impl : room_impl->members) { for (const auto& member_impl : room_impl->members) {
Member member; Member member;
member.nickname = member_impl.nickname; 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.mac_address = member_impl.mac_address;
member.game_info = member_impl.game_info; member.game_info = member_impl.game_info;
member_list.push_back(member); member_list.push_back(member);
@ -570,6 +1038,11 @@ bool Room::HasPassword() const {
return !room_impl->password.empty(); return !room_impl->password.empty();
} }
void Room::SetVerifyUID(const std::string& uid) {
std::lock_guard<std::mutex> lock(room_impl->verify_UID_mutex);
room_impl->verify_UID = uid;
}
void Room::Destroy() { void Room::Destroy() {
room_impl->state = State::Closed; room_impl->state = State::Closed;
room_impl->room_thread->join(); room_impl->room_thread->join();

@ -9,10 +9,11 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "common/common_types.h" #include "common/common_types.h"
#include "network/verify_user.h"
namespace Network { 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; constexpr u16 DefaultRoomPort = 24872;
@ -25,11 +26,13 @@ constexpr std::size_t NumChannels = 1; // Number of channels used for the connec
struct RoomInformation { struct RoomInformation {
std::string name; ///< Name of the server std::string name; ///< Name of the server
std::string description; ///< Server description
u32 member_slots; ///< Maximum number of members in this room 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 u16 port; ///< The port of this room
std::string preferred_game; ///< Game to advertise that you want to play std::string preferred_game; ///< Game to advertise that you want to play
u64 preferred_game_id; ///< Title ID for the advertised game 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 { struct GameInfo {
@ -57,7 +60,31 @@ enum RoomMessageTypes : u8 {
IdMacCollision, IdMacCollision,
IdVersionMismatch, IdVersionMismatch,
IdWrongPassword, 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. /// This is what a server [person creating a server] would use.
@ -69,9 +96,12 @@ public:
}; };
struct Member { struct Member {
std::string nickname; ///< The nickname of the member. std::string nickname; ///< The nickname of the member.
GameInfo game_info; ///< The current game of the member std::string username; ///< The web services username of the member. Can be empty.
MacAddress mac_address; ///< The assigned mac address of the member. 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(); Room();
@ -87,6 +117,11 @@ public:
*/ */
const RoomInformation& GetRoomInformation() const; 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. * Gets a list of the mbmers connected to the room.
*/ */
@ -97,14 +132,33 @@ public:
*/ */
bool HasPassword() const; bool HasPassword() const;
using UsernameBanList = std::vector<std::string>;
using IPBanList = std::vector<std::string>;
using BanList = std::pair<UsernameBanList, IPBanList>;
/** /**
* Creates the socket for this room. Will bind to default address if * Creates the socket for this room. Will bind to default address if
* server is empty string. * server is empty string.
*/ */
bool Create(const std::string& name, const std::string& server = "", bool Create(const std::string& name, const std::string& description = "",
u16 server_port = DefaultRoomPort, const std::string& password = "", const std::string& server = "", u16 server_port = DefaultRoomPort,
const std::string& password = "",
const u32 max_connections = MaxConcurrentConnections, 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<VerifyUser::Backend> 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 * Destroys the socket

@ -31,9 +31,14 @@ public:
std::atomic<State> state{State::Idle}; ///< Current state of the RoomMember. std::atomic<State> state{State::Idle}; ///< Current state of the RoomMember.
void SetState(const State new_state); void SetState(const State new_state);
void SetError(const Error new_error);
bool IsConnected() const; 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. MacAddress mac_address; ///< The mac_address of this member.
std::mutex network_mutex; ///< Mutex that controls access to the `client` variable. std::mutex network_mutex; ///< Mutex that controls access to the `client` variable.
@ -54,8 +59,11 @@ public:
private: private:
CallbackSet<WifiPacket> callback_set_wifi_packet; CallbackSet<WifiPacket> callback_set_wifi_packet;
CallbackSet<ChatEntry> callback_set_chat_messages; CallbackSet<ChatEntry> callback_set_chat_messages;
CallbackSet<StatusMessageEntry> callback_set_status_messages;
CallbackSet<RoomInformation> callback_set_room_information; CallbackSet<RoomInformation> callback_set_room_information;
CallbackSet<State> callback_set_state; CallbackSet<State> callback_set_state;
CallbackSet<Error> callback_set_error;
CallbackSet<Room::BanList> callback_set_ban_list;
}; };
Callbacks callbacks; ///< All CallbackSets to all events 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 * Sends a request to the server, asking for permission to join a room with the specified
* nickname and preferred mac. * nickname and preferred mac.
* @params nickname The desired nickname. * @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 preferred_mac The preferred MAC address to use in the room, the NoPreferredMac tells
* @params password The password for the room * @params password The password for the room
* the server to assign one for us. * 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 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. * Extracts a MAC Address from a received ENet packet.
@ -104,6 +113,19 @@ public:
*/ */
void HandleChatPacket(const ENetEvent* event); 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 * 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<Error>(new_error);
}
bool RoomMember::RoomMemberImpl::IsConnected() const { 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() { void RoomMember::RoomMemberImpl::MemberLoop() {
@ -143,37 +169,78 @@ void RoomMember::RoomMemberImpl::MemberLoop() {
case IdChatMessage: case IdChatMessage:
HandleChatPacket(&event); HandleChatPacket(&event);
break; break;
case IdStatusMessage:
HandleStatusMessagePacket(&event);
break;
case IdRoomInformation: case IdRoomInformation:
HandleRoomInformationPacket(&event); HandleRoomInformationPacket(&event);
break; break;
case IdJoinSuccess: case IdJoinSuccess:
case IdJoinSuccessAsMod:
// The join request was successful, we are now in the room. // 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. // If we joined successfully, there must be at least one client in the room: us.
ASSERT_MSG(member_information.size() > 0, ASSERT_MSG(member_information.size() > 0,
"We have not yet received member information."); "We have not yet received member information.");
HandleJoinPacket(&event); // Get the MAC Address for the client 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; break;
case IdNameCollision: case IdNameCollision:
SetState(State::NameCollision); SetState(State::Idle);
SetError(Error::NameCollision);
break; break;
case IdMacCollision: case IdMacCollision:
SetState(State::MacCollision); SetState(State::Idle);
SetError(Error::MacCollision);
break;
case IdConsoleIdCollision:
SetState(State::Idle);
SetError(Error::ConsoleIdCollision);
break; break;
case IdVersionMismatch: case IdVersionMismatch:
SetState(State::WrongVersion); SetState(State::Idle);
SetError(Error::WrongVersion);
break; break;
case IdWrongPassword: case IdWrongPassword:
SetState(State::WrongPassword); SetState(State::Idle);
SetError(Error::WrongPassword);
break; break;
case IdCloseRoom: 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; break;
} }
enet_packet_destroy(event.packet); enet_packet_destroy(event.packet);
break; break;
case ENET_EVENT_TYPE_DISCONNECT: case ENET_EVENT_TYPE_DISCONNECT:
SetState(State::LostConnection); if (state == State::Joined || state == State::Moderator) {
SetState(State::Idle);
SetError(Error::LostConnection);
}
break; break;
} }
} }
@ -201,14 +268,18 @@ void RoomMember::RoomMemberImpl::Send(Packet&& packet) {
} }
void RoomMember::RoomMemberImpl::SendJoinRequest(const std::string& nickname, void RoomMember::RoomMemberImpl::SendJoinRequest(const std::string& nickname,
const std::string& console_id_hash,
const MacAddress& preferred_mac, const MacAddress& preferred_mac,
const std::string& password) { const std::string& password,
const std::string& token) {
Packet packet; Packet packet;
packet << static_cast<u8>(IdJoinRequest); packet << static_cast<u8>(IdJoinRequest);
packet << nickname; packet << nickname;
packet << console_id_hash;
packet << preferred_mac; packet << preferred_mac;
packet << network_version; packet << network_version;
packet << password; packet << password;
packet << token;
Send(std::move(packet)); Send(std::move(packet));
} }
@ -221,14 +292,17 @@ void RoomMember::RoomMemberImpl::HandleRoomInformationPacket(const ENetEvent* ev
RoomInformation info{}; RoomInformation info{};
packet >> info.name; packet >> info.name;
packet >> info.description;
packet >> info.member_slots; packet >> info.member_slots;
packet >> info.uid;
packet >> info.port; packet >> info.port;
packet >> info.preferred_game; packet >> info.preferred_game;
packet >> info.host_username;
room_information.name = info.name; room_information.name = info.name;
room_information.description = info.description;
room_information.member_slots = info.member_slots; room_information.member_slots = info.member_slots;
room_information.port = info.port; room_information.port = info.port;
room_information.preferred_game = info.preferred_game; room_information.preferred_game = info.preferred_game;
room_information.host_username = info.host_username;
u32 num_members; u32 num_members;
packet >> num_members; packet >> num_members;
@ -239,6 +313,16 @@ void RoomMember::RoomMemberImpl::HandleRoomInformationPacket(const ENetEvent* ev
packet >> member.mac_address; packet >> member.mac_address;
packet >> member.game_info.name; packet >> member.game_info.name;
packet >> member.game_info.id; packet >> member.game_info.id;
packet >> member.username;
packet >> member.display_name;
packet >> member.avatar_url;
{
std::lock_guard<std::mutex> lock(username_mutex);
if (member.nickname == nickname) {
username = member.username;
}
}
} }
Invoke(room_information); Invoke(room_information);
} }
@ -252,7 +336,6 @@ void RoomMember::RoomMemberImpl::HandleJoinPacket(const ENetEvent* event) {
// Parse the MAC Address from the packet // Parse the MAC Address from the packet
packet >> mac_address; packet >> mac_address;
SetState(State::Joined);
} }
void RoomMember::RoomMemberImpl::HandleWifiPackets(const ENetEvent* event) { void RoomMember::RoomMemberImpl::HandleWifiPackets(const ENetEvent* event) {
@ -286,10 +369,40 @@ void RoomMember::RoomMemberImpl::HandleChatPacket(const ENetEvent* event) {
ChatEntry chat_entry{}; ChatEntry chat_entry{};
packet >> chat_entry.nickname; packet >> chat_entry.nickname;
packet >> chat_entry.username;
packet >> chat_entry.message; packet >> chat_entry.message;
Invoke<ChatEntry>(chat_entry); Invoke<ChatEntry>(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<StatusMessageTypes>(type);
packet >> status_message_entry.nickname;
packet >> status_message_entry.username;
Invoke<StatusMessageEntry>(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<Room::BanList>(ban_list);
}
void RoomMember::RoomMemberImpl::Disconnect() { void RoomMember::RoomMemberImpl::Disconnect() {
member_information.clear(); member_information.clear();
room_information.member_slots = 0; room_information.member_slots = 0;
@ -329,6 +442,12 @@ RoomMember::RoomMemberImpl::Callbacks::Get() {
return callback_set_state; return callback_set_state;
} }
template <>
RoomMember::RoomMemberImpl::CallbackSet<RoomMember::Error>&
RoomMember::RoomMemberImpl::Callbacks::Get() {
return callback_set_error;
}
template <> template <>
RoomMember::RoomMemberImpl::CallbackSet<RoomInformation>& RoomMember::RoomMemberImpl::CallbackSet<RoomInformation>&
RoomMember::RoomMemberImpl::Callbacks::Get() { RoomMember::RoomMemberImpl::Callbacks::Get() {
@ -340,6 +459,18 @@ RoomMember::RoomMemberImpl::CallbackSet<ChatEntry>& RoomMember::RoomMemberImpl::
return callback_set_chat_messages; return callback_set_chat_messages;
} }
template <>
RoomMember::RoomMemberImpl::CallbackSet<StatusMessageEntry>&
RoomMember::RoomMemberImpl::Callbacks::Get() {
return callback_set_status_messages;
}
template <>
RoomMember::RoomMemberImpl::CallbackSet<Room::BanList>&
RoomMember::RoomMemberImpl::Callbacks::Get() {
return callback_set_ban_list;
}
template <typename T> template <typename T>
void RoomMember::RoomMemberImpl::Invoke(const T& data) { void RoomMember::RoomMemberImpl::Invoke(const T& data) {
std::lock_guard<std::mutex> lock(callback_mutex); std::lock_guard<std::mutex> lock(callback_mutex);
@ -380,6 +511,11 @@ const std::string& RoomMember::GetNickname() const {
return room_member_impl->nickname; return room_member_impl->nickname;
} }
const std::string& RoomMember::GetUsername() const {
std::lock_guard<std::mutex> lock(room_member_impl->username_mutex);
return room_member_impl->username;
}
const MacAddress& RoomMember::GetMacAddress() const { const MacAddress& RoomMember::GetMacAddress() const {
ASSERT_MSG(IsConnected(), "Tried to get MAC address while not connected"); ASSERT_MSG(IsConnected(), "Tried to get MAC address while not connected");
return room_member_impl->mac_address; return room_member_impl->mac_address;
@ -389,9 +525,10 @@ RoomInformation RoomMember::GetRoomInformation() const {
return room_member_impl->room_information; return room_member_impl->room_information;
} }
void RoomMember::Join(const std::string& nick, const char* server_addr, u16 server_port, void RoomMember::Join(const std::string& nick, const std::string& console_id_hash,
u16 client_port, const MacAddress& preferred_mac, const char* server_addr, u16 server_port, u16 client_port,
const std::string& password) { const MacAddress& preferred_mac, const std::string& password,
const std::string& token) {
// If the member is connected, kill the connection first // If the member is connected, kill the connection first
if (room_member_impl->loop_thread && room_member_impl->loop_thread->joinable()) { if (room_member_impl->loop_thread && room_member_impl->loop_thread->joinable()) {
Leave(); 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); enet_host_connect(room_member_impl->client, &address, NumChannels, 0);
if (!room_member_impl->server) { if (!room_member_impl->server) {
room_member_impl->SetState(State::Error); room_member_impl->SetState(State::Idle);
room_member_impl->SetError(Error::UnknownError);
return; 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) { if (net > 0 && event.type == ENET_EVENT_TYPE_CONNECT) {
room_member_impl->nickname = nick; room_member_impl->nickname = nick;
room_member_impl->StartLoop(); 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); SendGameInfo(room_member_impl->current_game_info);
} else { } else {
enet_peer_disconnect(room_member_impl->server, 0); 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)); 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<u8>(type);
packet << nickname;
room_member_impl->Send(std::move(packet));
}
void RoomMember::RequestBanList() {
if (!IsConnected())
return;
Packet packet;
packet << static_cast<u8>(IdModGetBanList);
room_member_impl->Send(std::move(packet));
}
RoomMember::CallbackHandle<RoomMember::State> RoomMember::BindOnStateChanged( RoomMember::CallbackHandle<RoomMember::State> RoomMember::BindOnStateChanged(
std::function<void(const RoomMember::State&)> callback) { std::function<void(const RoomMember::State&)> callback) {
return room_member_impl->Bind(callback); return room_member_impl->Bind(callback);
} }
RoomMember::CallbackHandle<RoomMember::Error> RoomMember::BindOnError(
std::function<void(const RoomMember::Error&)> callback) {
return room_member_impl->Bind(callback);
}
RoomMember::CallbackHandle<WifiPacket> RoomMember::BindOnWifiPacketReceived( RoomMember::CallbackHandle<WifiPacket> RoomMember::BindOnWifiPacketReceived(
std::function<void(const WifiPacket&)> callback) { std::function<void(const WifiPacket&)> callback) {
return room_member_impl->Bind(callback); return room_member_impl->Bind(callback);
@ -486,6 +651,16 @@ RoomMember::CallbackHandle<ChatEntry> RoomMember::BindOnChatMessageRecieved(
return room_member_impl->Bind(callback); return room_member_impl->Bind(callback);
} }
RoomMember::CallbackHandle<StatusMessageEntry> RoomMember::BindOnStatusMessageReceived(
std::function<void(const StatusMessageEntry&)> callback) {
return room_member_impl->Bind(callback);
}
RoomMember::CallbackHandle<Room::BanList> RoomMember::BindOnBanListReceived(
std::function<void(const Room::BanList&)> callback) {
return room_member_impl->Bind(callback);
}
template <typename T> template <typename T>
void RoomMember::Unbind(CallbackHandle<T> handle) { void RoomMember::Unbind(CallbackHandle<T> handle) {
std::lock_guard<std::mutex> lock(room_member_impl->callback_mutex); std::lock_guard<std::mutex> lock(room_member_impl->callback_mutex);
@ -503,7 +678,10 @@ void RoomMember::Leave() {
template void RoomMember::Unbind(CallbackHandle<WifiPacket>); template void RoomMember::Unbind(CallbackHandle<WifiPacket>);
template void RoomMember::Unbind(CallbackHandle<RoomMember::State>); template void RoomMember::Unbind(CallbackHandle<RoomMember::State>);
template void RoomMember::Unbind(CallbackHandle<RoomMember::Error>);
template void RoomMember::Unbind(CallbackHandle<RoomInformation>); template void RoomMember::Unbind(CallbackHandle<RoomInformation>);
template void RoomMember::Unbind(CallbackHandle<ChatEntry>); template void RoomMember::Unbind(CallbackHandle<ChatEntry>);
template void RoomMember::Unbind(CallbackHandle<StatusMessageEntry>);
template void RoomMember::Unbind(CallbackHandle<Room::BanList>);
} // namespace Network } // namespace Network

@ -35,7 +35,17 @@ struct WifiPacket {
/// Represents a chat message. /// Represents a chat message.
struct ChatEntry { struct ChatEntry {
std::string nickname; ///< Nickname of the client who sent this message. 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: public:
enum class State : u8 { enum class State : u8 {
Uninitialized, ///< Not initialized Uninitialized, ///< Not initialized
Idle, ///< Default state Idle, ///< Default state (i.e. not connected)
Error, ///< Some error [permissions to network device missing or something]
Joining, ///< The client is attempting to join a room. 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 LostConnection, ///< Connection closed
HostKicked, ///< Kicked by the host
// Reasons why connection was rejected // Reasons why connection was rejected
NameCollision, ///< Somebody is already using this name UnknownError, ///< Some error [permissions to network device missing or something]
MacCollision, ///< Somebody is already using that mac-address NameCollision, ///< Somebody is already using this name
WrongVersion, ///< The room version is not the same as for this RoomMember MacCollision, ///< Somebody is already using that mac-address
WrongPassword, ///< The password doesn't match the one from the Room ConsoleIdCollision, ///< Somebody in the room has the same Console ID
CouldNotConnect ///< The room is not responding to a connection attempt 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 { 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 GameInfo game_info; ///< Name of the game they're currently playing, or empty if they're
/// not playing anything. /// not playing anything.
MacAddress mac_address; ///< MAC address associated with this member. MacAddress mac_address; ///< MAC address associated with this member.
@ -98,6 +124,11 @@ public:
*/ */
const std::string& GetNickname() const; const std::string& GetNickname() const;
/**
* Returns the username of the RoomMember.
*/
const std::string& GetUsername() const;
/** /**
* Returns the MAC address of the RoomMember. * 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. * 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", void Join(const std::string& nickname, const std::string& console_id_hash,
const u16 server_port = DefaultRoomPort, const u16 client_port = 0, const char* server_addr = "127.0.0.1", const u16 server_port = DefaultRoomPort,
const MacAddress& preferred_mac = NoPreferredMac, const std::string& password = ""); 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. * Sends a WiFi packet to the room.
@ -139,6 +172,19 @@ public:
*/ */
void SendGameInfo(const GameInfo& game_info); 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 * 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 * changed. The function wil be called every time the event is triggered. The callback function
@ -148,6 +194,15 @@ public:
*/ */
CallbackHandle<State> BindOnStateChanged(std::function<void(const State&)> callback); CallbackHandle<State> BindOnStateChanged(std::function<void(const State&)> 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<Error> BindOnError(std::function<void(const Error&)> callback);
/** /**
* Binds a function to an event that will be triggered every time a WifiPacket is received. * 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. * The function wil be called everytime the event is triggered.
@ -178,6 +233,26 @@ public:
CallbackHandle<ChatEntry> BindOnChatMessageRecieved( CallbackHandle<ChatEntry> BindOnChatMessageRecieved(
std::function<void(const ChatEntry&)> callback); std::function<void(const ChatEntry&)> 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<StatusMessageEntry> BindOnStatusMessageReceived(
std::function<void(const StatusMessageEntry&)> 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<Room::BanList> BindOnBanListReceived(
std::function<void(const Room::BanList&)> callback);
/** /**
* Leaves the current room. * Leaves the current room.
*/ */
@ -192,24 +267,44 @@ static const char* GetStateStr(const RoomMember::State& s) {
switch (s) { switch (s) {
case RoomMember::State::Idle: case RoomMember::State::Idle:
return "Idle"; return "Idle";
case RoomMember::State::Error:
return "Error";
case RoomMember::State::Joining: case RoomMember::State::Joining:
return "Joining"; return "Joining";
case RoomMember::State::Joined: case RoomMember::State::Joined:
return "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"; return "LostConnection";
case RoomMember::State::NameCollision: case RoomMember::Error::HostKicked:
return "HostKicked";
case RoomMember::Error::UnknownError:
return "UnknownError";
case RoomMember::Error::NameCollision:
return "NameCollision"; return "NameCollision";
case RoomMember::State::MacCollision: case RoomMember::Error::MacCollision:
return "MacCollision"; return "MaxCollision";
case RoomMember::State::WrongVersion: case RoomMember::Error::ConsoleIdCollision:
return "ConsoleIdCollision";
case RoomMember::Error::WrongVersion:
return "WrongVersion"; return "WrongVersion";
case RoomMember::State::WrongPassword: case RoomMember::Error::WrongPassword:
return "WrongPassword"; return "WrongPassword";
case RoomMember::State::CouldNotConnect: case RoomMember::Error::CouldNotConnect:
return "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"; return "Unknown";
} }

@ -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

@ -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 <string>
#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

@ -5,6 +5,8 @@ add_library(web_service STATIC
telemetry_json.h telemetry_json.h
verify_login.cpp verify_login.cpp
verify_login.h verify_login.h
verify_user_jwt.cpp
verify_user_jwt.h
web_backend.cpp web_backend.cpp
web_backend.h web_backend.h
) )
@ -15,4 +17,4 @@ get_directory_property(OPENSSL_LIBS
DIRECTORY ${PROJECT_SOURCE_DIR}/externals/libressl DIRECTORY ${PROJECT_SOURCE_DIR}/externals/libressl
DEFINITION OPENSSL_LIBS) DEFINITION OPENSSL_LIBS)
target_compile_definitions(web_service PRIVATE -DCPPHTTPLIB_OPENSSL_SUPPORT) 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)

@ -12,21 +12,36 @@
namespace AnnounceMultiplayerRoom { namespace AnnounceMultiplayerRoom {
void to_json(nlohmann::json& json, const Room::Member& member) { 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["gameName"] = member.game_name;
json["gameId"] = member.game_id; json["gameId"] = member.game_id;
} }
void from_json(const nlohmann::json& json, Room::Member& member) { void from_json(const nlohmann::json& json, Room::Member& member) {
member.name = json.at("name").get<std::string>(); member.nickname = json.at("nickname").get<std::string>();
member.game_name = json.at("gameName").get<std::string>(); member.game_name = json.at("gameName").get<std::string>();
member.game_id = json.at("gameId").get<u64>(); member.game_id = json.at("gameId").get<u64>();
try {
member.username = json.at("username").get<std::string>();
member.avatar_url = json.at("avatarUrl").get<std::string>();
} 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) { void to_json(nlohmann::json& json, const Room& room) {
json["id"] = room.UID;
json["port"] = room.port; json["port"] = room.port;
json["name"] = room.name; json["name"] = room.name;
if (!room.description.empty()) {
json["description"] = room.description;
}
json["preferredGameName"] = room.preferred_game; json["preferredGameName"] = room.preferred_game;
json["preferredGameId"] = room.preferred_game_id; json["preferredGameId"] = room.preferred_game_id;
json["maxPlayers"] = room.max_player; 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) { void from_json(const nlohmann::json& json, Room& room) {
room.verify_UID = json.at("externalGuid").get<std::string>();
room.ip = json.at("address").get<std::string>(); room.ip = json.at("address").get<std::string>();
room.name = json.at("name").get<std::string>(); room.name = json.at("name").get<std::string>();
try {
room.description = json.at("description").get<std::string>();
} 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<std::string>(); room.owner = json.at("owner").get<std::string>();
room.port = json.at("port").get<u16>(); room.port = json.at("port").get<u16>();
room.preferred_game = json.at("preferredGameName").get<std::string>(); room.preferred_game = json.at("preferredGameName").get<std::string>();
@ -59,12 +81,12 @@ void from_json(const nlohmann::json& json, Room& room) {
namespace WebService { namespace WebService {
void RoomJson::SetRoomInformation(const std::string& uid, const std::string& name, const u16 port, void RoomJson::SetRoomInformation(const std::string& name, const std::string& description,
const u32 max_player, const u32 net_version, const u16 port, const u32 max_player, const u32 net_version,
const bool has_password, const std::string& preferred_game, const bool has_password, const std::string& preferred_game,
const u64 preferred_game_id) { const u64 preferred_game_id) {
room.name = name; room.name = name;
room.UID = uid; room.description = description;
room.port = port; room.port = port;
room.max_player = max_player; room.max_player = max_player;
room.net_version = net_version; 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 = preferred_game;
room.preferred_game_id = preferred_game_id; 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 AnnounceMultiplayerRoom::MacAddress& mac_address, const u64 game_id,
const std::string& game_name) { const std::string& game_name) {
AnnounceMultiplayerRoom::Room::Member member; AnnounceMultiplayerRoom::Room::Member member;
member.name = nickname; member.username = username;
member.nickname = nickname;
member.avatar_url = avatar_url;
member.mac_address = mac_address; member.mac_address = mac_address;
member.game_id = game_id; member.game_id = game_id;
member.game_name = game_name; member.game_name = game_name;
room.members.push_back(member); 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; 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<AnnounceMultiplayerRoom::Room>();
room_id = reply_json.at("id").get<std::string>();
return room.verify_UID;
} }
void RoomJson::ClearPlayers() { void RoomJson::ClearPlayers() {
@ -101,12 +142,14 @@ AnnounceMultiplayerRoom::RoomList RoomJson::GetRoomList() {
} }
void RoomJson::Delete() { void RoomJson::Delete() {
nlohmann::json json; if (room_id.empty()) {
json["id"] = room.UID; LOG_ERROR(WebService, "Room must be registered to be deleted");
return;
}
Common::DetachedTasks::AddTask( 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. // 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);
}); });
} }

@ -20,14 +20,16 @@ public:
RoomJson(const std::string& host, const std::string& username, const std::string& token) RoomJson(const std::string& host, const std::string& username, const std::string& token)
: client(host, username, token), host(host), username(username), token(token) {} : client(host, username, token), host(host), username(username), token(token) {}
~RoomJson() = default; ~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 u32 max_player, const u32 net_version, const bool has_password,
const std::string& preferred_game, const std::string& preferred_game,
const u64 preferred_game_id) override; 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 AnnounceMultiplayerRoom::MacAddress& mac_address, const u64 game_id,
const std::string& game_name) override; const std::string& game_name) override;
Common::WebResult Announce() override; Common::WebResult Update() override;
std::string Register() override;
void ClearPlayers() override; void ClearPlayers() override;
AnnounceMultiplayerRoom::RoomList GetRoomList() override; AnnounceMultiplayerRoom::RoomList GetRoomList() override;
void Delete() override; void Delete() override;
@ -38,6 +40,7 @@ private:
std::string host; std::string host;
std::string username; std::string username;
std::string token; std::string token;
std::string room_id;
}; };
} // namespace WebService } // namespace WebService

@ -0,0 +1,60 @@
// Copyright 2018 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <system_error>
#include <jwt/jwt.hpp>
#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<std::string>("username");
}
if (decoded.payload().has_claim("displayName")) {
user_data.display_name = decoded.payload().get_claim_value<std::string>("displayName");
}
if (decoded.payload().has_claim("avatarUrl")) {
user_data.avatar_url = decoded.payload().get_claim_value<std::string>("avatarUrl");
}
if (decoded.payload().has_claim("roles")) {
auto roles = decoded.payload().get_claim_value<std::vector<std::string>>("roles");
user_data.moderator = std::find(roles.begin(), roles.end(), "moderator") != roles.end();
}
return user_data;
}
} // namespace WebService

@ -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 <fmt/format.h>
#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

@ -7,6 +7,7 @@
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <LUrlParser.h> #include <LUrlParser.h>
#include <fmt/format.h>
#include <httplib.h> #include <httplib.h>
#include "common/common_types.h" #include "common/common_types.h"
#include "common/logging/log.h" #include "common/logging/log.h"
@ -32,8 +33,9 @@ struct Client::Impl {
} }
/// A generic function handles POST, GET and DELETE request together /// A generic function handles POST, GET and DELETE request together
Common::WebResult GenericJson(const std::string& method, const std::string& path, Common::WebResult GenericRequest(const std::string& method, const std::string& path,
const std::string& data, bool allow_anonymous) { const std::string& data, bool allow_anonymous,
const std::string& accept) {
if (jwt.empty()) { if (jwt.empty()) {
UpdateJWT(); UpdateJWT();
} }
@ -44,11 +46,11 @@ struct Client::Impl {
"Credentials needed"}; "Credentials needed"};
} }
auto result = GenericJson(method, path, data, jwt); auto result = GenericRequest(method, path, data, accept, jwt);
if (result.result_string == "401") { if (result.result_string == "401") {
// Try again with new JWT // Try again with new JWT
UpdateJWT(); UpdateJWT();
result = GenericJson(method, path, data, jwt); result = GenericRequest(method, path, data, accept, jwt);
} }
return result; return result;
@ -60,9 +62,10 @@ struct Client::Impl {
* username + token is used if jwt is empty but username and token are * 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 * not empty anonymous if all of jwt, username and token are empty
*/ */
Common::WebResult GenericJson(const std::string& method, const std::string& path, Common::WebResult GenericRequest(const std::string& method, const std::string& path,
const std::string& data, const std::string& jwt = "", const std::string& data, const std::string& accept,
const std::string& username = "", const std::string& token = "") { const std::string& jwt = "", const std::string& username = "",
const std::string& token = "") {
if (cli == nullptr) { if (cli == nullptr) {
auto parsedUrl = LUrlParser::clParseURL::ParseURL(host); auto parsedUrl = LUrlParser::clParseURL::ParseURL(host);
int port; int port;
@ -133,8 +136,7 @@ struct Client::Impl {
return Common::WebResult{Common::WebResult::Code::WrongContent, ""}; return Common::WebResult{Common::WebResult::Code::WrongContent, ""};
} }
if (content_type->second.find("application/json") == std::string::npos && if (content_type->second.find(accept) == std::string::npos) {
content_type->second.find("text/html; charset=utf-8") == std::string::npos) {
LOG_ERROR(WebService, "{} to {} returned wrong content: {}", method, host + path, LOG_ERROR(WebService, "{} to {} returned wrong content: {}", method, host + path,
content_type->second); content_type->second);
return Common::WebResult{Common::WebResult::Code::WrongContent, "Wrong content"}; return Common::WebResult{Common::WebResult::Code::WrongContent, "Wrong content"};
@ -148,7 +150,7 @@ struct Client::Impl {
return; 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) { if (result.result_code != Common::WebResult::Code::Success) {
LOG_ERROR(WebService, "UpdateJWT failed"); LOG_ERROR(WebService, "UpdateJWT failed");
} else { } else {
@ -181,16 +183,29 @@ Client::~Client() = default;
Common::WebResult Client::PostJson(const std::string& path, const std::string& data, Common::WebResult Client::PostJson(const std::string& path, const std::string& data,
bool allow_anonymous) { 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) { 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, Common::WebResult Client::DeleteJson(const std::string& path, const std::string& data,
bool allow_anonymous) { 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 } // namespace WebService

@ -46,6 +46,29 @@ public:
Common::WebResult DeleteJson(const std::string& path, const std::string& data, Common::WebResult DeleteJson(const std::string& path, const std::string& data,
bool allow_anonymous); 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: private:
struct Impl; struct Impl;
std::unique_ptr<Impl> impl; std::unique_ptr<Impl> impl;