From 7a18670bfd228246368112ef14679798b6f44e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 23 Aug 2026 19:40:16 +0200 Subject: [PATCH 1/5] [Server/Client/Protocol] Add developer staff role Introduce a Developer staff level (proto flag 32, DB admin bit 8) that sits between admin and moderator: no kick/ban/warn/report/admin powers, but gets server log access via a new developer command container family (GET_SERVER_STATS, VIEWLOG_HISTORY) and an idle-timeout exemption. - Protocol: IsDeveloper flag, developer_commands.proto envelope, Command_GetServerStats/Command_GetLogHistory, Response_GetServerStats, Command_AdjustMod.should_be_developer - Servatrice: fail-closed developer dispatcher, uptime snapshot handler, shared log history handler reuse, bit-8 DB mapping - Client: burgundy pawn/badge/labels/sort order, prepareDeveloperCommand, minimal Developer stats tab, log tab access, promote/demote actions Took 24 minutes Took 18 seconds # Commit time for manual adjustment: # Took 15 minutes --- cockatrice/CMakeLists.txt | 1 + .../src/interface/pixel_map_generator.cpp | 3 + .../widgets/server/user/user_context_menu.cpp | 33 ++++- .../widgets/server/user/user_context_menu.h | 2 + .../widgets/server/user/user_info_box.cpp | 2 + .../widgets/server/user/user_info_popup.cpp | 5 + .../widgets/server/user/user_list_painter.cpp | 10 +- .../widgets/server/user/user_list_widget.cpp | 6 +- .../interface/widgets/tabs/tab_developer.cpp | 122 ++++++++++++++++++ .../interface/widgets/tabs/tab_developer.h | 43 ++++++ .../src/interface/widgets/tabs/tab_logs.cpp | 25 +++- .../src/interface/widgets/tabs/tab_logs.h | 3 +- .../interface/widgets/tabs/tab_moderation.cpp | 3 + .../interface/widgets/tabs/tab_supervisor.cpp | 52 +++++++- .../interface/widgets/tabs/tab_supervisor.h | 6 +- .../client/abstract/abstract_client.cpp | 8 ++ .../network/client/abstract/abstract_client.h | 1 + .../server/remote/server_protocolhandler.cpp | 38 +++++- .../server/remote/server_protocolhandler.h | 7 + .../libcockatrice/protocol/pb/CMakeLists.txt | 4 + .../protocol/pb/admin_commands.proto | 1 + .../protocol/pb/command_get_log_history.proto | 19 +++ .../pb/command_get_server_stats.proto | 8 ++ .../libcockatrice/protocol/pb/commands.proto | 2 + .../protocol/pb/developer_commands.proto | 8 ++ .../libcockatrice/protocol/pb/response.proto | 1 + .../pb/response_get_server_stats.proto | 19 +++ .../protocol/pb/serverinfo_user.proto | 1 + .../src/servatrice_database_interface.cpp | 9 +- servatrice/src/serversocketinterface.cpp | 81 +++++++++++- servatrice/src/serversocketinterface.h | 6 + 31 files changed, 510 insertions(+), 19 deletions(-) create mode 100644 cockatrice/src/interface/widgets/tabs/tab_developer.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_developer.h create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 2f629fed2..a171bb32e 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -380,6 +380,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp + src/interface/widgets/tabs/tab_developer.cpp src/interface/widgets/tabs/tab_game.cpp src/interface/widgets/tabs/tab_home.cpp src/interface/widgets/tabs/tab_logs.cpp diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 9b8c4bcdc..d7d67c6bf 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -14,6 +14,7 @@ #define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff"; #define DEFAULT_COLOR_MODERATOR_RIGHT "#000000"; #define DEFAULT_COLOR_ADMIN "#ff2701"; +#define DEFAULT_COLOR_DEVELOPER "#800020" /** * Clamps an svg render size so that rendering does not exceed a multiple of the requested size. @@ -359,6 +360,8 @@ QIcon UserLevelPixmapGenerator::generateIconDefault(int height, if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { colorLeft = DEFAULT_COLOR_ADMIN; + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + colorLeft = DEFAULT_COLOR_DEVELOPER; } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { colorLeft = DEFAULT_COLOR_MODERATOR_LEFT; colorRight = DEFAULT_COLOR_MODERATOR_RIGHT; 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 8d5d423f6..c8a842604 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -51,6 +51,8 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromMod = new QAction(QString(), this); aPromoteToJudge = new QAction(QString(), this); aDemoteFromJudge = new QAction(QString(), this); + aPromoteToDeveloper = new QAction(QString(), this); + aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); aInvestigateUser = new QAction(QString(), this); @@ -76,6 +78,8 @@ void UserContextMenu::retranslateUi() aDemoteFromMod->setText(tr("Dem&ote user from moderator")); aPromoteToJudge->setText(tr("Promote user to &judge")); aDemoteFromJudge->setText(tr("Demote user from judge")); + aPromoteToDeveloper->setText(tr("Promote user to &developer")); + aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); aInvestigateUser->setText(tr("Investigate user")); } @@ -268,7 +272,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const const Command_AdjustMod &cmd = commandContainer.admin_command(0).GetExtension(Command_AdjustMod::ext); if (resp.response_code() == Response::RespOk) { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Success"), tr("Successfully promoted user.")); } else { @@ -276,7 +280,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const } } else { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to promote user.")); } else { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to demote user.")); @@ -437,6 +441,15 @@ void UserContextMenu::showContextMenu(const QPoint &pos, (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { menu->addAction(aPromoteToJudge); } + + if (userLevel.testFlag(ServerInfo_User::IsDeveloper) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aDemoteFromDeveloper); + + } else if (userLevel.testFlag(ServerInfo_User::IsRegistered) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aPromoteToDeveloper); + } } aDetails->setEnabled(true); aChat->setEnabled(anotherUser && online); @@ -455,6 +468,10 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aInvestigateUser->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); + aPromoteToJudge->setEnabled(anotherUser); + aDemoteFromJudge->setEnabled(anotherUser); + aPromoteToDeveloper->setEnabled(anotherUser); + aDemoteFromDeveloper->setEnabled(anotherUser); QAction *actionClicked = menu->exec(pos); if (actionClicked == nullptr) { @@ -489,6 +506,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execAdjustMod(userName, actionClicked == aPromoteToMod); } else if (actionClicked == aPromoteToJudge || actionClicked == aDemoteFromJudge) { execAdjustJudge(userName, actionClicked == aPromoteToJudge); + } else if (actionClicked == aPromoteToDeveloper || actionClicked == aDemoteFromDeveloper) { + execAdjustDeveloper(userName, actionClicked == aPromoteToDeveloper); } else if (actionClicked == aBanHistory) { execBanHistory(userName); } else if (actionClicked == aWarnUser) { @@ -698,4 +717,14 @@ void UserContextMenu::execAdjustJudge(const QString &userName, bool shouldBeJudg PendingCommand *pend = client->prepareAdminCommand(cmd); connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); client->sendCommand(pend); +} + +void UserContextMenu::execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_developer(shouldBeDeveloper); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); } \ No newline at end of file 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 f1ce931f8..6abbc057a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -45,6 +45,7 @@ private: QAction *aBan, *aBanHistory; QAction *aPromoteToMod, *aDemoteFromMod; QAction *aPromoteToJudge, *aDemoteFromJudge; + QAction *aPromoteToDeveloper, *aDemoteFromDeveloper; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; std::function()> gameInviteLinkProvider; @@ -123,6 +124,7 @@ public: void execInvestigateUser(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); + void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); private: void execInvite(const QString &userName, const GameInviteOption &option); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 875bdfb05..3d89cecf5 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -122,6 +122,8 @@ void UserInfoBox::updateInfo(const ServerInfo_User &user) QString userLevelText; if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { userLevelText = tr("Administrator"); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + userLevelText = tr("Developer"); } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { userLevelText = tr("Moderator"); } else if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index fb610e814..8be76eea0 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -245,6 +245,9 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) if (level.testFlag(ServerInfo_User::IsAdmin)) { return QColor(245, 158, 11); } + if (level.testFlag(ServerInfo_User::IsDeveloper)) { + return QColor(185, 28, 28); + } if (level.testFlag(ServerInfo_User::IsModerator)) { return QColor(59, 130, 246); } @@ -300,6 +303,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) } badge; if (level.testFlag(ServerInfo_User::IsAdmin)) { badge = {"ADMIN", QColor(245, 158, 11)}; + } else if (level.testFlag(ServerInfo_User::IsDeveloper)) { + badge = {"DEV", QColor(185, 28, 28)}; } else if (level.testFlag(ServerInfo_User::IsModerator)) { badge = {"MOD", QColor(59, 130, 246)}; } else if (level.testFlag(ServerInfo_User::IsJudge)) { diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 5c65b090d..34a3d6ae1 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -49,6 +49,8 @@ QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool onl if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { accentColor = QColor(245, 158, 11); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + accentColor = QColor(185, 28, 28); } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { accentColor = QColor(59, 130, 246); } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { @@ -299,6 +301,8 @@ QList UserListPainter::buildBadges(const UserLevelFlags if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { badges << Badge{"ADMIN", QColor(245, 158, 11)}; + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + badges << Badge{"DEV", QColor(185, 28, 28)}; } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { badges << Badge{"MOD", QColor(59, 130, 246)}; } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { @@ -385,9 +389,9 @@ void UserListPainter::paint(QPainter *painter, const QString userName = QString::fromStdString(userInfo.name()); const QString privLevel = QString::fromStdString(userInfo.privlevel()); const QColor accentColor = getAccentColor(userLevel, online); - const bool hasRole = userLevel.testFlag(ServerInfo_User::IsAdmin) || - userLevel.testFlag(ServerInfo_User::IsModerator) || - userLevel.testFlag(ServerInfo_User::IsJudge); + const bool hasRole = + userLevel.testFlag(ServerInfo_User::IsAdmin) || userLevel.testFlag(ServerInfo_User::IsDeveloper) || + userLevel.testFlag(ServerInfo_User::IsModerator) || userLevel.testFlag(ServerInfo_User::IsJudge); const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); const int cardRight = getCardRight(option, rect); 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 a8c99c979..1bb7c5288 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -234,9 +234,11 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt()); const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt()); - // Admins & Mods need no additional comparison checks, just to see if they're an admin or a moderator + // Admins, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer + // or a moderator static const QList userLevelWithNoOtherPrefOrder = { - ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator}; + ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper, + ServerInfo_User_UserLevelFlag_IsModerator}; for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp new file mode 100644 index 000000000..3ffe4a610 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -0,0 +1,122 @@ +/** + * @file tab_developer.cpp + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#include "tab_developer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client) + : Tab(_tabSupervisor), client(_client) +{ + statsTable = new QTableWidget(0, 2); + statsTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + statsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + statsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + statsTable->setSelectionMode(QAbstractItemView::SingleSelection); + statsTable->horizontalHeader()->setStretchLastSection(true); + statsTable->verticalHeader()->setVisible(false); + + statusLabel = new QLabel; + + refreshButton = new QPushButton; + refreshButton->setAutoDefault(true); + connect(refreshButton, &QPushButton::clicked, this, &TabDeveloper::refreshClicked); + + auto *buttonLayout = new QHBoxLayout; + buttonLayout->addWidget(statusLabel, 1, Qt::AlignLeft); + buttonLayout->addWidget(refreshButton, 0, Qt::AlignRight); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(statsTable); + mainLayout->addLayout(buttonLayout); + + auto *central = new QWidget; + central->setLayout(mainLayout); + setCentralWidget(central); + + retranslateUi(); +} + +void TabDeveloper::retranslateUi() +{ + refreshButton->setText(tr("Refresh server stats")); + statsTable->setHorizontalHeaderLabels(QString(tr("Statistic;Value")).split(";")); + if (statsTable->rowCount() == 0) { + statusLabel->clear(); + } +} + +QString TabDeveloper::formatBytes(quint64 bytes) +{ + const quint64 kib = 1024; + const quint64 mib = 1024 * kib; + const quint64 gib = 1024 * mib; + if (bytes >= gib) { + return tr("%1 GiB").arg(QString::number(bytes / static_cast(gib), 'f', 2)); + } + if (bytes >= mib) { + return tr("%1 MiB").arg(QString::number(bytes / static_cast(mib), 'f', 2)); + } + if (bytes >= kib) { + return tr("%1 KiB").arg(QString::number(bytes / static_cast(kib), 'f', 2)); + } + return tr("%1 bytes").arg(bytes); +} + +void TabDeveloper::appendStatRow(const QString &name, const QString &value) +{ + const int row = statsTable->rowCount(); + statsTable->insertRow(row); + statsTable->setItem(row, 0, new QTableWidgetItem(name)); + statsTable->setItem(row, 1, new QTableWidgetItem(value)); +} + +void TabDeveloper::refreshClicked() +{ + Command_GetServerStats cmd; + PendingCommand *pend = client->prepareDeveloperCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeveloper::serverStatsResponse); + client->sendCommand(pend); +} + +void TabDeveloper::serverStatsResponse(const Response &resp) +{ + if (resp.response_code() != Response::RespOk) { + statusLabel->setText(tr("Failed to collect server statistics.")); + return; + } + + const Response_GetServerStats &response = resp.GetExtension(Response_GetServerStats::ext); + + statsTable->setRowCount(0); + appendStatRow(tr("Registered users online"), QString::number(response.users_count())); + appendStatRow(tr("Moderators online"), QString::number(response.mods_count())); + appendStatRow(tr("Games running"), QString::number(response.games_count())); + appendStatRow(tr("Traffic sent (last tick)"), formatBytes(response.tx_bytes())); + appendStatRow(tr("Traffic received (last tick)"), formatBytes(response.rx_bytes())); + + const qint64 uptime = static_cast(response.uptime_secs()); + const int days = static_cast(uptime / 86400); + const int hours = static_cast((uptime % 86400) / 3600); + const int minutes = static_cast((uptime % 3600) / 60); + appendStatRow(tr("Server uptime"), days > 0 ? tr("%1d %2h %3m").arg(days).arg(hours).arg(minutes) + : tr("%1h %2m").arg(hours).arg(minutes)); + + const QDateTime snapshotTime = QDateTime::fromSecsSinceEpoch(static_cast(response.timest())); + appendStatRow(tr("Snapshot taken"), snapshotTime.toLocalTime().toString("yyyy-MM-dd HH:mm")); + + statsTable->resizeColumnsToContents(); + statusLabel->setText(tr("Updated %1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm"))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.h b/cockatrice/src/interface/widgets/tabs/tab_developer.h new file mode 100644 index 000000000..501b14e3e --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h @@ -0,0 +1,43 @@ +/** + * @file tab_developer.h + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#ifndef TAB_DEVELOPER_H +#define TAB_DEVELOPER_H + +#include "tab.h" + +class AbstractClient; +class QLabel; +class QPushButton; +class QTableWidget; +class Response; + +class TabDeveloper : public Tab +{ + Q_OBJECT +private: + AbstractClient *client; + QTableWidget *statsTable; + QPushButton *refreshButton; + QLabel *statusLabel; + + void appendStatRow(const QString &name, const QString &value); + static QString formatBytes(quint64 bytes); + +private slots: + void refreshClicked(); + void serverStatsResponse(const Response &resp); + +public: + explicit TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Developer"); + } +}; + +#endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index e3678a903..cd019d6d9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -14,12 +14,14 @@ #include #include #include +#include #include #include #include #include -TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) + : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); roomTable->setColumnCount(6); @@ -117,7 +119,26 @@ void TabLog::getClicked() }; cmd.set_date_range(dateRange); cmd.set_maximum_results(maximumResults->value()); - PendingCommand *pend = client->prepareModeratorCommand(cmd); + + PendingCommand *pend; + if (canUseDeveloperCommands) { + // Developers query logs through the developer command family. + Command_GetLogHistory devCmd; + devCmd.set_user_name(cmd.user_name()); + devCmd.set_ip_address(cmd.ip_address()); + devCmd.set_game_name(cmd.game_name()); + devCmd.set_game_id(cmd.game_id()); + devCmd.set_message(cmd.message()); + for (int i = 0; i < cmd.log_location_size(); ++i) { + devCmd.add_log_location(cmd.log_location(i)); + } + devCmd.set_date_range(cmd.date_range()); + devCmd.set_maximum_results(cmd.maximum_results()); + pend = client->prepareDeveloperCommand(devCmd); + } else { + pend = client->prepareModeratorCommand(cmd); + } + connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse); client->sendCommand(pend); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h index 5d164dc92..8e914ea64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -33,6 +33,7 @@ class TabLog : public Tab Q_OBJECT private: AbstractClient *client; + bool canUseDeveloperCommands; QLabel *labelFindUserName, *labelFindIPAddress, *labelFindGameName, *labelFindGameID, *labelMessage, *labelMaximum, *labelDescription; LineEditUnfocusable *findUsername, *findIPAddress, *findGameName, *findGameID, *findMessage; @@ -58,7 +59,7 @@ private slots: void restartLayout(); public: - TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client); + TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands = false); ~TabLog() override; void retranslateUi() override; [[nodiscard]] QString getTabText() const override diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 077b876d2..b9c37f1af 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -381,6 +381,9 @@ void TabModeration::moderatorLoginsResponse(const Response &response) if (login.user_level() & ServerInfo_User::IsAdmin) { levels << tr("Admin"); } + if (login.user_level() & ServerInfo_User::IsDeveloper) { + levels << tr("Developer"); + } if (login.user_level() & ServerInfo_User::IsModerator) { levels << tr("Moderator"); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index b0dac3e7c..bb242e349 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -15,6 +15,7 @@ #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" +#include "tab_developer.h" #include "tab_game.h" #include "tab_home.h" #include "tab_logs.h" @@ -118,7 +119,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), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false) + tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -204,6 +205,10 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * aTabModeration->setCheckable(true); connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration); + aTabDeveloper = new QAction(this); + aTabDeveloper->setCheckable(true); + connect(aTabDeveloper, &QAction::triggered, this, &TabSupervisor::actTabDeveloper); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabSupervisor::refreshShortcuts); refreshShortcuts(); @@ -245,6 +250,7 @@ void TabSupervisor::retranslateUi() aTabLog->setText(tr("Logs")); aTabReport->setText(tr("Report Queue")); aTabModeration->setText(tr("Moderation")); + aTabDeveloper->setText(tr("Developer")); // tabs QList tabs; @@ -256,6 +262,7 @@ void TabSupervisor::retranslateUi() tabs.append(tabLog); tabs.append(tabReport); tabs.append(tabModeration); + tabs.append(tabDeveloper); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -523,6 +530,19 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) openTabCardArtRules(); } + if (userInfo->user_level() & ServerInfo_User::IsDeveloper) { + tabsMenu->addSeparator(); + tabsMenu->addAction(aTabDeveloper); + // Developers without moderation rights get log access through their + // own role. Moderators already have the Logs entry from above. + if (!(userInfo->user_level() & ServerInfo_User::IsModerator)) { + tabsMenu->addAction(aTabLog); + if (SettingsCache::instance().tabs().getTabLogOpen()) { + openTabLog(); + } + } + } + retranslateUi(); } @@ -535,6 +555,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; localClients = _clients; @@ -582,6 +603,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabDeveloper) { + tabDeveloper->close(); + } } QList tabsToDelete; @@ -810,7 +834,10 @@ void TabSupervisor::actTabLog(bool checked) void TabSupervisor::openTabLog() { - tabLog = new TabLog(this, client); + // Developers without moderation rights query logs through the developer + // command family, so tell the tab which family to use. + const bool isDeveloper = (userInfo->user_level() & ServerInfo_User::IsDeveloper) != 0; + tabLog = new TabLog(this, client, isDeveloper); myAddTab(tabLog, aTabLog); connect(tabLog, &QObject::destroyed, this, [this] { tabLog = nullptr; @@ -872,6 +899,27 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::actTabDeveloper(bool checked) +{ + if (checked && !tabDeveloper) { + openTabDeveloper(); + setCurrentWidget(tabDeveloper); + } else if (!checked && tabDeveloper) { + tabDeveloper->closeRequest(); + } +} + +void TabSupervisor::openTabDeveloper() +{ + tabDeveloper = new TabDeveloper(this, client); + myAddTab(tabDeveloper, aTabDeveloper); + connect(tabDeveloper, &QObject::destroyed, this, [this] { + tabDeveloper = nullptr; + aTabDeveloper->setChecked(false); + }); + aTabDeveloper->setChecked(true); +} + void TabSupervisor::updatePingTime(int value, int max) { if (!tabServer) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index b389bad3e..aec1d7418 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -45,6 +45,7 @@ class TabReport; class TabModeration; class TabAccount; class TabDeckEditor; +class TabDeveloper; class TabLog; class RoomEvent; class GameEventContainer; @@ -108,6 +109,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; QList replayTabs; @@ -117,7 +119,7 @@ private: QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, - *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration; + *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration, *aTabDeveloper; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); @@ -207,6 +209,7 @@ private slots: void actTabLog(bool checked); void actTabReport(bool checked); void actTabModeration(bool checked); + void actTabDeveloper(bool checked); void openTabVisualDeckStorage(); void openTabHome(); @@ -218,6 +221,7 @@ private slots: void openTabCardArtRules(); void openTabLog(); void openTabReport(); + void openTabDeveloper(); void updateCurrent(int index); void updatePingTime(int value, int max); diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index d6316deb3..de3f896f5 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -253,3 +253,11 @@ PendingCommand *AbstractClient::prepareAdminCommand(const ::google::protobuf::Me c->GetReflection()->MutableMessage(c, cmd.GetDescriptor()->FindExtensionByName("ext"))->CopyFrom(cmd); return new PendingCommand(cont); } + +PendingCommand *AbstractClient::prepareDeveloperCommand(const ::google::protobuf::Message &cmd) +{ + CommandContainer cont; + DeveloperCommand *c = cont.add_developer_command(); + c->GetReflection()->MutableMessage(c, cmd.GetDescriptor()->FindExtensionByName("ext"))->CopyFrom(cmd); + return new PendingCommand(cont); +} diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 1ef9a31e4..af22a5c9d 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -173,6 +173,7 @@ public: static PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId); static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd); static PendingCommand *prepareAdminCommand(const ::google::protobuf::Message &cmd); + static PendingCommand *prepareDeveloperCommand(const ::google::protobuf::Message &cmd); QMap clientFeatures; }; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 899df6529..8422d703d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -388,6 +388,33 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons return finalResponseCode; } +Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer(const CommandContainer &cont, + ResponseContainer &rc) +{ + if (!userInfo) { + return Response::RespLoginNeeded; + } + if (!(userInfo->user_level() & ServerInfo_User::IsDeveloper)) { + return Response::RespLoginNeeded; + } + + resetIdleTimer(); + + Response::ResponseCode finalResponseCode = Response::RespOk; + for (int i = cont.developer_command_size() - 1; i >= 0; --i) { + Response::ResponseCode resp = Response::RespInvalidCommand; + const DeveloperCommand &sc = cont.developer_command(i); + const int num = getPbExtension(sc); + logDebugMessage(getSafeDebugString(sc)); + + resp = processExtendedDeveloperCommand(num, sc, rc); + if (resp != Response::RespOk) { + finalResponseCode = resp; + } + } + return finalResponseCode; +} + void Server_ProtocolHandler::processCommandContainer(const CommandContainer &cont) { // Command processing must be disabled after prepareDestroy() has been called. @@ -410,6 +437,8 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con finalResponseCode = processModeratorCommandContainer(cont, responseContainer); } else if (cont.admin_command_size()) { finalResponseCode = processAdminCommandContainer(cont, responseContainer); + } else if (cont.developer_command_size()) { + finalResponseCode = processDeveloperCommandContainer(cont, responseContainer); } else { finalResponseCode = Response::RespInvalidCommand; } @@ -454,11 +483,12 @@ void Server_ProtocolHandler::pingClockTimeout() prepareDestroy(); } - // PrivLevel users, Moderators, and Admins are not subject to the server idle timeout policy + // PrivLevel users, Moderators, Admins, and Developers are not subject to the server idle timeout policy const bool hasPrivLevel = userInfo && QString::fromStdString(userInfo->privlevel()).toLower() != "none"; - const bool isModOrAdmin = - userInfo && (userInfo->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin)); - if (!hasPrivLevel && !isModOrAdmin) { + const bool isStaff = + userInfo && (userInfo->user_level() & + (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin | ServerInfo_User::IsDeveloper)); + if (!hasPrivLevel && !isStaff) { if ((server->getIdleClientTimeout() > 0) && (idleClientWarningSent)) { if (timeRunning - lastActionReceived > server->getIdleClientTimeout()) { prepareDestroy(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 0d05b91c8..d62213188 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -27,6 +27,7 @@ class CommandContainer; class SessionCommand; class ModeratorCommand; class AdminCommand; +class DeveloperCommand; class Command_Ping; class Command_Login; @@ -98,6 +99,12 @@ private: { return Response::RespFunctionNotAllowed; } + Response::ResponseCode processDeveloperCommandContainer(const CommandContainer &cont, ResponseContainer &rc); + virtual Response::ResponseCode + processExtendedDeveloperCommand(int /* cmdType */, const DeveloperCommand & /* cmd */, ResponseContainer & /* rc */) + { + return Response::RespFunctionNotAllowed; + } void resetIdleTimer(); private slots: diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 3a193ae3c..df62b4afb 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -22,6 +22,8 @@ set(PROTO_FILES command_del_counter.proto command_delete_arrow.proto command_draw_cards.proto + command_get_log_history.proto + command_get_server_stats.proto command_dump_zone.proto command_flip_card.proto command_game_say.proto @@ -71,6 +73,7 @@ set(PROTO_FILES context_ready_start.proto context_set_sideboard_lock.proto context_undo_draw.proto + developer_commands.proto event_add_to_list.proto event_attach_card.proto event_change_zone_properties.proto @@ -141,6 +144,7 @@ set(PROTO_FILES response_forgotpasswordrequest.proto response_get_admin_notes.proto response_get_games_of_user.proto + response_get_server_stats.proto response_get_user_info.proto response_join_room.proto response_list_users.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto index f8b34b3f8..f1f85e376 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto @@ -37,6 +37,7 @@ message Command_AdjustMod { required string user_name = 1; optional bool should_be_mod = 2; optional bool should_be_judge = 3; + optional bool should_be_developer = 4; } message Command_ResetUserPassword { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto new file mode 100644 index 000000000..7ae13bbee --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto @@ -0,0 +1,19 @@ +syntax = "proto2"; +import "developer_commands.proto"; + +// Developer counterpart of Command_ViewLogHistory: identical query fields, but +// routed through the developer command family so developers never need the +// moderator command container. +message Command_GetLogHistory { + extend DeveloperCommand { + optional Command_GetLogHistory ext = 1001; + } + optional string user_name = 1; // user that created message + optional string ip_address = 2; // ip address of user that created message + optional string game_name = 3; // client id of user that created the message + optional string game_id = 4; // game number the message was sent to + optional string message = 5; // raw message that was sent + repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) + required uint32 date_range = 7; // the length of time (in minutes) to look back for + optional uint32 maximum_results = 8; // the maximum number of query results +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto new file mode 100644 index 000000000..33c56293b --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "developer_commands.proto"; + +message Command_GetServerStats { + extend DeveloperCommand { + optional Command_GetServerStats ext = 1000; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto index b6eaf6733..964407819 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto @@ -4,6 +4,7 @@ import "game_commands.proto"; import "room_commands.proto"; import "moderator_commands.proto"; import "admin_commands.proto"; +import "developer_commands.proto"; message CommandContainer { optional uint64 cmd_id = 1; @@ -16,4 +17,5 @@ message CommandContainer { repeated RoomCommand room_command = 102; repeated ModeratorCommand moderator_command = 103; repeated AdminCommand admin_command = 104; + repeated DeveloperCommand developer_command = 105; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto new file mode 100644 index 000000000..bed47d44c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +message DeveloperCommand { + enum DeveloperCommandType { + GET_SERVER_STATS = 1000; + VIEWLOG_HISTORY = 1001; + } + extensions 100 to max; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index 14ba737b5..42a42fcc0 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -77,6 +77,7 @@ message Response { FORGOT_PASSWORD_REQUEST = 1016; // Response to password reset request PASSWORD_SALT = 1017; // Response containing password salt GET_ADMIN_NOTES = 1018; // Response with admin notes + GET_SERVER_STATS = 1019; // Response with server status statistics REPLAY_LIST = 1100; // Response listing replays REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_GET_CODE = 1102; // Response containing replay code diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto new file mode 100644 index 000000000..fb8a0cae2 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto @@ -0,0 +1,19 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_GetServerStats { + extend Response { + optional Response_GetServerStats ext = 1220; + } + + optional uint64 users_count = 1; + optional uint64 mods_count = 2; + optional uint64 games_count = 3; + + // Traffic recorded during the last status update tick + optional uint64 tx_bytes = 4; + optional uint64 rx_bytes = 5; + + optional uint64 uptime_secs = 6; + optional uint64 timest = 7; // unix timestamp of the snapshot +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto index 98cc3ce6a..ea3f56705 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto @@ -8,6 +8,7 @@ message ServerInfo_User { IsModerator = 4; IsAdmin = 8; IsJudge = 16; + IsDeveloper = 32; }; message PawnColorsOverride { optional string left_side = 1; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 847be61da..cb55764d0 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -641,6 +641,10 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer userLevel |= ServerInfo_User::IsJudge; } + if (is_admin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } + result.set_user_level(userLevel); const QString country = query->value(3).toString(); @@ -1448,7 +1452,7 @@ QList Servatrice_DatabaseInterface::getModeratorLastL 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"); + "WHERE (u.admin & 15) <> 0 ORDER BY u.name"); if (!execSqlQuery(query)) { qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error"; @@ -1469,6 +1473,9 @@ QList Servatrice_DatabaseInterface::getModeratorLastL if (isAdmin & 4) { userLevel |= ServerInfo_User::IsJudge; } + if (isAdmin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } loginDetails.set_user_level(userLevel); if (!query->value(2).isNull()) { diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 2a8b5f0a4..95037b2e2 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -47,6 +47,8 @@ #include #include #include +#include +#include #include #include #include @@ -80,6 +82,7 @@ #include #include #include +#include #include #include #include @@ -337,6 +340,38 @@ AbstractServerSocketInterface::processExtendedAdminCommand(int cmdType, const Ad } } +// DEVELOPER FUNCTIONS. +// Permission is checked by processDeveloperCommandContainer. Only stats-style +// queries live here, never community moderation or server administration. +Response::ResponseCode AbstractServerSocketInterface::processExtendedDeveloperCommand(int cmdType, + const DeveloperCommand &cmd, + ResponseContainer &rc) +{ + switch ((DeveloperCommand::DeveloperCommandType)cmdType) { + case DeveloperCommand::GET_SERVER_STATS: + return cmdGetServerStats(cmd.GetExtension(Command_GetServerStats::ext), rc); + case DeveloperCommand::VIEWLOG_HISTORY: { + // Same query as the moderator log view, just carried by the + // developer command family. + const Command_GetLogHistory &devCmd = cmd.GetExtension(Command_GetLogHistory::ext); + Command_ViewLogHistory modCmd; + modCmd.set_user_name(devCmd.user_name()); + modCmd.set_ip_address(devCmd.ip_address()); + modCmd.set_game_name(devCmd.game_name()); + modCmd.set_game_id(devCmd.game_id()); + modCmd.set_message(devCmd.message()); + for (int i = 0; i < devCmd.log_location_size(); ++i) { + modCmd.add_log_location(devCmd.log_location(i)); + } + modCmd.set_date_range(devCmd.date_range()); + modCmd.set_maximum_results(devCmd.maximum_results()); + return cmdGetLogHistory(modCmd, rc); + } + default: + return Response::RespFunctionNotAllowed; + } +} + Response::ResponseCode AbstractServerSocketInterface::cmdAddToList(const Command_AddToList &cmd, ResponseContainer &rc) { if (authState != PasswordRight) { @@ -1643,6 +1678,38 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportUserInfo(const Co return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdGetServerStats(const Command_GetServerStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + // Servatrice::statusUpdate() periodically snapshots server health into the + // uptime table. Serve the freshest snapshot for this server. + QSqlQuery *query = sqlInterface->prepareQuery( + "SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, UNIX_TIMESTAMP(timest) " + "FROM {prefix}_uptime WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1"); + query->bindValue(":id_server", servatrice->getServerID()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + auto *re = new Response_GetServerStats; + if (query->next()) { + re->set_users_count(query->value(0).toUInt()); + re->set_mods_count(query->value(1).toUInt()); + re->set_games_count(query->value(2).toUInt()); + re->set_tx_bytes(query->value(3).toUInt()); + re->set_rx_bytes(query->value(4).toUInt()); + re->set_uptime_secs(query->value(5).toUInt()); + re->set_timest(query->value(6).toUInt()); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */, ResponseContainer &rc) { @@ -3215,7 +3282,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN if (user) { Event_ConnectionClosed event; event.set_reason(Event_ConnectionClosed::DEMOTED); - event.set_reason_str("Your moderator and/or judge status has been revoked."); + event.set_reason_str("Your moderator, judge, and/or developer status has been revoked."); event.set_end_time(QDateTime::currentDateTime().toSecsSinceEpoch()); SessionEvent *se = user->prepareSessionEvent(event); @@ -3257,6 +3324,18 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAdjustMod(const Command } } + if (cmd.has_should_be_developer()) { + if (cmd.should_be_developer()) { + if (!addAdminFlagToUser(userName, 8)) { + return Response::RespInternalError; + } + } else { + if (!removeAdminFlagFromUser(userName, 8)) { + return Response::RespInternalError; + } + } + } + return Response::RespOk; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 600796b5f..85bdba958 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include #include #include @@ -151,6 +153,8 @@ private: processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); @@ -172,6 +176,8 @@ private: Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetServerStats(const Command_GetServerStats &cmd, ResponseContainer &rc); + bool addAdminFlagToUser(const QString &user, int flag); bool removeAdminFlagFromUser(const QString &user, int flag); From 914f0a2aeef14ea6b542b8f21187a4f3216b0384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 30 Aug 2026 21:06:31 +0200 Subject: [PATCH 2/5] [Server/Client/Protocol] Address developer role review feedback Address ZeizaZach's review of the developer staff role: - Nudge the developer log query to exclude private chat and sender IPs (the ModeratorCommand path still sees everything). - Deduplicate Command_GetLogHistory into Command_ViewLogHistory, which now extends both ModeratorCommand (ext) and DeveloperCommand (dev_ext); the client picks the DeveloperCommand-scoped extension by extendee, and the server reads it via the extension number. - Pull the uptime snapshot SQL into Servatrice_DatabaseInterface as getLatestUptimeSnapshot() and widen the reported counters to 64-bit. - Document the admin bitfield (1 admin, 2 moderator, 4 judge, 8 developer) and add a server-side test for the developer command path. --- .../interface/widgets/tabs/tab_developer.cpp | 2 +- .../src/interface/widgets/tabs/tab_logs.cpp | 22 +-- .../interface/widgets/tabs/tab_supervisor.cpp | 8 +- .../client/abstract/abstract_client.cpp | 15 +- .../libcockatrice/protocol/pb/CMakeLists.txt | 3 +- .../protocol/pb/command_get_log_history.proto | 19 --- .../protocol/pb/moderator_commands.proto | 4 + servatrice/servatrice.sql | 3 + .../src/servatrice_database_interface.cpp | 32 ++++ .../src/servatrice_database_interface.h | 15 ++ servatrice/src/serversocketinterface.cpp | 57 +++----- servatrice/src/serversocketinterface.h | 3 +- tests/CMakeLists.txt | 7 + tests/server_developer_role_test.cpp | 137 ++++++++++++++++++ 14 files changed, 251 insertions(+), 76 deletions(-) delete mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto create mode 100644 tests/server_developer_role_test.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp index 3ffe4a610..b8e6a8033 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -94,7 +94,7 @@ void TabDeveloper::refreshClicked() void TabDeveloper::serverStatsResponse(const Response &resp) { if (resp.response_code() != Response::RespOk) { - statusLabel->setText(tr("Failed to collect server statistics.")); + statusLabel->setText(tr("No server statistics available yet.")); return; } diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index cd019d6d9..d5b704818 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -14,7 +14,6 @@ #include #include #include -#include #include #include #include @@ -82,7 +81,9 @@ void TabLog::getClicked() if (!mainRoom->isChecked() && !gameRoom->isChecked() && !privateChat->isChecked()) { mainRoom->setChecked(true); gameRoom->setChecked(true); - privateChat->setChecked(true); + if (!canUseDeveloperCommands) { + privateChat->setChecked(true); + } } if (maximumResults->value() == 0) { @@ -123,18 +124,7 @@ void TabLog::getClicked() PendingCommand *pend; if (canUseDeveloperCommands) { // Developers query logs through the developer command family. - Command_GetLogHistory devCmd; - devCmd.set_user_name(cmd.user_name()); - devCmd.set_ip_address(cmd.ip_address()); - devCmd.set_game_name(cmd.game_name()); - devCmd.set_game_id(cmd.game_id()); - devCmd.set_message(cmd.message()); - for (int i = 0; i < cmd.log_location_size(); ++i) { - devCmd.add_log_location(cmd.log_location(i)); - } - devCmd.set_date_range(cmd.date_range()); - devCmd.set_maximum_results(cmd.maximum_results()); - pend = client->prepareDeveloperCommand(devCmd); + pend = client->prepareDeveloperCommand(cmd); } else { pend = client->prepareModeratorCommand(cmd); } @@ -192,6 +182,10 @@ void TabLog::createDock() mainRoom = new QCheckBox(tr("Main Room")); gameRoom = new QCheckBox(tr("Game Room")); privateChat = new QCheckBox(tr("Private Chat")); + if (canUseDeveloperCommands) { + // Developers cannot query private conversations. + privateChat->setVisible(false); + } pastDays = new QRadioButton(tr("Past X Days: ")); today = new QRadioButton(tr("Today")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index bb242e349..ee89b4792 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -834,10 +834,10 @@ void TabSupervisor::actTabLog(bool checked) void TabSupervisor::openTabLog() { - // Developers without moderation rights query logs through the developer - // command family, so tell the tab which family to use. - const bool isDeveloper = (userInfo->user_level() & ServerInfo_User::IsDeveloper) != 0; - tabLog = new TabLog(this, client, isDeveloper); + // Developers query logs through the developer command family, so tell the + // tab which family to use. + const bool useDeveloperCommands = (userInfo->user_level() & ServerInfo_User::IsDeveloper) != 0; + tabLog = new TabLog(this, client, useDeveloperCommands); myAddTab(tabLog, aTabLog); connect(tabLog, &QObject::destroyed, this, [this] { tabLog = nullptr; diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index de3f896f5..687d93666 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -258,6 +258,19 @@ PendingCommand *AbstractClient::prepareDeveloperCommand(const ::google::protobuf { CommandContainer cont; DeveloperCommand *c = cont.add_developer_command(); - c->GetReflection()->MutableMessage(c, cmd.GetDescriptor()->FindExtensionByName("ext"))->CopyFrom(cmd); + // A developer command message may also be usable through other command + // families, so select the extension scoped to DeveloperCommand rather than + // guessing by name. + const ::google::protobuf::Descriptor *cmdDescriptor = cmd.GetDescriptor(); + const ::google::protobuf::Descriptor *developerDescriptor = DeveloperCommand::descriptor(); + const ::google::protobuf::FieldDescriptor *developerExtension = nullptr; + for (int i = 0; i < cmdDescriptor->extension_count(); ++i) { + if (cmdDescriptor->extension(i)->containing_type() == developerDescriptor) { + developerExtension = cmdDescriptor->extension(i); + break; + } + } + Q_ASSERT(developerExtension != nullptr); + c->GetReflection()->MutableMessage(c, developerExtension)->CopyFrom(cmd); return new PendingCommand(cont); } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index df62b4afb..73745e7ed 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -22,11 +22,10 @@ set(PROTO_FILES command_del_counter.proto command_delete_arrow.proto command_draw_cards.proto - command_get_log_history.proto - command_get_server_stats.proto command_dump_zone.proto command_flip_card.proto command_game_say.proto + command_get_server_stats.proto command_inc_card_counter.proto command_inc_counter.proto command_kick_from_game.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto deleted file mode 100644 index 7ae13bbee..000000000 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_log_history.proto +++ /dev/null @@ -1,19 +0,0 @@ -syntax = "proto2"; -import "developer_commands.proto"; - -// Developer counterpart of Command_ViewLogHistory: identical query fields, but -// routed through the developer command family so developers never need the -// moderator command container. -message Command_GetLogHistory { - extend DeveloperCommand { - optional Command_GetLogHistory ext = 1001; - } - optional string user_name = 1; // user that created message - optional string ip_address = 2; // ip address of user that created message - optional string game_name = 3; // client id of user that created the message - optional string game_id = 4; // game number the message was sent to - optional string message = 5; // raw message that was sent - repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) - required uint32 date_range = 7; // the length of time (in minutes) to look back for - optional uint32 maximum_results = 8; // the maximum number of query results -} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 685408830..4f1e80c27 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -1,4 +1,5 @@ syntax = "proto2"; +import "developer_commands.proto"; message ModeratorCommand { enum ModeratorCommandType { BAN_FROM_SERVER = 1000; @@ -80,6 +81,9 @@ message Command_ViewLogHistory { extend ModeratorCommand { optional Command_ViewLogHistory ext = 1005; } + extend DeveloperCommand { + optional Command_ViewLogHistory dev_ext = 1001; + } optional string user_name = 1; // user that created message optional string ip_address = 2; // ip address of user that created message optional string game_name = 3; // client id of user that created the message diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 5dbf69cbc..cfb1ef5d8 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -25,6 +25,9 @@ INSERT INTO cockatrice_schema_version VALUES(36); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, + -- Bitfield of staff levels: 1 = admin (implies moderator), 2 = moderator, + -- 4 = judge, 8 = developer. Operators set these by hand with + -- "UPDATE cockatrice_users SET admin = ...". `admin` tinyint(1) NOT NULL, `name` varchar(35) NOT NULL, `realname` varchar(255) NOT NULL, diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index cb55764d0..36604bae7 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1487,6 +1487,38 @@ QList Servatrice_DatabaseInterface::getModeratorLastL return results; } +Servatrice_DatabaseInterface::UptimeSnapshot Servatrice_DatabaseInterface::getLatestUptimeSnapshot(int serverId) +{ + UptimeSnapshot snapshot; + + if (!checkSql()) { + return snapshot; + } + + QSqlQuery *query = prepareQuery("SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, " + "UNIX_TIMESTAMP(timest) FROM {prefix}_uptime " + "WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1"); + query->bindValue(":id_server", serverId); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect server stats snapshot: SQL Error"; + return snapshot; + } + + if (query->next()) { + snapshot.valid = true; + snapshot.usersCount = query->value(0).toULongLong(); + snapshot.modsCount = query->value(1).toULongLong(); + snapshot.gamesCount = query->value(2).toULongLong(); + snapshot.txBytes = query->value(3).toULongLong(); + snapshot.rxBytes = query->value(4).toULongLong(); + snapshot.uptimeSecs = query->value(5).toULongLong(); + snapshot.timest = query->value(6).toULongLong(); + } + + return snapshot; +} + bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) { if (!checkSql()) { diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index cd76ae288..a891c7a3d 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -140,6 +140,21 @@ public: QList getUserSessions(const QString &userName, int limit); QList getUserAlts(const QString &userName); QList getModeratorLastLogins(); + + // Uptime snapshot as recorded by Servatrice::statusUpdate() into the + // {prefix}_uptime table. valid is false when no snapshot exists yet. + struct UptimeSnapshot + { + bool valid = false; + quint64 usersCount = 0; + quint64 modsCount = 0; + quint64 gamesCount = 0; + quint64 txBytes = 0; + quint64 rxBytes = 0; + quint64 uptimeSecs = 0; + quint64 timest = 0; + }; + UptimeSnapshot getLatestUptimeSnapshot(int serverId); bool removeUserAvatar(const QString &userName); bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 95037b2e2..bff7828b8 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -287,7 +287,7 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo case ModeratorCommand::REPORT_RESOLVE: return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); case ModeratorCommand::VIEWLOG_HISTORY: - return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc); + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true); case ModeratorCommand::GRANT_REPLAY_ACCESS: return cmdGrantReplayAccess(cmd.GetExtension(Command_GrantReplayAccess::ext), rc); case ModeratorCommand::REPLAY_DOWNLOAD_BY_GAME_ID: @@ -351,21 +351,9 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedDeveloperCo case DeveloperCommand::GET_SERVER_STATS: return cmdGetServerStats(cmd.GetExtension(Command_GetServerStats::ext), rc); case DeveloperCommand::VIEWLOG_HISTORY: { - // Same query as the moderator log view, just carried by the - // developer command family. - const Command_GetLogHistory &devCmd = cmd.GetExtension(Command_GetLogHistory::ext); - Command_ViewLogHistory modCmd; - modCmd.set_user_name(devCmd.user_name()); - modCmd.set_ip_address(devCmd.ip_address()); - modCmd.set_game_name(devCmd.game_name()); - modCmd.set_game_id(devCmd.game_id()); - modCmd.set_message(devCmd.message()); - for (int i = 0; i < devCmd.log_location_size(); ++i) { - modCmd.add_log_location(devCmd.log_location(i)); - } - modCmd.set_date_range(devCmd.date_range()); - modCmd.set_maximum_results(devCmd.maximum_results()); - return cmdGetLogHistory(modCmd, rc); + // Same query as the moderator log view, carried by the developer + // command family, but narrows out private chats and sender IPs. + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::dev_ext), rc, false); } default: return Response::RespFunctionNotAllowed; @@ -1055,12 +1043,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const // MODERATOR FUNCTIONS. // May be called by admins and moderators. Permission is checked by the calling function. Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, - ResponseContainer &rc) + ResponseContainer &rc, + bool allowPrivateChat) { QList messageList; QString userName = nameFromStdString(cmd.user_name()); - QString ipAddress = nameFromStdString(cmd.ip_address()); + QString ipAddress = allowPrivateChat ? nameFromStdString(cmd.ip_address()) : QString(); QString gameName = nameFromStdString(cmd.game_name()); QString gameID = nameFromStdString(cmd.game_id()); QString message = textFromStdString(cmd.message()); @@ -1075,7 +1064,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com if (nameFromStdString(cmd.log_location(i)).simplified() == "game") { gameType = true; } - if (nameFromStdString(cmd.log_location(i)).simplified() == "chat") { + if (nameFromStdString(cmd.log_location(i)).simplified() == "chat" && allowPrivateChat) { chatType = true; } } @@ -1089,7 +1078,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com QListIterator messageIterator(sqlInterface->getMessageLogHistory( userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults)); while (messageIterator.hasNext()) { - re->add_log_message()->CopyFrom(messageIterator.next()); + ServerInfo_ChatMessage chatMessage = messageIterator.next(); + if (!allowPrivateChat) { + chatMessage.clear_sender_ip(); + } + re->add_log_message()->CopyFrom(chatMessage); } } else { ServerInfo_ChatMessage chatMessage; @@ -1687,24 +1680,20 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetServerStats(const Co // Servatrice::statusUpdate() periodically snapshots server health into the // uptime table. Serve the freshest snapshot for this server. - QSqlQuery *query = sqlInterface->prepareQuery( - "SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, UNIX_TIMESTAMP(timest) " - "FROM {prefix}_uptime WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1"); - query->bindValue(":id_server", servatrice->getServerID()); - if (!sqlInterface->execSqlQuery(query)) { + const auto snapshot = sqlInterface->getLatestUptimeSnapshot(servatrice->getServerID()); + if (!snapshot.valid) { + // No snapshot yet (fresh server, or statusUpdate() has not ticked). return Response::RespInternalError; } auto *re = new Response_GetServerStats; - if (query->next()) { - re->set_users_count(query->value(0).toUInt()); - re->set_mods_count(query->value(1).toUInt()); - re->set_games_count(query->value(2).toUInt()); - re->set_tx_bytes(query->value(3).toUInt()); - re->set_rx_bytes(query->value(4).toUInt()); - re->set_uptime_secs(query->value(5).toUInt()); - re->set_timest(query->value(6).toUInt()); - } + re->set_users_count(snapshot.usersCount); + re->set_mods_count(snapshot.modsCount); + re->set_games_count(snapshot.gamesCount); + re->set_tx_bytes(snapshot.txBytes); + re->set_rx_bytes(snapshot.rxBytes); + re->set_uptime_secs(snapshot.uptimeSecs); + re->set_timest(snapshot.timest); rc.setResponseExtension(re); return Response::RespOk; diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 85bdba958..d9ea5eb02 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -117,7 +117,8 @@ private: 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 + cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc, bool allowPrivateChat); Response::ResponseCode cmdGetBanHistory(const Command_GetBanHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnList(const Command_GetWarnList &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnHistory(const Command_GetWarnHistory &cmd, ResponseContainer &rc); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 34784538b..8045cd255 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 server_developer_role_test COMMAND server_developer_role_test) add_test(NAME warning_categories_test COMMAND warning_categories_test) add_test(NAME lag_monitor_test COMMAND lag_monitor_test) add_test(NAME latency_tracker_test COMMAND latency_tracker_test) @@ -30,6 +31,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(server_developer_role_test server_developer_role_test.cpp) add_executable(warning_categories_test warning_categories_test.cpp) add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) @@ -70,6 +72,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(server_developer_role_test gtest) add_dependencies(warning_categories_test gtest) add_dependencies(lag_monitor_test gtest) add_dependencies(latency_tracker_test gtest) @@ -104,6 +107,10 @@ target_link_libraries( target_link_libraries( server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) target_link_libraries( warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) diff --git a/tests/server_developer_role_test.cpp b/tests/server_developer_role_test.cpp new file mode 100644 index 000000000..127f606a8 --- /dev/null +++ b/tests/server_developer_role_test.cpp @@ -0,0 +1,137 @@ +/** @file server_developer_role_test.cpp + * @brief Tests for the developer staff role authorization and dispatch. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +// The server_remote library references the global RNG, which is normally +// defined by the servatrice/client executable main(). Provide a stub so the +// unit test can link against it. +RNG_Abstract *rng = nullptr; + +namespace +{ + +class TestDeveloperHandler : public Server_ProtocolHandler +{ +public: + explicit TestDeveloperHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + // Buffer the last response code sent to the client so tests can assert on + // the outcome of processCommandContainer(). + Response::ResponseCode lastResponseCode = Response::RespNothing; + int dispatchCount = 0; + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } + + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &, ResponseContainer &) override + { + ++dispatchCount; + // Fail closed for anything not explicitly handled. + if (cmdType != DeveloperCommand::GET_SERVER_STATS) { + return Response::RespFunctionNotAllowed; + } + return Response::RespOk; + } +}; + +class DeveloperRoleTest : public ::testing::Test +{ +protected: + Server server; + TestDeveloperHandler handler{&server}; + + void setUserLevel(uint32_t level) + { + ServerInfo_User user; + user.set_user_level(level); + handler.setUserInfo(user); + } +}; + +TEST_F(DeveloperRoleTest, RejectsWhenNotLoggedIn) +{ + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsPlainUser) +{ + setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsModeratorThatIsNotDeveloper) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); +} + +TEST_F(DeveloperRoleTest, DispatchesToDeveloperCommandForDeveloper) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + DeveloperCommand *cmd = cont.add_developer_command(); + cmd->MutableExtension(Command_GetServerStats::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(DeveloperRoleTest, FailClosedForUnknownDeveloperCommand) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + cont.add_developer_command(); // no extension set -> getPbExtension() returns -1 + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_EQ(handler.dispatchCount, 1); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 60c6f074de2e214948d164041bd91efa141c9da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 30 Aug 2026 21:11:08 +0200 Subject: [PATCH 3/5] Add missing trailing newline to user_context_menu.cpp --- .../src/interface/widgets/server/user/user_context_menu.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c8a842604..72e7c41b2 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -727,4 +727,4 @@ void UserContextMenu::execAdjustDeveloper(const QString &userName, bool shouldBe PendingCommand *pend = client->prepareAdminCommand(cmd); connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); client->sendCommand(pend); -} \ No newline at end of file +} From 763b5cf74daf93ee200f704f75825936236b41e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 30 Aug 2026 21:24:22 +0200 Subject: [PATCH 4/5] Remove stale includes of deleted command_get_log_history proto The Command_GetLogHistory message was folded into Command_ViewLogHistory, which deleted command_get_log_history.proto, but serversocketinterface still #included its generated header. Fresh CI builds fail on the missing file; local builds masked it by reusing a previously generated header. --- servatrice/src/serversocketinterface.cpp | 1 - servatrice/src/serversocketinterface.h | 1 - 2 files changed, 2 deletions(-) diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index bff7828b8..7f49e272e 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -47,7 +47,6 @@ #include #include #include -#include #include #include #include diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index d9ea5eb02..c7516b405 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,7 +24,6 @@ #include #include #include -#include #include #include #include From ab243cc499a18ed15396352887c0d603bc7c6fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Wed, 2 Sep 2026 13:52:25 +0200 Subject: [PATCH 5/5] [Server] Exclude chat rows when private-chat filter is bypassable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A developer who omits log_location entirely — or sends only "chat" — leaves chatType, gameType, roomType all false, so getMessageLogHistory skips the target_type clause and returns every row, private messages included. When !allowPrivateChat the server now forces game+room when no surviving location was requested, guaranteeing the query always carries a target_type restriction. [Client] Demote mod+dev to moderator path in log-tab dispatch The developer command family is strictly weaker than the moderator one (no private chat, no sender_ip, ip filter ignored), so granting the developer bit to an existing moderator must not silently strip their capabilities. useDeveloperCommands is now true only when the user holds the developer bit and not the moderator bit. [Client] Hide the IP-address filter for developer log tab users The developer path ignores the ip_address query field server-side. Showing the field lets a developer type an IP and get results that are silently unfiltered by it rather than an empty result set — reads as a broken filter. Hide labelFindIPAddress/findIPAddress alongside the privateChat checkbox. --- cockatrice/src/interface/widgets/tabs/tab_logs.cpp | 4 ++++ .../src/interface/widgets/tabs/tab_supervisor.cpp | 7 +++++-- servatrice/src/serversocketinterface.cpp | 10 ++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index d5b704818..f73d06b57 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -185,6 +185,10 @@ void TabLog::createDock() if (canUseDeveloperCommands) { // Developers cannot query private conversations. privateChat->setVisible(false); + // The developer family ignores the IP filter server-side, so showing + // the field would silently unfilter the result by it. Hide it. + labelFindIPAddress->setVisible(false); + findIPAddress->setVisible(false); } pastDays = new QRadioButton(tr("Past X Days: ")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index ee89b4792..1b61bf80f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -835,8 +835,11 @@ void TabSupervisor::actTabLog(bool checked) void TabSupervisor::openTabLog() { // Developers query logs through the developer command family, so tell the - // tab which family to use. - const bool useDeveloperCommands = (userInfo->user_level() & ServerInfo_User::IsDeveloper) != 0; + // tab which family to use. The moderator family is strictly stronger, so a + // moderator who also holds the developer bit keeps the moderator path — the + // developer bit only selects the (narrowed) developer family on its own. + const bool useDeveloperCommands = (userInfo->user_level() & ServerInfo_User::IsDeveloper) && + !(userInfo->user_level() & ServerInfo_User::IsModerator); tabLog = new TabLog(this, client, useDeveloperCommands); myAddTab(tabLog, aTabLog); connect(tabLog, &QObject::destroyed, this, [this] { diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 7f49e272e..4b1502a15 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -1068,6 +1068,16 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com } } + // For callers that must not see private conversations, never leave the + // target-type filter empty: if the request only asked for "chat" (or for + // nothing at all) the query below would carry no target_type restriction + // and would return every row, private messages included. Fall back to the + // game/room diagnostics the caller is allowed to see. + if (!allowPrivateChat && !gameType && !roomType) { + gameType = true; + roomType = true; + } + int dateRange = cmd.date_range(); int maximumResults = cmd.maximum_results();