From ed4eb1cb31ae46b779d72f8005e0ff3a35645b6d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:00:13 +0200 Subject: [PATCH] [Server/Client/Protocol] Reporting users + moderation queue functionality (#7091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server/Client/Protocol] Reporting users + moderation queue functionality Took 6 minutes Took 3 minutes Took 8 seconds Took 11 minutes Took 12 minutes Took 7 minutes Took 15 seconds Took 2 minutes Took 1 minute Took 30 seconds Took 16 seconds * CI Fix Took 6 minutes * CI Fix Took 6 minutes * [Protocol] Add moderation investigation commands Adds the protocol layer for the moderation investigation suite: - Command_GetUserSessions/GetUserAlts/GetModeratorLastLogins/ResetUserPassword/RemoveUserAvatar (1013-1017) - Response extensions 1215-1219 with ServerInfo messages for sessions, alts, and staff logins - last_login on Response_ReportUserInfo and warning_il on Response_WarnList Took 2 minutes * [Utility] Add warning categories parser with infraction levels Parses the server's 'officialwarnings' setting (comma-separated, optional '|IL' suffix) into WarningCategory structs so the client can display the infraction level of each warning category. Includes GTest coverage. * [Server] Add moderation investigation tools Implements the server side of the moderation suite: - getUserSessions/getUserAlts/getModeratorLastLogins/removeUserAvatar DB methods - Handlers for all five new commands with audit records (PASSWORD_RESET, REMOVE_USER_AVATAR); password resets return a generated temporary password - cmdGetWarnList now reports per-category infraction levels from the officialwarnings setting; cmdReportUserInfo reports last_login - Update servatrice.ini.example with the warning taxonomy - Password/avatar mutations report RespNameNotFound when the user does not exist * [Client] Add moderation tab with investigate, password reset, and avatar removal - New Moderation tab: search a user to show account info, alternate accounts, login sessions, and staff last logins; actions to reset the user's password (shows the generated temporary password) and remove the user's avatar - 'Investigate user' entry in the user context menu opens the tab pre-loaded for that user - Warning dialog shows the infraction level of each warning category - Tab wired into TabSupervisor with a moderator-gated menu action, shortcut, and tabs.ini persistence (default closed) * [Server/Client/Protocol] Address PR #7091 review: security, bug, and perf fixes Security: - Promote RESET_USER_PASSWORD to admin-only dispatch (was moderator-accessible) - Reject password reset on users with equal/higher privilege than caller - Notify affected user via Event_NotifyUser::CUSTOM when password is reset - Add server-side category whitelist for reports - Drop reporter name fallback in comment/details authorization (ID-only) - Force password change: new DB column + login enforcement + client disconnect Bugs: - XSS via QTextEdit::append() → insertPlainText() in report tab and utils - allNotified initialized to true even with empty recipients list - Warning combo box: use currentData() instead of baked-in display text - Report resolution now records who resolved (resolved_by column + audit) Performance: - IP-correlation subquery: add 6-month window + LIMIT 200 - getUserSessions: clamp limit to 500 Non-blocking: - Palette-aware colors in report_utils.cpp (dark/light mode) - Report list pagination: offset/limit fields + total_count in response - SessionCommand enum gap comment for reserved values 1201-1203 Schema: 36→37 (force_password_change), 37→38 (resolved_by) Took 12 minutes Took 16 seconds * Fix macOs pedantry Took 5 minutes --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 6 + .../remote_connection_controller.cpp | 9 + .../src/client/settings/shortcuts_settings.h | 4 + .../intents/contexts/context_join_game.h | 1 + .../intents/intent_join_server_game.cpp | 2 +- .../widgets/dialogs/dlg_my_reports.cpp | 297 +++++ .../widgets/dialogs/dlg_my_reports.h | 52 + .../widgets/dialogs/dlg_report_user.cpp | 191 +++ .../widgets/dialogs/dlg_report_user.h | 42 + .../widgets/server/chat_view/chat_view.cpp | 21 + .../widgets/server/chat_view/chat_view.h | 10 + .../widgets/server/game_selector.cpp | 24 +- .../interface/widgets/server/game_selector.h | 22 +- .../widgets/server/user/user_context_menu.cpp | 30 +- .../widgets/server/user/user_context_menu.h | 3 + .../widgets/server/user/user_list_widget.cpp | 14 +- .../widgets/server/user/user_list_widget.h | 2 +- .../interface/widgets/tabs/tab_account.cpp | 14 + .../src/interface/widgets/tabs/tab_account.h | 3 + .../interface/widgets/tabs/tab_moderation.cpp | 462 +++++++ .../interface/widgets/tabs/tab_moderation.h | 83 ++ .../src/interface/widgets/tabs/tab_report.cpp | 927 +++++++++++++ .../src/interface/widgets/tabs/tab_report.h | 124 ++ .../interface/widgets/tabs/tab_supervisor.cpp | 132 +- .../interface/widgets/tabs/tab_supervisor.h | 11 +- .../widgets/utility/report_utils.cpp | 119 ++ .../interface/widgets/utility/report_utils.h | 36 + .../interface_tabs_settings_provider.h | 2 + .../network/server/remote/server.h | 3 +- .../remote/server_abstractuserinterface.cpp | 4 + .../remote/server_abstractuserinterface.h | 1 + .../server/remote/server_database_interface.h | 3 + .../server/remote/server_protocolhandler.cpp | 3 + .../libcockatrice/protocol/pb/CMakeLists.txt | 25 + .../protocol/pb/admin_commands.proto | 8 + .../command_replay_download_by_game_id.proto | 9 + .../protocol/pb/command_report.proto | 13 + .../pb/command_report_add_comment.proto | 10 + .../protocol/pb/command_report_assign.proto | 9 + .../protocol/pb/command_report_details.proto | 9 + .../protocol/pb/command_report_list.proto | 11 + .../protocol/pb/command_report_my_list.proto | 8 + .../protocol/pb/command_report_resolve.proto | 11 + .../protocol/pb/command_report_stats.proto | 8 + .../pb/command_report_user_info.proto | 9 + .../protocol/pb/event_notify_user.proto | 2 + .../protocol/pb/moderator_commands.proto | 39 + .../libcockatrice/protocol/pb/response.proto | 1 + .../pb/response_moderator_last_logins.proto | 10 + .../pb/response_remove_user_avatar.proto | 9 + .../response_replay_download_by_game_id.proto | 10 + .../protocol/pb/response_report_details.proto | 10 + .../protocol/pb/response_report_list.proto | 11 + .../protocol/pb/response_report_my_list.proto | 10 + .../protocol/pb/response_report_stats.proto | 36 + .../pb/response_report_user_info.proto | 21 + .../pb/response_reset_user_password.proto | 12 + .../protocol/pb/response_user_alts.proto | 10 + .../protocol/pb/response_user_sessions.proto | 10 + .../protocol/pb/response_warn_list.proto | 3 + .../pb/serverinfo_moderator_login.proto | 11 + .../protocol/pb/serverinfo_report.proto | 36 + .../protocol/pb/serverinfo_user_alt.proto | 16 + .../protocol/pb/serverinfo_user_session.proto | 14 + .../protocol/pb/session_commands.proto | 5 + .../libcockatrice/settings/tabs_settings.cpp | 20 + .../libcockatrice/settings/tabs_settings.h | 4 + libcockatrice_utility/CMakeLists.txt | 6 +- .../libcockatrice/utility/string_limits.h | 13 + .../utility/warning_categories.cpp | 24 + .../utility/warning_categories.h | 28 + .../migrations/servatrice_0035_to_0036.sql | 53 + servatrice/servatrice.ini.example | 13 +- servatrice/servatrice.sql | 49 +- servatrice/src/servatrice.cpp | 17 + servatrice/src/servatrice.h | 13 + .../src/servatrice_database_interface.cpp | 190 ++- .../src/servatrice_database_interface.h | 10 +- servatrice/src/serversocketinterface.cpp | 1164 ++++++++++++++++- servatrice/src/serversocketinterface.h | 43 +- tests/CMakeLists.txt | 6 + tests/settings/settings_defaults_test.cpp | 6 + tests/warning_categories_test.cpp | 89 ++ 83 files changed, 4768 insertions(+), 43 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_report_user.h create mode 100644 cockatrice/src/interface/widgets/tabs/tab_moderation.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_moderation.h create mode 100644 cockatrice/src/interface/widgets/tabs/tab_report.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_report.h create mode 100644 cockatrice/src/interface/widgets/utility/report_utils.cpp create mode 100644 cockatrice/src/interface/widgets/utility/report_utils.h create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto create mode 100644 libcockatrice_utility/libcockatrice/utility/warning_categories.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/warning_categories.h create mode 100644 servatrice/migrations/servatrice_0035_to_0036.sql create mode 100644 tests/warning_categories_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a966ec51f..ed196e501 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -43,7 +43,9 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_load_remote_deck.cpp src/interface/widgets/dialogs/dlg_local_game_options.cpp src/interface/widgets/dialogs/dlg_manage_sets.cpp + src/interface/widgets/dialogs/dlg_my_reports.cpp src/interface/widgets/dialogs/dlg_register.cpp + src/interface/widgets/dialogs/dlg_report_user.cpp src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp @@ -282,6 +284,8 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/report_utils.cpp + src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h @@ -375,6 +379,8 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_home.cpp src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_message.cpp + src/interface/widgets/tabs/tab_moderation.cpp + src/interface/widgets/tabs/tab_report.cpp src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_room.cpp src/interface/widgets/tabs/tab_server.cpp diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 4e425fb66..53dde125f 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -296,6 +296,15 @@ void ConnectionController::onLoginError(int r, return; } + case Response::RespPasswordChangeRequired: { + QMessageBox::information( + dialogParent, tr("Password Change Required"), + tr("An administrator has reset your password. Please contact your server administrator to obtain " + "your temporary password, then log in and change it via Account -> Change Password.")); + remoteClient->disconnectFromServer(); + return; + } + case Response::RespServerFull: { QMessageBox::critical(dialogParent, tr("Server Full"), tr("The server has reached its maximum user capacity, please check back later.")); diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 95155b8d1..f4ebc204e 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -786,6 +786,10 @@ private: ShortcutGroup::Tabs)}, {"Tabs/aTabLogs", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Logs"), parseSequenceString(""), ShortcutGroup::Tabs)}, + {"Tabs/aTabReport", + ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Report Queue"), parseSequenceString(""), ShortcutGroup::Tabs)}, + {"Tabs/aTabModeration", + ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Moderation"), parseSequenceString(""), ShortcutGroup::Tabs)}, }; }; diff --git a/cockatrice/src/interface/intents/contexts/context_join_game.h b/cockatrice/src/interface/intents/contexts/context_join_game.h index 102e2a520..2e5a88ea2 100644 --- a/cockatrice/src/interface/intents/contexts/context_join_game.h +++ b/cockatrice/src/interface/intents/contexts/context_join_game.h @@ -6,6 +6,7 @@ struct ContextJoinGame { ContextJoinRoom roomContext; int gameId; + bool asSpectator = false; }; #endif // COCKATRICE_CONTEXT_JOIN_GAME_H diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp index fb9c4d5ce..205c4dc70 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -55,7 +55,7 @@ bool IntentJoinServerGame::tryJoinGame(TabRoom *room) return false; } - if (room->getGameSelector()->joinGameById(context->gameId)) { + if (room->getGameSelector()->joinGameById(context->gameId, context->asSpectator)) { emitFinished(); return true; } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp new file mode 100644 index 000000000..1af83bfb5 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -0,0 +1,297 @@ +#include "dlg_my_reports.h" + +#include "../utility/report_utils.h" +#include "abstract_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ID = 0; +constexpr int COL_TIME = 1; +constexpr int COL_REPORTED = 2; +constexpr int COL_CATEGORY = 3; +constexpr int COL_GAMEID = 4; +constexpr int COL_STATUS = 5; +constexpr int COL_ASSIGNED = 6; +constexpr int COL_COUNT = 7; +} // namespace + +DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) + : QDialog(parent), client(_client), selectedReportId(-1) +{ + setWindowTitle(tr("My Reports")); + setMinimumSize(800, 500); + + table = new QTableWidget(0, COL_COUNT); + table->setHorizontalHeaderLabels( + {tr("#"), tr("Time"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To")}); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(true); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + connect(table, &QTableWidget::itemSelectionChanged, this, &DlgMyReports::onSelectionChanged); + + auto *detailsGroup = new QGroupBox(tr("Report Details")); + descriptionEdit = new QTextEdit; + descriptionEdit->setReadOnly(true); + descriptionEdit->setFixedHeight(80); + + auto *chatGroup = new QGroupBox(tr("Chat Log Context")); + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLayout->setContentsMargins(4, 4, 4, 4); + chatLayout->addWidget(chatLogEdit); + + auto *commentsLabel = new QLabel(tr("Comments:")); + commentsEdit = new QTextEdit; + commentsEdit->setReadOnly(true); + commentsEdit->setFixedHeight(120); + + auto *addCommentLabel = new QLabel(tr("Add a comment:")); + commentInput = new QLineEdit; + commentInput->setPlaceholderText(tr("Type your comment here...")); + commentButton = new QPushButton(tr("Send")); + commentButton->setEnabled(false); + connect(commentButton, &QPushButton::clicked, this, &DlgMyReports::addComment); + connect(commentInput, &QLineEdit::returnPressed, this, &DlgMyReports::addComment); + + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->setContentsMargins(4, 4, 4, 4); + detailsLayout->addWidget(descriptionEdit); + detailsLayout->addWidget(chatGroup); + detailsLayout->addWidget(commentsLabel); + detailsLayout->addWidget(commentsEdit); + detailsLayout->addWidget(addCommentLabel); + auto *commentRow = new QHBoxLayout; + commentRow->addWidget(commentInput); + commentRow->addWidget(commentButton); + detailsLayout->addLayout(commentRow); + + closeButton = new QPushButton(tr("Close")); + connect(closeButton, &QPushButton::clicked, this, &QDialog::accept); + + refreshButton = new QPushButton(tr("Refresh")); + connect(refreshButton, &QPushButton::clicked, this, &DlgMyReports::refreshList); + + statusLabel = new QLabel; + + auto *bottomBar = new QHBoxLayout; + bottomBar->addWidget(statusLabel); + bottomBar->addStretch(); + bottomBar->addWidget(refreshButton); + bottomBar->addWidget(closeButton); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(table, 1); + layout->addWidget(detailsGroup); + layout->addLayout(bottomBar); + + setActionsEnabled(false); + refreshList(); +} + +void DlgMyReports::refreshList() +{ + selectedReportIdBeforeRefresh = selectedReportId; + commentDraftBeforeRefresh = commentInput->text(); + statusLabel->setText(tr("Loading...")); + refreshButton->setEnabled(false); + table->setRowCount(0); + currentReports.clear(); + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + setActionsEnabled(false); + selectedReportId = -1; + + Command_ReportMyList cmd; + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportListResponse); + client->sendCommand(pend); +} + +void DlgMyReports::reportListResponse(const Response &response) +{ + refreshButton->setEnabled(true); + + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Failed to load reports.")); + return; + } + + const Response_ReportMyList &resp = response.GetExtension(Response_ReportMyList::ext); + currentReports.clear(); + for (int i = 0; i < resp.reports_size(); ++i) { + currentReports.append(resp.reports(i)); + } + + table->setSortingEnabled(false); + table->setRowCount(currentReports.size()); + + for (int row = 0; row < currentReports.size(); ++row) { + const ServerInfo_Report &r = currentReports[row]; + + report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, + COL_STATUS, COL_ASSIGNED); + } + + table->setSortingEnabled(true); + table->resizeColumnsToContents(); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + + if (selectedReportIdBeforeRefresh >= 0) { + for (int row = 0; row < table->rowCount(); ++row) { + if (table->item(row, COL_ID) && + table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) { + table->setCurrentCell(row, 0); + break; + } + } + } + + if (commentInput->text().isEmpty()) { + commentInput->setText(commentDraftBeforeRefresh); + } + + statusLabel->setText(tr("%1 report(s)").arg(currentReports.size())); +} + +void DlgMyReports::onSelectionChanged() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + commentButton->setEnabled(false); + selectedReportId = -1; + return; + } + + const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt(); + selectedReportId = reportId; + + for (const ServerInfo_Report &r : currentReports) { + if (r.report_id() == reportId) { + descriptionEdit->setPlainText(QString::fromStdString(r.description())); + break; + } + } + + chatLogEdit->setPlainText(tr("Loading...")); + commentsEdit->setPlainText(tr("Loading...")); + + Command_ReportDetails cmd; + cmd.set_report_id(reportId); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); + client->sendCommand(pend); + + QString status = table->item(row, COL_STATUS)->text(); + bool canComment = (status == "open" || status == "assigned"); + commentButton->setEnabled(canComment); + commentInput->setEnabled(canComment); + if (!canComment) { + commentInput->setPlaceholderText(tr("This report is closed.")); + } else { + commentInput->setPlaceholderText(tr("Type your comment here...")); + } +} + +void DlgMyReports::reportDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + if (selectedReportId == -1) { + return; + } + chatLogEdit->clear(); + commentsEdit->setPlainText(tr("Failed to load report details.")); + return; + } + + const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext); + const ServerInfo_Report &r = resp.report(); + + if (selectedReportId != r.report_id()) { + return; + } + + loadReportDetails(r); +} + +void DlgMyReports::loadReportDetails(const ServerInfo_Report &report) +{ + report_utils::renderReportDetails(chatLogEdit, commentsEdit, report, tr("No comments yet."), tr("[Moderator]"), + tr("[You]")); +} + +void DlgMyReports::addComment() +{ + if (selectedReportId < 0) { + return; + } + + QString text = commentInput->text().trimmed(); + if (text.isEmpty()) { + return; + } + + commentButton->setEnabled(false); + + Command_ReportAddComment cmd; + cmd.set_report_id(selectedReportId); + cmd.set_comment(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::addCommentResponse); + client->sendCommand(pend); +} + +void DlgMyReports::addCommentResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + commentInput->clear(); + refreshList(); + } else { + commentButton->setEnabled(true); + } +} + +void DlgMyReports::setActionsEnabled(bool enabled) +{ + commentButton->setEnabled(enabled); + commentInput->setEnabled(enabled); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h new file mode 100644 index 000000000..09b1963dd --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h @@ -0,0 +1,52 @@ +#ifndef COCKATRICE_DLG_MY_REPORTS_H +#define COCKATRICE_DLG_MY_REPORTS_H + +#include +#include +#include +#include + +class AbstractClient; +class QTableWidget; +class QTextEdit; +class QLineEdit; +class QPushButton; +class QLabel; + +class DlgMyReports : public QDialog +{ + Q_OBJECT +public: + explicit DlgMyReports(AbstractClient *_client, QWidget *parent = nullptr); + +private slots: + void refreshList(); + void reportListResponse(const Response &response); + void onSelectionChanged(); + void reportDetailsResponse(const Response &response); + void addComment(); + void addCommentResponse(const Response &response); + +private: + void loadReportDetails(const ServerInfo_Report &report); + void setActionsEnabled(bool enabled); + + AbstractClient *client; + + QTableWidget *table; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QTextEdit *commentsEdit; + QLineEdit *commentInput; + QPushButton *commentButton; + QPushButton *refreshButton; + QPushButton *closeButton; + QLabel *statusLabel; + + QList currentReports; + int selectedReportId; + int selectedReportIdBeforeRefresh = -1; + QString commentDraftBeforeRefresh; +}; + +#endif // COCKATRICE_DLG_MY_REPORTS_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp new file mode 100644 index 000000000..9519846e2 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -0,0 +1,191 @@ +#include "dlg_report_user.h" + +#include "abstract_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgReportUser::DlgReportUser(AbstractClient *_client, + const QString &_reportedUser, + int _gameId, + const QString &_autoChatLog, + QWidget *parent) + : QDialog(parent), client(_client), reportedUser(_reportedUser), gameId(_gameId) +{ + setWindowTitle(tr("Report User")); + setMinimumWidth(500); + + auto *infoLabel = + new QLabel(tr("Reports are reviewed by moderators. False reports may result in account penalties.")); + infoLabel->setWordWrap(true); + infoLabel->setStyleSheet("color: palette(placeholderText); padding: 5px;"); + + auto *reportGroup = new QGroupBox(tr("Report Details")); + auto *reportGrid = new QGridLayout(reportGroup); + + reportGrid->addWidget(new QLabel(tr("Reported User:")), 0, 0); + reportedUserLabel = new QLabel(reportedUser); + reportedUserLabel->setStyleSheet("font-weight: bold;"); + reportGrid->addWidget(reportedUserLabel, 0, 1); + + reportGrid->addWidget(new QLabel(tr("Game ID:")), 1, 0); + if (gameId >= 0) { + gameIdLabel = new QLabel(QString::number(gameId)); + gameIdLabel->setStyleSheet("font-weight: bold;"); + reportGrid->addWidget(gameIdLabel, 1, 1); + } else { + gameIdEdit = new QLineEdit; + gameIdEdit->setPlaceholderText(tr("(Optional) Enter game ID if available")); + gameIdEdit->setToolTip(tr("If the report is related to a specific game, enter its ID.")); + reportGrid->addWidget(gameIdEdit, 1, 1); + gameIdLabel = nullptr; + } + + auto *categoryGroup = new QGroupBox(tr("Category")); + auto *categoryGrid = new QGridLayout(categoryGroup); + + categoryBox = new QComboBox; + categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating"); + categoryBox->setItemData(categoryBox->count() - 1, + tr("Using external tools, card marked manipulation, or exploiting game bugs"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Harassment / Abuse"), "harassment"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Hate speech"), "hate_speech"); + categoryBox->setItemData(categoryBox->count() - 1, + tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole); + categoryBox->addItem(tr("Spam"), "spam"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Other"), "other"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"), + Qt::ToolTipRole); + + categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0); + categoryGrid->addWidget(categoryBox, 0, 1); + + auto *descGroup = new QGroupBox(tr("Description")); + auto *descLayout = new QVBoxLayout(descGroup); + + descriptionEdit = new QTextEdit; + descriptionEdit->setPlaceholderText( + tr("Please describe what happened. Include dates, game details, or any evidence if available.")); + descriptionEdit->setFixedHeight(120); + descLayout->addWidget(descriptionEdit); + + auto *chatGroup = new QGroupBox(tr("Chat Log Context")); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + if (!_autoChatLog.isEmpty()) { + chatLogEdit->setPlainText(_autoChatLog); + } else { + chatLogEdit->setPlaceholderText(tr("No chat context available (not triggered from chat).")); + } + chatLogEdit->setFixedHeight(100); + chatLayout->addWidget(chatLogEdit); + + auto *chatNote = new QLabel( + tr("This chat log is captured from your local chat window and may not reflect the full conversation.")); + chatNote->setWordWrap(true); + chatNote->setStyleSheet("color: palette(placeholderText);"); + chatLayout->addWidget(chatNote); + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report")); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgReportUser::actSubmit); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(infoLabel); + layout->addWidget(reportGroup); + layout->addWidget(categoryGroup); + layout->addWidget(descGroup); + layout->addWidget(chatGroup); + layout->addWidget(buttonBox); +} + +void DlgReportUser::actSubmit() +{ + const QString description = descriptionEdit->toPlainText().trimmed(); + if (description.isEmpty()) { + QMessageBox::warning(this, tr("Missing description"), tr("Please describe what happened before submitting.")); + return; + } + + QMessageBox::StandardButton reply = + QMessageBox::question(this, tr("Confirm Report"), + tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()), + QMessageBox::Yes | QMessageBox::No); + + if (reply != QMessageBox::Yes) { + return; + } + + buttonBox->setEnabled(false); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submitting...")); + + Command_Report cmd; + cmd.set_reported_user(reportedUser.toStdString()); + cmd.set_category(categoryBox->currentData().toString().toStdString()); + cmd.set_description(description.toStdString()); + + if (gameId >= 0) { + cmd.set_game_id(gameId); + } else if (gameIdEdit && !gameIdEdit->text().trimmed().isEmpty()) { + bool ok; + int manualGameId = gameIdEdit->text().trimmed().toInt(&ok); + if (ok && manualGameId > 0) { + cmd.set_game_id(manualGameId); + } + } + + const QString chatLog = chatLogEdit->toPlainText().trimmed(); + if (!chatLog.isEmpty()) { + cmd.set_chat_log(chatLog.toStdString()); + } + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgReportUser::reportResponse); + client->sendCommand(pend); +} + +void DlgReportUser::reportResponse(const Response &response) +{ + buttonBox->setEnabled(true); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report")); + + if (response.response_code() == Response::RespOk) { + QMessageBox::information(this, tr("Report Submitted"), + tr("Your report has been submitted and will be reviewed by a moderator. Thank you.")); + accept(); + } else if (response.response_code() == Response::RespTooManyRequests) { + QMessageBox::warning(this, tr("Submission Failed"), + tr("You have reached the daily report limit. Please try again later.")); + } else if (response.response_code() == Response::RespNameNotFound) { + QMessageBox::warning( + this, tr("Submission Failed"), + tr("The reported user could not be found. Guests (unregistered users) cannot be reported.")); + } else { + QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again.")); + } +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h new file mode 100644 index 000000000..c59fc5084 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -0,0 +1,42 @@ +#ifndef COCKATRICE_DLG_REPORT_USER_H +#define COCKATRICE_DLG_REPORT_USER_H + +#include +#include + +class AbstractClient; +class QComboBox; +class QDialogButtonBox; +class QLineEdit; +class QTextEdit; +class QLabel; + +class DlgReportUser : public QDialog +{ + Q_OBJECT +public: + DlgReportUser(AbstractClient *_client, + const QString &_reportedUser, + int _gameId = -1, + const QString &_autoChatLog = QString(), + QWidget *parent = nullptr); + +private slots: + void actSubmit(); + void reportResponse(const Response &response); + +private: + AbstractClient *client; + QString reportedUser; + int gameId; + + QLabel *reportedUserLabel; + QLabel *gameIdLabel = nullptr; + QLineEdit *gameIdEdit = nullptr; + QComboBox *categoryBox; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QDialogButtonBox *buttonBox; +}; + +#endif // COCKATRICE_DLG_REPORT_USER_H diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index e62195c2f..bebc2e3c4 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -273,6 +273,14 @@ void ChatView::appendMessage(QString message, // messageType should be Event_RoomSay::UserMessage though we don't actually check bool isUserMessage = !(userName.toLower() == "servatrice" || userName.isEmpty()); bool sameSender = isUserMessage && userName == lastSender; + + if (isUserMessage) { + chatHistory.append({userName, message, QDateTime::currentDateTime()}); + while (chatHistory.size() > MAX_CHAT_HISTORY) { + chatHistory.removeFirst(); + } + } + QTextCursor cursor = prepareBlock(sameSender); lastSender = userName; @@ -650,6 +658,19 @@ void ChatView::clearChat() document()->clear(); lastSender = ""; evenNumber = true; + chatHistory.clear(); +} + +QString ChatView::getRecentChatLog(int maxMessages) const +{ + QStringList lines; + int start = qMax(0, chatHistory.size() - maxMessages); + for (int i = start; i < chatHistory.size(); ++i) { + const ChatLogEntry &entry = chatHistory.at(i); + lines.append( + QString("[%1] %2: %3").arg(entry.timestamp.toString("hh:mm:ss")).arg(entry.userName).arg(entry.message)); + } + return lines.join("\n"); } void ChatView::redactMessages(const QString &userName, int amount) diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index c58efa2c6..671b35163 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -33,6 +33,13 @@ public: QTextBlock block; }; +struct ChatLogEntry +{ + QString userName; + QString message; + QDateTime timestamp; +}; + class ChatView : public QTextBrowser { Q_OBJECT @@ -65,6 +72,8 @@ private: QString hoveredContent; QAction *messageClicked; QMap> userMessagePositions; + QList chatHistory; + static constexpr int MAX_CHAT_HISTORY = 200; [[nodiscard]] QTextFragment getFragmentUnderMouse(const QPoint &pos) const; QTextCursor prepareBlock(bool same = false); @@ -107,6 +116,7 @@ public: bool playerBold = false); void clearChat(); void redactMessages(const QString &userName, int amount); + QString getRecentChatLog(int maxMessages = 50) const; protected: void enterEvent(QEnterEvent *event) override; diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 28e2ae607..f41002247 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -359,7 +359,11 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) return; } - const ServerInfo_Game &game = gameListModel->getGame(ind.data(Qt::UserRole).toInt()); + joinGame(gameListModel->getGame(ind.data(Qt::UserRole).toInt()), asSpectator, asJudge); +} + +void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, const bool asJudge) +{ if (tabSupervisor->switchToGameTabIfAlreadyExists(game.game_id())) { return; } @@ -414,18 +418,16 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) disableButtons(); } -bool GameSelector::joinGameById(int gameId) +bool GameSelector::joinGameById(const int gameId, const bool asSpectator) { - auto *model = gameListView->model(); - - for (int row = 0; row < model->rowCount(); ++row) { - QModelIndex idx = model->index(row, 0); - const ServerInfo_Game &game = gameListModel->getGame(idx.data(Qt::UserRole).toInt()); - if (game.game_id() == gameId) { - gameListView->setCurrentIndex(idx); - joinGame(); - return true; + for (int row = 0; row < gameListModel->rowCount(); ++row) { + const ServerInfo_Game &game = gameListModel->getGame(row); + if (game.game_id() != gameId) { + continue; } + + joinGame(game, asSpectator); + return true; } qWarning() << "Game" << gameId << "not found"; diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index da34d5322..9af39cf50 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -171,6 +171,17 @@ private: */ void joinGame(bool asSpectator = false, bool asJudge = false); + /** + * @brief Performs the join or spectate action for a specific game. + * @param game The game to join. + * @param asSpectator True to join as a spectator, false to join as a player. + * @param asJudge True to join as a judge, false to join as a player. + * + * Unlike the selection-based overload, this does not depend on the game being + * visible in the filtered game list. + */ + void joinGame(const ServerInfo_Game &game, bool asSpectator = false, bool asJudge = false); + public: /** * @brief Constructs a GameSelector widget. @@ -202,7 +213,16 @@ public: * @param info The ServerInfo_Game object containing information about the game to update. */ void processGameInfo(const ServerInfo_Game &info); - bool joinGameById(int gameId); + /** + * @brief Finds a game by ID and joins or spectates it. + * @param gameId The ID of the game to join. + * @param asSpectator True to join as a spectator, false to join as a player. + * @return True if the game was found and joined, false otherwise. + * + * Unlike the selection-based overload, this does not depend on the game + * being visible in the filtered game list. + */ + bool joinGameById(int gameId, bool asSpectator = false); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 372dbfc19..646f2ee33 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -1,5 +1,6 @@ #include "user_context_menu.h" +#include "../../dialogs/dlg_report_user.h" #include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../../interface/widgets/tabs/tab_supervisor.h" @@ -41,6 +42,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aAddToIgnoreList = new QAction(QString(), this); aRemoveFromIgnoreList = new QAction(QString(), this); aKick = new QAction(QString(), this); + aReport = new QAction(QString(), this); aWarnUser = new QAction(QString(), this); aWarnHistory = new QAction(QString(), this); aBan = new QAction(QString(), this); @@ -50,6 +52,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aPromoteToJudge = new QAction(QString(), this); aDemoteFromJudge = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); + aInvestigateUser = new QAction(QString(), this); retranslateUi(); } @@ -64,6 +67,7 @@ void UserContextMenu::retranslateUi() aAddToIgnoreList->setText(tr("Add to &ignore list")); aRemoveFromIgnoreList->setText(tr("Remove from &ignore list")); aKick->setText(tr("Kick from &game")); + aReport->setText(tr("Report user")); aWarnUser->setText(tr("Warn user")); aWarnHistory->setText(tr("View user's war&n history")); aBan->setText(tr("Ban from &server")); @@ -73,6 +77,7 @@ void UserContextMenu::retranslateUi() aPromoteToJudge->setText(tr("Promote user to &judge")); aDemoteFromJudge->setText(tr("Demote user from judge")); aGetAdminNotes->setText(tr("View admin notes")); + aInvestigateUser->setText(tr("Investigate user")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -144,7 +149,8 @@ void UserContextMenu::warnUser_processGetWarningsListResponse(const Response &r) if (response.warning_size() > 0) { for (int i = 0; i < response.warning_size(); ++i) { - dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified()); + int startingIl = i < response.warning_il_size() ? response.warning_il(i) : 1; + dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified(), startingIl); } } dlg->show(); @@ -395,6 +401,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aRemoveMessages = new QAction(tr("Remove this user's messages"), this); menu->addAction(aRemoveMessages); } + if (userListProxy->isOwnUserRegistered()) { + menu->addAction(aReport); + } if (game && (game->isHost() || !tabSupervisor->getAdminLocked())) { menu->addSeparator(); menu->addAction(aKick); @@ -408,6 +417,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aBanHistory); menu->addSeparator(); menu->addAction(aGetAdminNotes); + menu->addAction(aInvestigateUser); menu->addSeparator(); if (userLevel.testFlag(ServerInfo_User::IsModerator) && @@ -431,6 +441,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aDetails->setEnabled(true); aChat->setEnabled(anotherUser && online); aShowGames->setEnabled(online); + aReport->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -441,6 +452,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBan->setEnabled(anotherUser); aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); + aInvestigateUser->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); @@ -462,6 +474,15 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execRemoveFromIgnore(userName); } else if (actionClicked == aKick) { execKick(playerId); + } else if (actionClicked == aReport) { + int gameId = game ? game->getGameMetaInfo()->gameId() : -1; + QString autoChatLog; + if (chatView) { + autoChatLog = chatView->getRecentChatLog(50); + } + auto dlgReport = new DlgReportUser(client, userName, gameId, autoChatLog, static_cast(parent())); + dlgReport->setAttribute(Qt::WA_DeleteOnClose); + dlgReport->exec(); } else if (actionClicked == aBan) { execBan(userName); } else if (actionClicked == aPromoteToMod || actionClicked == aDemoteFromMod) { @@ -476,6 +497,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execWarnHistory(userName); } else if (actionClicked == aGetAdminNotes) { execAdminNotes(userName); + } else if (actionClicked == aInvestigateUser) { + execInvestigateUser(userName); } else if (actionClicked == aCopyToClipBoard) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setText(deckHash); @@ -652,6 +675,11 @@ void UserContextMenu::execAdminNotes(const QString &userName) client->sendCommand(pend); } +void UserContextMenu::execInvestigateUser(const QString &userName) +{ + tabSupervisor->openTabModeration(userName); +} + void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) { Command_AdjustMod cmd; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 70bbff977..f1ce931f8 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -41,12 +41,14 @@ private: QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; QAction *aKick; + QAction *aReport; QAction *aBan, *aBanHistory; QAction *aPromoteToMod, *aDemoteFromMod; QAction *aPromoteToJudge, *aDemoteFromJudge; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; std::function()> gameInviteLinkProvider; + QAction *aInvestigateUser; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -118,6 +120,7 @@ public: void execBanHistory(const QString &userName); void execWarnHistory(const QString &userName); void execAdminNotes(const QString &userName); + void execInvestigateUser(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 3dac7944d..2534ee62c 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -151,7 +151,7 @@ WarningDialog::WarningDialog(const QString userName, const QString clientID, QWi warnClientID = new QLineEdit(clientID); warnClientID->setMaxLength(MAX_NAME_LENGTH); warningOption = new QComboBox(); - warningOption->addItem(""); + warningOption->addItem("", ""); deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); @@ -184,7 +184,7 @@ void WarningDialog::okClicked() return; } - if (warningOption->currentText().simplified().isEmpty()) { + if (warningOption->currentData().toString().simplified().isEmpty()) { QMessageBox::critical(this, tr("Error"), tr("Warning to use can not be blank, please select a valid warning to send.")); return; @@ -205,7 +205,7 @@ QString WarningDialog::getWarnID() const QString WarningDialog::getReason() const { - return warningOption->currentText().simplified(); + return warningOption->currentData().toString().simplified(); } int WarningDialog::getDeleteMessages() const @@ -213,9 +213,13 @@ int WarningDialog::getDeleteMessages() const return deleteMessages->isChecked() ? -1 : 0; } -void WarningDialog::addWarningOption(const QString warning) +void WarningDialog::addWarningOption(const QString warning, int startingIl) { - warningOption->addItem(warning); + if (startingIl > 1) { + warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning); + } else { + warningOption->addItem(warning, warning); + } } void BanDialog::okClicked() diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 0407ad8ca..e7a5116ef 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -85,7 +85,7 @@ public: [[nodiscard]] QString getWarnID() const; [[nodiscard]] QString getReason() const; [[nodiscard]] int getDeleteMessages() const; - void addWarningOption(const QString warning); + void addWarningOption(const QString warning, int startingIl = 1); }; class AdminNotesDialog : public QDialog diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.cpp b/cockatrice/src/interface/widgets/tabs/tab_account.cpp index 2c30178f3..410a48d40 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_account.cpp @@ -1,6 +1,7 @@ #include "tab_account.h" #include "../client/sound_engine.h" +#include "../interface/widgets/dialogs/dlg_my_reports.h" #include "../interface/widgets/server/user/user_info_box.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_widget.h" @@ -49,6 +50,11 @@ TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, c auto *vbox = new QVBoxLayout; vbox->addWidget(userInfoBox); + + myReportsButton = new QPushButton(tr("My Reports")); + connect(myReportsButton, &QPushButton::clicked, this, &TabAccount::openMyReports); + vbox->addWidget(myReportsButton); + vbox->addWidget(allUsersList); auto *addToBuddyList = new QHBoxLayout; @@ -126,6 +132,7 @@ void TabAccount::addToList(const std::string &listName, const QString &userName) void TabAccount::retranslateUi() { + myReportsButton->setText(tr("My Reports")); allUsersList->retranslateUi(); buddyList->retranslateUi(); ignoreList->retranslateUi(); @@ -240,3 +247,10 @@ void TabAccount::processRemoveFromListEvent(const Event_RemoveFromList &event) userList->deleteUser(user); } + +void TabAccount::openMyReports() +{ + auto *dlg = new DlgMyReports(client, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->exec(); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.h b/cockatrice/src/interface/widgets/tabs/tab_account.h index 887038ebb..68054c7a1 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.h +++ b/cockatrice/src/interface/widgets/tabs/tab_account.h @@ -18,6 +18,7 @@ class Event_RemoveFromList; class Event_UserJoined; class Event_UserLeft; class LineEditUnfocusable; +class QPushButton; class Response; class ServerInfo_User; class UserInfoBox; @@ -41,6 +42,7 @@ private slots: void processRemoveFromListEvent(const Event_RemoveFromList &event); void addToIgnoreList(); void addToBuddyList(); + void openMyReports(); private: AbstractClient *client; @@ -50,6 +52,7 @@ private: UserInfoBox *userInfoBox; LineEditUnfocusable *addBuddyEdit; LineEditUnfocusable *addIgnoreEdit; + QPushButton *myReportsButton; void addToList(const std::string &listName, const QString &userName); public: diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp new file mode 100644 index 000000000..077b876d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -0,0 +1,462 @@ +#include "tab_moderation.h" + +#include "abstract_client.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ALTS_USER = 0; +constexpr int COL_ALTS_EMAIL = 1; +constexpr int COL_ALTS_CLIENTID = 2; +constexpr int COL_ALTS_REGISTERED = 3; +constexpr int COL_ALTS_LAST_LOGIN = 4; +constexpr int COL_ALTS_WARNS = 5; +constexpr int COL_ALTS_BANS = 6; +constexpr int COL_ALTS_ACTIVE = 7; +constexpr int COL_ALTS_COUNT = 8; + +constexpr int COL_SESSIONS_IP = 0; +constexpr int COL_SESSIONS_CLIENTID = 1; +constexpr int COL_SESSIONS_START = 2; +constexpr int COL_SESSIONS_END = 3; +constexpr int COL_SESSIONS_TYPE = 4; +constexpr int COL_SESSIONS_COUNT = 5; + +constexpr int COL_STAFF_USER = 0; +constexpr int COL_STAFF_LEVEL = 1; +constexpr int COL_STAFF_LAST_LOGIN = 2; +constexpr int COL_STAFF_COUNT = 3; +} // namespace + +TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) + : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::returnPressed, this, &TabModeration::investigateUser); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabModeration::investigateUser); + + resetPasswordButton = new QPushButton; + connect(resetPasswordButton, &QPushButton::clicked, this, &TabModeration::resetPassword); + + removeAvatarButton = new QPushButton; + connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(investigateButton); + topBar->addStretch(); + topBar->addWidget(resetPasswordButton); + topBar->addWidget(removeAvatarButton); + + userInfoGroup = new QGroupBox; + userInfoNameLabel = new QLabel; + userInfoNameValue = new QLabel; + userInfoRegisteredLabel = new QLabel; + userInfoRegisteredValue = new QLabel; + userInfoLastLoginLabel = new QLabel; + userInfoLastLoginValue = new QLabel; + userInfoStatusLabel = new QLabel; + userInfoStatusValue = new QLabel; + userInfoCountsLabel = new QLabel; + userInfoCountsValue = new QLabel; + userInfoNotesLabel = new QLabel; + userInfoNotesEdit = new QTextEdit; + userInfoNotesEdit->setReadOnly(true); + + auto *infoGrid = new QGridLayout; + infoGrid->addWidget(userInfoNameLabel, 0, 0); + infoGrid->addWidget(userInfoNameValue, 0, 1); + infoGrid->addWidget(userInfoRegisteredLabel, 0, 2); + infoGrid->addWidget(userInfoRegisteredValue, 0, 3); + infoGrid->addWidget(userInfoLastLoginLabel, 1, 0); + infoGrid->addWidget(userInfoLastLoginValue, 1, 1); + infoGrid->addWidget(userInfoStatusLabel, 1, 2); + infoGrid->addWidget(userInfoStatusValue, 1, 3); + infoGrid->addWidget(userInfoCountsLabel, 2, 0); + infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3); + infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop); + infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3); + infoGrid->setColumnStretch(1, 1); + infoGrid->setColumnStretch(3, 1); + + auto *infoLayout = new QVBoxLayout(userInfoGroup); + infoLayout->addLayout(infoGrid); + + auto configureTable = [](QTableWidget *table) { + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); + }; + + altsGroup = new QGroupBox; + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + configureTable(altsTable); + auto *altsLayout = new QVBoxLayout(altsGroup); + altsLayout->addWidget(altsTable); + + sessionsGroup = new QGroupBox; + sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); + configureTable(sessionsTable); + auto *sessionsLayout = new QVBoxLayout(sessionsGroup); + sessionsLayout->addWidget(sessionsTable); + + staffGroup = new QGroupBox; + staffTable = new QTableWidget(0, COL_STAFF_COUNT); + configureTable(staffTable); + refreshStaffButton = new QPushButton; + connect(refreshStaffButton, &QPushButton::clicked, this, &TabModeration::requestModeratorLogins); + auto *staffHeader = new QHBoxLayout; + staffHeader->addStretch(); + staffHeader->addWidget(refreshStaffButton); + auto *staffLayout = new QVBoxLayout(staffGroup); + staffLayout->addWidget(staffTable); + staffLayout->addLayout(staffHeader); + + auto *splitter = new QSplitter(Qt::Vertical); + splitter->addWidget(userInfoGroup); + splitter->addWidget(altsGroup); + splitter->addWidget(sessionsGroup); + splitter->addWidget(staffGroup); + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 2); + splitter->setStretchFactor(2, 2); + splitter->setStretchFactor(3, 1); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addWidget(splitter); + + retranslateUi(); + clearUserData(); + requestModeratorLogins(); + investigate(initialUser); +} + +void TabModeration::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + investigateButton->setText(tr("Investigate")); + resetPasswordButton->setText(tr("Reset Password")); + removeAvatarButton->setText(tr("Remove Avatar")); + refreshStaffButton->setText(tr("Refresh")); + + userInfoGroup->setTitle(tr("User Info")); + userInfoNameLabel->setText(tr("Name:")); + userInfoRegisteredLabel->setText(tr("Registered:")); + userInfoLastLoginLabel->setText(tr("Last login:")); + userInfoStatusLabel->setText(tr("Status:")); + userInfoCountsLabel->setText(tr("Counts:")); + userInfoNotesLabel->setText(tr("Admin notes:")); + + altsGroup->setTitle(tr("Alts")); + sessionsGroup->setTitle(tr("Sessions")); + staffGroup->setTitle(tr("Staff Last Logins")); + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), + tr("Warns"), tr("Bans"), tr("Active")}); + sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")}); + staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")}); +} + +QString TabModeration::formatEpoch(quint64 ts) const +{ + if (ts == 0) { + return tr("Unknown"); + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); +} + +void TabModeration::clearUserData() +{ + currentUser.clear(); + userInfoNameValue->clear(); + userInfoRegisteredValue->clear(); + userInfoLastLoginValue->clear(); + userInfoStatusValue->clear(); + userInfoCountsValue->clear(); + userInfoNotesEdit->clear(); + altsTable->setRowCount(0); + sessionsTable->setRowCount(0); + resetPasswordButton->setEnabled(false); + removeAvatarButton->setEnabled(false); +} + +void TabModeration::investigate(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + searchEdit->setText(userName); + investigateUser(); +} + +void TabModeration::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + currentUser = userName; + resetPasswordButton->setEnabled(true); + removeAvatarButton->setEnabled(true); + altsTable->setRowCount(0); + sessionsTable->setRowCount(0); + requestUserInfo(userName); + requestSessions(userName); + requestAlts(userName); +} + +void TabModeration::requestUserInfo(const QString &userName) +{ + userInfoNameValue->setText(userName); + userInfoRegisteredValue->setText(tr("Loading...")); + userInfoLastLoginValue->setText(tr("Loading...")); + userInfoStatusValue->clear(); + userInfoCountsValue->clear(); + userInfoNotesEdit->clear(); + + Command_ReportUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::userInfoResponse); + client->sendCommand(pend); +} + +void TabModeration::userInfoResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + userInfoRegisteredValue->clear(); + userInfoLastLoginValue->clear(); + userInfoStatusValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != currentUser) { + return; + } + + userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); + userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); + + QStringList statusParts; + statusParts << (resp.is_active() ? tr("active") : tr("inactive")); + if (resp.has_is_admin() && resp.is_admin()) { + statusParts << tr("admin"); + } + userInfoStatusValue->setText(statusParts.join(", ")); + + userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") + .arg(resp.total_reports()) + .arg(resp.total_bans()) + .arg(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + userInfoNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabModeration::requestSessions(const QString &userName) +{ + Command_GetUserSessions cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse); + client->sendCommand(pend); +} + +void TabModeration::sessionsResponse(const Response &response) +{ + sessionsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_UserSessions &resp = response.GetExtension(Response_UserSessions::ext); + sessionsTable->setRowCount(resp.sessions_size()); + for (int i = 0; i < resp.sessions_size(); ++i) { + const ServerInfo_UserSession &session = resp.sessions(i); + sessionsTable->setItem(i, COL_SESSIONS_IP, new QTableWidgetItem(QString::fromStdString(session.ip_address()))); + sessionsTable->setItem(i, COL_SESSIONS_CLIENTID, + new QTableWidgetItem(QString::fromStdString(session.clientid()))); + sessionsTable->setItem(i, COL_SESSIONS_START, new QTableWidgetItem(formatEpoch(session.start_time()))); + sessionsTable->setItem( + i, COL_SESSIONS_END, + new QTableWidgetItem(session.end_time() == 0 ? tr("Active") : formatEpoch(session.end_time()))); + sessionsTable->setItem(i, COL_SESSIONS_TYPE, + new QTableWidgetItem(QString::fromStdString(session.connection_type()))); + } +} + +void TabModeration::requestAlts(const QString &userName) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse); + client->sendCommand(pend); +} + +void TabModeration::altsResponse(const Response &response) +{ + altsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + altsTable->setRowCount(resp.alts_size()); + for (int i = 0; i < resp.alts_size(); ++i) { + const ServerInfo_UserAlt &alt = resp.alts(i); + altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + } +} + +void TabModeration::requestModeratorLogins() +{ + Command_GetModeratorLastLogins cmd; + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::moderatorLoginsResponse); + client->sendCommand(pend); +} + +void TabModeration::moderatorLoginsResponse(const Response &response) +{ + staffTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_ModeratorLastLogins &resp = response.GetExtension(Response_ModeratorLastLogins::ext); + staffTable->setRowCount(resp.logins_size()); + for (int i = 0; i < resp.logins_size(); ++i) { + const ServerInfo_ModeratorLogin &login = resp.logins(i); + staffTable->setItem(i, COL_STAFF_USER, new QTableWidgetItem(QString::fromStdString(login.user_name()))); + staffTable->setItem(i, COL_STAFF_LAST_LOGIN, new QTableWidgetItem(formatEpoch(login.last_login()))); + + QStringList levels; + if (login.user_level() & ServerInfo_User::IsAdmin) { + levels << tr("Admin"); + } + if (login.user_level() & ServerInfo_User::IsModerator) { + levels << tr("Moderator"); + } + if (login.user_level() & ServerInfo_User::IsJudge) { + levels << tr("Judge"); + } + staffTable->setItem(i, COL_STAFF_LEVEL, new QTableWidgetItem(levels.join(" / "))); + } +} + +void TabModeration::resetPassword() +{ + if (currentUser.isEmpty()) { + return; + } + + QMessageBox::StandardButton choice = + QMessageBox::warning(this, tr("Reset Password"), + tr("Reset the password of %1? A temporary password will be generated and shown to you. " + "The user must change it on their first login.") + .arg(currentUser), + QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); + if (choice != QMessageBox::Ok) { + return; + } + + Command_ResetUserPassword cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::resetPasswordResponse); + client->sendCommand(pend); +} + +void TabModeration::resetPasswordResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), tr("Password reset failed.")); + return; + } + + const Response_ResetUserPassword &resp = response.GetExtension(Response_ResetUserPassword::ext); + QMessageBox::information(this, tr("Password reset"), + tr("Temporary password for %1:\n\n%2\n\nPass it to the user through a secure channel.") + .arg(QString::fromStdString(resp.user_name())) + .arg(QString::fromStdString(resp.temporary_password()))); +} + +void TabModeration::removeAvatar() +{ + if (currentUser.isEmpty()) { + return; + } + + QMessageBox::StandardButton choice = + QMessageBox::warning(this, tr("Remove Avatar"), + tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), + QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); + if (choice != QMessageBox::Ok) { + return; + } + + Command_RemoveUserAvatar cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::removeAvatarResponse); + client->sendCommand(pend); +} + +void TabModeration::removeAvatarResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), tr("Could not remove the avatar.")); + return; + } + + const Response_RemoveUserAvatar &resp = response.GetExtension(Response_RemoveUserAvatar::ext); + QMessageBox::information(this, tr("Avatar removed"), + tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name()))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h new file mode 100644 index 000000000..0a534992b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -0,0 +1,83 @@ +#ifndef TAB_MODERATION_H +#define TAB_MODERATION_H + +#include "tab.h" + +#include + +class AbstractClient; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTableWidget; +class QTextEdit; + +/** + * Staff investigation tool. Lets moderators look up a user's account data, + * alternate accounts, login sessions, and staff login activity, and offers + * the password-reset and remove-avatar actions. + */ +class TabModeration : public Tab +{ + Q_OBJECT +public: + explicit TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Moderation"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void userInfoResponse(const Response &response); + void sessionsResponse(const Response &response); + void altsResponse(const Response &response); + void moderatorLoginsResponse(const Response &response); + void resetPassword(); + void resetPasswordResponse(const Response &response); + void removeAvatar(); + void removeAvatarResponse(const Response &response); + +private: + void requestUserInfo(const QString &userName); + void requestSessions(const QString &userName); + void requestAlts(const QString &userName); + void requestModeratorLogins(); + void clearUserData(); + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + + QLineEdit *searchEdit; + QPushButton *investigateButton; + QPushButton *resetPasswordButton; + QPushButton *removeAvatarButton; + + QGroupBox *userInfoGroup; + QLabel *userInfoNameLabel; + QLabel *userInfoNameValue; + QLabel *userInfoRegisteredLabel; + QLabel *userInfoRegisteredValue; + QLabel *userInfoLastLoginLabel; + QLabel *userInfoLastLoginValue; + QLabel *userInfoStatusLabel; + QLabel *userInfoStatusValue; + QLabel *userInfoCountsLabel; + QLabel *userInfoCountsValue; + QLabel *userInfoNotesLabel; + QTextEdit *userInfoNotesEdit; + + QGroupBox *altsGroup; + QTableWidget *altsTable; + QGroupBox *sessionsGroup; + QTableWidget *sessionsTable; + QGroupBox *staffGroup; + QTableWidget *staffTable; + QPushButton *refreshStaffButton; +}; + +#endif // TAB_MODERATION_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp new file mode 100644 index 000000000..0b9108f9b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -0,0 +1,927 @@ +#include "tab_report.h" + +#include "../utility/report_utils.h" +#include "abstract_client.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ID = 0; +constexpr int COL_TIME = 1; +constexpr int COL_REPORTER = 2; +constexpr int COL_REPORTED = 3; +constexpr int COL_CATEGORY = 4; +constexpr int COL_GAMEID = 5; +constexpr int COL_STATUS = 6; +constexpr int COL_ASSIGNED = 7; +constexpr int COL_REPLAY = 8; +constexpr int COL_ROOM = 9; +constexpr int COL_COUNT = 10; +constexpr int REFRESH_INTERVAL_MS = 300000; +} // namespace + +TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::textChanged, this, &TabReport::applyFilters); + + statusFilter = new QComboBox; + connect(statusFilter, &QComboBox::currentIndexChanged, this, &TabReport::applyFilters); + + unresolvedOnlyBox = new QCheckBox; + unresolvedOnlyBox->setChecked(true); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + connect(unresolvedOnlyBox, &QCheckBox::checkStateChanged, this, &TabReport::refreshList); +#else + connect(unresolvedOnlyBox, &QCheckBox::stateChanged, this, &TabReport::refreshList); +#endif + + refreshButton = new QPushButton; + connect(refreshButton, &QPushButton::clicked, this, &TabReport::refreshList); + + refreshTimer = new QTimer(this); + refreshTimer->setInterval(REFRESH_INTERVAL_MS); + connect(refreshTimer, &QTimer::timeout, this, [this]() { + if (tabSupervisor->currentWidget() == this) { + refreshList(); + } + }); + refreshTimer->start(); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(statusFilter); + topBar->addWidget(unresolvedOnlyBox); + topBar->addStretch(); + topBar->addWidget(refreshButton); + + statsLabel = new QLabel; + + table = new QTableWidget(0, COL_COUNT); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(true); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents); + connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); + + descGroup = new QGroupBox; + descriptionEdit = new QTextEdit; + descriptionEdit->setReadOnly(true); + auto *descLayout = new QVBoxLayout(descGroup); + descLayout->setContentsMargins(4, 4, 4, 4); + descLayout->addWidget(descriptionEdit); + + chatGroup = new QGroupBox; + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLayout->setContentsMargins(4, 4, 4, 4); + chatLayout->addWidget(chatLogEdit); + + commentsGroup = new QGroupBox; + commentsEdit = new QTextEdit; + commentsEdit->setReadOnly(true); + auto *commentInputRow = new QHBoxLayout; + commentInput = new QLineEdit; + commentButton = new QPushButton; + commentButton->setEnabled(false); + connect(commentButton, &QPushButton::clicked, this, &TabReport::addComment); + connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); + commentInputRow->addWidget(commentInput); + commentInputRow->addWidget(commentButton); + auto *commentsLayout = new QVBoxLayout(commentsGroup); + commentsLayout->setContentsMargins(4, 4, 4, 4); + commentsLayout->addWidget(commentsEdit); + commentsLayout->addLayout(commentInputRow); + + userContextGroup = new QGroupBox; + userContextName = new QLabel; + userContextAccountAge = new QLabel; + userContextReports = new QLabel; + userContextBans = new QLabel; + userContextWarns = new QLabel; + userContextNotes = new QTextEdit; + userContextNotes->setReadOnly(true); + userContextNotes->setMaximumHeight(80); + userContextRecentReports = new QTextEdit; + userContextRecentReports->setReadOnly(true); + userContextRecentReports->setMaximumHeight(120); + userContextUserLabel = new QLabel; + userContextAgeLabel = new QLabel; + userContextReportsLabel = new QLabel; + userContextBansLabel = new QLabel; + userContextWarnsLabel = new QLabel; + userContextNotesLabel = new QLabel; + userContextRecentReportsLabel = new QLabel; + auto *ucLayout = new QGridLayout(userContextGroup); + ucLayout->setContentsMargins(8, 8, 8, 8); + ucLayout->addWidget(userContextUserLabel, 0, 0); + ucLayout->addWidget(userContextName, 0, 1, 1, 3); + ucLayout->addWidget(userContextAgeLabel, 1, 0); + ucLayout->addWidget(userContextAccountAge, 1, 1); + ucLayout->addWidget(userContextReportsLabel, 1, 2); + ucLayout->addWidget(userContextReports, 1, 3); + ucLayout->addWidget(userContextBansLabel, 2, 0); + ucLayout->addWidget(userContextBans, 2, 1); + ucLayout->addWidget(userContextWarnsLabel, 2, 2); + ucLayout->addWidget(userContextWarns, 2, 3); + ucLayout->addWidget(userContextNotesLabel, 3, 0, Qt::AlignTop); + ucLayout->addWidget(userContextNotes, 3, 1, 1, 3); + ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4); + ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4); + userContextGroup->setVisible(false); + + statsGroup = new QGroupBox; + statsGroup->setCheckable(true); + statsTotalLabel = new QLabel; + statsTrendLabel = new QLabel; + statsCategoriesLabel = new QLabel; + statsDetailText = new QTextEdit; + statsDetailText->setReadOnly(true); + statsDetailText->setMaximumHeight(150); + auto *statsContent = new QWidget; + auto *sgLayout = new QVBoxLayout(statsContent); + sgLayout->setContentsMargins(8, 8, 8, 8); + sgLayout->addWidget(statsTotalLabel); + sgLayout->addWidget(statsTrendLabel); + sgLayout->addWidget(statsCategoriesLabel); + sgLayout->addWidget(statsDetailText); + auto *statsGroupLayout = new QVBoxLayout(statsGroup); + statsGroupLayout->setContentsMargins(0, 0, 0, 0); + statsGroupLayout->addWidget(statsContent); + statsGroup->setChecked(true); + connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) { + statsContent->setVisible(checked); + if (checked) { + requestStats(); + } + }); + + detailSplitter = new QSplitter(Qt::Vertical); + detailSplitter->addWidget(descGroup); + detailSplitter->addWidget(chatGroup); + detailSplitter->addWidget(commentsGroup); + detailSplitter->setStretchFactor(0, 1); + detailSplitter->setStretchFactor(1, 1); + detailSplitter->setStretchFactor(2, 2); + + assignButton = new QPushButton; + resolveButton = new QPushButton; + resolveWithNoteButton = new QPushButton; + dismissButton = new QPushButton; + viewReplayButton = new QPushButton; + joinGameButton = new QPushButton; + connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); + connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); }); + connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); }); + connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); }); + connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay); + connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); + + statusLabel = new QLabel; + + auto *actionBar = new QHBoxLayout; + actionBar->addWidget(assignButton); + actionBar->addWidget(resolveButton); + actionBar->addWidget(resolveWithNoteButton); + actionBar->addWidget(dismissButton); + actionBar->addSpacing(20); + actionBar->addWidget(viewReplayButton); + actionBar->addWidget(joinGameButton); + actionBar->addStretch(); + actionBar->addWidget(statusLabel); + + auto *layout = new QVBoxLayout(centralWidget); + layout->addLayout(topBar); + layout->addWidget(statsLabel); + layout->addWidget(table, 2); + layout->addWidget(detailSplitter, 1); + layout->addWidget(userContextGroup); + layout->addWidget(statsGroup); + layout->addLayout(actionBar); + + retranslateUi(); + + setActionsEnabled(false); + refreshList(); +} + +void TabReport::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("Search by username, category...")); + statusFilter->clear(); + statusFilter->addItem(tr("All Statuses"), ""); + statusFilter->addItem(tr("Open"), "open"); + statusFilter->addItem(tr("Assigned"), "assigned"); + statusFilter->addItem(tr("Resolved"), "resolved"); + statusFilter->addItem(tr("Dismissed"), "dismissed"); + unresolvedOnlyBox->setText(tr("Unresolved only")); + refreshButton->setText(tr("Refresh")); + + table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"), + tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")}); + + descGroup->setTitle(tr("Description")); + chatGroup->setTitle(tr("Chat Log Context")); + chatGroup->setToolTip( + tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited.")); + commentsGroup->setTitle(tr("Comments / Thread")); + descriptionEdit->setPlaceholderText(tr("No description.")); + chatLogEdit->setPlaceholderText(tr("No chat log attached.")); + commentsEdit->setPlaceholderText(tr("No comments yet.")); + commentInput->setPlaceholderText(tr("Type a reply...")); + commentButton->setText(tr("Send")); + + userContextGroup->setTitle(tr("Reported User Context")); + userContextUserLabel->setText(tr("User:")); + userContextAgeLabel->setText(tr("Account Age:")); + userContextReportsLabel->setText(tr("Reports:")); + userContextBansLabel->setText(tr("Bans:")); + userContextWarnsLabel->setText(tr("Warns:")); + userContextNotesLabel->setText(tr("Admin Notes:")); + userContextRecentReportsLabel->setText(tr("Recent Reports Against User:")); + userContextAccountAge->setText(QString()); + userContextReports->setText(QString()); + userContextBans->setText(QString()); + userContextWarns->setText(QString()); + + statsGroup->setTitle(tr("Report Statistics")); + + assignButton->setText(tr("Assign to Me")); + resolveButton->setText(tr("Resolve")); + resolveWithNoteButton->setText(tr("Resolve with note...")); + dismissButton->setText(tr("Dismiss...")); + viewReplayButton->setText(tr("View Replay")); + joinGameButton->setText(tr("Join Game")); +} + +void TabReport::refreshList() +{ + selectedReportIdBeforeRefresh = selectedReportId(); + commentDraftBeforeRefresh = commentInput->text(); + previousSelectedReportValid = selectedReportInfo(previousSelectedReport); + + refreshButton->setEnabled(false); + statusLabel->setText(tr("Loading...")); + table->setRowCount(0); + allReports.clear(); + filteredReports.clear(); + commentButton->setEnabled(false); + setActionsEnabled(false); + + Command_ReportList cmd; + cmd.set_unresolved_only(unresolvedOnlyBox->isChecked()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::reportListResponse); + client->sendCommand(pend); +} + +void TabReport::reportListResponse(const Response &response) +{ + refreshButton->setEnabled(true); + + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Failed to load reports.")); + return; + } + + const Response_ReportList &resp = response.GetExtension(Response_ReportList::ext); + allReports.clear(); + for (int i = 0; i < resp.reports_size(); ++i) { + allReports.append(resp.reports(i)); + } + + applyFilters(); + updateStats(); + + if (selectedReportIdBeforeRefresh >= 0) { + bool found = false; + for (int row = 0; row < table->rowCount(); ++row) { + if (table->item(row, COL_ID) && + table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) { + found = true; + { + QSignalBlocker blocker(table); + table->setCurrentCell(row, 0); + } + break; + } + } + + if (found) { + bool dataChanged = false; + ServerInfo_Report newReport; + if (!selectedReportInfo(newReport) || !previousSelectedReportValid || + previousSelectedReport.description() != newReport.description() || + previousSelectedReport.status() != newReport.status() || + previousSelectedReport.resolution_note() != newReport.resolution_note() || + previousSelectedReport.assigned_mod_name() != newReport.assigned_mod_name()) { + dataChanged = true; + } + + if (dataChanged || detailsRequestedReportId != selectedReportIdBeforeRefresh) { + loadReportDetails(selectedReportIdBeforeRefresh); + } + updateActionStates(); + } else { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + setActionsEnabled(false); + userContextGroup->setVisible(false); + } + } + + if (commentInput->text().isEmpty()) { + commentInput->setText(commentDraftBeforeRefresh); + } + + if (statsGroup->isChecked()) { + requestStats(); + } +} + +void TabReport::applyFilters() +{ + filteredReports.clear(); + + const QString searchText = searchEdit->text().toLower(); + const QString statusFilterValue = statusFilter->currentData().toString(); + + for (const ServerInfo_Report &r : allReports) { + bool matchesSearch = searchText.isEmpty() || + QString::fromStdString(r.reporter_name()).toLower().contains(searchText) || + QString::fromStdString(r.reported_user_name()).toLower().contains(searchText) || + QString::fromStdString(r.category()).toLower().contains(searchText) || + QString::fromStdString(r.description()).toLower().contains(searchText); + + bool matchesStatus = statusFilterValue.isEmpty() || QString::fromStdString(r.status()) == statusFilterValue; + + if (matchesSearch && matchesStatus) { + filteredReports.append(r); + } + } + + table->setSortingEnabled(false); + table->setRowCount(filteredReports.size()); + + for (int row = 0; row < filteredReports.size(); ++row) { + const ServerInfo_Report &r = filteredReports[row]; + + report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, + COL_STATUS, COL_ASSIGNED); + + table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); + + table->setItem(row, COL_REPLAY, + new QTableWidgetItem(r.has_replay_id() && r.replay_id() > 0 ? tr("Yes") : tr("No"))); + + table->setItem(row, COL_ROOM, + new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); + } + + table->setSortingEnabled(true); + table->resizeColumnsToContents(); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + + statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); +} + +void TabReport::updateStats() +{ + int open = 0, assigned = 0, resolved = 0, dismissed = 0; + for (const ServerInfo_Report &r : allReports) { + QString status = QString::fromStdString(r.status()); + if (status == "open") { + open++; + } else if (status == "assigned") { + assigned++; + } else if (status == "resolved") { + resolved++; + } else if (status == "dismissed") { + dismissed++; + } + } + + statsLabel->setText(tr("%1 open | %2 assigned | %3 resolved | %4 dismissed") + .arg(open) + .arg(assigned) + .arg(resolved) + .arg(dismissed)); +} + +void TabReport::onSelectionChanged() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + commentButton->setEnabled(false); + setActionsEnabled(false); + userContextGroup->setVisible(false); + return; + } + + const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt(); + loadReportDetails(reportId); + updateActionStates(); +} + +void TabReport::loadReportDetails(int reportId) +{ + detailsRequestedReportId = reportId; + + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == reportId) { + descriptionEdit->setPlainText(QString::fromStdString(r.description())); + + QString reportedUser = QString::fromStdString(r.reported_user_name()); + if (!reportedUser.isEmpty()) { + requestUserInfo(reportedUser); + } else { + userContextGroup->setVisible(false); + } + + QString status = QString::fromStdString(r.status()); + bool canComment = (status == "open" || status == "assigned"); + commentButton->setEnabled(canComment); + commentInput->setEnabled(canComment); + if (!canComment) { + commentInput->setPlaceholderText(tr("This report is closed.")); + } else { + commentInput->setPlaceholderText(tr("Type a reply...")); + } + break; + } + } + + chatLogEdit->setPlainText(tr("Loading...")); + commentsEdit->setPlainText(tr("Loading...")); + + Command_ReportDetails cmd; + cmd.set_report_id(reportId); + + const int seq = ++detailsRequestSeq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, [this, seq](const Response &response) { + if (seq != detailsRequestSeq) { + return; + } + reportDetailsResponse(response); + }); + client->sendCommand(pend); +} + +void TabReport::reportDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + if (selectedReportId() == detailsRequestedReportId) { + chatLogEdit->clear(); + commentsEdit->setPlainText(tr("Failed to load report details.")); + } + return; + } + + const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext); + const ServerInfo_Report &r = resp.report(); + + if (selectedReportId() != r.report_id()) { + return; + } + + report_utils::renderReportDetails(chatLogEdit, commentsEdit, r, tr("No comments yet."), tr("[Moderator]"), + tr("[Reporter]")); +} + +void TabReport::updateActionStates() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_STATUS)) { + setActionsEnabled(false); + return; + } + + ServerInfo_Report report; + if (!selectedReportInfo(report)) { + setActionsEnabled(false); + return; + } + + const QString status = QString::fromStdString(report.status()); + assignButton->setEnabled(status == "open"); + resolveButton->setEnabled(status == "open" || status == "assigned"); + resolveWithNoteButton->setEnabled(status == "open" || status == "assigned"); + dismissButton->setEnabled(status == "open" || status == "assigned"); + + const bool hasGameId = report.game_id() > 0; + const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0; + viewReplayButton->setEnabled(hasReplay); + joinGameButton->setEnabled(hasGameId && report.has_room_id() && report.room_id() > 0); +} + +void TabReport::setActionsEnabled(bool enabled) +{ + assignButton->setEnabled(enabled); + resolveButton->setEnabled(enabled); + resolveWithNoteButton->setEnabled(enabled); + dismissButton->setEnabled(enabled); + viewReplayButton->setEnabled(false); + joinGameButton->setEnabled(false); + commentButton->setEnabled(enabled); + commentInput->setEnabled(enabled); +} + +int TabReport::selectedReportId() const +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + return -1; + } + return table->item(row, COL_ID)->data(Qt::UserRole).toInt(); +} + +bool TabReport::selectedReportInfo(ServerInfo_Report &info) const +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return false; + } + + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == reportId) { + info = r; + return true; + } + } + + return false; +} + +void TabReport::assignReport() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Assigning...")); + + Command_ReportAssign cmd; + cmd.set_report_id(reportId); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::assignResponse); + client->sendCommand(pend); +} + +void TabReport::assignResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + statusLabel->setText(tr("Assigned.")); + refreshList(); + } else { + statusLabel->setText(tr("Assignment failed.")); + updateActionStates(); + } +} + +void TabReport::resolveReport(bool dismissed, bool promptNote) +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + QString note; + if (promptNote) { + bool ok; + note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"), + dismissed ? tr("Optional note:") : tr("Resolution note (optional):"), + QLineEdit::Normal, QString(), &ok); + if (!ok) { + return; + } + } + + setActionsEnabled(false); + statusLabel->setText(dismissed ? tr("Dismissing...") : tr("Resolving...")); + + Command_ReportResolve cmd; + cmd.set_report_id(reportId); + cmd.set_dismissed(dismissed); + if (!note.isEmpty()) { + cmd.set_resolution_note(note.toStdString()); + } + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::resolveResponse); + client->sendCommand(pend); +} + +void TabReport::resolveResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + statusLabel->setText(tr("Done.")); + refreshList(); + } else { + statusLabel->setText(tr("Action failed.")); + updateActionStates(); + } +} + +void TabReport::viewReplay() +{ + ServerInfo_Report report; + if (!selectedReportInfo(report) || report.game_id() <= 0) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Loading replay...")); + + Command_ReplayDownloadByGameId cmd; + cmd.set_game_id(report.game_id()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::viewReplayResponse); + client->sendCommand(pend); +} + +void TabReport::viewReplayResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("No replay available for this game.")); + updateActionStates(); + return; + } + + const Response_ReplayDownloadByGameId &resp = response.GetExtension(Response_ReplayDownloadByGameId::ext); + GameReplay *replay = new GameReplay; + if (replay->ParseFromString(resp.replay_data())) { + emit openReplay(replay); + statusLabel->setText(tr("Replay opened.")); + } else { + delete replay; + statusLabel->setText(tr("Failed to parse replay.")); + } + + updateActionStates(); +} + +void TabReport::joinGame() +{ + ServerInfo_Report report; + if (!selectedReportInfo(report) || report.game_id() <= 0) { + return; + } + + const int roomId = report.has_room_id() ? report.room_id() : -1; + if (roomId <= 0) { + statusLabel->setText(tr("No room recorded for this report, use the replay instead.")); + return; + } + + emit requestJoinGame(report.game_id(), roomId); + statusLabel->setText(tr("Attempting to join game...")); +} + +void TabReport::addComment() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + QString text = commentInput->text().trimmed(); + if (text.isEmpty()) { + return; + } + + commentButton->setEnabled(false); + + Command_ReportAddComment cmd; + cmd.set_report_id(reportId); + cmd.set_comment(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::addCommentResponse); + client->sendCommand(pend); +} + +void TabReport::addCommentResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + commentInput->clear(); + refreshList(); + } else { + commentButton->setEnabled(true); + statusLabel->setText(tr("Failed to send comment.")); + } +} + +void TabReport::requestUserInfo(const QString &userName) +{ + if (userName.isEmpty()) { + userContextGroup->setVisible(false); + return; + } + + lastRequestedUser = userName; + userContextGroup->setVisible(true); + userContextName->setText(userName); + userContextAccountAge->setText(tr("Loading...")); + userContextReports->clear(); + userContextBans->clear(); + userContextWarns->clear(); + userContextNotes->clear(); + userContextRecentReports->clear(); + + Command_ReportUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::userInfoResponse); + client->sendCommand(pend); +} + +void TabReport::userInfoResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + userContextAccountAge->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + + if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != lastRequestedUser) { + return; + } + + QDateTime regTime = QDateTime::fromSecsSinceEpoch(resp.registration_time()); + qint64 daysSinceReg = regTime.daysTo(QDateTime::currentDateTime()); + userContextAccountAge->setText(tr("%1 days (since %2)").arg(daysSinceReg).arg(regTime.toString("yyyy-MM-dd"))); + + userContextReports->setText(QString::number(resp.total_reports())); + userContextBans->setText(QString::number(resp.total_bans())); + userContextWarns->setText(QString::number(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + userContextNotes->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + userContextNotes->setPlainText(tr("(none)")); + } + + userContextRecentReports->clear(); + if (resp.recent_reports_size() == 0) { + userContextRecentReports->setPlainText(tr("No previous reports against this user.")); + } else { + for (int i = 0; i < resp.recent_reports_size(); ++i) { + const ServerInfo_Report &r = resp.recent_reports(i); + QDateTime dt = QDateTime::fromSecsSinceEpoch(r.report_time()); + userContextRecentReports->moveCursor(QTextCursor::End); + userContextRecentReports->insertPlainText(QString("[%1] #%2 by %3 [%4]: %5\n") + .arg(dt.toString("yyyy-MM-dd")) + .arg(r.report_id()) + .arg(QString::fromStdString(r.reporter_name())) + .arg(QString::fromStdString(r.status())) + .arg(QString::fromStdString(r.category()))); + } + } +} + +void TabReport::requestStats() +{ + statsTotalLabel->setText(tr("Loading statistics...")); + statsTrendLabel->clear(); + statsCategoriesLabel->clear(); + statsDetailText->clear(); + + Command_ReportStats cmd; + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::statsResponse); + client->sendCommand(pend); +} + +void TabReport::statsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + statsTotalLabel->setText(tr("Error loading statistics.")); + return; + } + + const Response_ReportStats &resp = response.GetExtension(Response_ReportStats::ext); + + statsTotalLabel->setText(tr("Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed)") + .arg(resp.total_reports()) + .arg(resp.total_pending()) + .arg(resp.total_assigned()) + .arg(resp.total_resolved())); + + statsTrendLabel->setText(tr("Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h") + .arg(resp.reports_last_24h()) + .arg(resp.reports_last_7d()) + .arg(resp.reports_last_30d()) + .arg(resp.avg_resolution_hours(), 0, 'f', 1)); + + QString weekCompare = + tr("This week: %1 vs last week: %2 (%3%)") + .arg(resp.reports_this_week()) + .arg(resp.reports_last_week()) + .arg(resp.reports_last_week() > 0 ? QString::number(((resp.reports_this_week() - resp.reports_last_week()) * + 100.0 / resp.reports_last_week()), + 'f', 0) + : resp.reports_this_week() > 0 ? "new" + : "0"); + statsTrendLabel->setText(statsTrendLabel->text() + " | " + weekCompare); + + statsCategoriesLabel->setText(tr("By category:")); + statsDetailText->clear(); + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText(tr("=== Top Categories ===") + "\n"); + for (int i = 0; i < resp.category_counts_size(); ++i) { + const ReportCategoryCount &cc = resp.category_counts(i); + QString cat = QString::fromStdString(cc.category()); + cat.replace('_', ' '); + cat = cat.left(1).toUpper() + cat.mid(1); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText(QString(" %1: %2\n").arg(cat).arg(cc.count())); + } + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText("\n" + tr("=== Most Reported Users ===") + "\n"); + for (int i = 0; i < resp.top_reported_users_size(); ++i) { + const ReportTopUser &tu = resp.top_reported_users(i); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText( + QString(" %1: %2 reports\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count())); + } + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText("\n" + tr("=== Top Reporters ===") + "\n"); + for (int i = 0; i < resp.top_reporters_size(); ++i) { + const ReportTopUser &tu = resp.top_reporters(i); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText( + QString(" %1: %2 reports filed\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count())); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h new file mode 100644 index 000000000..b62a99ce2 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -0,0 +1,124 @@ +#ifndef TAB_REPORT_H +#define TAB_REPORT_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QCheckBox; +class QComboBox; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSplitter; +class QTableWidget; +class QTextEdit; +class QTimer; +class GameReplay; + +class TabReport : public Tab +{ + Q_OBJECT +public: + TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Report Queue"); + } + +signals: + void openReplay(GameReplay *replay); + void requestJoinGame(int gameId, int roomId); + +private slots: + void refreshList(); + void reportListResponse(const Response &response); + void assignReport(); + void assignResponse(const Response &response); + void resolveReport(bool dismissed, bool promptNote); + void resolveResponse(const Response &response); + void onSelectionChanged(); + void viewReplay(); + void viewReplayResponse(const Response &response); + void joinGame(); + void addComment(); + void addCommentResponse(const Response &response); + void requestUserInfo(const QString &userName); + void userInfoResponse(const Response &response); + void reportDetailsResponse(const Response &response); + void requestStats(); + void statsResponse(const Response &response); + +private: + int selectedReportId() const; + bool selectedReportInfo(ServerInfo_Report &info) const; + void setActionsEnabled(bool enabled); + void updateActionStates(); + void applyFilters(); + void updateStats(); + void loadReportDetails(int reportId); + + AbstractClient *client; + + QLineEdit *searchEdit; + QComboBox *statusFilter; + QCheckBox *unresolvedOnlyBox; + QPushButton *refreshButton; + QLabel *statsLabel; + QTableWidget *table; + QSplitter *detailSplitter; + QGroupBox *descGroup; + QGroupBox *chatGroup; + QGroupBox *commentsGroup; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QTextEdit *commentsEdit; + QLineEdit *commentInput; + QPushButton *commentButton; + QPushButton *assignButton; + QPushButton *resolveButton; + QPushButton *resolveWithNoteButton; + QPushButton *dismissButton; + QPushButton *viewReplayButton; + QPushButton *joinGameButton; + QLabel *statusLabel; + QTimer *refreshTimer; + + QGroupBox *userContextGroup; + QLabel *userContextName; + QLabel *userContextAccountAge; + QLabel *userContextReports; + QLabel *userContextBans; + QLabel *userContextWarns; + QTextEdit *userContextNotes; + QTextEdit *userContextRecentReports; + QLabel *userContextUserLabel; + QLabel *userContextAgeLabel; + QLabel *userContextReportsLabel; + QLabel *userContextBansLabel; + QLabel *userContextWarnsLabel; + QLabel *userContextNotesLabel; + QLabel *userContextRecentReportsLabel; + QString lastRequestedUser; + QGroupBox *statsGroup; + QLabel *statsTotalLabel; + QLabel *statsTrendLabel; + QLabel *statsCategoriesLabel; + QTextEdit *statsDetailText; + + QList allReports; + QList filteredReports; + int detailsRequestedReportId = -1; + int detailsRequestSeq = 0; + int selectedReportIdBeforeRefresh = -1; + QString commentDraftBeforeRefresh; + ServerInfo_Report previousSelectedReport; + bool previousSelectedReportValid = false; +}; + +#endif // TAB_REPORT_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 016d96434..f1b26da9d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../intents/intent_join_server_game.h" #include "../interface/pixel_map_generator.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" @@ -18,7 +19,9 @@ #include "tab_home.h" #include "tab_logs.h" #include "tab_message.h" +#include "tab_moderation.h" #include "tab_replays.h" +#include "tab_report.h" #include "tab_room.h" #include "tab_server.h" #include "tab_visual_database_display.h" @@ -31,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -113,7 +118,7 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/) TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), - tabLog(nullptr), isLocalGame(false) + tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -190,6 +195,14 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * aTabLog->setCheckable(true); connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog); + aTabReport = new QAction(this); + aTabReport->setCheckable(true); + connect(aTabReport, &QAction::triggered, this, &TabSupervisor::actTabReport); + + aTabModeration = new QAction(this); + aTabModeration->setCheckable(true); + connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabSupervisor::refreshShortcuts); refreshShortcuts(); @@ -229,6 +242,8 @@ void TabSupervisor::retranslateUi() aTabReplays->setText(tr("Game Replays")); aTabAdmin->setText(tr("Administration")); aTabLog->setText(tr("Logs")); + aTabReport->setText(tr("Report Queue")); + aTabModeration->setText(tr("Moderation")); // tabs QList tabs; @@ -238,6 +253,8 @@ void TabSupervisor::retranslateUi() tabs.append(tabAdmin); tabs.append(tabAccount); tabs.append(tabLog); + tabs.append(tabReport); + tabs.append(tabModeration); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -284,6 +301,8 @@ void TabSupervisor::refreshShortcuts() aTabReplays->setShortcuts(shortcuts.getShortcut("Tabs/aTabReplays")); aTabAdmin->setShortcuts(shortcuts.getShortcut("Tabs/aTabAdmin")); aTabLog->setShortcuts(shortcuts.getShortcut("Tabs/aTabLog")); + aTabReport->setShortcuts(shortcuts.getShortcut("Tabs/aTabReport")); + aTabModeration->setShortcuts(shortcuts.getShortcut("Tabs/aTabModeration")); } void TabSupervisor::closeEvent(QCloseEvent *event) @@ -485,6 +504,8 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addAction(aTabAdmin); tabsMenu->addAction(aTabLog); tabsMenu->addAction(aTabCardArtRules); + tabsMenu->addAction(aTabReport); + tabsMenu->addAction(aTabModeration); if (SettingsCache::instance().tabs().getTabAdminOpen()) { openTabAdmin(); @@ -492,6 +513,12 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().tabs().getTabLogOpen()) { openTabLog(); } + if (SettingsCache::instance().tabs().getTabReportOpen()) { + openTabReport(); + } + if (SettingsCache::instance().tabs().getTabModerationOpen()) { + openTabModeration(); + } openTabCardArtRules(); } @@ -505,6 +532,8 @@ void TabSupervisor::startLocal(const QList &_clients) tabAccount = nullptr; tabAdmin = nullptr; tabLog = nullptr; + tabReport = nullptr; + tabModeration = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; localClients = _clients; @@ -546,6 +575,12 @@ void TabSupervisor::stop() if (tabLog) { tabLog->close(); } + if (tabReport) { + tabReport->close(); + } + if (tabModeration) { + tabModeration->close(); + } } QList tabsToDelete; @@ -783,6 +818,59 @@ void TabSupervisor::openTabLog() aTabLog->setChecked(true); } +void TabSupervisor::actTabReport(bool checked) +{ + SettingsCache::instance().tabs().setTabReportOpen(checked); + if (checked && !tabReport) { + openTabReport(); + setCurrentWidget(tabReport); + } else if (!checked && tabReport) { + tabReport->closeRequest(); + } +} + +void TabSupervisor::openTabReport() +{ + tabReport = new TabReport(this, client); + myAddTab(tabReport, aTabReport); + connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay); + connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame); + connect(tabReport, &QObject::destroyed, this, [this] { + tabReport = nullptr; + aTabReport->setChecked(false); + }); + aTabReport->setChecked(true); +} + +void TabSupervisor::actTabModeration(bool checked) +{ + SettingsCache::instance().tabs().setTabModerationOpen(checked); + if (checked && !tabModeration) { + openTabModeration(); + setCurrentWidget(tabModeration); + } else if (!checked && tabModeration) { + tabModeration->closeRequest(); + } +} + +void TabSupervisor::openTabModeration(const QString &userName) +{ + if (tabModeration) { + setCurrentWidget(tabModeration); + if (!userName.isEmpty()) { + tabModeration->investigate(userName); + } + return; + } + tabModeration = new TabModeration(this, client, userName); + myAddTab(tabModeration, aTabModeration); + connect(tabModeration, &QObject::destroyed, this, [this] { + tabModeration = nullptr; + aTabModeration->setChecked(false); + }); + aTabModeration->setChecked(true); +} + void TabSupervisor::updatePingTime(int value, int max) { if (!tabServer) { @@ -900,6 +988,30 @@ void TabSupervisor::replayLeft(TabGame *tab) replayTabs.removeOne(tab); } +void TabSupervisor::joinReportGame(const int gameId, const int roomId) +{ + auto *remoteClient = qobject_cast(client); + if (!remoteClient) { + actShowPopup(tr("Report joins are only available on a remote server.")); + return; + } + + auto ctx = std::make_unique(); + ctx->roomContext.serverContext.hostname = remoteClient->peerName(); + ctx->roomContext.serverContext.port = QString::number(remoteClient->peerPort()); + ctx->roomContext.roomId = roomId; + ctx->gameId = gameId; + ctx->asSpectator = true; + + auto *joinGameIntent = new IntentJoinServerGame(this, remoteClient, std::move(ctx)); + joinGameIntent->setParent(this); + connect(joinGameIntent, &Intent::failed, this, [gameId](const QString &reason) { + actShowPopup(tr("Could not join game %1.\n%2").arg(gameId).arg(reason)); + }); + + joinGameIntent->execute(); +} + TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus) { if (receiverName == QString::fromStdString(userInfo->name())) { @@ -1284,6 +1396,24 @@ void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event) } break; } + case Event_NotifyUser::REPORT_RESOLVED: { + QString title = QString::fromStdString(event.custom_title()).simplified(); + QString content = QString::fromStdString(event.custom_content()).trimmed(); + if (!title.isEmpty() && !content.isEmpty()) { + actShowPopup(title + "\n" + content); + QApplication::alert(this); + } + break; + } + case Event_NotifyUser::REPORT_COMMENT: { + QString title = QString::fromStdString(event.custom_title()).simplified(); + QString content = QString::fromStdString(event.custom_content()).trimmed(); + if (!title.isEmpty() && !content.isEmpty()) { + actShowPopup(title + "\n" + content); + QApplication::alert(this); + } + break; + } default:; } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 81ad22f54..32ed14504 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -40,6 +40,8 @@ class TabDeckStorage; class TabReplays; class TabAdmin; class TabMessage; +class TabReport; +class TabModeration; class TabAccount; class TabDeckEditor; class TabLog; @@ -103,6 +105,8 @@ private: TabAdmin *tabAdmin; TabCardArtRules *tabCardArtRules; TabLog *tabLog; + TabReport *tabReport; + TabModeration *tabModeration; QMap roomTabs; QMap gameTabs; QList replayTabs; @@ -112,7 +116,7 @@ private: QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, - *aTabCardArtRules, *aTabLog; + *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); @@ -183,6 +187,8 @@ public slots: TabArchidekt *addArchidektTab(); TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false); void openReplay(GameReplay *replay); + void joinReportGame(int gameId, int roomId); + void openTabModeration(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); @@ -198,6 +204,8 @@ private slots: void actTabDeckStorage(bool checked); void actTabAdmin(bool checked); void actTabLog(bool checked); + void actTabReport(bool checked); + void actTabModeration(bool checked); void openTabVisualDeckStorage(); void openTabHome(); @@ -208,6 +216,7 @@ private slots: void actTabCardArtRules(bool checked); void openTabCardArtRules(); void openTabLog(); + void openTabReport(); void updateCurrent(int index); void updatePingTime(int value, int max); diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp new file mode 100644 index 000000000..aa4558e5c --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -0,0 +1,119 @@ +#include "report_utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace report_utils +{ + +namespace +{ +QColor reportStatusColor(const QString &status) +{ + const QColor text = qApp->palette().color(QPalette::Text); + const int luminance = (299 * text.red() + 587 * text.green() + 114 * text.blue()) / 1000; + const bool dark = luminance < 128; + + if (status == "open") { + return dark ? QColor("#e06060") : QColor("#c0392b"); + } + if (status == "assigned") { + return dark ? QColor("#e0a030") : QColor("#d68910"); + } + if (status == "resolved") { + return dark ? QColor("#50c878") : QColor("#1e8449"); + } + if (status == "dismissed") { + return qApp->palette().color(QPalette::PlaceholderText); + } + return QColor(); +} + +void setReportStatusItemColor(QTableWidgetItem *item, const QString &status) +{ + const QColor color = reportStatusColor(status); + if (color.isValid()) { + item->setForeground(QBrush(color)); + } +} +} // namespace + +QString formatReportCategory(const QString &category) +{ + QString result = category; + result.replace('_', ' '); + return result.left(1).toUpper() + result.mid(1); +} + +QString formatReportTime(qint64 secondsSinceEpoch) +{ + const QDateTime dt = QDateTime::fromSecsSinceEpoch(secondsSinceEpoch); + return dt.toString("yyyy-MM-dd hh:mm"); +} + +void fillReportTableRow(QTableWidget *table, + int row, + const ServerInfo_Report &report, + int idColumn, + int timeColumn, + int reportedColumn, + int categoryColumn, + int gameIdColumn, + int statusColumn, + int assignedColumn) +{ + auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); + idItem->setData(Qt::UserRole, report.report_id()); + table->setItem(row, idColumn, idItem); + + table->setItem(row, timeColumn, new QTableWidgetItem(formatReportTime(report.report_time()))); + table->setItem(row, reportedColumn, new QTableWidgetItem(QString::fromStdString(report.reported_user_name()))); + table->setItem(row, categoryColumn, + new QTableWidgetItem(formatReportCategory(QString::fromStdString(report.category())))); + table->setItem(row, gameIdColumn, + new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString())); + + auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status())); + setReportStatusItemColor(statusItem, QString::fromStdString(report.status())); + table->setItem(row, statusColumn, statusItem); + + table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); +} + +void renderReportDetails(QTextEdit *chatLogEdit, + QTextEdit *commentsEdit, + const ServerInfo_Report &report, + const QString &commentsEmptyText, + const QString &moderatorPrefix, + const QString &nonModeratorPrefix) +{ + if (report.has_chat_log() && !report.chat_log().empty()) { + chatLogEdit->setPlainText(QString::fromStdString(report.chat_log())); + } else { + chatLogEdit->clear(); + } + + commentsEdit->clear(); + + if (report.comments_size() == 0) { + commentsEdit->setPlainText(commentsEmptyText); + return; + } + + for (int i = 0; i < report.comments_size(); ++i) { + const ServerInfo_ReportComment &c = report.comments(i); + const QString author = QString::fromStdString(c.author_name()); + const QString text = QString::fromStdString(c.comment_text()); + const QString prefix = c.is_moderator() ? moderatorPrefix : nonModeratorPrefix; + commentsEdit->moveCursor(QTextCursor::End); + commentsEdit->insertPlainText( + QString("[%1] %2 %3:\n%4\n\n").arg(formatReportTime(c.comment_time()), prefix, author, text)); + } +} + +} // namespace report_utils diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h new file mode 100644 index 000000000..c6cc96c18 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -0,0 +1,36 @@ +#ifndef REPORT_UTILS_H +#define REPORT_UTILS_H + +#include +#include + +class QTableWidget; +class QTextEdit; + +namespace report_utils +{ + +QString formatReportCategory(const QString &category); +QString formatReportTime(qint64 secondsSinceEpoch); + +void fillReportTableRow(QTableWidget *table, + int row, + const ServerInfo_Report &report, + int idColumn, + int timeColumn, + int reportedColumn, + int categoryColumn, + int gameIdColumn, + int statusColumn, + int assignedColumn); + +void renderReportDetails(QTextEdit *chatLogEdit, + QTextEdit *commentsEdit, + const ServerInfo_Report &report, + const QString &commentsEmptyText, + const QString &moderatorPrefix, + const QString &nonModeratorPrefix); + +} // namespace report_utils + +#endif // REPORT_UTILS_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h index bbe475903..a81616cb0 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -19,6 +19,8 @@ public: [[nodiscard]] virtual bool getTabReplaysOpen() const = 0; [[nodiscard]] virtual bool getTabAdminOpen() const = 0; [[nodiscard]] virtual bool getTabLogOpen() const = 0; + [[nodiscard]] virtual bool getTabReportOpen() const = 0; + [[nodiscard]] virtual bool getTabModerationOpen() const = 0; }; #endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 12e71ebff..0ded27afa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -38,7 +38,8 @@ enum AuthenticationResult UsernameInvalid, RegistrationRequired, UserIsInactive, - ClientIdRequired + ClientIdRequired, + PasswordChangeRequired }; class Server : public QObject diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp index 641be1eed..31fa13d81 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp @@ -69,6 +69,10 @@ void Server_AbstractUserInterface::sendResponseContainer(const ResponseContainer } } +void Server_AbstractUserInterface::onLogin(ResponseContainer &) +{ +} + void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game) { qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h index b11260003..2da72c01d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h @@ -42,6 +42,7 @@ public: void playerRemovedFromGame(Server_Game *game); void playerAddedToGame(int gameId, int roomId, int playerId); void joinPersistentGames(ResponseContainer &rc); + virtual void onLogin(ResponseContainer &rc); QMap> getGames() const { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index b43dbde42..1e4fc990b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -172,6 +172,9 @@ public: { return false; } + virtual void setForcePasswordChange(const QString & /* user */, bool /* force */) + { + } }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 561115084..899df6529 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -564,6 +564,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd return Response::RespClientIdRequired; case UserIsInactive: return Response::RespAccountNotActivated; + case PasswordChangeRequired: + return Response::RespPasswordChangeRequired; default: authState = res; usingRealPassword = needsHash; @@ -614,6 +616,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd joinPersistentGames(rc); databaseInterface->removeForgotPassword(userName); + onLogin(rc); rc.setResponseExtension(re); return Response::RespOk; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 6a9e40d2d..3a193ae3c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -35,10 +35,20 @@ set(PROTO_FILES command_ready_start.proto command_replay_delete_match.proto command_replay_download.proto + command_replay_download_by_game_id.proto command_replay_get_code.proto command_replay_list.proto command_replay_modify_match.proto command_replay_submit_code.proto + command_report.proto + command_report_add_comment.proto + command_report_assign.proto + command_report_details.proto + command_report_list.proto + command_report_my_list.proto + command_report_resolve.proto + command_report_stats.proto + command_report_user_info.proto command_reveal_cards.proto command_reverse_turn.proto command_roll_die.proto @@ -138,8 +148,19 @@ set(PROTO_FILES response_password_salt.proto response_register.proto response_replay_download.proto + response_replay_download_by_game_id.proto response_replay_get_code.proto response_replay_list.proto + response_report_details.proto + response_report_list.proto + response_report_my_list.proto + response_report_stats.proto + response_report_user_info.proto + response_moderator_last_logins.proto + response_remove_user_avatar.proto + response_reset_user_password.proto + response_user_alts.proto + response_user_sessions.proto response_viewlog_history.proto response_warn_history.proto response_warn_list.proto @@ -155,13 +176,17 @@ set(PROTO_FILES serverinfo_deckstorage.proto serverinfo_game.proto serverinfo_gametype.proto + serverinfo_moderator_login.proto serverinfo_player.proto serverinfo_playerping.proto serverinfo_playerproperties.proto serverinfo_replay.proto serverinfo_replay_match.proto + serverinfo_report.proto serverinfo_room.proto serverinfo_user.proto + serverinfo_user_alt.proto + serverinfo_user_session.proto serverinfo_warning.proto serverinfo_zone.proto session_commands.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto index 8faaec2d2..f8b34b3f8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto @@ -5,6 +5,7 @@ message AdminCommand { SHUTDOWN_SERVER = 1001; RELOAD_CONFIG = 1002; ADJUST_MOD = 1003; + RESET_USER_PASSWORD = 1016; } extensions 100 to max; } @@ -37,3 +38,10 @@ message Command_AdjustMod { optional bool should_be_mod = 2; optional bool should_be_judge = 3; } + +message Command_ResetUserPassword { + extend AdminCommand { + optional Command_ResetUserPassword ext = 1016; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto new file mode 100644 index 000000000..8fa61a517 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReplayDownloadByGameId { + extend ModeratorCommand { + optional Command_ReplayDownloadByGameId ext = 1203; + } + required sint32 game_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto new file mode 100644 index 000000000..3a8c1548e --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_Report { + extend SessionCommand { + optional Command_Report ext = 1200; + } + optional string reported_user = 1; + optional int32 game_id = 2; + optional string category = 3; + optional string description = 4; + optional string chat_log = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto new file mode 100644 index 000000000..1fc5696e5 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportAddComment { + extend SessionCommand { + optional Command_ReportAddComment ext = 1205; + } + required int32 report_id = 1; + required string comment = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto new file mode 100644 index 000000000..2b72205f8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportAssign { + extend ModeratorCommand { + optional Command_ReportAssign ext = 1201; + } + required int32 report_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto new file mode 100644 index 000000000..e3ae8cfb4 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportDetails { + extend SessionCommand { + optional Command_ReportDetails ext = 1206; + } + required int32 report_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto new file mode 100644 index 000000000..6993de1a7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportList { + extend ModeratorCommand { + optional Command_ReportList ext = 1200; + } + optional bool unresolved_only = 1; + optional uint32 offset = 2 [default = 0]; + optional uint32 limit = 3 [default = 100]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto new file mode 100644 index 000000000..7ee18a65d --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportMyList { + extend SessionCommand { + optional Command_ReportMyList ext = 1204; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto new file mode 100644 index 000000000..312ca0f9d --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportResolve { + extend ModeratorCommand { + optional Command_ReportResolve ext = 1202; + } + required int32 report_id = 1; + optional string resolution_note = 2; + optional bool dismissed = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto new file mode 100644 index 000000000..b3c6eb1a9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportStats { + extend ModeratorCommand { + optional Command_ReportStats ext = 1205; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto new file mode 100644 index 000000000..8f83cb5a3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportUserInfo { + extend ModeratorCommand { + optional Command_ReportUserInfo ext = 1204; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto index 3a90d278b..b722dfc71 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto @@ -9,6 +9,8 @@ message Event_NotifyUser { WARNING = 2; IDLEWARNING = 3; CUSTOM = 4; + REPORT_RESOLVED = 5; + REPORT_COMMENT = 6; } extend SessionEvent { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index ca46e4dd7..685408830 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -14,6 +14,17 @@ message ModeratorCommand { ADD_CARD_ART_RULE = 1010; REMOVE_CARD_ART_RULE = 1011; LIST_CARD_ART_RULES = 1012; + GET_USER_SESSIONS = 1013; + GET_USER_ALTS = 1014; + GET_MODERATOR_LAST_LOGINS = 1015; + RESET_USER_PASSWORD = 1016; + REMOVE_USER_AVATAR = 1017; + REPORT_LIST = 1200; + REPORT_ASSIGN = 1201; + REPORT_RESOLVE = 1202; + REPLAY_DOWNLOAD_BY_GAME_ID = 1203; + REPORT_USER_INFO = 1204; + REPORT_STATS = 1205; } extensions 100 to max; } @@ -135,3 +146,31 @@ message Command_ListCardArtRules { optional Command_ListCardArtRules ext = 1012; } } + +message Command_GetUserSessions { + extend ModeratorCommand { + optional Command_GetUserSessions ext = 1013; + } + optional string user_name = 1; + optional uint32 limit = 2 [default = 110]; +} + +message Command_GetUserAlts { + extend ModeratorCommand { + optional Command_GetUserAlts ext = 1014; + } + optional string user_name = 1; +} + +message Command_GetModeratorLastLogins { + extend ModeratorCommand { + optional Command_GetModeratorLastLogins ext = 1015; + } +} + +message Command_RemoveUserAvatar { + extend ModeratorCommand { + optional Command_RemoveUserAvatar ext = 1017; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index e1f415ce6..14ba737b5 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -53,6 +53,7 @@ message Response { RespClientUpdateRequired = 35; // Client is missing features that the server is requiring RespServerFull = 36; // Server user limit reached RespEmailBlackListed = 37; // Server has blocked the email address provided for registration for some reason + RespPasswordChangeRequired = 38; // Server requires the user to change their password before proceeding } // Type of response, used to route handling on the client diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto new file mode 100644 index 000000000..6288880c4 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_moderator_login.proto"; + +message Response_ModeratorLastLogins { + extend Response { + optional Response_ModeratorLastLogins ext = 1217; + } + repeated ServerInfo_ModeratorLogin logins = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto new file mode 100644 index 000000000..e5697d4e0 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_RemoveUserAvatar { + extend Response { + optional Response_RemoveUserAvatar ext = 1219; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto new file mode 100644 index 000000000..77a5feb81 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ReplayDownloadByGameId { + extend Response { + optional Response_ReplayDownloadByGameId ext = 1203; + } + optional bytes replay_data = 1; + optional sint32 replay_id = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto new file mode 100644 index 000000000..463beaefc --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportDetails { + extend Response { + optional Response_ReportDetails ext = 1214; + } + optional ServerInfo_Report report = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto new file mode 100644 index 000000000..73d9fbef8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportList { + extend Response { + optional Response_ReportList ext = 1210; + } + repeated ServerInfo_Report reports = 1; + optional uint32 total_count = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto new file mode 100644 index 000000000..fdd02a3f1 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportMyList { + extend Response { + optional Response_ReportMyList ext = 1213; + } + repeated ServerInfo_Report reports = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto new file mode 100644 index 000000000..8bd2cf766 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto @@ -0,0 +1,36 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ReportStats { + extend Response { + optional Response_ReportStats ext = 1212; + } + + optional int32 total_reports = 1; + optional int32 total_pending = 2; + optional int32 total_assigned = 3; + optional int32 total_resolved = 4; + + optional int32 reports_last_24h = 5; + optional int32 reports_last_7d = 6; + optional int32 reports_last_30d = 7; + + optional double avg_resolution_hours = 8; + + optional int32 reports_this_week = 9; + optional int32 reports_last_week = 10; + + repeated ReportCategoryCount category_counts = 11; + repeated ReportTopUser top_reported_users = 12; + repeated ReportTopUser top_reporters = 13; +} + +message ReportCategoryCount { + optional string category = 1; + optional int32 count = 2; +} + +message ReportTopUser { + optional string user_name = 1; + optional int32 count = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto new file mode 100644 index 000000000..5f9e8ec38 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto @@ -0,0 +1,21 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportUserInfo { + extend Response { + optional Response_ReportUserInfo ext = 1211; + } + + optional string user_name = 1; + optional int32 total_reports = 2; + optional int32 total_bans = 3; + optional int32 total_warns = 4; + optional int64 registration_time = 5; + optional bool is_admin = 6; + optional bool is_active = 7; + optional string admin_notes = 8; + repeated ServerInfo_Report recent_reports = 9; + // Last known login of the user, epoch seconds; 0 = unknown. + optional int64 last_login = 10; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto new file mode 100644 index 000000000..aa379e573 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ResetUserPassword { + extend Response { + optional Response_ResetUserPassword ext = 1218; + } + optional string user_name = 1; + // The generated temporary password, shown to the moderator who + // requested the reset. The affected user must change it on first login. + optional string temporary_password = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto new file mode 100644 index 000000000..6bc48d6cb --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_user_alt.proto"; + +message Response_UserAlts { + extend Response { + optional Response_UserAlts ext = 1216; + } + repeated ServerInfo_UserAlt alts = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto new file mode 100644 index 000000000..7174fd3f7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_user_session.proto"; + +message Response_UserSessions { + extend Response { + optional Response_UserSessions ext = 1215; + } + repeated ServerInfo_UserSession sessions = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto index d48352529..cddd08a51 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto @@ -8,4 +8,7 @@ message Response_WarnList { repeated string warning = 1; optional string user_name = 2; optional string user_clientid = 3; + // Recommended starting intervention level per warning category, + // aligned by index with `warning`. Absent or shorter lists default to 1. + repeated uint32 warning_il = 4; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto new file mode 100644 index 000000000..21db2d3fa --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; + +/** + * The last login date of a staff member (moderator/judge/admin). + * Used by the moderation "Moderator Last Logins" tool. + */ +message ServerInfo_ModeratorLogin { + optional string user_name = 1; // staff account name + optional uint64 last_login = 2; // last known login, epoch seconds; 0 = unknown + optional uint32 user_level = 3; // ServerInfo_User::UserLevelFlag mask (moderator/judge/admin) +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto new file mode 100644 index 000000000..a145fd855 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto @@ -0,0 +1,36 @@ +syntax = "proto2"; + +message ServerInfo_ReportComment { + optional string author_name = 1; + optional string comment_text = 2; + optional int64 comment_time = 3; + optional bool is_moderator = 4; +} + +message ServerInfo_Report { + optional int32 report_id = 1; + + optional string reporter_name = 2; + optional string reported_user_name = 3; + + optional int32 game_id = 4; + optional int32 replay_id = 5; + optional int32 room_id = 6; + + optional string category = 7; + optional string status = 8; + + optional string description = 9; + + optional int64 report_time = 10; + + optional string assigned_mod_name = 11; + + optional int64 resolution_time = 12; + + repeated ServerInfo_ReportComment comments = 13; + + optional string chat_log = 14; + + optional string resolution_note = 15; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto new file mode 100644 index 000000000..191dd5063 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; + +/** + * An account that shares an IP address, client id or eMail address with + * the account being investigated. Used by the moderation "Get User Alts" tool. + */ +message ServerInfo_UserAlt { + optional string user_name = 1; // account name + optional string email = 2; // registration eMail + optional string clientid = 3; // client id + optional uint64 registration_time = 4; // account registration, epoch seconds + optional uint64 last_login = 5; // last known login, epoch seconds; 0 = unknown + optional uint32 warn_count = 6; // number of warnings on record + optional uint32 ban_count = 7; // number of bans on record + optional bool is_active = 8; // account is not deactivated/banned +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto new file mode 100644 index 000000000..7ce240050 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto @@ -0,0 +1,14 @@ +syntax = "proto2"; + +/** + * A single login session of a user on the server, as stored in the + * sessions table. Used by the moderation "Get User Sessions" tool. + */ +message ServerInfo_UserSession { + optional string user_name = 1; // account that was logged in + optional string ip_address = 2; // IP address used for the session + optional string clientid = 3; // client id used for the session + optional uint64 start_time = 4; // session start, epoch seconds + optional uint64 end_time = 5; // session end, epoch seconds; 0 = still active + optional string connection_type = 6; // "tcp" or "websocket" +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 9d207c711..fee8c36a8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -34,6 +34,11 @@ message SessionCommand { REPLAY_DELETE_MATCH = 1103; REPLAY_GET_CODE = 1104; REPLAY_SUBMIT_CODE = 1105; + REPORT = 1200; + // 1201-1203 reserved: removed during squash + REPORT_MY_LIST = 1204; + REPORT_ADD_COMMENT = 1205; + REPORT_DETAILS = 1206; } extensions 100 to max; } diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp index 78e48ed5b..85a1424a6 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -96,6 +96,16 @@ void TabsSettings::setStartupRoomName(const QString &roomName) emit startupRoomNameChanged(roomName); } +bool TabsSettings::getTabReportOpen() const +{ + return getValue("report", QString(), QString(), false).toBool(); +} + +bool TabsSettings::getTabModerationOpen() const +{ + return getValue("moderation", QString(), QString(), false).toBool(); +} + void TabsSettings::setTabVisualDeckStorageOpen(bool value) { setValue(value, "visualDeckStorage"); @@ -130,3 +140,13 @@ void TabsSettings::setTabLogOpen(bool value) { setValue(value, "log"); } + +void TabsSettings::setTabReportOpen(bool value) +{ + setValue(value, "report"); +} + +void TabsSettings::setTabModerationOpen(bool value) +{ + setValue(value, "moderation"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h index 0d5da80af..365d91af7 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -41,6 +41,8 @@ public: [[nodiscard]] bool getTabReplaysOpen() const override; [[nodiscard]] bool getTabAdminOpen() const override; [[nodiscard]] bool getTabLogOpen() const override; + [[nodiscard]] bool getTabReportOpen() const override; + [[nodiscard]] bool getTabModerationOpen() const override; void setStartupTabIndex(int value); void setStartupServerHost(const QString &host); @@ -53,6 +55,8 @@ public: void setTabReplaysOpen(bool value); void setTabAdminOpen(bool value); void setTabLogOpen(bool value); + void setTabReportOpen(bool value); + void setTabModerationOpen(bool value); signals: void startupTabIndexChanged(int index); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 3a81f179a..c6411ea76 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -5,8 +5,9 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) -set(UTILITY_SOURCES libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp - libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp +set(UTILITY_SOURCES + libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp libcockatrice/utility/passwordhasher.cpp + libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/warning_categories.cpp ) set(UTILITY_HEADERS @@ -24,6 +25,7 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/warning_categories.h ) add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) diff --git a/libcockatrice_utility/libcockatrice/utility/string_limits.h b/libcockatrice_utility/libcockatrice/utility/string_limits.h index cca804bf0..5079ec46d 100644 --- a/libcockatrice_utility/libcockatrice/utility/string_limits.h +++ b/libcockatrice_utility/libcockatrice/utility/string_limits.h @@ -22,6 +22,19 @@ inline QString textFromStdString(const std::string &_string) { return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_TEXT_LENGTH)); } +/** @brief Returns a QString from a std::string, truncated to at most MAX_TEXT_LENGTH bytes, keeping the tail. */ +inline QString textTailFromStdString(const std::string &_string) +{ + if (int(_string.size()) <= MAX_TEXT_LENGTH) { + return QString::fromUtf8(_string.data(), int(_string.size())); + } + + int start = int(_string.size()) - MAX_TEXT_LENGTH; + while (start < int(_string.size()) && (static_cast(_string[start]) & 0xC0) == 0x80) { + ++start; + } + return QString::fromUtf8(_string.data() + start, int(_string.size()) - start); +} /** @brief Returns a QString from a std::string, truncated to at most MAX_FILE_LENGTH bytes. */ inline QString fileFromStdString(const std::string &_string) { diff --git a/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp new file mode 100644 index 000000000..b5ca563ff --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp @@ -0,0 +1,24 @@ +#include "warning_categories.h" + +QList parseWarningCategories(const QString &value) +{ + QList categories; + const QStringList entries = value.split(',', Qt::SkipEmptyParts); + for (const QString &entry : entries) { + const QStringList parts = entry.split('|'); + WarningCategory category; + category.name = parts.first().trimmed(); + if (category.name.isEmpty()) { + continue; + } + if (parts.size() > 1) { + bool ok = false; + const int il = parts.at(1).trimmed().toInt(&ok); + if (ok && il > 0) { + category.startingIl = il; + } + } + categories.append(category); + } + return categories; +} diff --git a/libcockatrice_utility/libcockatrice/utility/warning_categories.h b/libcockatrice_utility/libcockatrice/utility/warning_categories.h new file mode 100644 index 000000000..ce5308cf4 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/warning_categories.h @@ -0,0 +1,28 @@ +#ifndef WARNING_CATEGORIES_H +#define WARNING_CATEGORIES_H + +#include +#include + +/** + * A warning category the server offers to moderators, optionally carrying a + * recommended starting intervention level (see the moderator guide). + */ +struct WarningCategory +{ + QString name; + int startingIl = 1; +}; + +/** + * Parses the `server/officialwarnings` setting value into warning categories. + * + * Entries are separated by commas. Each entry is a category name, optionally + * followed by "|" and the recommended starting intervention level: + * "Abusive Language|1,Cheating|2,Spamming" + * Entries without an explicit level default to intervention level 1. + * Empty entries are skipped. + */ +QList parseWarningCategories(const QString &value); + +#endif // WARNING_CATEGORIES_H diff --git a/servatrice/migrations/servatrice_0035_to_0036.sql b/servatrice/migrations/servatrice_0035_to_0036.sql new file mode 100644 index 000000000..9727eeba5 --- /dev/null +++ b/servatrice/migrations/servatrice_0035_to_0036.sql @@ -0,0 +1,53 @@ +-- Servatrice db migration from version 35 to version 36 + +CREATE TABLE IF NOT EXISTS `cockatrice_reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `reporter_id` int(7) unsigned NULL, + `reporter_name` varchar(35) NOT NULL, + `reported_user_id` int(7) unsigned NULL, + `reported_user_name` varchar(35) NOT NULL, + `game_id` int(7) unsigned NULL, + `room_id` int(7) unsigned NULL, + `category` varchar(255) NOT NULL, + `description` text NOT NULL, + `chat_log` mediumtext NULL, + `created_at` datetime NOT NULL, + `resolution_time` datetime NULL, + `status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open', + `assigned_to` int(7) unsigned NULL, + `resolved_by` int(7) unsigned NULL, + `resolution_note` text, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_created` (`created_at`), + INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`), + INDEX `idx_reported_user_name` (`reported_user_name`), + INDEX `idx_status_created` (`status`, `created_at`), + FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `report_id` int(11) unsigned NOT NULL, + `author_name` varchar(35) NOT NULL, + `author_id` int(7) unsigned NULL, + `comment_text` text NOT NULL, + `created_at` datetime NOT NULL, + `is_moderator` tinyint(1) NOT NULL DEFAULT 0, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_report_id` (`report_id`), + INDEX `idx_notified` (`notified`), + FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `cockatrice_users` + ADD COLUMN `force_password_change` tinyint(1) NOT NULL DEFAULT 0 + AFTER `passwordLastChangedDate`; + +UPDATE cockatrice_schema_version SET version=36 WHERE version=35; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index fac743c39..c1940c22f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -79,8 +79,10 @@ requiredfeatures="" ; You can define custom warnings that users are sent when the moderation staff uses the right client warn user ; menu option. This list is comma seperated that each item will appear in the drop down list for staff members -; to choose from. Example: "Flaming,Foul Language" -officialwarnings="Flaming,Spamming,Causing Drama,Abusive Language" +; to choose from. Each entry may optionally carry a recommended starting intervention level (see the moderator +; guide) by appending "|" and the level number. Entries without an explicit level default to intervention +; level 1. Example: "Flaming,Foul Language" +officialwarnings="Abusive Language|1,Calling Out User|1,Causing Drama|1,Cheating|2,Disrespecting Staff|1,Disrupting a Draft|1,Inappropriate Avatar|3,Inappropriate Game Name|1,Kicking Without Valid Reason|1,Spamming|1,Targeted Harassment|2" ; Maximum time in seconds a player can stay connected but idle. Default is 3600 (0 = disabled) ; Clients will be notified at the 90% time period of pending disconnection if they do not take action. @@ -373,6 +375,13 @@ command_counting_interval=10 ; Maximum number of game commands in an interval before new commands gets dropped; default is 20 max_command_count_per_interval=20 +[reporting] +; Maximum number of user reports a single user can file per day; default is 10; set to 0 to disable the limit +max_reports_per_day=10 + +; Maximum number of report comments a single user can post per hour; default is 30; set to 0 to disable the limit +max_comments_per_hour=30 + [logging] ; Admin/Moderators can query the stored logs for information when looking up reports by various players. This ; option can allow or disallow them from doing so. diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 7f530063c..5dbf69cbc 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(35); +INSERT INTO cockatrice_schema_version VALUES(36); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `privlevelStartDate` datetime NOT NULL, `privlevelEndDate` datetime NOT NULL, `passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `force_password_change` tinyint(1) NOT NULL DEFAULT 0, `leftPawnColorOverride` varchar(255), `rightPawnColorOverride` varchar(255), `card_art_params` TEXT DEFAULT NULL, @@ -233,6 +234,52 @@ CREATE TABLE IF NOT EXISTS `cockatrice_warnings` ( INDEX `idx_user_name` (`user_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `cockatrice_reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `reporter_id` int(7) unsigned NULL, + `reporter_name` varchar(35) NOT NULL, + `reported_user_id` int(7) unsigned NULL, + `reported_user_name` varchar(35) NOT NULL, + `game_id` int(7) unsigned NULL, + `room_id` int(7) unsigned NULL, + `category` varchar(255) NOT NULL, + `description` text NOT NULL, + `chat_log` mediumtext NULL, + `created_at` datetime NOT NULL, + `resolution_time` datetime NULL, + `status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open', + `assigned_to` int(7) unsigned NULL, + `resolved_by` int(7) unsigned NULL, + `resolution_note` text, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_created` (`created_at`), + INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`), + INDEX `idx_reported_user_name` (`reported_user_name`), + INDEX `idx_status_created` (`status`, `created_at`), + FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `report_id` int(11) unsigned NOT NULL, + `author_name` varchar(35) NOT NULL, + `author_id` int(7) unsigned NULL, + `comment_text` text NOT NULL, + `created_at` datetime NOT NULL, + `is_moderator` tinyint(1) NOT NULL DEFAULT 0, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_report_id` (`report_id`), + INDEX `idx_notified` (`notified`), + FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `cockatrice_log` ( `log_time` datetime NOT NULL, `sender_id` int(7) unsigned NULL, diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 4305f6882..db8751658 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -514,6 +514,23 @@ QList Servatrice::getServerList() const return result; } +std::shared_ptr Servatrice::getCachedReportStats() const +{ + QMutexLocker locker(&reportStatsMutex); + if (!reportStatsTimestamp.isValid() || + reportStatsTimestamp.secsTo(QDateTime::currentDateTime()) >= reportStatsCacheTtlSeconds) { + return nullptr; + } + return reportStatsCache; +} + +void Servatrice::cacheReportStats(const Response_ReportStats &stats) +{ + QMutexLocker locker(&reportStatsMutex); + reportStatsCache = std::make_shared(stats); + reportStatsTimestamp = QDateTime::currentDateTime(); +} + int Servatrice::getUsersWithAddress(const QHostAddress &address) const { int result = 0; diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 6eb00c165..8b0f5ad60 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -20,6 +20,7 @@ #ifndef SERVATRICE_H #define SERVATRICE_H +#include #include #include #include @@ -29,6 +30,8 @@ #include #include #include +#include +#include #include #include @@ -173,6 +176,11 @@ private: int nextShutdownMessageMinutes; QTimer *shutdownTimer; + mutable QMutex reportStatsMutex; + QDateTime reportStatsTimestamp; + std::shared_ptr reportStatsCache; + static constexpr int reportStatsCacheTtlSeconds = 60; + mutable QMutex serverListMutex; QList serverList; void updateServerList(); @@ -283,6 +291,11 @@ public: void removeIslInterface(int _serverId); QReadWriteLock islLock; + // The moderation queue statistics are shared between all connected moderators and + // cached briefly to avoid re-running several full-table queries on every refresh. + std::shared_ptr getCachedReportStats() const; + void cacheReportStats(const Response_ReportStats &stats); + QList getServerList() const; }; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index d5e1f13ef..847be61da 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); @@ -339,8 +340,8 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot return UserIsBanned; } - QSqlQuery *passwordQuery = - prepareQuery("select password_sha512, active from {prefix}_users where name = :name"); + QSqlQuery *passwordQuery = prepareQuery( + "select password_sha512, active, force_password_change from {prefix}_users where name = :name"); passwordQuery->bindValue(":name", user); if (!execSqlQuery(passwordQuery)) { qCWarning(DatabaseInterfaceLog) << "Login denied: SQL error"; @@ -350,6 +351,7 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot if (passwordQuery->next()) { const QString correctPasswordSha512 = passwordQuery->value(0).toString(); const bool userIsActive = passwordQuery->value(1).toBool(); + const bool forceChange = passwordQuery->value(2).toBool(); if (!userIsActive) { qCWarning(DatabaseInterfaceLog) << "Login denied: user not active"; return UserIsInactive; @@ -361,6 +363,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot hashedPassword = password; } if (correctPasswordSha512 == hashedPassword) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } qCDebug(DatabaseInterfaceLog) << "Login accepted: password right"; return PasswordRight; } else { @@ -1084,11 +1090,22 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, "passwordLastChangedDate = NOW() where name = :name"); passwordQuery->bindValue(":password", passwordSha512); passwordQuery->bindValue(":name", user); - if (execSqlQuery(passwordQuery)) { - return true; + if (!execSqlQuery(passwordQuery)) { + return false; + } + return passwordQuery->numRowsAffected() > 0; +} + +void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force) +{ + if (!checkSql()) { + return; } - return false; + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET force_password_change = :force WHERE name = :name"); + query->bindValue(":force", force ? 1 : 0); + query->bindValue(":name", user); + execSqlQuery(query); } bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, @@ -1314,6 +1331,169 @@ QList Servatrice_DatabaseInterface::getUserWarnHistory(const return results; } +QList Servatrice_DatabaseInterface::getUserSessions(const QString &userName, int limit) +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT user_name, ip_address, clientid, " + "UNIX_TIMESTAMP(start_time), UNIX_TIMESTAMP(end_time), connection_type " + "FROM {prefix}_sessions WHERE user_name = :user_name " + "ORDER BY start_time DESC LIMIT :limit"); + query->bindValue(":user_name", userName); + query->bindValue(":limit", limit); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect session history information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserSession sessionDetails; + sessionDetails.set_user_name(query->value(0).toString().toStdString()); + sessionDetails.set_ip_address(query->value(1).toString().toStdString()); + sessionDetails.set_clientid(query->value(2).toString().toStdString()); + sessionDetails.set_start_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + sessionDetails.set_end_time(query->value(4).toLongLong()); + } + sessionDetails.set_connection_type(query->value(5).toString().toStdString()); + results << sessionDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +{ + QList results; + + if (!checkSql()) { + return results; + } + + // Seed account identifiers used to find related accounts + QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + seedQuery->bindValue(":user_name", userName); + if (!execSqlQuery(seedQuery) || !seedQuery->next()) { + return results; + } + const QString seedEmail = seedQuery->value(0).toString(); + const QString seedClientId = seedQuery->value(1).toString(); + + QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE u.name = :user_name"; + if (!seedEmail.isEmpty()) { + queryString.append(" OR u.email = :seed_email"); + } + if (!seedClientId.isEmpty()) { + queryString.append(" OR u.clientid = :seed_clientid"); + } + queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s " + "WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 " + "WHERE s2.user_name = :user_name" + " AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))" + " AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) " + "ORDER BY u.name LIMIT 200"); + + QSqlQuery *query = prepareQuery(queryString); + query->bindValue(":user_name", userName); + if (!seedEmail.isEmpty()) { + query->bindValue(":seed_email", seedEmail); + } + if (!seedClientId.isEmpty()) { + query->bindValue(":seed_clientid", seedClientId); + } + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserAlt altDetails; + altDetails.set_user_name(query->value(0).toString().toStdString()); + altDetails.set_email(query->value(1).toString().toStdString()); + altDetails.set_clientid(query->value(2).toString().toStdString()); + altDetails.set_registration_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + altDetails.set_last_login(query->value(4).toLongLong()); + } + altDetails.set_warn_count(query->value(5).toInt()); + altDetails.set_ban_count(query->value(6).toInt()); + altDetails.set_is_active(query->value(7).toBool()); + results << altDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getModeratorLastLogins() +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT u.name, u.admin, UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE (u.admin & 7) <> 0 ORDER BY u.name"); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_ModeratorLogin loginDetails; + loginDetails.set_user_name(query->value(0).toString().toStdString()); + + const int isAdmin = query->value(1).toInt(); + int userLevel = ServerInfo_User::IsUser | ServerInfo_User::IsRegistered; + if (isAdmin & 1) { + userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator; + } else if (isAdmin & 2) { + userLevel |= ServerInfo_User::IsModerator; + } + if (isAdmin & 4) { + userLevel |= ServerInfo_User::IsJudge; + } + loginDetails.set_user_level(userLevel); + + if (!query->value(2).isNull()) { + loginDetails.set_last_login(query->value(2).toLongLong()); + } + results << loginDetails; + } + + return results; +} + +bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) +{ + if (!checkSql()) { + return false; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET avatar_bmp = '' WHERE name = :user_name"); + query->bindValue(":user_name", userName); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + QList Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 1e3501ec7..cd76ae288 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -6,11 +6,14 @@ #include #include #include +#include +#include +#include #include #include #include -#define DATABASE_SCHEMA_VERSION 35 +#define DATABASE_SCHEMA_VERSION 36 class Servatrice; @@ -119,6 +122,7 @@ public: bool oldPasswordNeedsHash, const QString &newPassword, bool newPasswordNeedsHash) override; + void setForcePasswordChange(const QString &user, bool force) override; QList getUserBanHistory(const QString userName); bool addWarning(const QString userName, const QString adminName, const QString warningReason, const QString clientID); @@ -133,6 +137,10 @@ public: bool &room, int &range, int &maxresults); + QList getUserSessions(const QString &userName, int limit); + QList getUserAlts(const QString &userName); + QList getModeratorLastLogins(); + bool removeUserAvatar(const QString &userName); bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; bool doesForgotPasswordExist(const QString &user); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index d4a1b9217..2a8b5f0a4 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -49,10 +49,20 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -70,20 +80,36 @@ #include #include #include +#include #include #include +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include #include +#include +#include +#include #include +#include #include #include #include @@ -223,6 +249,14 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm case SessionCommand::REQUEST_PASSWORD_SALT: return cmdRequestPasswordSalt(cmd.GetExtension(Command_RequestPasswordSalt::ext), rc); break; + case SessionCommand::REPORT: + return cmdReport(cmd.GetExtension(Command_Report::ext), rc); + case SessionCommand::REPORT_MY_LIST: + return cmdReportMyList(cmd.GetExtension(Command_ReportMyList::ext), rc); + case SessionCommand::REPORT_ADD_COMMENT: + return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc); + case SessionCommand::REPORT_DETAILS: + return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -243,10 +277,18 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetWarnHistory(cmd.GetExtension(Command_GetWarnHistory::ext), rc); case ModeratorCommand::WARN_LIST: return cmdGetWarnList(cmd.GetExtension(Command_GetWarnList::ext), rc); + case ModeratorCommand::REPORT_LIST: + return cmdReportList(cmd.GetExtension(Command_ReportList::ext), rc); + case ModeratorCommand::REPORT_ASSIGN: + return cmdReportAssign(cmd.GetExtension(Command_ReportAssign::ext), rc); + case ModeratorCommand::REPORT_RESOLVE: + return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); case ModeratorCommand::VIEWLOG_HISTORY: return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc); case ModeratorCommand::GRANT_REPLAY_ACCESS: return cmdGrantReplayAccess(cmd.GetExtension(Command_GrantReplayAccess::ext), rc); + case ModeratorCommand::REPLAY_DOWNLOAD_BY_GAME_ID: + return cmdReplayDownloadByGameId(cmd.GetExtension(Command_ReplayDownloadByGameId::ext), rc); case ModeratorCommand::FORCE_ACTIVATE_USER: return cmdForceActivateUser(cmd.GetExtension(Command_ForceActivateUser::ext), rc); case ModeratorCommand::GET_ADMIN_NOTES: @@ -259,6 +301,18 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdRemoveCardArtRule(cmd.GetExtension((Command_RemoveCardArtRule::ext)), rc); case ModeratorCommand::LIST_CARD_ART_RULES: return cmdListCardArtRules(cmd.GetExtension((Command_ListCardArtRules::ext)), rc); + case ModeratorCommand::REPORT_USER_INFO: + return cmdReportUserInfo(cmd.GetExtension(Command_ReportUserInfo::ext), rc); + case ModeratorCommand::REPORT_STATS: + return cmdReportStats(cmd.GetExtension(Command_ReportStats::ext), rc); + case ModeratorCommand::GET_USER_SESSIONS: + return cmdGetUserSessions(cmd.GetExtension(Command_GetUserSessions::ext), rc); + case ModeratorCommand::GET_USER_ALTS: + return cmdGetUserAlts(cmd.GetExtension(Command_GetUserAlts::ext), rc); + case ModeratorCommand::GET_MODERATOR_LAST_LOGINS: + return cmdGetModeratorLastLogins(cmd.GetExtension(Command_GetModeratorLastLogins::ext), rc); + case ModeratorCommand::REMOVE_USER_AVATAR: + return cmdRemoveUserAvatar(cmd.GetExtension(Command_RemoveUserAvatar::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -276,6 +330,8 @@ AbstractServerSocketInterface::processExtendedAdminCommand(int cmdType, const Ad return cmdReloadConfig(cmd.GetExtension(Command_ReloadConfig::ext), rc); case AdminCommand::ADJUST_MOD: return cmdAdjustMod(cmd.GetExtension(Command_AdjustMod::ext), rc); + case AdminCommand::RESET_USER_PASSWORD: + return cmdResetUserPassword(cmd.GetExtension(Command_ResetUserPassword::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -1067,9 +1123,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetWarnList(const Comma Response_WarnList *re = new Response_WarnList; QString officialWarnings = settingsCache->value("server/officialwarnings").toString(); - QStringList warningsList = officialWarnings.split(",", Qt::SkipEmptyParts); - for (const QString &warning : warningsList) { - re->add_warning(warning.toStdString()); + const QList categories = parseWarningCategories(officialWarnings); + for (const WarningCategory &category : categories) { + re->add_warning(category.name.toStdString()); + re->add_warning_il(category.startingIl); } re->set_user_name(nameFromStdString(cmd.user_name()).toStdString()); re->set_user_clientid(nameFromStdString(cmd.user_clientid()).toStdString()); @@ -1253,6 +1310,1010 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Command_ReportList &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const bool unresolvedOnly = cmd.unresolved_only(); + const int offset = static_cast(cmd.offset()); + const int limit = qMin(static_cast(cmd.limit()), 1000); + + // Columns: 0=id, 1=reporter_name, 2=reported_user_name, 3=game_id, + // 4=category, 5=description, 6=created_at, 7=status, + // 8=resolution_note, 9=assigned_mod_name, + // 10=room_id, 11=replay_id + QString whereClause; + if (unresolvedOnly) { + whereClause = "WHERE r.status = 'open' OR r.status = 'assigned' "; + } + + // Total count query + QSqlQuery *countQuery = sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports r " + whereClause); + int totalCount = 0; + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next()) { + totalCount = countQuery->value(0).toInt(); + } + + QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "; + + if (unresolvedOnly) { + queryStr += "WHERE r.status = 'open' OR r.status = 'assigned' "; + } + + queryStr += "ORDER BY r.created_at DESC LIMIT :limit OFFSET :offset"; + + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":limit", limit); + query->bindValue(":offset", offset); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + Response_ReportList *re = new Response_ReportList; + re->set_total_count(totalCount); + + while (query->next()) { + ServerInfo_Report *info = re->add_reports(); + + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(1).toString().toStdString()); + info->set_reported_user_name(query->value(2).toString().toStdString()); + + if (!query->value(3).isNull()) { + info->set_game_id(query->value(3).toInt()); + } + + info->set_category(query->value(4).toString().toStdString()); + info->set_description(query->value(5).toString().toStdString()); + info->set_report_time(query->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(7).toString().toStdString()); + + if (!query->value(8).isNull()) { + info->set_resolution_note(query->value(8).toString().toStdString()); + } + + if (!query->value(9).isNull()) { + info->set_assigned_mod_name(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_room_id(query->value(10).toInt()); + } + + if (!query->value(11).isNull()) { + info->set_replay_id(query->value(11).toInt()); + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportAssign(const Command_ReportAssign &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + int modId = userInfo->id(); + + QSqlQuery *lookupQuery = sqlInterface->prepareQuery("SELECT status FROM {prefix}_reports WHERE id = :id"); + lookupQuery->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(lookupQuery)) { + return Response::RespInternalError; + } + + if (!lookupQuery->next()) { + return Response::RespNameNotFound; + } + + if (lookupQuery->value(0).toString() != "open") { + return Response::RespInvalidData; + } + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = 'assigned', assigned_to = :mod_id " + "WHERE id = :id AND status = 'open'"); + + query->bindValue(":mod_id", modId); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + // The report was taken by another moderator between the lookup and this update. + return Response::RespInvalidData; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportResolve(const Command_ReportResolve &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString note = textFromStdString(cmd.resolution_note()); + bool dismissed = cmd.dismissed(); + + QString newStatus = dismissed ? "dismissed" : "resolved"; + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = :status, resolution_note = :note, " + "resolution_time = NOW(), resolved_by = :mod_id " + "WHERE id = :id AND status IN ('open', 'assigned')"); + + query->bindValue(":status", newStatus); + query->bindValue(":note", note); + query->bindValue(":mod_id", userInfo->id()); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + return Response::RespInvalidData; + } + + sqlInterface->addAuditRecord(QString::number(reportId), this->getAddress(), + QString::fromStdString(userInfo->clientid()), + dismissed ? "REPORT_DISMISSED" : "REPORT_RESOLVED", + QString("Report #%1 %2").arg(reportId).arg(newStatus), true); + + // Notifying the reporter is best-effort: the resolve already succeeded. If any of the lookup + // queries below fail, the report is simply left with notified = 0 and the notification is + // delivered later via sendPendingReportNotifications. + QSqlQuery *lookupQuery = + sqlInterface->prepareQuery("SELECT reporter_id, reported_user_name FROM {prefix}_reports WHERE id = :id"); + lookupQuery->bindValue(":id", reportId); + + QString reporterName; + QString reportedUser; + if (sqlInterface->execSqlQuery(lookupQuery) && lookupQuery->next()) { + int reporterId = lookupQuery->value(0).toInt(); + reportedUser = lookupQuery->value(1).toString(); + + QSqlQuery *nameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + nameQuery->bindValue(":id", reporterId); + if (sqlInterface->execSqlQuery(nameQuery) && nameQuery->next()) { + reporterName = nameQuery->value(0).toString(); + } + } + + const QString ownName = QString::fromStdString(userInfo->name()); + if (!reporterName.isEmpty()) { + if (reporterName == ownName) { + // Resolving your own report: no self-notification needed, but mark it as notified + // so it is not delivered on the next login via sendPendingReportNotifications. + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } else { + QReadLocker clientsLocker(&servatrice->clientsLock); + AbstractServerSocketInterface *reporter = + static_cast(server->getUsers().value(reporterName)); + if (reporter) { + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_RESOLVED); + event.set_custom_title(dismissed ? tr("Report Dismissed").toStdString() + : tr("Report Resolved").toStdString()); + + QString content = dismissed ? tr("Your report about %1 has been dismissed.").arg(reportedUser) + : tr("Your report about %1 has been resolved.").arg(reportedUser); + if (!note.isEmpty()) { + content += "\n" + tr("Note: %1").arg(note); + } + event.set_custom_content(content.toStdString()); + + SessionEvent *se = reporter->prepareSessionEvent(event); + reporter->sendProtocolItem(*se); + delete se; + + // Only mark the report as notified if the reporter is still connected; otherwise the + // notification is delivered on their next login via sendPendingReportNotifications. + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } + } + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportUserInfo(const Command_ReportUserInfo &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + QString userName = nameFromStdString(cmd.user_name()); + + QSqlQuery *userQuery = sqlInterface->prepareQuery("SELECT u.admin, u.active, u.registrationDate, u.adminnotes " + "FROM {prefix}_users u " + "WHERE u.name = :name"); + userQuery->bindValue(":name", userName); + + if (!sqlInterface->execSqlQuery(userQuery)) { + return Response::RespInternalError; + } + + if (!userQuery->next()) { + return Response::RespNameNotFound; + } + + Response_ReportUserInfo *re = new Response_ReportUserInfo; + re->set_user_name(cmd.user_name()); + + re->set_is_admin(userQuery->value(0).toBool()); + re->set_is_active(userQuery->value(1).toBool()); + re->set_registration_time(userQuery->value(2).toDateTime().toSecsSinceEpoch()); + + if (!userQuery->value(3).isNull()) { + re->set_admin_notes(userQuery->value(3).toString().toStdString()); + } + + QSqlQuery *reportCountQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reported_user_name = :name"); + reportCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(reportCountQuery) && reportCountQuery->next()) { + re->set_total_reports(reportCountQuery->value(0).toInt()); + } + + QSqlQuery *banCountQuery = sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_bans WHERE user_name = :name"); + banCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(banCountQuery) && banCountQuery->next()) { + re->set_total_bans(banCountQuery->value(0).toInt()); + } + + QSqlQuery *warnCountQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_warnings WHERE user_name = :name"); + warnCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(warnCountQuery) && warnCountQuery->next()) { + re->set_total_warns(warnCountQuery->value(0).toInt()); + } + + QSqlQuery *lastLoginQuery = sqlInterface->prepareQuery("SELECT UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_user_analytics a " + "JOIN {prefix}_users u ON u.id = a.id " + "WHERE u.name = :name"); + lastLoginQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(lastLoginQuery) && lastLoginQuery->next() && !lastLoginQuery->value(0).isNull()) { + re->set_last_login(lastLoginQuery->value(0).toLongLong()); + } + + QSqlQuery *recentQuery = + sqlInterface->prepareQuery("SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, ru.name AS assigned_mod_name " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users ru ON r.assigned_to = ru.id " + "WHERE r.reported_user_name = :name " + "ORDER BY r.created_at DESC LIMIT 10"); + recentQuery->bindValue(":name", userName); + + if (sqlInterface->execSqlQuery(recentQuery)) { + while (recentQuery->next()) { + ServerInfo_Report *info = re->add_recent_reports(); + info->set_report_id(recentQuery->value(0).toInt()); + info->set_reporter_name(recentQuery->value(1).toString().toStdString()); + info->set_reported_user_name(recentQuery->value(2).toString().toStdString()); + + if (!recentQuery->value(3).isNull()) { + info->set_game_id(recentQuery->value(3).toInt()); + } + + info->set_category(recentQuery->value(4).toString().toStdString()); + info->set_description(recentQuery->value(5).toString().toStdString()); + info->set_report_time(recentQuery->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(recentQuery->value(7).toString().toStdString()); + + if (!recentQuery->value(8).isNull()) { + info->set_resolution_note(recentQuery->value(8).toString().toStdString()); + } + + if (!recentQuery->value(9).isNull()) { + info->set_assigned_mod_name(recentQuery->value(9).toString().toStdString()); + } + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + if (const auto cached = servatrice->getCachedReportStats()) { + rc.setResponseExtension(new Response_ReportStats(*cached)); + return Response::RespOk; + } + + Response_ReportStats *re = new Response_ReportStats; + + QSqlQuery *overviewQuery = sqlInterface->prepareQuery( + "SELECT COUNT(*) AS total, " + "SUM(status IN ('open', 'assigned')) AS pending, " + "SUM(status = 'assigned') AS assigned, " + "SUM(status IN ('resolved', 'dismissed')) AS resolved, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)) AS last24h, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS last7d, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) AS last30d, " + "SUM(created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)) AS this_week " + "FROM {prefix}_reports"); + if (!sqlInterface->execSqlQuery(overviewQuery) || !overviewQuery->next()) { + delete re; + return Response::RespInternalError; + } + + re->set_total_reports(overviewQuery->value(0).toInt()); + re->set_total_pending(overviewQuery->value(1).toInt()); + re->set_total_assigned(overviewQuery->value(2).toInt()); + re->set_total_resolved(overviewQuery->value(3).toInt()); + re->set_reports_last_24h(overviewQuery->value(4).toInt()); + re->set_reports_last_7d(overviewQuery->value(5).toInt()); + re->set_reports_last_30d(overviewQuery->value(6).toInt()); + re->set_reports_this_week(overviewQuery->value(7).toInt()); + + bool allQueriesOk = true; + + QSqlQuery *lastWeekQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE created_at >= DATE_SUB(CURDATE(), " + "INTERVAL WEEKDAY(CURDATE()) + 7 DAY) " + "AND created_at < DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)"); + if (sqlInterface->execSqlQuery(lastWeekQuery) && lastWeekQuery->next()) { + re->set_reports_last_week(lastWeekQuery->value(0).toInt()); + } else { + allQueriesOk = false; + } + + QSqlQuery *avgResQuery = sqlInterface->prepareQuery( + "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, resolution_time)) " + "FROM {prefix}_reports WHERE status IN ('resolved','dismissed') AND resolution_time IS NOT NULL"); + if (sqlInterface->execSqlQuery(avgResQuery) && avgResQuery->next()) { + if (!avgResQuery->value(0).isNull()) { + re->set_avg_resolution_hours(avgResQuery->value(0).toDouble()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *catQuery = sqlInterface->prepareQuery( + "SELECT category, COUNT(*) AS cnt FROM {prefix}_reports GROUP BY category ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(catQuery)) { + while (catQuery->next()) { + ReportCategoryCount *cc = re->add_category_counts(); + cc->set_category(catQuery->value(0).toString().toStdString()); + cc->set_count(catQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReportedQuery = + sqlInterface->prepareQuery("SELECT reported_user_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reported_user_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReportedQuery)) { + while (topReportedQuery->next()) { + ReportTopUser *tu = re->add_top_reported_users(); + tu->set_user_name(topReportedQuery->value(0).toString().toStdString()); + tu->set_count(topReportedQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReporterQuery = + sqlInterface->prepareQuery("SELECT reporter_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reporter_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReporterQuery)) { + while (topReporterQuery->next()) { + ReportTopUser *tu = re->add_top_reporters(); + tu->set_user_name(topReporterQuery->value(0).toString().toStdString()); + tu->set_count(topReporterQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + if (allQueriesOk) { + servatrice->cacheReportStats(*re); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserSessions(const Command_GetUserSessions &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserSessions *re = new Response_UserSessions; + const int limit = qMin(static_cast(cmd.limit()), 500); + const QList sessions = sqlInterface->getUserSessions(userName, limit); + for (const ServerInfo_UserSession &session : sessions) { + re->add_sessions()->CopyFrom(session); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Command_GetUserAlts &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserAlts *re = new Response_UserAlts; + const QList alts = sqlInterface->getUserAlts(userName); + for (const ServerInfo_UserAlt &alt : alts) { + re->add_alts()->CopyFrom(alt); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + Response_ModeratorLastLogins *re = new Response_ModeratorLastLogins; + const QList logins = sqlInterface->getModeratorLastLogins(); + for (const ServerInfo_ModeratorLogin &login : logins) { + re->add_logins()->CopyFrom(login); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const Command_ResetUserPassword &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + // Look up the target user's privilege level to prevent escalation. + QSqlQuery *privQuery = sqlInterface->prepareQuery("SELECT admin FROM {prefix}_users WHERE name = :name"); + privQuery->bindValue(":name", userName); + if (!sqlInterface->execSqlQuery(privQuery) || !privQuery->next()) { + return Response::RespNameNotFound; + } + const int targetAdmin = privQuery->value(0).toInt(); + const bool targetIsAdmin = targetAdmin & 1; + const bool targetIsMod = targetAdmin & 2; + + const bool callerIsAdmin = userInfo->user_level() & ServerInfo_User::IsAdmin; + + // A moderator must not reset the password of an admin or another moderator. + if (!callerIsAdmin && (targetIsAdmin || targetIsMod)) { + return Response::RespAccessDenied; + } + + const QString tempPassword = PasswordHasher::generateRandomSalt(); + if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + return Response::RespInternalError; + } + sqlInterface->setForcePasswordChange(userName, true); + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "PASSWORD_RESET", "Admin password reset", true); + + // Notify the affected user if they are currently online. + QReadLocker clientsLocker(&servatrice->clientsLock); + AbstractServerSocketInterface *targetSession = + static_cast(server->getUsers().value(userName)); + if (targetSession) { + Event_NotifyUser event; + event.set_type(Event_NotifyUser::CUSTOM); + event.set_custom_title(tr("Password Reset").toStdString()); + event.set_custom_content(tr("An administrator has reset your password. Please log in with the new " + "password provided to you and change it immediately.") + .toStdString()); + SessionEvent *se = targetSession->prepareSessionEvent(event); + targetSession->sendProtocolItem(*se); + delete se; + } + + Response_ResetUserPassword *re = new Response_ResetUserPassword; + re->set_user_name(userName.toStdString()); + re->set_temporary_password(tempPassword.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + if (!sqlInterface->removeUserAvatar(userName)) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_AVATAR", "Moderator removed user avatar", true); + + Response_RemoveUserAvatar *re = new Response_RemoveUserAvatar; + re->set_user_name(userName.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT id, reported_user_name, status, resolution_note " + "FROM {prefix}_reports " + "WHERE reporter_id = :reporter_id AND notified = 0 " + "AND status IN ('resolved', 'dismissed')"); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return; + } + + while (query->next()) { + const int reportId = query->value(0).toInt(); + const QString reportedUser = query->value(1).toString(); + const bool dismissed = query->value(2).toString() == "dismissed"; + const QString note = query->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_RESOLVED); + event.set_custom_title(dismissed ? tr("Report Dismissed").toStdString() : tr("Report Resolved").toStdString()); + + QString content = dismissed ? tr("Your report about %1 has been dismissed.").arg(reportedUser) + : tr("Your report about %1 has been resolved.").arg(reportedUser); + if (!note.isEmpty()) { + content += "\n" + tr("Note: %1").arg(note); + } + event.set_custom_content(content.toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + QSqlQuery *commentQuery = sqlInterface->prepareQuery("SELECT c.id, c.report_id, c.author_name, c.comment_text " + "FROM {prefix}_report_comments c " + "JOIN {prefix}_reports r ON r.id = c.report_id " + "WHERE c.notified = 0 " + "AND c.author_id != :user_id " + "AND ((c.is_moderator = 1 AND r.reporter_id = :user_id) " + "OR (c.is_moderator = 0 AND r.assigned_to = :user_id) " + "OR (c.is_moderator = 1 AND r.assigned_to = :user_id)) " + "ORDER BY c.created_at ASC"); + commentQuery->bindValue(":user_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(commentQuery)) { + return; + } + + while (commentQuery->next()) { + const qlonglong commentId = commentQuery->value(0).toLongLong(); + const int reportId = commentQuery->value(1).toInt(); + const QString authorName = commentQuery->value(2).toString(); + const QString commentText = commentQuery->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content(tr("%1 commented:\n%2").arg(authorName, commentText).toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } +} + +void AbstractServerSocketInterface::onLogin(ResponseContainer &rc) +{ + sendPendingReportNotifications(rc); +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Command_ReportMyList & /*cmd */, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + sendPendingReportNotifications(rc); + + QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.reporter_id = :reporter_id " + "ORDER BY r.created_at DESC LIMIT 200"; + + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + Response_ReportMyList *re = new Response_ReportMyList; + + while (query->next()) { + ServerInfo_Report *info = re->add_reports(); + + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(1).toString().toStdString()); + info->set_reported_user_name(query->value(2).toString().toStdString()); + + if (!query->value(3).isNull()) { + info->set_game_id(query->value(3).toInt()); + } + + info->set_category(query->value(4).toString().toStdString()); + info->set_description(query->value(5).toString().toStdString()); + info->set_report_time(query->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(7).toString().toStdString()); + + if (!query->value(8).isNull()) { + info->set_resolution_note(query->value(8).toString().toStdString()); + } + + if (!query->value(9).isNull()) { + info->set_assigned_mod_name(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_room_id(query->value(10).toInt()); + } + + if (!query->value(11).isNull()) { + info->set_replay_id(query->value(11).toInt()); + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const int reportId = cmd.report_id(); + + // Columns: 0=id, 1=reporter_id, 2=reporter_name, 3=reported_user_name, 4=game_id, + // 5=category, 6=description, 7=created_at, 8=status, + // 9=resolution_note, 10=assigned_mod_name, 11=chat_log, + // 12=room_id, 13=resolution_time, 14=replay_id + QString queryStr = "SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.chat_log, r.room_id, " + "r.resolution_time, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.id = :id"; + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + const bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + const int reporterId = query->value(1).toInt(); + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + ServerInfo_Report *info = new ServerInfo_Report; + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(2).toString().toStdString()); + info->set_reported_user_name(query->value(3).toString().toStdString()); + + if (!query->value(4).isNull()) { + info->set_game_id(query->value(4).toInt()); + } + + info->set_category(query->value(5).toString().toStdString()); + info->set_description(query->value(6).toString().toStdString()); + info->set_report_time(query->value(7).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(8).toString().toStdString()); + + if (!query->value(9).isNull()) { + info->set_resolution_note(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_assigned_mod_name(query->value(10).toString().toStdString()); + } + + if (!query->value(11).isNull()) { + info->set_chat_log(query->value(11).toString().toStdString()); + } + + if (!query->value(12).isNull()) { + info->set_room_id(query->value(12).toInt()); + } + + if (!query->value(13).isNull()) { + info->set_resolution_time(query->value(13).toDateTime().toSecsSinceEpoch()); + } + + if (!query->value(14).isNull()) { + info->set_replay_id(query->value(14).toInt()); + } + + QSqlQuery *commentQuery = + sqlInterface->prepareQuery("SELECT c.author_name, c.comment_text, c.created_at, c.is_moderator " + "FROM {prefix}_report_comments c " + "WHERE c.report_id = :report_id ORDER BY c.created_at ASC"); + commentQuery->bindValue(":report_id", reportId); + + if (sqlInterface->execSqlQuery(commentQuery)) { + while (commentQuery->next()) { + ServerInfo_ReportComment *comment = info->add_comments(); + comment->set_author_name(commentQuery->value(0).toString().toStdString()); + comment->set_comment_text(commentQuery->value(1).toString().toStdString()); + comment->set_comment_time(commentQuery->value(2).toDateTime().toSecsSinceEpoch()); + comment->set_is_moderator(commentQuery->value(3).toBool()); + } + } + + Response_ReportDetails *re = new Response_ReportDetails; + re->set_allocated_report(info); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportAddComment(const Command_ReportAddComment &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString commentText = textFromStdString(cmd.comment()).trimmed(); + + if (commentText.isEmpty()) { + return Response::RespInvalidData; + } + + const int maxCommentsPerHour = settingsCache->value("reporting/max_comments_per_hour", 30).toInt(); + if (maxCommentsPerHour > 0) { + QSqlQuery *countQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_report_comments WHERE author_id = :id " + "AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)"); + countQuery->bindValue(":id", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxCommentsPerHour) { + return Response::RespTooManyRequests; + } + } + + QSqlQuery *checkQuery = sqlInterface->prepareQuery( + "SELECT reporter_id, reporter_name, assigned_to, status FROM {prefix}_reports WHERE id = :id"); + checkQuery->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(checkQuery)) { + return Response::RespInternalError; + } + + if (!checkQuery->next()) { + return Response::RespNameNotFound; + } + + int reporterId = checkQuery->value(0).toInt(); + QString reporterName = checkQuery->value(1).toString(); + int assignedToId = checkQuery->value(2).toInt(); + + bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + + // Only the reporter (by id) or a moderator may comment on a report. + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + QString reportStatus = checkQuery->value(3).toString(); + if (reportStatus == "resolved" || reportStatus == "dismissed") { + return Response::RespInvalidData; + } + + QSqlQuery *insertQuery = sqlInterface->prepareQuery( + "INSERT INTO {prefix}_report_comments (report_id, author_name, author_id, comment_text, created_at, " + "is_moderator) " + "VALUES (:report_id, :author_name, :author_id, :comment_text, NOW(), :is_moderator)"); + insertQuery->bindValue(":report_id", reportId); + insertQuery->bindValue(":author_name", QString::fromStdString(userInfo->name())); + insertQuery->bindValue(":author_id", userInfo->id()); + insertQuery->bindValue(":comment_text", commentText); + insertQuery->bindValue(":is_moderator", isMod); + + if (!sqlInterface->execSqlQuery(insertQuery)) { + return Response::RespInternalError; + } + + const qlonglong commentId = insertQuery->lastInsertId().toLongLong(); + + QStringList recipients; + if (isMod) { + // A moderator comment reaches both the reporter and (if assigned) the assigned moderator. + recipients.append(reporterName); + if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + } else if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + + const QString ownName = QString::fromStdString(userInfo->name()); + bool allNotified = !recipients.isEmpty(); + QReadLocker clientsLocker(&servatrice->clientsLock); + for (const QString ¬ifyName : recipients) { + if (notifyName == ownName) { + continue; + } + + AbstractServerSocketInterface *notify = + static_cast(server->getUsers().value(notifyName)); + if (!notify) { + // The recipient is offline; leave `notified` = 0 so the notification is + // delivered on their next login via sendPendingReportNotifications. + allNotified = false; + continue; + } + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content( + tr("%1 commented:\n%2").arg(QString::fromStdString(userInfo->name()), commentText).toStdString()); + + SessionEvent *se = notify->prepareSessionEvent(event); + notify->sendProtocolItem(*se); + delete se; + } + + if (allNotified) { + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + return Response::RespOk; +} + +Response::ResponseCode +AbstractServerSocketInterface::cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT r.id, r.replay FROM {prefix}_replays r " + "WHERE r.id_game = :game_id ORDER BY r.id DESC LIMIT 1"); + query->bindValue(":game_id", cmd.game_id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + int replayId = query->value(0).toInt(); + QByteArray data = query->value(1).toByteArray(); + + Response_ReplayDownloadByGameId *re = new Response_ReplayDownloadByGameId; + re->set_replay_data(data.data(), data.size()); + re->set_replay_id(replayId); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc) { @@ -1785,6 +2846,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C return Response::RespWrongPassword; } + databaseInterface->setForcePasswordChange(userName, false); + return Response::RespOk; } @@ -1921,6 +2984,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con "PASSWORD_RESET", "", true); } + sqlInterface->setForcePasswordChange(nameFromStdString(cmd.user_name()), false); sqlInterface->removeForgotPassword(nameFromStdString(cmd.user_name())); return Response::RespOk; } @@ -1989,6 +3053,100 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Report &cmd, ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + if (!cmd.has_reported_user() || !cmd.has_category() || !cmd.has_description()) { + return Response::RespInvalidData; + } + + QString reporterName = QString::fromStdString(userInfo->name()); + QString reportedUser = nameFromStdString(cmd.reported_user()); + QString category = nameFromStdString(cmd.category()); + QString description = textFromStdString(cmd.description()); + QString chatLog = textTailFromStdString(cmd.chat_log()); + + if (reportedUser.isEmpty() || category.isEmpty() || description.isEmpty()) { + return Response::RespInvalidData; + } + + static const QStringList validCategories = {"cheating", "bug_abuse", "verbal_abuse", "other"}; + if (!validCategories.contains(category.toLower())) { + return Response::RespInvalidData; + } + + const int maxReportsPerDay = settingsCache->value("reporting/max_reports_per_day", 10).toInt(); + if (maxReportsPerDay > 0) { + QSqlQuery *countQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reporter_id = :id " + "AND created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":id", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxReportsPerDay) { + return Response::RespTooManyRequests; + } + } + + int reportedUserId = -1; + QSqlQuery *lookupQuery = sqlInterface->prepareQuery("SELECT id FROM {prefix}_users WHERE name = :name"); + lookupQuery->bindValue(":name", reportedUser); + if (sqlInterface->execSqlQuery(lookupQuery) && lookupQuery->next()) { + reportedUserId = lookupQuery->value(0).toInt(); + } + + if (reportedUserId == -1) { + return Response::RespNameNotFound; + } + + int roomId = 0; + const int gameId = cmd.game_id(); + if (gameId > 0) { + QReadLocker roomsLocker(&servatrice->roomsLock); + const QMap &rooms = servatrice->getRooms(); + for (auto it = rooms.constBegin(); it != rooms.constEnd(); ++it) { + QReadLocker gamesLocker(&it.value()->gamesLock); + if (it.value()->getGames().contains(gameId)) { + roomId = it.key(); + break; + } + } + } + + QSqlQuery *query = + sqlInterface->prepareQuery("insert into {prefix}_reports " + "(reporter_id, reporter_name, reported_user_id, reported_user_name, " + "game_id, room_id, category, description, chat_log, created_at, status) " + "values " + "(:reporter_id, :reporter_name, :reported_user_id, :reported_user_name, " + ":game_id, :room_id, :category, :description, :chat_log, NOW(), 'open')"); + + query->bindValue(":reporter_id", userInfo->id()); + query->bindValue(":reporter_name", reporterName); + query->bindValue(":reported_user_id", reportedUserId); + query->bindValue(":reported_user_name", reportedUser); + + query->bindValue(":game_id", gameId > 0 ? gameId : QVariant()); + + query->bindValue(":room_id", roomId > 0 ? roomId : QVariant()); + + query->bindValue(":category", category); + query->bindValue(":description", description); + query->bindValue(":chat_log", chatLog.isEmpty() ? QVariant() : chatLog); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + // ADMIN FUNCTIONS. // Permission is checked by the calling function. diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 0d66ae78f..600796b5f 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,6 +24,16 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include class Servatrice; @@ -50,6 +60,7 @@ class Command_BanFromServer; class Command_UpdateServerMessage; class Command_ShutdownServer; class Command_ReloadConfig; +class Command_ReplayDownloadByGameId; class Command_AccountEdit; class Command_AccountImage; @@ -67,7 +78,7 @@ signals: void incTxBytes(qint64 amount); protected: - void logDebugMessage(const QString &message); + void logDebugMessage(const QString &message) override; bool tooManyRegistrationAttempts(const QString &ipAddress); virtual void writeToSocket(QByteArray &data) = 0; @@ -102,6 +113,7 @@ private: Response::ResponseCode cmdReplayGetCode(const Command_ReplayGetCode &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, ResponseContainer &rc); Response::ResponseCode cmdBanFromServer(const Command_BanFromServer &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportList(const Command_ReportList &cmd, ResponseContainer &rc); Response::ResponseCode cmdWarnUser(const Command_WarnUser &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetBanHistory(const Command_GetBanHistory &cmd, ResponseContainer &rc); @@ -109,6 +121,10 @@ private: Response::ResponseCode cmdGetWarnHistory(const Command_GetWarnHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdShutdownServer(const Command_ShutdownServer &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &); + Response::ResponseCode cmdReportResolve(const Command_ReportResolve &cmd, ResponseContainer &); + Response::ResponseCode cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc); Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc); Response::ResponseCode cmdActivateAccount(const Command_Activate &cmd, ResponseContainer & /* rc */); Response::ResponseCode cmdReloadConfig(const Command_ReloadConfig & /* cmd */, ResponseContainer & /*rc*/); @@ -122,10 +138,19 @@ private: Response::ResponseCode cmdForgotPasswordChallenge(const Command_ForgotPasswordChallenge &cmd, ResponseContainer &rc); Response::ResponseCode cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd, ResponseContainer &rc); - Response::ResponseCode processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReport(const Command_Report &cmd, ResponseContainer &); + Response::ResponseCode cmdReportMyList(const Command_ReportMyList &cmd, ResponseContainer &rc); + void sendPendingReportNotifications(ResponseContainer &rc); + void onLogin(ResponseContainer &rc) override; + Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc); Response::ResponseCode - processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc); - Response::ResponseCode processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc); + processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); @@ -141,6 +166,12 @@ private: Response::ResponseCode cmdGetAdminNotes(const Command_GetAdminNotes &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateAdminNotes(const Command_UpdateAdminNotes &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserSessions(const Command_GetUserSessions &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserAlts(const Command_GetUserAlts &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc); + Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); + Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + bool addAdminFlagToUser(const QString &user, int flag); bool removeAdminFlagFromUser(const QString &user, int flag); @@ -157,9 +188,9 @@ public: bool initSession(); virtual QHostAddress getPeerAddress() const = 0; - virtual QString getAddress() const = 0; + QString getAddress() const override = 0; - void transmitProtocolItem(const ServerMessage &item); + void transmitProtocolItem(const ServerMessage &item) override; }; class TcpServerSocketInterface : public AbstractServerSocketInterface diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 51809912b..804293784 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,6 +11,7 @@ add_test(NAME playmat_resolver_test COMMAND playmat_resolver_test) add_test(NAME server_card_counter_test COMMAND server_card_counter_test) add_test(NAME server_counter_test COMMAND server_counter_test) add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) +add_test(NAME warning_categories_test COMMAND warning_categories_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) @@ -27,6 +28,7 @@ add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) add_executable(server_card_counter_test server_card_counter_test.cpp) add_executable(server_counter_test server_counter_test.cpp) add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) +add_executable(warning_categories_test warning_categories_test.cpp) find_package(GTest) @@ -63,6 +65,7 @@ if(NOT GTEST_FOUND) add_dependencies(server_card_counter_test gtest) add_dependencies(server_counter_test gtest) add_dependencies(server_rate_limiter_test gtest) + add_dependencies(warning_categories_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) @@ -94,6 +97,9 @@ target_link_libraries( target_link_libraries( server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 1a2fc1176..6c79d5227 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -232,6 +232,12 @@ TEST_F(SettingsDefaultsTest, Tabs_AllTabsOpen_Default) ASSERT_EQ(s.getTabLogOpen(), true); } +TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabModerationOpen(), false); +} + // --- ChatSettings --- TEST_F(SettingsDefaultsTest, Chat_Mention_Default) diff --git a/tests/warning_categories_test.cpp b/tests/warning_categories_test.cpp new file mode 100644 index 000000000..432885dd9 --- /dev/null +++ b/tests/warning_categories_test.cpp @@ -0,0 +1,89 @@ +#include "gtest/gtest.h" +#include +#include + +TEST(WarningCategoriesTest, EmptyValueYieldsNoCategories) +{ + EXPECT_TRUE(parseWarningCategories(QString()).isEmpty()); + EXPECT_TRUE(parseWarningCategories(QString("")).isEmpty()); +} + +TEST(WarningCategoriesTest, PlainNamesDefaultToInterventionLevelOne) +{ + const QList categories = parseWarningCategories("Flaming,Spamming,Causing Drama"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ("Flaming", categories.at(0).name); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ("Spamming", categories.at(1).name); + EXPECT_EQ(1, categories.at(1).startingIl); + EXPECT_EQ("Causing Drama", categories.at(2).name); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, ExplicitInterventionLevelsAreParsed) +{ + const QList categories = parseWarningCategories("Cheating|2,Inappropriate Avatar|3"); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Cheating", categories.at(0).name); + EXPECT_EQ(2, categories.at(0).startingIl); + EXPECT_EQ("Inappropriate Avatar", categories.at(1).name); + EXPECT_EQ(3, categories.at(1).startingIl); +} + +TEST(WarningCategoriesTest, MixedEntriesKeepDefaultsForThoseWithoutLevels) +{ + const QList categories = parseWarningCategories("Abusive Language|1,Cheating|2,Spamming"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ(2, categories.at(1).startingIl); + EXPECT_EQ("Spamming", categories.at(2).name); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, EmptyEntriesAreSkipped) +{ + const QList categories = parseWarningCategories("Spamming,,Cheating|2,"); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Spamming", categories.at(0).name); + EXPECT_EQ("Cheating", categories.at(1).name); +} + +TEST(WarningCategoriesTest, WhitespaceIsTrimmed) +{ + const QList categories = parseWarningCategories(" Abusive Language , Cheating | 2 "); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Abusive Language", categories.at(0).name); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ("Cheating", categories.at(1).name); + EXPECT_EQ(2, categories.at(1).startingIl); +} + +TEST(WarningCategoriesTest, InvalidInterventionLevelsFallBackToOne) +{ + const QList categories = parseWarningCategories("Spamming|abc,Cheating|0,Targeted Harassment|-3"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ(1, categories.at(1).startingIl); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, EntryWithOnlyLevelIsSkipped) +{ + const QList categories = parseWarningCategories("|2,Spamming|2"); + + ASSERT_EQ(1, categories.size()); + EXPECT_EQ("Spamming", categories.at(0).name); + EXPECT_EQ(2, categories.at(0).startingIl); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}