[Server/Client/Protocol] Reporting users + moderation queue functionality (#7091)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run

* [Server/Client/Protocol] Reporting users + moderation queue functionality

Took 6 minutes

Took 3 minutes

Took 8 seconds

Took 11 minutes

Took 12 minutes

Took 7 minutes

Took 15 seconds

Took 2 minutes

Took 1 minute


Took 30 seconds

Took 16 seconds

* CI Fix

Took 6 minutes

* CI Fix

Took 6 minutes

* [Protocol] Add moderation investigation commands

Adds the protocol layer for the moderation investigation suite:
- Command_GetUserSessions/GetUserAlts/GetModeratorLastLogins/ResetUserPassword/RemoveUserAvatar (1013-1017)
- Response extensions 1215-1219 with ServerInfo messages for sessions, alts, and staff logins
- last_login on Response_ReportUserInfo and warning_il on Response_WarnList

Took 2 minutes

* [Utility] Add warning categories parser with infraction levels

Parses the server's 'officialwarnings' setting (comma-separated, optional
'|IL' suffix) into WarningCategory structs so the client can display the
infraction level of each warning category. Includes GTest coverage.

* [Server] Add moderation investigation tools

Implements the server side of the moderation suite:
- getUserSessions/getUserAlts/getModeratorLastLogins/removeUserAvatar DB methods
- Handlers for all five new commands with audit records (PASSWORD_RESET,
  REMOVE_USER_AVATAR); password resets return a generated temporary password
- cmdGetWarnList now reports per-category infraction levels from the
  officialwarnings setting; cmdReportUserInfo reports last_login
- Update servatrice.ini.example with the warning taxonomy
- Password/avatar mutations report RespNameNotFound when the user does not exist

* [Client] Add moderation tab with investigate, password reset, and avatar removal

- New Moderation tab: search a user to show account info, alternate
  accounts, login sessions, and staff last logins; actions to reset the
  user's password (shows the generated temporary password) and remove the
  user's avatar
- 'Investigate user' entry in the user context menu opens the tab pre-loaded
  for that user
- Warning dialog shows the infraction level of each warning category
- Tab wired into TabSupervisor with a moderator-gated menu action, shortcut,
  and tabs.ini persistence (default closed)

* [Server/Client/Protocol] Address PR #7091 review: security, bug, and perf fixes

Security:
- Promote RESET_USER_PASSWORD to admin-only dispatch (was moderator-accessible)
- Reject password reset on users with equal/higher privilege than caller
- Notify affected user via Event_NotifyUser::CUSTOM when password is reset
- Add server-side category whitelist for reports
- Drop reporter name fallback in comment/details authorization (ID-only)
- Force password change: new DB column + login enforcement + client disconnect

Bugs:
- XSS via QTextEdit::append() → insertPlainText() in report tab and utils
- allNotified initialized to true even with empty recipients list
- Warning combo box: use currentData() instead of baked-in display text
- Report resolution now records who resolved (resolved_by column + audit)

Performance:
- IP-correlation subquery: add 6-month window + LIMIT 200
- getUserSessions: clamp limit to 500

Non-blocking:
- Palette-aware colors in report_utils.cpp (dark/light mode)
- Report list pagination: offset/limit fields + total_count in response
- SessionCommand enum gap comment for reserved values 1201-1203

Schema: 36→37 (force_password_change), 37→38 (resolved_by)

Took 12 minutes


Took 16 seconds

* Fix macOs pedantry

Took 5 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-08-21 15:00:13 +02:00 committed by GitHub
parent 9eafd90a91
commit ed4eb1cb31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 4768 additions and 43 deletions

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../intents/intent_join_server_game.h"
#include "../interface/pixel_map_generator.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/user/user_list_manager.h"
@ -18,7 +19,9 @@
#include "tab_home.h"
#include "tab_logs.h"
#include "tab_message.h"
#include "tab_moderation.h"
#include "tab_replays.h"
#include "tab_report.h"
#include "tab_room.h"
#include "tab_server.h"
#include "tab_visual_database_display.h"
@ -31,6 +34,7 @@
#include <QPainter>
#include <QSystemTrayIcon>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/network/client/remote/remote_client.h>
#include <libcockatrice/protocol/pb/event_game_joined.pb.h>
#include <libcockatrice/protocol/pb/event_notify_user.pb.h>
#include <libcockatrice/protocol/pb/event_user_message.pb.h>
@ -39,6 +43,7 @@
#include <libcockatrice/protocol/pb/room_event.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_room.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/chat_settings.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
@ -113,7 +118,7 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/)
TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent)
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr),
tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr),
tabLog(nullptr), isLocalGame(false)
tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false)
{
setElideMode(Qt::ElideRight);
setMovable(true);
@ -190,6 +195,14 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
aTabLog->setCheckable(true);
connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog);
aTabReport = new QAction(this);
aTabReport->setCheckable(true);
connect(aTabReport, &QAction::triggered, this, &TabSupervisor::actTabReport);
aTabModeration = new QAction(this);
aTabModeration->setCheckable(true);
connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration);
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&TabSupervisor::refreshShortcuts);
refreshShortcuts();
@ -229,6 +242,8 @@ void TabSupervisor::retranslateUi()
aTabReplays->setText(tr("Game Replays"));
aTabAdmin->setText(tr("Administration"));
aTabLog->setText(tr("Logs"));
aTabReport->setText(tr("Report Queue"));
aTabModeration->setText(tr("Moderation"));
// tabs
QList<Tab *> tabs;
@ -238,6 +253,8 @@ void TabSupervisor::retranslateUi()
tabs.append(tabAdmin);
tabs.append(tabAccount);
tabs.append(tabLog);
tabs.append(tabReport);
tabs.append(tabModeration);
QMapIterator<int, TabRoom *> roomIterator(roomTabs);
while (roomIterator.hasNext()) {
tabs.append(roomIterator.next().value());
@ -284,6 +301,8 @@ void TabSupervisor::refreshShortcuts()
aTabReplays->setShortcuts(shortcuts.getShortcut("Tabs/aTabReplays"));
aTabAdmin->setShortcuts(shortcuts.getShortcut("Tabs/aTabAdmin"));
aTabLog->setShortcuts(shortcuts.getShortcut("Tabs/aTabLog"));
aTabReport->setShortcuts(shortcuts.getShortcut("Tabs/aTabReport"));
aTabModeration->setShortcuts(shortcuts.getShortcut("Tabs/aTabModeration"));
}
void TabSupervisor::closeEvent(QCloseEvent *event)
@ -485,6 +504,8 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
tabsMenu->addAction(aTabAdmin);
tabsMenu->addAction(aTabLog);
tabsMenu->addAction(aTabCardArtRules);
tabsMenu->addAction(aTabReport);
tabsMenu->addAction(aTabModeration);
if (SettingsCache::instance().tabs().getTabAdminOpen()) {
openTabAdmin();
@ -492,6 +513,12 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
if (SettingsCache::instance().tabs().getTabLogOpen()) {
openTabLog();
}
if (SettingsCache::instance().tabs().getTabReportOpen()) {
openTabReport();
}
if (SettingsCache::instance().tabs().getTabModerationOpen()) {
openTabModeration();
}
openTabCardArtRules();
}
@ -505,6 +532,8 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
tabAccount = nullptr;
tabAdmin = nullptr;
tabLog = nullptr;
tabReport = nullptr;
tabModeration = nullptr;
isLocalGame = true;
userInfo = new ServerInfo_User;
localClients = _clients;
@ -546,6 +575,12 @@ void TabSupervisor::stop()
if (tabLog) {
tabLog->close();
}
if (tabReport) {
tabReport->close();
}
if (tabModeration) {
tabModeration->close();
}
}
QList<Tab *> tabsToDelete;
@ -783,6 +818,59 @@ void TabSupervisor::openTabLog()
aTabLog->setChecked(true);
}
void TabSupervisor::actTabReport(bool checked)
{
SettingsCache::instance().tabs().setTabReportOpen(checked);
if (checked && !tabReport) {
openTabReport();
setCurrentWidget(tabReport);
} else if (!checked && tabReport) {
tabReport->closeRequest();
}
}
void TabSupervisor::openTabReport()
{
tabReport = new TabReport(this, client);
myAddTab(tabReport, aTabReport);
connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay);
connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame);
connect(tabReport, &QObject::destroyed, this, [this] {
tabReport = nullptr;
aTabReport->setChecked(false);
});
aTabReport->setChecked(true);
}
void TabSupervisor::actTabModeration(bool checked)
{
SettingsCache::instance().tabs().setTabModerationOpen(checked);
if (checked && !tabModeration) {
openTabModeration();
setCurrentWidget(tabModeration);
} else if (!checked && tabModeration) {
tabModeration->closeRequest();
}
}
void TabSupervisor::openTabModeration(const QString &userName)
{
if (tabModeration) {
setCurrentWidget(tabModeration);
if (!userName.isEmpty()) {
tabModeration->investigate(userName);
}
return;
}
tabModeration = new TabModeration(this, client, userName);
myAddTab(tabModeration, aTabModeration);
connect(tabModeration, &QObject::destroyed, this, [this] {
tabModeration = nullptr;
aTabModeration->setChecked(false);
});
aTabModeration->setChecked(true);
}
void TabSupervisor::updatePingTime(int value, int max)
{
if (!tabServer) {
@ -900,6 +988,30 @@ void TabSupervisor::replayLeft(TabGame *tab)
replayTabs.removeOne(tab);
}
void TabSupervisor::joinReportGame(const int gameId, const int roomId)
{
auto *remoteClient = qobject_cast<RemoteClient *>(client);
if (!remoteClient) {
actShowPopup(tr("Report joins are only available on a remote server."));
return;
}
auto ctx = std::make_unique<ContextJoinGame>();
ctx->roomContext.serverContext.hostname = remoteClient->peerName();
ctx->roomContext.serverContext.port = QString::number(remoteClient->peerPort());
ctx->roomContext.roomId = roomId;
ctx->gameId = gameId;
ctx->asSpectator = true;
auto *joinGameIntent = new IntentJoinServerGame(this, remoteClient, std::move(ctx));
joinGameIntent->setParent(this);
connect(joinGameIntent, &Intent::failed, this, [gameId](const QString &reason) {
actShowPopup(tr("Could not join game %1.\n%2").arg(gameId).arg(reason));
});
joinGameIntent->execute();
}
TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus)
{
if (receiverName == QString::fromStdString(userInfo->name())) {
@ -1284,6 +1396,24 @@ void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event)
}
break;
}
case Event_NotifyUser::REPORT_RESOLVED: {
QString title = QString::fromStdString(event.custom_title()).simplified();
QString content = QString::fromStdString(event.custom_content()).trimmed();
if (!title.isEmpty() && !content.isEmpty()) {
actShowPopup(title + "\n" + content);
QApplication::alert(this);
}
break;
}
case Event_NotifyUser::REPORT_COMMENT: {
QString title = QString::fromStdString(event.custom_title()).simplified();
QString content = QString::fromStdString(event.custom_content()).trimmed();
if (!title.isEmpty() && !content.isEmpty()) {
actShowPopup(title + "\n" + content);
QApplication::alert(this);
}
break;
}
default:;
}
}