[Server/Client/Protocol] Add developer staff role (#7211)

* [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

* [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.

* Add missing trailing newline to user_context_menu.cpp

* 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.

* [Server] Exclude chat rows when private-chat filter is bypassable

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.

* Developer pawn is silver.

* [Client] Fix indentation of merged Card Art Rules / Developer tabs

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-09-11 17:18:56 +02:00 committed by GitHub
parent 7d867b9745
commit d5d99e4dfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 708 additions and 27 deletions

View file

@ -382,6 +382,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

View file

@ -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 "#B8B8B8"
/**
* 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;

View file

@ -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<QWidget *>(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<QWidget *>(parent()), tr("Failed"), tr("Failed to promote user."));
} else {
QMessageBox::information(static_cast<QWidget *>(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 && !userListProxy->isUserIgnored(userName));
@ -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) {
@ -706,4 +725,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);
}

View file

@ -45,6 +45,7 @@ private:
QAction *aBan, *aBanHistory;
QAction *aPromoteToMod, *aDemoteFromMod;
QAction *aPromoteToJudge, *aDemoteFromJudge;
QAction *aPromoteToDeveloper, *aDemoteFromDeveloper;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> 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);

View file

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

View file

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

View file

@ -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::Badge> 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);

View file

@ -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<ServerInfo_User_UserLevelFlag> 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)) {

View file

@ -0,0 +1,122 @@
/**
* @file tab_developer.cpp
* @ingroup ServerTabs
*/
//! \todo Document this file.
#include "tab_developer.h"
#include <QDateTime>
#include <QHeaderView>
#include <QLabel>
#include <QPushButton>
#include <QTableWidget>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/response_get_server_stats.pb.h>
#include <libcockatrice/protocol/pending_command.h>
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<double>(gib), 'f', 2));
}
if (bytes >= mib) {
return tr("%1 MiB").arg(QString::number(bytes / static_cast<double>(mib), 'f', 2));
}
if (bytes >= kib) {
return tr("%1 KiB").arg(QString::number(bytes / static_cast<double>(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("No server statistics available yet."));
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<qint64>(response.uptime_secs());
const int days = static_cast<int>(uptime / 86400);
const int hours = static_cast<int>((uptime % 86400) / 3600);
const int minutes = static_cast<int>((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<qint64>(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")));
}

View file

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

View file

@ -19,7 +19,8 @@
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/string_limits.h>
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);
@ -80,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) {
@ -117,7 +120,15 @@ 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.
pend = client->prepareDeveloperCommand(cmd);
} else {
pend = client->prepareModeratorCommand(cmd);
}
connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse);
client->sendCommand(pend);
}
@ -171,6 +182,14 @@ 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);
// 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: "));
today = new QRadioButton(tr("Today"));

View file

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

View file

@ -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");
}

View file

@ -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"
@ -119,7 +120,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
tabModeration(nullptr), isLocalGame(false)
tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{
setElideMode(Qt::ElideRight);
setMovable(true);
@ -205,6 +206,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();
@ -247,6 +252,7 @@ void TabSupervisor::retranslateUi()
aTabReport->setText(tr("Report Queue"));
aTabModeration->setText(tr("Moderation"));
aTabCardArtRules->setText(tr("Card Art Rules"));
aTabDeveloper->setText(tr("Developer"));
// tabs
QList<Tab *> tabs;
@ -259,6 +265,7 @@ void TabSupervisor::retranslateUi()
tabs.append(tabReport);
tabs.append(tabModeration);
tabs.append(tabCardArtRules);
tabs.append(tabDeveloper);
QMapIterator<int, TabRoom *> roomIterator(roomTabs);
while (roomIterator.hasNext()) {
tabs.append(roomIterator.next().value());
@ -528,6 +535,19 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
}
}
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();
}
@ -540,6 +560,7 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
tabLog = nullptr;
tabReport = nullptr;
tabModeration = nullptr;
tabDeveloper = nullptr;
isLocalGame = true;
userInfo = new ServerInfo_User;
localClients = _clients;
@ -590,6 +611,9 @@ void TabSupervisor::stop()
if (tabCardArtRules) {
tabCardArtRules->close();
}
if (tabDeveloper) {
tabDeveloper->close();
}
}
QList<Tab *> tabsToDelete;
@ -819,7 +843,13 @@ void TabSupervisor::actTabLog(bool checked)
void TabSupervisor::openTabLog()
{
tabLog = new TabLog(this, client);
// Developers query logs through the developer command family, so tell the
// 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] {
tabLog = nullptr;
@ -881,6 +911,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) {

View file

@ -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<int, TabRoom *> roomTabs;
QMap<int, TabGame *> gameTabs;
QList<TabGame *> 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);

View file

@ -253,3 +253,24 @@ 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();
// 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);
}

View file

@ -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<QString, bool> clientFeatures;
};

View file

@ -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();

View file

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

View file

@ -25,6 +25,7 @@ set(PROTO_FILES
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
@ -71,6 +72,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
@ -140,6 +142,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

View file

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

View file

@ -0,0 +1,8 @@
syntax = "proto2";
import "developer_commands.proto";
message Command_GetServerStats {
extend DeveloperCommand {
optional Command_GetServerStats ext = 1000;
}
}

View file

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

View file

@ -0,0 +1,8 @@
syntax = "proto2";
message DeveloperCommand {
enum DeveloperCommandType {
GET_SERVER_STATS = 1000;
VIEWLOG_HISTORY = 1001;
}
extensions 100 to max;
}

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ message ServerInfo_User {
IsModerator = 4;
IsAdmin = 8;
IsJudge = 16;
IsDeveloper = 32;
};
message PawnColorsOverride {
optional string left_side = 1;

View file

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

View file

@ -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<ServerInfo_ModeratorLogin> 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<ServerInfo_ModeratorLogin> 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()) {
@ -1480,6 +1487,38 @@ QList<ServerInfo_ModeratorLogin> 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()) {

View file

@ -140,6 +140,21 @@ public:
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName);
QList<ServerInfo_ModeratorLogin> 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;

View file

@ -47,6 +47,7 @@
#include <libcockatrice/protocol/pb/command_deck_list.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
#include <libcockatrice/protocol/pb/command_replay_download.pb.h>
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
@ -80,6 +81,7 @@
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response_forgotpasswordrequest.pb.h>
#include <libcockatrice/protocol/pb/response_get_admin_notes.pb.h>
#include <libcockatrice/protocol/pb/response_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/response_moderator_last_logins.pb.h>
#include <libcockatrice/protocol/pb/response_password_salt.pb.h>
#include <libcockatrice/protocol/pb/response_register.pb.h>
@ -284,7 +286,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:
@ -337,6 +339,26 @@ 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, 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;
}
}
Response::ResponseCode AbstractServerSocketInterface::cmdAddToList(const Command_AddToList &cmd, ResponseContainer &rc)
{
if (authState != PasswordRight) {
@ -1020,12 +1042,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<ServerInfo_ChatMessage> 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());
@ -1040,11 +1063,21 @@ 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;
}
}
// 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();
@ -1054,7 +1087,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com
QListIterator<ServerInfo_ChatMessage> 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;
@ -1643,6 +1680,34 @@ 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.
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;
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;
}
Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */,
ResponseContainer &rc)
{
@ -3215,7 +3280,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 +3322,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;
}

View file

@ -24,6 +24,7 @@
#include <QMutex>
#include <QTcpSocket>
#include <QWebSocket>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
#include <libcockatrice/protocol/pb/command_report.pb.h>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
@ -115,7 +116,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);
@ -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);

View file

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

View file

@ -0,0 +1,137 @@
/** @file server_developer_role_test.cpp
* @brief Tests for the developer staff role authorization and dispatch.
* @ingroup Tests
*/
#include <gtest/gtest.h>
#include <libcockatrice/network/server/remote/server.h>
#include <libcockatrice/network/server/remote/server_protocolhandler.h>
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/developer_commands.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/rng/rng_abstract.h>
// 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();
}