[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

@ -43,7 +43,9 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_load_remote_deck.cpp
src/interface/widgets/dialogs/dlg_local_game_options.cpp
src/interface/widgets/dialogs/dlg_manage_sets.cpp
src/interface/widgets/dialogs/dlg_my_reports.cpp
src/interface/widgets/dialogs/dlg_register.cpp
src/interface/widgets/dialogs/dlg_report_user.cpp
src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp
src/interface/widgets/dialogs/dlg_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp
@ -282,6 +284,8 @@ set(cockatrice_SOURCES
src/interface/widgets/settings_page/user_interface_settings_page.cpp
src/interface/widgets/utility/custom_line_edit.cpp
src/interface/widgets/utility/get_text_with_max.cpp
src/interface/widgets/utility/report_utils.cpp
src/interface/widgets/utility/report_utils.h
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
@ -375,6 +379,8 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_home.cpp
src/interface/widgets/tabs/tab_logs.cpp
src/interface/widgets/tabs/tab_message.cpp
src/interface/widgets/tabs/tab_moderation.cpp
src/interface/widgets/tabs/tab_report.cpp
src/interface/widgets/tabs/tab_replays.cpp
src/interface/widgets/tabs/tab_room.cpp
src/interface/widgets/tabs/tab_server.cpp

View file

@ -296,6 +296,15 @@ void ConnectionController::onLoginError(int r,
return;
}
case Response::RespPasswordChangeRequired: {
QMessageBox::information(
dialogParent, tr("Password Change Required"),
tr("An administrator has reset your password. Please contact your server administrator to obtain "
"your temporary password, then log in and change it via Account -> Change Password."));
remoteClient->disconnectFromServer();
return;
}
case Response::RespServerFull: {
QMessageBox::critical(dialogParent, tr("Server Full"),
tr("The server has reached its maximum user capacity, please check back later."));

View file

@ -786,6 +786,10 @@ private:
ShortcutGroup::Tabs)},
{"Tabs/aTabLogs",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Logs"), parseSequenceString(""), ShortcutGroup::Tabs)},
{"Tabs/aTabReport",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Report Queue"), parseSequenceString(""), ShortcutGroup::Tabs)},
{"Tabs/aTabModeration",
ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Moderation"), parseSequenceString(""), ShortcutGroup::Tabs)},
};
};

View file

@ -6,6 +6,7 @@ struct ContextJoinGame
{
ContextJoinRoom roomContext;
int gameId;
bool asSpectator = false;
};
#endif // COCKATRICE_CONTEXT_JOIN_GAME_H

View file

@ -55,7 +55,7 @@ bool IntentJoinServerGame::tryJoinGame(TabRoom *room)
return false;
}
if (room->getGameSelector()->joinGameById(context->gameId)) {
if (room->getGameSelector()->joinGameById(context->gameId, context->asSpectator)) {
emitFinished();
return true;
}

View file

@ -0,0 +1,297 @@
#include "dlg_my_reports.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include <QFontDatabase>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_my_list.pb.h>
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
#include <libcockatrice/protocol/pb/response_report_my_list.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ID = 0;
constexpr int COL_TIME = 1;
constexpr int COL_REPORTED = 2;
constexpr int COL_CATEGORY = 3;
constexpr int COL_GAMEID = 4;
constexpr int COL_STATUS = 5;
constexpr int COL_ASSIGNED = 6;
constexpr int COL_COUNT = 7;
} // namespace
DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent)
: QDialog(parent), client(_client), selectedReportId(-1)
{
setWindowTitle(tr("My Reports"));
setMinimumSize(800, 500);
table = new QTableWidget(0, COL_COUNT);
table->setHorizontalHeaderLabels(
{tr("#"), tr("Time"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To")});
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSortingEnabled(true);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
connect(table, &QTableWidget::itemSelectionChanged, this, &DlgMyReports::onSelectionChanged);
auto *detailsGroup = new QGroupBox(tr("Report Details"));
descriptionEdit = new QTextEdit;
descriptionEdit->setReadOnly(true);
descriptionEdit->setFixedHeight(80);
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit);
auto *commentsLabel = new QLabel(tr("Comments:"));
commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true);
commentsEdit->setFixedHeight(120);
auto *addCommentLabel = new QLabel(tr("Add a comment:"));
commentInput = new QLineEdit;
commentInput->setPlaceholderText(tr("Type your comment here..."));
commentButton = new QPushButton(tr("Send"));
commentButton->setEnabled(false);
connect(commentButton, &QPushButton::clicked, this, &DlgMyReports::addComment);
connect(commentInput, &QLineEdit::returnPressed, this, &DlgMyReports::addComment);
auto *detailsLayout = new QVBoxLayout(detailsGroup);
detailsLayout->setContentsMargins(4, 4, 4, 4);
detailsLayout->addWidget(descriptionEdit);
detailsLayout->addWidget(chatGroup);
detailsLayout->addWidget(commentsLabel);
detailsLayout->addWidget(commentsEdit);
detailsLayout->addWidget(addCommentLabel);
auto *commentRow = new QHBoxLayout;
commentRow->addWidget(commentInput);
commentRow->addWidget(commentButton);
detailsLayout->addLayout(commentRow);
closeButton = new QPushButton(tr("Close"));
connect(closeButton, &QPushButton::clicked, this, &QDialog::accept);
refreshButton = new QPushButton(tr("Refresh"));
connect(refreshButton, &QPushButton::clicked, this, &DlgMyReports::refreshList);
statusLabel = new QLabel;
auto *bottomBar = new QHBoxLayout;
bottomBar->addWidget(statusLabel);
bottomBar->addStretch();
bottomBar->addWidget(refreshButton);
bottomBar->addWidget(closeButton);
auto *layout = new QVBoxLayout(this);
layout->addWidget(table, 1);
layout->addWidget(detailsGroup);
layout->addLayout(bottomBar);
setActionsEnabled(false);
refreshList();
}
void DlgMyReports::refreshList()
{
selectedReportIdBeforeRefresh = selectedReportId;
commentDraftBeforeRefresh = commentInput->text();
statusLabel->setText(tr("Loading..."));
refreshButton->setEnabled(false);
table->setRowCount(0);
currentReports.clear();
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
setActionsEnabled(false);
selectedReportId = -1;
Command_ReportMyList cmd;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportListResponse);
client->sendCommand(pend);
}
void DlgMyReports::reportListResponse(const Response &response)
{
refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Failed to load reports."));
return;
}
const Response_ReportMyList &resp = response.GetExtension(Response_ReportMyList::ext);
currentReports.clear();
for (int i = 0; i < resp.reports_size(); ++i) {
currentReports.append(resp.reports(i));
}
table->setSortingEnabled(false);
table->setRowCount(currentReports.size());
for (int row = 0; row < currentReports.size(); ++row) {
const ServerInfo_Report &r = currentReports[row];
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
COL_STATUS, COL_ASSIGNED);
}
table->setSortingEnabled(true);
table->resizeColumnsToContents();
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
if (selectedReportIdBeforeRefresh >= 0) {
for (int row = 0; row < table->rowCount(); ++row) {
if (table->item(row, COL_ID) &&
table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) {
table->setCurrentCell(row, 0);
break;
}
}
}
if (commentInput->text().isEmpty()) {
commentInput->setText(commentDraftBeforeRefresh);
}
statusLabel->setText(tr("%1 report(s)").arg(currentReports.size()));
}
void DlgMyReports::onSelectionChanged()
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_ID)) {
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
commentButton->setEnabled(false);
selectedReportId = -1;
return;
}
const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt();
selectedReportId = reportId;
for (const ServerInfo_Report &r : currentReports) {
if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
break;
}
}
chatLogEdit->setPlainText(tr("Loading..."));
commentsEdit->setPlainText(tr("Loading..."));
Command_ReportDetails cmd;
cmd.set_report_id(reportId);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
client->sendCommand(pend);
QString status = table->item(row, COL_STATUS)->text();
bool canComment = (status == "open" || status == "assigned");
commentButton->setEnabled(canComment);
commentInput->setEnabled(canComment);
if (!canComment) {
commentInput->setPlaceholderText(tr("This report is closed."));
} else {
commentInput->setPlaceholderText(tr("Type your comment here..."));
}
}
void DlgMyReports::reportDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
if (selectedReportId == -1) {
return;
}
chatLogEdit->clear();
commentsEdit->setPlainText(tr("Failed to load report details."));
return;
}
const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext);
const ServerInfo_Report &r = resp.report();
if (selectedReportId != r.report_id()) {
return;
}
loadReportDetails(r);
}
void DlgMyReports::loadReportDetails(const ServerInfo_Report &report)
{
report_utils::renderReportDetails(chatLogEdit, commentsEdit, report, tr("No comments yet."), tr("[Moderator]"),
tr("[You]"));
}
void DlgMyReports::addComment()
{
if (selectedReportId < 0) {
return;
}
QString text = commentInput->text().trimmed();
if (text.isEmpty()) {
return;
}
commentButton->setEnabled(false);
Command_ReportAddComment cmd;
cmd.set_report_id(selectedReportId);
cmd.set_comment(text.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::addCommentResponse);
client->sendCommand(pend);
}
void DlgMyReports::addCommentResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
commentInput->clear();
refreshList();
} else {
commentButton->setEnabled(true);
}
}
void DlgMyReports::setActionsEnabled(bool enabled)
{
commentButton->setEnabled(enabled);
commentInput->setEnabled(enabled);
}

View file

@ -0,0 +1,52 @@
#ifndef COCKATRICE_DLG_MY_REPORTS_H
#define COCKATRICE_DLG_MY_REPORTS_H
#include <QDialog>
#include <QList>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
class AbstractClient;
class QTableWidget;
class QTextEdit;
class QLineEdit;
class QPushButton;
class QLabel;
class DlgMyReports : public QDialog
{
Q_OBJECT
public:
explicit DlgMyReports(AbstractClient *_client, QWidget *parent = nullptr);
private slots:
void refreshList();
void reportListResponse(const Response &response);
void onSelectionChanged();
void reportDetailsResponse(const Response &response);
void addComment();
void addCommentResponse(const Response &response);
private:
void loadReportDetails(const ServerInfo_Report &report);
void setActionsEnabled(bool enabled);
AbstractClient *client;
QTableWidget *table;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QTextEdit *commentsEdit;
QLineEdit *commentInput;
QPushButton *commentButton;
QPushButton *refreshButton;
QPushButton *closeButton;
QLabel *statusLabel;
QList<ServerInfo_Report> currentReports;
int selectedReportId;
int selectedReportIdBeforeRefresh = -1;
QString commentDraftBeforeRefresh;
};
#endif // COCKATRICE_DLG_MY_REPORTS_H

View file

@ -0,0 +1,191 @@
#include "dlg_report_user.h"
#include "abstract_client.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFontDatabase>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report.pb.h>
#include <libcockatrice/protocol/pending_command.h>
DlgReportUser::DlgReportUser(AbstractClient *_client,
const QString &_reportedUser,
int _gameId,
const QString &_autoChatLog,
QWidget *parent)
: QDialog(parent), client(_client), reportedUser(_reportedUser), gameId(_gameId)
{
setWindowTitle(tr("Report User"));
setMinimumWidth(500);
auto *infoLabel =
new QLabel(tr("Reports are reviewed by moderators. False reports may result in account penalties."));
infoLabel->setWordWrap(true);
infoLabel->setStyleSheet("color: palette(placeholderText); padding: 5px;");
auto *reportGroup = new QGroupBox(tr("Report Details"));
auto *reportGrid = new QGridLayout(reportGroup);
reportGrid->addWidget(new QLabel(tr("Reported User:")), 0, 0);
reportedUserLabel = new QLabel(reportedUser);
reportedUserLabel->setStyleSheet("font-weight: bold;");
reportGrid->addWidget(reportedUserLabel, 0, 1);
reportGrid->addWidget(new QLabel(tr("Game ID:")), 1, 0);
if (gameId >= 0) {
gameIdLabel = new QLabel(QString::number(gameId));
gameIdLabel->setStyleSheet("font-weight: bold;");
reportGrid->addWidget(gameIdLabel, 1, 1);
} else {
gameIdEdit = new QLineEdit;
gameIdEdit->setPlaceholderText(tr("(Optional) Enter game ID if available"));
gameIdEdit->setToolTip(tr("If the report is related to a specific game, enter its ID."));
reportGrid->addWidget(gameIdEdit, 1, 1);
gameIdLabel = nullptr;
}
auto *categoryGroup = new QGroupBox(tr("Category"));
auto *categoryGrid = new QGridLayout(categoryGroup);
categoryBox = new QComboBox;
categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating");
categoryBox->setItemData(categoryBox->count() - 1,
tr("Using external tools, card marked manipulation, or exploiting game bugs"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Harassment / Abuse"), "harassment");
categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Hate speech"), "hate_speech");
categoryBox->setItemData(categoryBox->count() - 1,
tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole);
categoryBox->addItem(tr("Spam"), "spam");
categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Other"), "other");
categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"),
Qt::ToolTipRole);
categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0);
categoryGrid->addWidget(categoryBox, 0, 1);
auto *descGroup = new QGroupBox(tr("Description"));
auto *descLayout = new QVBoxLayout(descGroup);
descriptionEdit = new QTextEdit;
descriptionEdit->setPlaceholderText(
tr("Please describe what happened. Include dates, game details, or any evidence if available."));
descriptionEdit->setFixedHeight(120);
descLayout->addWidget(descriptionEdit);
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
if (!_autoChatLog.isEmpty()) {
chatLogEdit->setPlainText(_autoChatLog);
} else {
chatLogEdit->setPlaceholderText(tr("No chat context available (not triggered from chat)."));
}
chatLogEdit->setFixedHeight(100);
chatLayout->addWidget(chatLogEdit);
auto *chatNote = new QLabel(
tr("This chat log is captured from your local chat window and may not reflect the full conversation."));
chatNote->setWordWrap(true);
chatNote->setStyleSheet("color: palette(placeholderText);");
chatLayout->addWidget(chatNote);
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report"));
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgReportUser::actSubmit);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addWidget(infoLabel);
layout->addWidget(reportGroup);
layout->addWidget(categoryGroup);
layout->addWidget(descGroup);
layout->addWidget(chatGroup);
layout->addWidget(buttonBox);
}
void DlgReportUser::actSubmit()
{
const QString description = descriptionEdit->toPlainText().trimmed();
if (description.isEmpty()) {
QMessageBox::warning(this, tr("Missing description"), tr("Please describe what happened before submitting."));
return;
}
QMessageBox::StandardButton reply =
QMessageBox::question(this, tr("Confirm Report"),
tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()),
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) {
return;
}
buttonBox->setEnabled(false);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submitting..."));
Command_Report cmd;
cmd.set_reported_user(reportedUser.toStdString());
cmd.set_category(categoryBox->currentData().toString().toStdString());
cmd.set_description(description.toStdString());
if (gameId >= 0) {
cmd.set_game_id(gameId);
} else if (gameIdEdit && !gameIdEdit->text().trimmed().isEmpty()) {
bool ok;
int manualGameId = gameIdEdit->text().trimmed().toInt(&ok);
if (ok && manualGameId > 0) {
cmd.set_game_id(manualGameId);
}
}
const QString chatLog = chatLogEdit->toPlainText().trimmed();
if (!chatLog.isEmpty()) {
cmd.set_chat_log(chatLog.toStdString());
}
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgReportUser::reportResponse);
client->sendCommand(pend);
}
void DlgReportUser::reportResponse(const Response &response)
{
buttonBox->setEnabled(true);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report"));
if (response.response_code() == Response::RespOk) {
QMessageBox::information(this, tr("Report Submitted"),
tr("Your report has been submitted and will be reviewed by a moderator. Thank you."));
accept();
} else if (response.response_code() == Response::RespTooManyRequests) {
QMessageBox::warning(this, tr("Submission Failed"),
tr("You have reached the daily report limit. Please try again later."));
} else if (response.response_code() == Response::RespNameNotFound) {
QMessageBox::warning(
this, tr("Submission Failed"),
tr("The reported user could not be found. Guests (unregistered users) cannot be reported."));
} else {
QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again."));
}
}

View file

@ -0,0 +1,42 @@
#ifndef COCKATRICE_DLG_REPORT_USER_H
#define COCKATRICE_DLG_REPORT_USER_H
#include <QDialog>
#include <libcockatrice/protocol/pb/response.pb.h>
class AbstractClient;
class QComboBox;
class QDialogButtonBox;
class QLineEdit;
class QTextEdit;
class QLabel;
class DlgReportUser : public QDialog
{
Q_OBJECT
public:
DlgReportUser(AbstractClient *_client,
const QString &_reportedUser,
int _gameId = -1,
const QString &_autoChatLog = QString(),
QWidget *parent = nullptr);
private slots:
void actSubmit();
void reportResponse(const Response &response);
private:
AbstractClient *client;
QString reportedUser;
int gameId;
QLabel *reportedUserLabel;
QLabel *gameIdLabel = nullptr;
QLineEdit *gameIdEdit = nullptr;
QComboBox *categoryBox;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QDialogButtonBox *buttonBox;
};
#endif // COCKATRICE_DLG_REPORT_USER_H

View file

@ -273,6 +273,14 @@ void ChatView::appendMessage(QString message,
// messageType should be Event_RoomSay::UserMessage though we don't actually check
bool isUserMessage = !(userName.toLower() == "servatrice" || userName.isEmpty());
bool sameSender = isUserMessage && userName == lastSender;
if (isUserMessage) {
chatHistory.append({userName, message, QDateTime::currentDateTime()});
while (chatHistory.size() > MAX_CHAT_HISTORY) {
chatHistory.removeFirst();
}
}
QTextCursor cursor = prepareBlock(sameSender);
lastSender = userName;
@ -650,6 +658,19 @@ void ChatView::clearChat()
document()->clear();
lastSender = "";
evenNumber = true;
chatHistory.clear();
}
QString ChatView::getRecentChatLog(int maxMessages) const
{
QStringList lines;
int start = qMax(0, chatHistory.size() - maxMessages);
for (int i = start; i < chatHistory.size(); ++i) {
const ChatLogEntry &entry = chatHistory.at(i);
lines.append(
QString("[%1] %2: %3").arg(entry.timestamp.toString("hh:mm:ss")).arg(entry.userName).arg(entry.message));
}
return lines.join("\n");
}
void ChatView::redactMessages(const QString &userName, int amount)

View file

@ -33,6 +33,13 @@ public:
QTextBlock block;
};
struct ChatLogEntry
{
QString userName;
QString message;
QDateTime timestamp;
};
class ChatView : public QTextBrowser
{
Q_OBJECT
@ -65,6 +72,8 @@ private:
QString hoveredContent;
QAction *messageClicked;
QMap<QString, QVector<UserMessagePosition>> userMessagePositions;
QList<ChatLogEntry> chatHistory;
static constexpr int MAX_CHAT_HISTORY = 200;
[[nodiscard]] QTextFragment getFragmentUnderMouse(const QPoint &pos) const;
QTextCursor prepareBlock(bool same = false);
@ -107,6 +116,7 @@ public:
bool playerBold = false);
void clearChat();
void redactMessages(const QString &userName, int amount);
QString getRecentChatLog(int maxMessages = 50) const;
protected:
void enterEvent(QEnterEvent *event) override;

View file

@ -359,7 +359,11 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
return;
}
const ServerInfo_Game &game = gameListModel->getGame(ind.data(Qt::UserRole).toInt());
joinGame(gameListModel->getGame(ind.data(Qt::UserRole).toInt()), asSpectator, asJudge);
}
void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, const bool asJudge)
{
if (tabSupervisor->switchToGameTabIfAlreadyExists(game.game_id())) {
return;
}
@ -414,18 +418,16 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
disableButtons();
}
bool GameSelector::joinGameById(int gameId)
bool GameSelector::joinGameById(const int gameId, const bool asSpectator)
{
auto *model = gameListView->model();
for (int row = 0; row < model->rowCount(); ++row) {
QModelIndex idx = model->index(row, 0);
const ServerInfo_Game &game = gameListModel->getGame(idx.data(Qt::UserRole).toInt());
if (game.game_id() == gameId) {
gameListView->setCurrentIndex(idx);
joinGame();
return true;
for (int row = 0; row < gameListModel->rowCount(); ++row) {
const ServerInfo_Game &game = gameListModel->getGame(row);
if (game.game_id() != gameId) {
continue;
}
joinGame(game, asSpectator);
return true;
}
qWarning() << "Game" << gameId << "not found";

View file

@ -171,6 +171,17 @@ private:
*/
void joinGame(bool asSpectator = false, bool asJudge = false);
/**
* @brief Performs the join or spectate action for a specific game.
* @param game The game to join.
* @param asSpectator True to join as a spectator, false to join as a player.
* @param asJudge True to join as a judge, false to join as a player.
*
* Unlike the selection-based overload, this does not depend on the game being
* visible in the filtered game list.
*/
void joinGame(const ServerInfo_Game &game, bool asSpectator = false, bool asJudge = false);
public:
/**
* @brief Constructs a GameSelector widget.
@ -202,7 +213,16 @@ public:
* @param info The ServerInfo_Game object containing information about the game to update.
*/
void processGameInfo(const ServerInfo_Game &info);
bool joinGameById(int gameId);
/**
* @brief Finds a game by ID and joins or spectates it.
* @param gameId The ID of the game to join.
* @param asSpectator True to join as a spectator, false to join as a player.
* @return True if the game was found and joined, false otherwise.
*
* Unlike the selection-based overload, this does not depend on the game
* being visible in the filtered game list.
*/
bool joinGameById(int gameId, bool asSpectator = false);
};
#endif

View file

@ -1,5 +1,6 @@
#include "user_context_menu.h"
#include "../../dialogs/dlg_report_user.h"
#include "../../interface/widgets/tabs/tab_account.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
@ -41,6 +42,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aAddToIgnoreList = new QAction(QString(), this);
aRemoveFromIgnoreList = new QAction(QString(), this);
aKick = new QAction(QString(), this);
aReport = new QAction(QString(), this);
aWarnUser = new QAction(QString(), this);
aWarnHistory = new QAction(QString(), this);
aBan = new QAction(QString(), this);
@ -50,6 +52,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aPromoteToJudge = new QAction(QString(), this);
aDemoteFromJudge = new QAction(QString(), this);
aGetAdminNotes = new QAction(QString(), this);
aInvestigateUser = new QAction(QString(), this);
retranslateUi();
}
@ -64,6 +67,7 @@ void UserContextMenu::retranslateUi()
aAddToIgnoreList->setText(tr("Add to &ignore list"));
aRemoveFromIgnoreList->setText(tr("Remove from &ignore list"));
aKick->setText(tr("Kick from &game"));
aReport->setText(tr("Report user"));
aWarnUser->setText(tr("Warn user"));
aWarnHistory->setText(tr("View user's war&n history"));
aBan->setText(tr("Ban from &server"));
@ -73,6 +77,7 @@ void UserContextMenu::retranslateUi()
aPromoteToJudge->setText(tr("Promote user to &judge"));
aDemoteFromJudge->setText(tr("Demote user from judge"));
aGetAdminNotes->setText(tr("View admin notes"));
aInvestigateUser->setText(tr("Investigate user"));
}
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
@ -144,7 +149,8 @@ void UserContextMenu::warnUser_processGetWarningsListResponse(const Response &r)
if (response.warning_size() > 0) {
for (int i = 0; i < response.warning_size(); ++i) {
dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified());
int startingIl = i < response.warning_il_size() ? response.warning_il(i) : 1;
dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified(), startingIl);
}
}
dlg->show();
@ -395,6 +401,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aRemoveMessages = new QAction(tr("Remove this user's messages"), this);
menu->addAction(aRemoveMessages);
}
if (userListProxy->isOwnUserRegistered()) {
menu->addAction(aReport);
}
if (game && (game->isHost() || !tabSupervisor->getAdminLocked())) {
menu->addSeparator();
menu->addAction(aKick);
@ -408,6 +417,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addAction(aBanHistory);
menu->addSeparator();
menu->addAction(aGetAdminNotes);
menu->addAction(aInvestigateUser);
menu->addSeparator();
if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
@ -431,6 +441,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aDetails->setEnabled(true);
aChat->setEnabled(anotherUser && online);
aShowGames->setEnabled(online);
aReport->setEnabled(anotherUser);
aAddToBuddyList->setEnabled(anotherUser);
aRemoveFromBuddyList->setEnabled(anotherUser);
aAddToIgnoreList->setEnabled(anotherUser);
@ -441,6 +452,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aBan->setEnabled(anotherUser);
aBanHistory->setEnabled(anotherUser);
aGetAdminNotes->setEnabled(anotherUser);
aInvestigateUser->setEnabled(anotherUser);
aPromoteToMod->setEnabled(anotherUser);
aDemoteFromMod->setEnabled(anotherUser);
@ -462,6 +474,15 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execRemoveFromIgnore(userName);
} else if (actionClicked == aKick) {
execKick(playerId);
} else if (actionClicked == aReport) {
int gameId = game ? game->getGameMetaInfo()->gameId() : -1;
QString autoChatLog;
if (chatView) {
autoChatLog = chatView->getRecentChatLog(50);
}
auto dlgReport = new DlgReportUser(client, userName, gameId, autoChatLog, static_cast<QWidget *>(parent()));
dlgReport->setAttribute(Qt::WA_DeleteOnClose);
dlgReport->exec();
} else if (actionClicked == aBan) {
execBan(userName);
} else if (actionClicked == aPromoteToMod || actionClicked == aDemoteFromMod) {
@ -476,6 +497,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execWarnHistory(userName);
} else if (actionClicked == aGetAdminNotes) {
execAdminNotes(userName);
} else if (actionClicked == aInvestigateUser) {
execInvestigateUser(userName);
} else if (actionClicked == aCopyToClipBoard) {
QClipboard *clipboard = QGuiApplication::clipboard();
clipboard->setText(deckHash);
@ -652,6 +675,11 @@ void UserContextMenu::execAdminNotes(const QString &userName)
client->sendCommand(pend);
}
void UserContextMenu::execInvestigateUser(const QString &userName)
{
tabSupervisor->openTabModeration(userName);
}
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
{
Command_AdjustMod cmd;

View file

@ -41,12 +41,14 @@ private:
QAction *aAddToBuddyList, *aRemoveFromBuddyList;
QAction *aAddToIgnoreList, *aRemoveFromIgnoreList;
QAction *aKick;
QAction *aReport;
QAction *aBan, *aBanHistory;
QAction *aPromoteToMod, *aDemoteFromMod;
QAction *aPromoteToJudge, *aDemoteFromJudge;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
QAction *aInvestigateUser;
signals:
void openMessageDialog(const QString &userName, bool focus);
private slots:
@ -118,6 +120,7 @@ public:
void execBanHistory(const QString &userName);
void execWarnHistory(const QString &userName);
void execAdminNotes(const QString &userName);
void execInvestigateUser(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge);

View file

@ -151,7 +151,7 @@ WarningDialog::WarningDialog(const QString userName, const QString clientID, QWi
warnClientID = new QLineEdit(clientID);
warnClientID->setMaxLength(MAX_NAME_LENGTH);
warningOption = new QComboBox();
warningOption->addItem("");
warningOption->addItem("", "");
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
@ -184,7 +184,7 @@ void WarningDialog::okClicked()
return;
}
if (warningOption->currentText().simplified().isEmpty()) {
if (warningOption->currentData().toString().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("Warning to use can not be blank, please select a valid warning to send."));
return;
@ -205,7 +205,7 @@ QString WarningDialog::getWarnID() const
QString WarningDialog::getReason() const
{
return warningOption->currentText().simplified();
return warningOption->currentData().toString().simplified();
}
int WarningDialog::getDeleteMessages() const
@ -213,9 +213,13 @@ int WarningDialog::getDeleteMessages() const
return deleteMessages->isChecked() ? -1 : 0;
}
void WarningDialog::addWarningOption(const QString warning)
void WarningDialog::addWarningOption(const QString warning, int startingIl)
{
warningOption->addItem(warning);
if (startingIl > 1) {
warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning);
} else {
warningOption->addItem(warning, warning);
}
}
void BanDialog::okClicked()

View file

@ -85,7 +85,7 @@ public:
[[nodiscard]] QString getWarnID() const;
[[nodiscard]] QString getReason() const;
[[nodiscard]] int getDeleteMessages() const;
void addWarningOption(const QString warning);
void addWarningOption(const QString warning, int startingIl = 1);
};
class AdminNotesDialog : public QDialog

View file

@ -1,6 +1,7 @@
#include "tab_account.h"
#include "../client/sound_engine.h"
#include "../interface/widgets/dialogs/dlg_my_reports.h"
#include "../interface/widgets/server/user/user_info_box.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/server/user/user_list_widget.h"
@ -49,6 +50,11 @@ TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, c
auto *vbox = new QVBoxLayout;
vbox->addWidget(userInfoBox);
myReportsButton = new QPushButton(tr("My Reports"));
connect(myReportsButton, &QPushButton::clicked, this, &TabAccount::openMyReports);
vbox->addWidget(myReportsButton);
vbox->addWidget(allUsersList);
auto *addToBuddyList = new QHBoxLayout;
@ -126,6 +132,7 @@ void TabAccount::addToList(const std::string &listName, const QString &userName)
void TabAccount::retranslateUi()
{
myReportsButton->setText(tr("My Reports"));
allUsersList->retranslateUi();
buddyList->retranslateUi();
ignoreList->retranslateUi();
@ -240,3 +247,10 @@ void TabAccount::processRemoveFromListEvent(const Event_RemoveFromList &event)
userList->deleteUser(user);
}
void TabAccount::openMyReports()
{
auto *dlg = new DlgMyReports(client, this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
}

View file

@ -18,6 +18,7 @@ class Event_RemoveFromList;
class Event_UserJoined;
class Event_UserLeft;
class LineEditUnfocusable;
class QPushButton;
class Response;
class ServerInfo_User;
class UserInfoBox;
@ -41,6 +42,7 @@ private slots:
void processRemoveFromListEvent(const Event_RemoveFromList &event);
void addToIgnoreList();
void addToBuddyList();
void openMyReports();
private:
AbstractClient *client;
@ -50,6 +52,7 @@ private:
UserInfoBox *userInfoBox;
LineEditUnfocusable *addBuddyEdit;
LineEditUnfocusable *addIgnoreEdit;
QPushButton *myReportsButton;
void addToList(const std::string &listName, const QString &userName);
public:

View file

@ -0,0 +1,462 @@
#include "tab_moderation.h"
#include "abstract_client.h"
#include "tab_supervisor.h"
#include <QDateTime>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_moderator_last_logins.pb.h>
#include <libcockatrice/protocol/pb/response_remove_user_avatar.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/response_reset_user_password.pb.h>
#include <libcockatrice/protocol/pb/response_user_alts.pb.h>
#include <libcockatrice/protocol/pb/response_user_sessions.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ALTS_USER = 0;
constexpr int COL_ALTS_EMAIL = 1;
constexpr int COL_ALTS_CLIENTID = 2;
constexpr int COL_ALTS_REGISTERED = 3;
constexpr int COL_ALTS_LAST_LOGIN = 4;
constexpr int COL_ALTS_WARNS = 5;
constexpr int COL_ALTS_BANS = 6;
constexpr int COL_ALTS_ACTIVE = 7;
constexpr int COL_ALTS_COUNT = 8;
constexpr int COL_SESSIONS_IP = 0;
constexpr int COL_SESSIONS_CLIENTID = 1;
constexpr int COL_SESSIONS_START = 2;
constexpr int COL_SESSIONS_END = 3;
constexpr int COL_SESSIONS_TYPE = 4;
constexpr int COL_SESSIONS_COUNT = 5;
constexpr int COL_STAFF_USER = 0;
constexpr int COL_STAFF_LEVEL = 1;
constexpr int COL_STAFF_LAST_LOGIN = 2;
constexpr int COL_STAFF_COUNT = 3;
} // namespace
TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser)
: Tab(_tabSupervisor), client(_client)
{
auto *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
connect(searchEdit, &QLineEdit::returnPressed, this, &TabModeration::investigateUser);
investigateButton = new QPushButton;
connect(investigateButton, &QPushButton::clicked, this, &TabModeration::investigateUser);
resetPasswordButton = new QPushButton;
connect(resetPasswordButton, &QPushButton::clicked, this, &TabModeration::resetPassword);
removeAvatarButton = new QPushButton;
connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar);
auto *topBar = new QHBoxLayout;
topBar->addWidget(searchEdit);
topBar->addWidget(investigateButton);
topBar->addStretch();
topBar->addWidget(resetPasswordButton);
topBar->addWidget(removeAvatarButton);
userInfoGroup = new QGroupBox;
userInfoNameLabel = new QLabel;
userInfoNameValue = new QLabel;
userInfoRegisteredLabel = new QLabel;
userInfoRegisteredValue = new QLabel;
userInfoLastLoginLabel = new QLabel;
userInfoLastLoginValue = new QLabel;
userInfoStatusLabel = new QLabel;
userInfoStatusValue = new QLabel;
userInfoCountsLabel = new QLabel;
userInfoCountsValue = new QLabel;
userInfoNotesLabel = new QLabel;
userInfoNotesEdit = new QTextEdit;
userInfoNotesEdit->setReadOnly(true);
auto *infoGrid = new QGridLayout;
infoGrid->addWidget(userInfoNameLabel, 0, 0);
infoGrid->addWidget(userInfoNameValue, 0, 1);
infoGrid->addWidget(userInfoRegisteredLabel, 0, 2);
infoGrid->addWidget(userInfoRegisteredValue, 0, 3);
infoGrid->addWidget(userInfoLastLoginLabel, 1, 0);
infoGrid->addWidget(userInfoLastLoginValue, 1, 1);
infoGrid->addWidget(userInfoStatusLabel, 1, 2);
infoGrid->addWidget(userInfoStatusValue, 1, 3);
infoGrid->addWidget(userInfoCountsLabel, 2, 0);
infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3);
infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop);
infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3);
infoGrid->setColumnStretch(1, 1);
infoGrid->setColumnStretch(3, 1);
auto *infoLayout = new QVBoxLayout(userInfoGroup);
infoLayout->addLayout(infoGrid);
auto configureTable = [](QTableWidget *table) {
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
table->horizontalHeader()->setStretchLastSection(true);
};
altsGroup = new QGroupBox;
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
configureTable(altsTable);
auto *altsLayout = new QVBoxLayout(altsGroup);
altsLayout->addWidget(altsTable);
sessionsGroup = new QGroupBox;
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
configureTable(sessionsTable);
auto *sessionsLayout = new QVBoxLayout(sessionsGroup);
sessionsLayout->addWidget(sessionsTable);
staffGroup = new QGroupBox;
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
configureTable(staffTable);
refreshStaffButton = new QPushButton;
connect(refreshStaffButton, &QPushButton::clicked, this, &TabModeration::requestModeratorLogins);
auto *staffHeader = new QHBoxLayout;
staffHeader->addStretch();
staffHeader->addWidget(refreshStaffButton);
auto *staffLayout = new QVBoxLayout(staffGroup);
staffLayout->addWidget(staffTable);
staffLayout->addLayout(staffHeader);
auto *splitter = new QSplitter(Qt::Vertical);
splitter->addWidget(userInfoGroup);
splitter->addWidget(altsGroup);
splitter->addWidget(sessionsGroup);
splitter->addWidget(staffGroup);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 2);
splitter->setStretchFactor(2, 2);
splitter->setStretchFactor(3, 1);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
mainLayout->addWidget(splitter);
retranslateUi();
clearUserData();
requestModeratorLogins();
investigate(initialUser);
}
void TabModeration::retranslateUi()
{
searchEdit->setPlaceholderText(tr("User name"));
investigateButton->setText(tr("Investigate"));
resetPasswordButton->setText(tr("Reset Password"));
removeAvatarButton->setText(tr("Remove Avatar"));
refreshStaffButton->setText(tr("Refresh"));
userInfoGroup->setTitle(tr("User Info"));
userInfoNameLabel->setText(tr("Name:"));
userInfoRegisteredLabel->setText(tr("Registered:"));
userInfoLastLoginLabel->setText(tr("Last login:"));
userInfoStatusLabel->setText(tr("Status:"));
userInfoCountsLabel->setText(tr("Counts:"));
userInfoNotesLabel->setText(tr("Admin notes:"));
altsGroup->setTitle(tr("Alts"));
sessionsGroup->setTitle(tr("Sessions"));
staffGroup->setTitle(tr("Staff Last Logins"));
altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"),
tr("Warns"), tr("Bans"), tr("Active")});
sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")});
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
}
QString TabModeration::formatEpoch(quint64 ts) const
{
if (ts == 0) {
return tr("Unknown");
}
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm");
}
void TabModeration::clearUserData()
{
currentUser.clear();
userInfoNameValue->clear();
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->clear();
userInfoCountsValue->clear();
userInfoNotesEdit->clear();
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
resetPasswordButton->setEnabled(false);
removeAvatarButton->setEnabled(false);
}
void TabModeration::investigate(const QString &userName)
{
if (userName.isEmpty()) {
return;
}
searchEdit->setText(userName);
investigateUser();
}
void TabModeration::investigateUser()
{
const QString userName = searchEdit->text().simplified();
if (userName.isEmpty()) {
return;
}
currentUser = userName;
resetPasswordButton->setEnabled(true);
removeAvatarButton->setEnabled(true);
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
requestUserInfo(userName);
requestSessions(userName);
requestAlts(userName);
}
void TabModeration::requestUserInfo(const QString &userName)
{
userInfoNameValue->setText(userName);
userInfoRegisteredValue->setText(tr("Loading..."));
userInfoLastLoginValue->setText(tr("Loading..."));
userInfoStatusValue->clear();
userInfoCountsValue->clear();
userInfoNotesEdit->clear();
Command_ReportUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::userInfoResponse);
client->sendCommand(pend);
}
void TabModeration::userInfoResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != currentUser) {
return;
}
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time()));
userInfoLastLoginValue->setText(formatEpoch(resp.last_login()));
QStringList statusParts;
statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
if (resp.has_is_admin() && resp.is_admin()) {
statusParts << tr("admin");
}
userInfoStatusValue->setText(statusParts.join(", "));
userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
.arg(resp.total_reports())
.arg(resp.total_bans())
.arg(resp.total_warns()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
userInfoNotesEdit->setPlainText(tr("(none)"));
}
}
void TabModeration::requestSessions(const QString &userName)
{
Command_GetUserSessions cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse);
client->sendCommand(pend);
}
void TabModeration::sessionsResponse(const Response &response)
{
sessionsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_UserSessions &resp = response.GetExtension(Response_UserSessions::ext);
sessionsTable->setRowCount(resp.sessions_size());
for (int i = 0; i < resp.sessions_size(); ++i) {
const ServerInfo_UserSession &session = resp.sessions(i);
sessionsTable->setItem(i, COL_SESSIONS_IP, new QTableWidgetItem(QString::fromStdString(session.ip_address())));
sessionsTable->setItem(i, COL_SESSIONS_CLIENTID,
new QTableWidgetItem(QString::fromStdString(session.clientid())));
sessionsTable->setItem(i, COL_SESSIONS_START, new QTableWidgetItem(formatEpoch(session.start_time())));
sessionsTable->setItem(
i, COL_SESSIONS_END,
new QTableWidgetItem(session.end_time() == 0 ? tr("Active") : formatEpoch(session.end_time())));
sessionsTable->setItem(i, COL_SESSIONS_TYPE,
new QTableWidgetItem(QString::fromStdString(session.connection_type())));
}
}
void TabModeration::requestAlts(const QString &userName)
{
Command_GetUserAlts cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse);
client->sendCommand(pend);
}
void TabModeration::altsResponse(const Response &response)
{
altsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
altsTable->setRowCount(resp.alts_size());
for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i);
altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name())));
altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login())));
altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count())));
altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count())));
altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")));
}
}
void TabModeration::requestModeratorLogins()
{
Command_GetModeratorLastLogins cmd;
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::moderatorLoginsResponse);
client->sendCommand(pend);
}
void TabModeration::moderatorLoginsResponse(const Response &response)
{
staffTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_ModeratorLastLogins &resp = response.GetExtension(Response_ModeratorLastLogins::ext);
staffTable->setRowCount(resp.logins_size());
for (int i = 0; i < resp.logins_size(); ++i) {
const ServerInfo_ModeratorLogin &login = resp.logins(i);
staffTable->setItem(i, COL_STAFF_USER, new QTableWidgetItem(QString::fromStdString(login.user_name())));
staffTable->setItem(i, COL_STAFF_LAST_LOGIN, new QTableWidgetItem(formatEpoch(login.last_login())));
QStringList levels;
if (login.user_level() & ServerInfo_User::IsAdmin) {
levels << tr("Admin");
}
if (login.user_level() & ServerInfo_User::IsModerator) {
levels << tr("Moderator");
}
if (login.user_level() & ServerInfo_User::IsJudge) {
levels << tr("Judge");
}
staffTable->setItem(i, COL_STAFF_LEVEL, new QTableWidgetItem(levels.join(" / ")));
}
}
void TabModeration::resetPassword()
{
if (currentUser.isEmpty()) {
return;
}
QMessageBox::StandardButton choice =
QMessageBox::warning(this, tr("Reset Password"),
tr("Reset the password of %1? A temporary password will be generated and shown to you. "
"The user must change it on their first login.")
.arg(currentUser),
QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
if (choice != QMessageBox::Ok) {
return;
}
Command_ResetUserPassword cmd;
cmd.set_user_name(currentUser.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::resetPasswordResponse);
client->sendCommand(pend);
}
void TabModeration::resetPasswordResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"), tr("Password reset failed."));
return;
}
const Response_ResetUserPassword &resp = response.GetExtension(Response_ResetUserPassword::ext);
QMessageBox::information(this, tr("Password reset"),
tr("Temporary password for %1:\n\n%2\n\nPass it to the user through a secure channel.")
.arg(QString::fromStdString(resp.user_name()))
.arg(QString::fromStdString(resp.temporary_password())));
}
void TabModeration::removeAvatar()
{
if (currentUser.isEmpty()) {
return;
}
QMessageBox::StandardButton choice =
QMessageBox::warning(this, tr("Remove Avatar"),
tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser),
QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
if (choice != QMessageBox::Ok) {
return;
}
Command_RemoveUserAvatar cmd;
cmd.set_user_name(currentUser.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::removeAvatarResponse);
client->sendCommand(pend);
}
void TabModeration::removeAvatarResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"), tr("Could not remove the avatar."));
return;
}
const Response_RemoveUserAvatar &resp = response.GetExtension(Response_RemoveUserAvatar::ext);
QMessageBox::information(this, tr("Avatar removed"),
tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name())));
}

View file

@ -0,0 +1,83 @@
#ifndef TAB_MODERATION_H
#define TAB_MODERATION_H
#include "tab.h"
#include <libcockatrice/protocol/pb/response.pb.h>
class AbstractClient;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QTableWidget;
class QTextEdit;
/**
* Staff investigation tool. Lets moderators look up a user's account data,
* alternate accounts, login sessions, and staff login activity, and offers
* the password-reset and remove-avatar actions.
*/
class TabModeration : public Tab
{
Q_OBJECT
public:
explicit TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {});
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("Moderation");
}
void investigate(const QString &userName);
private slots:
void investigateUser();
void userInfoResponse(const Response &response);
void sessionsResponse(const Response &response);
void altsResponse(const Response &response);
void moderatorLoginsResponse(const Response &response);
void resetPassword();
void resetPasswordResponse(const Response &response);
void removeAvatar();
void removeAvatarResponse(const Response &response);
private:
void requestUserInfo(const QString &userName);
void requestSessions(const QString &userName);
void requestAlts(const QString &userName);
void requestModeratorLogins();
void clearUserData();
[[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client;
QString currentUser;
QLineEdit *searchEdit;
QPushButton *investigateButton;
QPushButton *resetPasswordButton;
QPushButton *removeAvatarButton;
QGroupBox *userInfoGroup;
QLabel *userInfoNameLabel;
QLabel *userInfoNameValue;
QLabel *userInfoRegisteredLabel;
QLabel *userInfoRegisteredValue;
QLabel *userInfoLastLoginLabel;
QLabel *userInfoLastLoginValue;
QLabel *userInfoStatusLabel;
QLabel *userInfoStatusValue;
QLabel *userInfoCountsLabel;
QLabel *userInfoCountsValue;
QLabel *userInfoNotesLabel;
QTextEdit *userInfoNotesEdit;
QGroupBox *altsGroup;
QTableWidget *altsTable;
QGroupBox *sessionsGroup;
QTableWidget *sessionsTable;
QGroupBox *staffGroup;
QTableWidget *staffTable;
QPushButton *refreshStaffButton;
};
#endif // TAB_MODERATION_H

View file

@ -0,0 +1,927 @@
#include "tab_report.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include "tab_supervisor.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDateTime>
#include <QFontDatabase>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSplitter>
#include <QTableWidget>
#include <QTextCursor>
#include <QTextEdit>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_assign.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/game_replay.pb.h>
#include <libcockatrice/protocol/pb/response_replay_download_by_game_id.pb.h>
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
#include <libcockatrice/protocol/pb/response_report_list.pb.h>
#include <libcockatrice/protocol/pb/response_report_stats.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ID = 0;
constexpr int COL_TIME = 1;
constexpr int COL_REPORTER = 2;
constexpr int COL_REPORTED = 3;
constexpr int COL_CATEGORY = 4;
constexpr int COL_GAMEID = 5;
constexpr int COL_STATUS = 6;
constexpr int COL_ASSIGNED = 7;
constexpr int COL_REPLAY = 8;
constexpr int COL_ROOM = 9;
constexpr int COL_COUNT = 10;
constexpr int REFRESH_INTERVAL_MS = 300000;
} // namespace
TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client)
{
auto *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
connect(searchEdit, &QLineEdit::textChanged, this, &TabReport::applyFilters);
statusFilter = new QComboBox;
connect(statusFilter, &QComboBox::currentIndexChanged, this, &TabReport::applyFilters);
unresolvedOnlyBox = new QCheckBox;
unresolvedOnlyBox->setChecked(true);
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
connect(unresolvedOnlyBox, &QCheckBox::checkStateChanged, this, &TabReport::refreshList);
#else
connect(unresolvedOnlyBox, &QCheckBox::stateChanged, this, &TabReport::refreshList);
#endif
refreshButton = new QPushButton;
connect(refreshButton, &QPushButton::clicked, this, &TabReport::refreshList);
refreshTimer = new QTimer(this);
refreshTimer->setInterval(REFRESH_INTERVAL_MS);
connect(refreshTimer, &QTimer::timeout, this, [this]() {
if (tabSupervisor->currentWidget() == this) {
refreshList();
}
});
refreshTimer->start();
auto *topBar = new QHBoxLayout;
topBar->addWidget(searchEdit);
topBar->addWidget(statusFilter);
topBar->addWidget(unresolvedOnlyBox);
topBar->addStretch();
topBar->addWidget(refreshButton);
statsLabel = new QLabel;
table = new QTableWidget(0, COL_COUNT);
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSortingEnabled(true);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents);
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
descGroup = new QGroupBox;
descriptionEdit = new QTextEdit;
descriptionEdit->setReadOnly(true);
auto *descLayout = new QVBoxLayout(descGroup);
descLayout->setContentsMargins(4, 4, 4, 4);
descLayout->addWidget(descriptionEdit);
chatGroup = new QGroupBox;
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit);
commentsGroup = new QGroupBox;
commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true);
auto *commentInputRow = new QHBoxLayout;
commentInput = new QLineEdit;
commentButton = new QPushButton;
commentButton->setEnabled(false);
connect(commentButton, &QPushButton::clicked, this, &TabReport::addComment);
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
commentInputRow->addWidget(commentInput);
commentInputRow->addWidget(commentButton);
auto *commentsLayout = new QVBoxLayout(commentsGroup);
commentsLayout->setContentsMargins(4, 4, 4, 4);
commentsLayout->addWidget(commentsEdit);
commentsLayout->addLayout(commentInputRow);
userContextGroup = new QGroupBox;
userContextName = new QLabel;
userContextAccountAge = new QLabel;
userContextReports = new QLabel;
userContextBans = new QLabel;
userContextWarns = new QLabel;
userContextNotes = new QTextEdit;
userContextNotes->setReadOnly(true);
userContextNotes->setMaximumHeight(80);
userContextRecentReports = new QTextEdit;
userContextRecentReports->setReadOnly(true);
userContextRecentReports->setMaximumHeight(120);
userContextUserLabel = new QLabel;
userContextAgeLabel = new QLabel;
userContextReportsLabel = new QLabel;
userContextBansLabel = new QLabel;
userContextWarnsLabel = new QLabel;
userContextNotesLabel = new QLabel;
userContextRecentReportsLabel = new QLabel;
auto *ucLayout = new QGridLayout(userContextGroup);
ucLayout->setContentsMargins(8, 8, 8, 8);
ucLayout->addWidget(userContextUserLabel, 0, 0);
ucLayout->addWidget(userContextName, 0, 1, 1, 3);
ucLayout->addWidget(userContextAgeLabel, 1, 0);
ucLayout->addWidget(userContextAccountAge, 1, 1);
ucLayout->addWidget(userContextReportsLabel, 1, 2);
ucLayout->addWidget(userContextReports, 1, 3);
ucLayout->addWidget(userContextBansLabel, 2, 0);
ucLayout->addWidget(userContextBans, 2, 1);
ucLayout->addWidget(userContextWarnsLabel, 2, 2);
ucLayout->addWidget(userContextWarns, 2, 3);
ucLayout->addWidget(userContextNotesLabel, 3, 0, Qt::AlignTop);
ucLayout->addWidget(userContextNotes, 3, 1, 1, 3);
ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4);
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
userContextGroup->setVisible(false);
statsGroup = new QGroupBox;
statsGroup->setCheckable(true);
statsTotalLabel = new QLabel;
statsTrendLabel = new QLabel;
statsCategoriesLabel = new QLabel;
statsDetailText = new QTextEdit;
statsDetailText->setReadOnly(true);
statsDetailText->setMaximumHeight(150);
auto *statsContent = new QWidget;
auto *sgLayout = new QVBoxLayout(statsContent);
sgLayout->setContentsMargins(8, 8, 8, 8);
sgLayout->addWidget(statsTotalLabel);
sgLayout->addWidget(statsTrendLabel);
sgLayout->addWidget(statsCategoriesLabel);
sgLayout->addWidget(statsDetailText);
auto *statsGroupLayout = new QVBoxLayout(statsGroup);
statsGroupLayout->setContentsMargins(0, 0, 0, 0);
statsGroupLayout->addWidget(statsContent);
statsGroup->setChecked(true);
connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) {
statsContent->setVisible(checked);
if (checked) {
requestStats();
}
});
detailSplitter = new QSplitter(Qt::Vertical);
detailSplitter->addWidget(descGroup);
detailSplitter->addWidget(chatGroup);
detailSplitter->addWidget(commentsGroup);
detailSplitter->setStretchFactor(0, 1);
detailSplitter->setStretchFactor(1, 1);
detailSplitter->setStretchFactor(2, 2);
assignButton = new QPushButton;
resolveButton = new QPushButton;
resolveWithNoteButton = new QPushButton;
dismissButton = new QPushButton;
viewReplayButton = new QPushButton;
joinGameButton = new QPushButton;
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); });
connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); });
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); });
connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
statusLabel = new QLabel;
auto *actionBar = new QHBoxLayout;
actionBar->addWidget(assignButton);
actionBar->addWidget(resolveButton);
actionBar->addWidget(resolveWithNoteButton);
actionBar->addWidget(dismissButton);
actionBar->addSpacing(20);
actionBar->addWidget(viewReplayButton);
actionBar->addWidget(joinGameButton);
actionBar->addStretch();
actionBar->addWidget(statusLabel);
auto *layout = new QVBoxLayout(centralWidget);
layout->addLayout(topBar);
layout->addWidget(statsLabel);
layout->addWidget(table, 2);
layout->addWidget(detailSplitter, 1);
layout->addWidget(userContextGroup);
layout->addWidget(statsGroup);
layout->addLayout(actionBar);
retranslateUi();
setActionsEnabled(false);
refreshList();
}
void TabReport::retranslateUi()
{
searchEdit->setPlaceholderText(tr("Search by username, category..."));
statusFilter->clear();
statusFilter->addItem(tr("All Statuses"), "");
statusFilter->addItem(tr("Open"), "open");
statusFilter->addItem(tr("Assigned"), "assigned");
statusFilter->addItem(tr("Resolved"), "resolved");
statusFilter->addItem(tr("Dismissed"), "dismissed");
unresolvedOnlyBox->setText(tr("Unresolved only"));
refreshButton->setText(tr("Refresh"));
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
descGroup->setTitle(tr("Description"));
chatGroup->setTitle(tr("Chat Log Context"));
chatGroup->setToolTip(
tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited."));
commentsGroup->setTitle(tr("Comments / Thread"));
descriptionEdit->setPlaceholderText(tr("No description."));
chatLogEdit->setPlaceholderText(tr("No chat log attached."));
commentsEdit->setPlaceholderText(tr("No comments yet."));
commentInput->setPlaceholderText(tr("Type a reply..."));
commentButton->setText(tr("Send"));
userContextGroup->setTitle(tr("Reported User Context"));
userContextUserLabel->setText(tr("User:"));
userContextAgeLabel->setText(tr("Account Age:"));
userContextReportsLabel->setText(tr("Reports:"));
userContextBansLabel->setText(tr("Bans:"));
userContextWarnsLabel->setText(tr("Warns:"));
userContextNotesLabel->setText(tr("Admin Notes:"));
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
userContextAccountAge->setText(QString());
userContextReports->setText(QString());
userContextBans->setText(QString());
userContextWarns->setText(QString());
statsGroup->setTitle(tr("Report Statistics"));
assignButton->setText(tr("Assign to Me"));
resolveButton->setText(tr("Resolve"));
resolveWithNoteButton->setText(tr("Resolve with note..."));
dismissButton->setText(tr("Dismiss..."));
viewReplayButton->setText(tr("View Replay"));
joinGameButton->setText(tr("Join Game"));
}
void TabReport::refreshList()
{
selectedReportIdBeforeRefresh = selectedReportId();
commentDraftBeforeRefresh = commentInput->text();
previousSelectedReportValid = selectedReportInfo(previousSelectedReport);
refreshButton->setEnabled(false);
statusLabel->setText(tr("Loading..."));
table->setRowCount(0);
allReports.clear();
filteredReports.clear();
commentButton->setEnabled(false);
setActionsEnabled(false);
Command_ReportList cmd;
cmd.set_unresolved_only(unresolvedOnlyBox->isChecked());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::reportListResponse);
client->sendCommand(pend);
}
void TabReport::reportListResponse(const Response &response)
{
refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Failed to load reports."));
return;
}
const Response_ReportList &resp = response.GetExtension(Response_ReportList::ext);
allReports.clear();
for (int i = 0; i < resp.reports_size(); ++i) {
allReports.append(resp.reports(i));
}
applyFilters();
updateStats();
if (selectedReportIdBeforeRefresh >= 0) {
bool found = false;
for (int row = 0; row < table->rowCount(); ++row) {
if (table->item(row, COL_ID) &&
table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) {
found = true;
{
QSignalBlocker blocker(table);
table->setCurrentCell(row, 0);
}
break;
}
}
if (found) {
bool dataChanged = false;
ServerInfo_Report newReport;
if (!selectedReportInfo(newReport) || !previousSelectedReportValid ||
previousSelectedReport.description() != newReport.description() ||
previousSelectedReport.status() != newReport.status() ||
previousSelectedReport.resolution_note() != newReport.resolution_note() ||
previousSelectedReport.assigned_mod_name() != newReport.assigned_mod_name()) {
dataChanged = true;
}
if (dataChanged || detailsRequestedReportId != selectedReportIdBeforeRefresh) {
loadReportDetails(selectedReportIdBeforeRefresh);
}
updateActionStates();
} else {
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
setActionsEnabled(false);
userContextGroup->setVisible(false);
}
}
if (commentInput->text().isEmpty()) {
commentInput->setText(commentDraftBeforeRefresh);
}
if (statsGroup->isChecked()) {
requestStats();
}
}
void TabReport::applyFilters()
{
filteredReports.clear();
const QString searchText = searchEdit->text().toLower();
const QString statusFilterValue = statusFilter->currentData().toString();
for (const ServerInfo_Report &r : allReports) {
bool matchesSearch = searchText.isEmpty() ||
QString::fromStdString(r.reporter_name()).toLower().contains(searchText) ||
QString::fromStdString(r.reported_user_name()).toLower().contains(searchText) ||
QString::fromStdString(r.category()).toLower().contains(searchText) ||
QString::fromStdString(r.description()).toLower().contains(searchText);
bool matchesStatus = statusFilterValue.isEmpty() || QString::fromStdString(r.status()) == statusFilterValue;
if (matchesSearch && matchesStatus) {
filteredReports.append(r);
}
}
table->setSortingEnabled(false);
table->setRowCount(filteredReports.size());
for (int row = 0; row < filteredReports.size(); ++row) {
const ServerInfo_Report &r = filteredReports[row];
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
COL_STATUS, COL_ASSIGNED);
table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name())));
table->setItem(row, COL_REPLAY,
new QTableWidgetItem(r.has_replay_id() && r.replay_id() > 0 ? tr("Yes") : tr("No")));
table->setItem(row, COL_ROOM,
new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : ""));
}
table->setSortingEnabled(true);
table->resizeColumnsToContents();
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size()));
}
void TabReport::updateStats()
{
int open = 0, assigned = 0, resolved = 0, dismissed = 0;
for (const ServerInfo_Report &r : allReports) {
QString status = QString::fromStdString(r.status());
if (status == "open") {
open++;
} else if (status == "assigned") {
assigned++;
} else if (status == "resolved") {
resolved++;
} else if (status == "dismissed") {
dismissed++;
}
}
statsLabel->setText(tr("%1 open | %2 assigned | %3 resolved | %4 dismissed")
.arg(open)
.arg(assigned)
.arg(resolved)
.arg(dismissed));
}
void TabReport::onSelectionChanged()
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_ID)) {
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
commentButton->setEnabled(false);
setActionsEnabled(false);
userContextGroup->setVisible(false);
return;
}
const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt();
loadReportDetails(reportId);
updateActionStates();
}
void TabReport::loadReportDetails(int reportId)
{
detailsRequestedReportId = reportId;
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
QString reportedUser = QString::fromStdString(r.reported_user_name());
if (!reportedUser.isEmpty()) {
requestUserInfo(reportedUser);
} else {
userContextGroup->setVisible(false);
}
QString status = QString::fromStdString(r.status());
bool canComment = (status == "open" || status == "assigned");
commentButton->setEnabled(canComment);
commentInput->setEnabled(canComment);
if (!canComment) {
commentInput->setPlaceholderText(tr("This report is closed."));
} else {
commentInput->setPlaceholderText(tr("Type a reply..."));
}
break;
}
}
chatLogEdit->setPlainText(tr("Loading..."));
commentsEdit->setPlainText(tr("Loading..."));
Command_ReportDetails cmd;
cmd.set_report_id(reportId);
const int seq = ++detailsRequestSeq;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, [this, seq](const Response &response) {
if (seq != detailsRequestSeq) {
return;
}
reportDetailsResponse(response);
});
client->sendCommand(pend);
}
void TabReport::reportDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
if (selectedReportId() == detailsRequestedReportId) {
chatLogEdit->clear();
commentsEdit->setPlainText(tr("Failed to load report details."));
}
return;
}
const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext);
const ServerInfo_Report &r = resp.report();
if (selectedReportId() != r.report_id()) {
return;
}
report_utils::renderReportDetails(chatLogEdit, commentsEdit, r, tr("No comments yet."), tr("[Moderator]"),
tr("[Reporter]"));
}
void TabReport::updateActionStates()
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_STATUS)) {
setActionsEnabled(false);
return;
}
ServerInfo_Report report;
if (!selectedReportInfo(report)) {
setActionsEnabled(false);
return;
}
const QString status = QString::fromStdString(report.status());
assignButton->setEnabled(status == "open");
resolveButton->setEnabled(status == "open" || status == "assigned");
resolveWithNoteButton->setEnabled(status == "open" || status == "assigned");
dismissButton->setEnabled(status == "open" || status == "assigned");
const bool hasGameId = report.game_id() > 0;
const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0;
viewReplayButton->setEnabled(hasReplay);
joinGameButton->setEnabled(hasGameId && report.has_room_id() && report.room_id() > 0);
}
void TabReport::setActionsEnabled(bool enabled)
{
assignButton->setEnabled(enabled);
resolveButton->setEnabled(enabled);
resolveWithNoteButton->setEnabled(enabled);
dismissButton->setEnabled(enabled);
viewReplayButton->setEnabled(false);
joinGameButton->setEnabled(false);
commentButton->setEnabled(enabled);
commentInput->setEnabled(enabled);
}
int TabReport::selectedReportId() const
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_ID)) {
return -1;
}
return table->item(row, COL_ID)->data(Qt::UserRole).toInt();
}
bool TabReport::selectedReportInfo(ServerInfo_Report &info) const
{
const int reportId = selectedReportId();
if (reportId < 0) {
return false;
}
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
info = r;
return true;
}
}
return false;
}
void TabReport::assignReport()
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
setActionsEnabled(false);
statusLabel->setText(tr("Assigning..."));
Command_ReportAssign cmd;
cmd.set_report_id(reportId);
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::assignResponse);
client->sendCommand(pend);
}
void TabReport::assignResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Assigned."));
refreshList();
} else {
statusLabel->setText(tr("Assignment failed."));
updateActionStates();
}
}
void TabReport::resolveReport(bool dismissed, bool promptNote)
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
QString note;
if (promptNote) {
bool ok;
note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
QLineEdit::Normal, QString(), &ok);
if (!ok) {
return;
}
}
setActionsEnabled(false);
statusLabel->setText(dismissed ? tr("Dismissing...") : tr("Resolving..."));
Command_ReportResolve cmd;
cmd.set_report_id(reportId);
cmd.set_dismissed(dismissed);
if (!note.isEmpty()) {
cmd.set_resolution_note(note.toStdString());
}
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::resolveResponse);
client->sendCommand(pend);
}
void TabReport::resolveResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
statusLabel->setText(tr("Done."));
refreshList();
} else {
statusLabel->setText(tr("Action failed."));
updateActionStates();
}
}
void TabReport::viewReplay()
{
ServerInfo_Report report;
if (!selectedReportInfo(report) || report.game_id() <= 0) {
return;
}
setActionsEnabled(false);
statusLabel->setText(tr("Loading replay..."));
Command_ReplayDownloadByGameId cmd;
cmd.set_game_id(report.game_id());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::viewReplayResponse);
client->sendCommand(pend);
}
void TabReport::viewReplayResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("No replay available for this game."));
updateActionStates();
return;
}
const Response_ReplayDownloadByGameId &resp = response.GetExtension(Response_ReplayDownloadByGameId::ext);
GameReplay *replay = new GameReplay;
if (replay->ParseFromString(resp.replay_data())) {
emit openReplay(replay);
statusLabel->setText(tr("Replay opened."));
} else {
delete replay;
statusLabel->setText(tr("Failed to parse replay."));
}
updateActionStates();
}
void TabReport::joinGame()
{
ServerInfo_Report report;
if (!selectedReportInfo(report) || report.game_id() <= 0) {
return;
}
const int roomId = report.has_room_id() ? report.room_id() : -1;
if (roomId <= 0) {
statusLabel->setText(tr("No room recorded for this report, use the replay instead."));
return;
}
emit requestJoinGame(report.game_id(), roomId);
statusLabel->setText(tr("Attempting to join game..."));
}
void TabReport::addComment()
{
const int reportId = selectedReportId();
if (reportId < 0) {
return;
}
QString text = commentInput->text().trimmed();
if (text.isEmpty()) {
return;
}
commentButton->setEnabled(false);
Command_ReportAddComment cmd;
cmd.set_report_id(reportId);
cmd.set_comment(text.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::addCommentResponse);
client->sendCommand(pend);
}
void TabReport::addCommentResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
commentInput->clear();
refreshList();
} else {
commentButton->setEnabled(true);
statusLabel->setText(tr("Failed to send comment."));
}
}
void TabReport::requestUserInfo(const QString &userName)
{
if (userName.isEmpty()) {
userContextGroup->setVisible(false);
return;
}
lastRequestedUser = userName;
userContextGroup->setVisible(true);
userContextName->setText(userName);
userContextAccountAge->setText(tr("Loading..."));
userContextReports->clear();
userContextBans->clear();
userContextWarns->clear();
userContextNotes->clear();
userContextRecentReports->clear();
Command_ReportUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::userInfoResponse);
client->sendCommand(pend);
}
void TabReport::userInfoResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
userContextAccountAge->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != lastRequestedUser) {
return;
}
QDateTime regTime = QDateTime::fromSecsSinceEpoch(resp.registration_time());
qint64 daysSinceReg = regTime.daysTo(QDateTime::currentDateTime());
userContextAccountAge->setText(tr("%1 days (since %2)").arg(daysSinceReg).arg(regTime.toString("yyyy-MM-dd")));
userContextReports->setText(QString::number(resp.total_reports()));
userContextBans->setText(QString::number(resp.total_bans()));
userContextWarns->setText(QString::number(resp.total_warns()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
userContextNotes->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
userContextNotes->setPlainText(tr("(none)"));
}
userContextRecentReports->clear();
if (resp.recent_reports_size() == 0) {
userContextRecentReports->setPlainText(tr("No previous reports against this user."));
} else {
for (int i = 0; i < resp.recent_reports_size(); ++i) {
const ServerInfo_Report &r = resp.recent_reports(i);
QDateTime dt = QDateTime::fromSecsSinceEpoch(r.report_time());
userContextRecentReports->moveCursor(QTextCursor::End);
userContextRecentReports->insertPlainText(QString("[%1] #%2 by %3 [%4]: %5\n")
.arg(dt.toString("yyyy-MM-dd"))
.arg(r.report_id())
.arg(QString::fromStdString(r.reporter_name()))
.arg(QString::fromStdString(r.status()))
.arg(QString::fromStdString(r.category())));
}
}
}
void TabReport::requestStats()
{
statsTotalLabel->setText(tr("Loading statistics..."));
statsTrendLabel->clear();
statsCategoriesLabel->clear();
statsDetailText->clear();
Command_ReportStats cmd;
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabReport::statsResponse);
client->sendCommand(pend);
}
void TabReport::statsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
statsTotalLabel->setText(tr("Error loading statistics."));
return;
}
const Response_ReportStats &resp = response.GetExtension(Response_ReportStats::ext);
statsTotalLabel->setText(tr("Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed)")
.arg(resp.total_reports())
.arg(resp.total_pending())
.arg(resp.total_assigned())
.arg(resp.total_resolved()));
statsTrendLabel->setText(tr("Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h")
.arg(resp.reports_last_24h())
.arg(resp.reports_last_7d())
.arg(resp.reports_last_30d())
.arg(resp.avg_resolution_hours(), 0, 'f', 1));
QString weekCompare =
tr("This week: %1 vs last week: %2 (%3%)")
.arg(resp.reports_this_week())
.arg(resp.reports_last_week())
.arg(resp.reports_last_week() > 0 ? QString::number(((resp.reports_this_week() - resp.reports_last_week()) *
100.0 / resp.reports_last_week()),
'f', 0)
: resp.reports_this_week() > 0 ? "new"
: "0");
statsTrendLabel->setText(statsTrendLabel->text() + " | " + weekCompare);
statsCategoriesLabel->setText(tr("By category:"));
statsDetailText->clear();
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(tr("=== Top Categories ===") + "\n");
for (int i = 0; i < resp.category_counts_size(); ++i) {
const ReportCategoryCount &cc = resp.category_counts(i);
QString cat = QString::fromStdString(cc.category());
cat.replace('_', ' ');
cat = cat.left(1).toUpper() + cat.mid(1);
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(QString(" %1: %2\n").arg(cat).arg(cc.count()));
}
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText("\n" + tr("=== Most Reported Users ===") + "\n");
for (int i = 0; i < resp.top_reported_users_size(); ++i) {
const ReportTopUser &tu = resp.top_reported_users(i);
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(
QString(" %1: %2 reports\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count()));
}
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText("\n" + tr("=== Top Reporters ===") + "\n");
for (int i = 0; i < resp.top_reporters_size(); ++i) {
const ReportTopUser &tu = resp.top_reporters(i);
statsDetailText->moveCursor(QTextCursor::End);
statsDetailText->insertPlainText(
QString(" %1: %2 reports filed\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count()));
}
}

View file

@ -0,0 +1,124 @@
#ifndef TAB_REPORT_H
#define TAB_REPORT_H
#include "tab.h"
#include <QList>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
class AbstractClient;
class QCheckBox;
class QComboBox;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSplitter;
class QTableWidget;
class QTextEdit;
class QTimer;
class GameReplay;
class TabReport : public Tab
{
Q_OBJECT
public:
TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client);
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("Report Queue");
}
signals:
void openReplay(GameReplay *replay);
void requestJoinGame(int gameId, int roomId);
private slots:
void refreshList();
void reportListResponse(const Response &response);
void assignReport();
void assignResponse(const Response &response);
void resolveReport(bool dismissed, bool promptNote);
void resolveResponse(const Response &response);
void onSelectionChanged();
void viewReplay();
void viewReplayResponse(const Response &response);
void joinGame();
void addComment();
void addCommentResponse(const Response &response);
void requestUserInfo(const QString &userName);
void userInfoResponse(const Response &response);
void reportDetailsResponse(const Response &response);
void requestStats();
void statsResponse(const Response &response);
private:
int selectedReportId() const;
bool selectedReportInfo(ServerInfo_Report &info) const;
void setActionsEnabled(bool enabled);
void updateActionStates();
void applyFilters();
void updateStats();
void loadReportDetails(int reportId);
AbstractClient *client;
QLineEdit *searchEdit;
QComboBox *statusFilter;
QCheckBox *unresolvedOnlyBox;
QPushButton *refreshButton;
QLabel *statsLabel;
QTableWidget *table;
QSplitter *detailSplitter;
QGroupBox *descGroup;
QGroupBox *chatGroup;
QGroupBox *commentsGroup;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QTextEdit *commentsEdit;
QLineEdit *commentInput;
QPushButton *commentButton;
QPushButton *assignButton;
QPushButton *resolveButton;
QPushButton *resolveWithNoteButton;
QPushButton *dismissButton;
QPushButton *viewReplayButton;
QPushButton *joinGameButton;
QLabel *statusLabel;
QTimer *refreshTimer;
QGroupBox *userContextGroup;
QLabel *userContextName;
QLabel *userContextAccountAge;
QLabel *userContextReports;
QLabel *userContextBans;
QLabel *userContextWarns;
QTextEdit *userContextNotes;
QTextEdit *userContextRecentReports;
QLabel *userContextUserLabel;
QLabel *userContextAgeLabel;
QLabel *userContextReportsLabel;
QLabel *userContextBansLabel;
QLabel *userContextWarnsLabel;
QLabel *userContextNotesLabel;
QLabel *userContextRecentReportsLabel;
QString lastRequestedUser;
QGroupBox *statsGroup;
QLabel *statsTotalLabel;
QLabel *statsTrendLabel;
QLabel *statsCategoriesLabel;
QTextEdit *statsDetailText;
QList<ServerInfo_Report> allReports;
QList<ServerInfo_Report> filteredReports;
int detailsRequestedReportId = -1;
int detailsRequestSeq = 0;
int selectedReportIdBeforeRefresh = -1;
QString commentDraftBeforeRefresh;
ServerInfo_Report previousSelectedReport;
bool previousSelectedReportValid = false;
};
#endif // TAB_REPORT_H

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

View file

@ -40,6 +40,8 @@ class TabDeckStorage;
class TabReplays;
class TabAdmin;
class TabMessage;
class TabReport;
class TabModeration;
class TabAccount;
class TabDeckEditor;
class TabLog;
@ -103,6 +105,8 @@ private:
TabAdmin *tabAdmin;
TabCardArtRules *tabCardArtRules;
TabLog *tabLog;
TabReport *tabReport;
TabModeration *tabModeration;
QMap<int, TabRoom *> roomTabs;
QMap<int, TabGame *> gameTabs;
QList<TabGame *> replayTabs;
@ -112,7 +116,7 @@ private:
QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage,
*aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin,
*aTabCardArtRules, *aTabLog;
*aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration;
int myAddTab(Tab *tab, QAction *manager = nullptr);
void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager);
@ -183,6 +187,8 @@ public slots:
TabArchidekt *addArchidektTab();
TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false);
void openReplay(GameReplay *replay);
void joinReportGame(int gameId, int roomId);
void openTabModeration(const QString &userName = {});
void switchToFirstAvailableNetworkTab();
void maximizeMainWindow();
void actTabVisualDeckStorage(bool checked);
@ -198,6 +204,8 @@ private slots:
void actTabDeckStorage(bool checked);
void actTabAdmin(bool checked);
void actTabLog(bool checked);
void actTabReport(bool checked);
void actTabModeration(bool checked);
void openTabVisualDeckStorage();
void openTabHome();
@ -208,6 +216,7 @@ private slots:
void actTabCardArtRules(bool checked);
void openTabCardArtRules();
void openTabLog();
void openTabReport();
void updateCurrent(int index);
void updatePingTime(int value, int max);

View file

@ -0,0 +1,119 @@
#include "report_utils.h"
#include <QApplication>
#include <QBrush>
#include <QDateTime>
#include <QPalette>
#include <QTableWidget>
#include <QTextCursor>
#include <QTextEdit>
namespace report_utils
{
namespace
{
QColor reportStatusColor(const QString &status)
{
const QColor text = qApp->palette().color(QPalette::Text);
const int luminance = (299 * text.red() + 587 * text.green() + 114 * text.blue()) / 1000;
const bool dark = luminance < 128;
if (status == "open") {
return dark ? QColor("#e06060") : QColor("#c0392b");
}
if (status == "assigned") {
return dark ? QColor("#e0a030") : QColor("#d68910");
}
if (status == "resolved") {
return dark ? QColor("#50c878") : QColor("#1e8449");
}
if (status == "dismissed") {
return qApp->palette().color(QPalette::PlaceholderText);
}
return QColor();
}
void setReportStatusItemColor(QTableWidgetItem *item, const QString &status)
{
const QColor color = reportStatusColor(status);
if (color.isValid()) {
item->setForeground(QBrush(color));
}
}
} // namespace
QString formatReportCategory(const QString &category)
{
QString result = category;
result.replace('_', ' ');
return result.left(1).toUpper() + result.mid(1);
}
QString formatReportTime(qint64 secondsSinceEpoch)
{
const QDateTime dt = QDateTime::fromSecsSinceEpoch(secondsSinceEpoch);
return dt.toString("yyyy-MM-dd hh:mm");
}
void fillReportTableRow(QTableWidget *table,
int row,
const ServerInfo_Report &report,
int idColumn,
int timeColumn,
int reportedColumn,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn)
{
auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
idItem->setData(Qt::UserRole, report.report_id());
table->setItem(row, idColumn, idItem);
table->setItem(row, timeColumn, new QTableWidgetItem(formatReportTime(report.report_time())));
table->setItem(row, reportedColumn, new QTableWidgetItem(QString::fromStdString(report.reported_user_name())));
table->setItem(row, categoryColumn,
new QTableWidgetItem(formatReportCategory(QString::fromStdString(report.category()))));
table->setItem(row, gameIdColumn,
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status()));
setReportStatusItemColor(statusItem, QString::fromStdString(report.status()));
table->setItem(row, statusColumn, statusItem);
table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));
}
void renderReportDetails(QTextEdit *chatLogEdit,
QTextEdit *commentsEdit,
const ServerInfo_Report &report,
const QString &commentsEmptyText,
const QString &moderatorPrefix,
const QString &nonModeratorPrefix)
{
if (report.has_chat_log() && !report.chat_log().empty()) {
chatLogEdit->setPlainText(QString::fromStdString(report.chat_log()));
} else {
chatLogEdit->clear();
}
commentsEdit->clear();
if (report.comments_size() == 0) {
commentsEdit->setPlainText(commentsEmptyText);
return;
}
for (int i = 0; i < report.comments_size(); ++i) {
const ServerInfo_ReportComment &c = report.comments(i);
const QString author = QString::fromStdString(c.author_name());
const QString text = QString::fromStdString(c.comment_text());
const QString prefix = c.is_moderator() ? moderatorPrefix : nonModeratorPrefix;
commentsEdit->moveCursor(QTextCursor::End);
commentsEdit->insertPlainText(
QString("[%1] %2 %3:\n%4\n\n").arg(formatReportTime(c.comment_time()), prefix, author, text));
}
}
} // namespace report_utils

View file

@ -0,0 +1,36 @@
#ifndef REPORT_UTILS_H
#define REPORT_UTILS_H
#include <QString>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
class QTableWidget;
class QTextEdit;
namespace report_utils
{
QString formatReportCategory(const QString &category);
QString formatReportTime(qint64 secondsSinceEpoch);
void fillReportTableRow(QTableWidget *table,
int row,
const ServerInfo_Report &report,
int idColumn,
int timeColumn,
int reportedColumn,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn);
void renderReportDetails(QTextEdit *chatLogEdit,
QTextEdit *commentsEdit,
const ServerInfo_Report &report,
const QString &commentsEmptyText,
const QString &moderatorPrefix,
const QString &nonModeratorPrefix);
} // namespace report_utils
#endif // REPORT_UTILS_H

View file

@ -19,6 +19,8 @@ public:
[[nodiscard]] virtual bool getTabReplaysOpen() const = 0;
[[nodiscard]] virtual bool getTabAdminOpen() const = 0;
[[nodiscard]] virtual bool getTabLogOpen() const = 0;
[[nodiscard]] virtual bool getTabReportOpen() const = 0;
[[nodiscard]] virtual bool getTabModerationOpen() const = 0;
};
#endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H

View file

@ -38,7 +38,8 @@ enum AuthenticationResult
UsernameInvalid,
RegistrationRequired,
UserIsInactive,
ClientIdRequired
ClientIdRequired,
PasswordChangeRequired
};
class Server : public QObject

View file

@ -69,6 +69,10 @@ void Server_AbstractUserInterface::sendResponseContainer(const ResponseContainer
}
}
void Server_AbstractUserInterface::onLogin(ResponseContainer &)
{
}
void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game)
{
qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId();

View file

@ -42,6 +42,7 @@ public:
void playerRemovedFromGame(Server_Game *game);
void playerAddedToGame(int gameId, int roomId, int playerId);
void joinPersistentGames(ResponseContainer &rc);
virtual void onLogin(ResponseContainer &rc);
QMap<int, QPair<int, int>> getGames() const
{

View file

@ -172,6 +172,9 @@ public:
{
return false;
}
virtual void setForcePasswordChange(const QString & /* user */, bool /* force */)
{
}
};
#endif

View file

@ -564,6 +564,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
return Response::RespClientIdRequired;
case UserIsInactive:
return Response::RespAccountNotActivated;
case PasswordChangeRequired:
return Response::RespPasswordChangeRequired;
default:
authState = res;
usingRealPassword = needsHash;
@ -614,6 +616,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
joinPersistentGames(rc);
databaseInterface->removeForgotPassword(userName);
onLogin(rc);
rc.setResponseExtension(re);
return Response::RespOk;
}

View file

@ -35,10 +35,20 @@ set(PROTO_FILES
command_ready_start.proto
command_replay_delete_match.proto
command_replay_download.proto
command_replay_download_by_game_id.proto
command_replay_get_code.proto
command_replay_list.proto
command_replay_modify_match.proto
command_replay_submit_code.proto
command_report.proto
command_report_add_comment.proto
command_report_assign.proto
command_report_details.proto
command_report_list.proto
command_report_my_list.proto
command_report_resolve.proto
command_report_stats.proto
command_report_user_info.proto
command_reveal_cards.proto
command_reverse_turn.proto
command_roll_die.proto
@ -138,8 +148,19 @@ set(PROTO_FILES
response_password_salt.proto
response_register.proto
response_replay_download.proto
response_replay_download_by_game_id.proto
response_replay_get_code.proto
response_replay_list.proto
response_report_details.proto
response_report_list.proto
response_report_my_list.proto
response_report_stats.proto
response_report_user_info.proto
response_moderator_last_logins.proto
response_remove_user_avatar.proto
response_reset_user_password.proto
response_user_alts.proto
response_user_sessions.proto
response_viewlog_history.proto
response_warn_history.proto
response_warn_list.proto
@ -155,13 +176,17 @@ set(PROTO_FILES
serverinfo_deckstorage.proto
serverinfo_game.proto
serverinfo_gametype.proto
serverinfo_moderator_login.proto
serverinfo_player.proto
serverinfo_playerping.proto
serverinfo_playerproperties.proto
serverinfo_replay.proto
serverinfo_replay_match.proto
serverinfo_report.proto
serverinfo_room.proto
serverinfo_user.proto
serverinfo_user_alt.proto
serverinfo_user_session.proto
serverinfo_warning.proto
serverinfo_zone.proto
session_commands.proto

View file

@ -5,6 +5,7 @@ message AdminCommand {
SHUTDOWN_SERVER = 1001;
RELOAD_CONFIG = 1002;
ADJUST_MOD = 1003;
RESET_USER_PASSWORD = 1016;
}
extensions 100 to max;
}
@ -37,3 +38,10 @@ message Command_AdjustMod {
optional bool should_be_mod = 2;
optional bool should_be_judge = 3;
}
message Command_ResetUserPassword {
extend AdminCommand {
optional Command_ResetUserPassword ext = 1016;
}
optional string user_name = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReplayDownloadByGameId {
extend ModeratorCommand {
optional Command_ReplayDownloadByGameId ext = 1203;
}
required sint32 game_id = 1;
}

View file

@ -0,0 +1,13 @@
syntax = "proto2";
import "session_commands.proto";
message Command_Report {
extend SessionCommand {
optional Command_Report ext = 1200;
}
optional string reported_user = 1;
optional int32 game_id = 2;
optional string category = 3;
optional string description = 4;
optional string chat_log = 5;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "session_commands.proto";
message Command_ReportAddComment {
extend SessionCommand {
optional Command_ReportAddComment ext = 1205;
}
required int32 report_id = 1;
required string comment = 2;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportAssign {
extend ModeratorCommand {
optional Command_ReportAssign ext = 1201;
}
required int32 report_id = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_ReportDetails {
extend SessionCommand {
optional Command_ReportDetails ext = 1206;
}
required int32 report_id = 1;
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportList {
extend ModeratorCommand {
optional Command_ReportList ext = 1200;
}
optional bool unresolved_only = 1;
optional uint32 offset = 2 [default = 0];
optional uint32 limit = 3 [default = 100];
}

View file

@ -0,0 +1,8 @@
syntax = "proto2";
import "session_commands.proto";
message Command_ReportMyList {
extend SessionCommand {
optional Command_ReportMyList ext = 1204;
}
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportResolve {
extend ModeratorCommand {
optional Command_ReportResolve ext = 1202;
}
required int32 report_id = 1;
optional string resolution_note = 2;
optional bool dismissed = 3;
}

View file

@ -0,0 +1,8 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportStats {
extend ModeratorCommand {
optional Command_ReportStats ext = 1205;
}
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "moderator_commands.proto";
message Command_ReportUserInfo {
extend ModeratorCommand {
optional Command_ReportUserInfo ext = 1204;
}
optional string user_name = 1;
}

View file

@ -9,6 +9,8 @@ message Event_NotifyUser {
WARNING = 2;
IDLEWARNING = 3;
CUSTOM = 4;
REPORT_RESOLVED = 5;
REPORT_COMMENT = 6;
}
extend SessionEvent {

View file

@ -14,6 +14,17 @@ message ModeratorCommand {
ADD_CARD_ART_RULE = 1010;
REMOVE_CARD_ART_RULE = 1011;
LIST_CARD_ART_RULES = 1012;
GET_USER_SESSIONS = 1013;
GET_USER_ALTS = 1014;
GET_MODERATOR_LAST_LOGINS = 1015;
RESET_USER_PASSWORD = 1016;
REMOVE_USER_AVATAR = 1017;
REPORT_LIST = 1200;
REPORT_ASSIGN = 1201;
REPORT_RESOLVE = 1202;
REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
REPORT_USER_INFO = 1204;
REPORT_STATS = 1205;
}
extensions 100 to max;
}
@ -135,3 +146,31 @@ message Command_ListCardArtRules {
optional Command_ListCardArtRules ext = 1012;
}
}
message Command_GetUserSessions {
extend ModeratorCommand {
optional Command_GetUserSessions ext = 1013;
}
optional string user_name = 1;
optional uint32 limit = 2 [default = 110];
}
message Command_GetUserAlts {
extend ModeratorCommand {
optional Command_GetUserAlts ext = 1014;
}
optional string user_name = 1;
}
message Command_GetModeratorLastLogins {
extend ModeratorCommand {
optional Command_GetModeratorLastLogins ext = 1015;
}
}
message Command_RemoveUserAvatar {
extend ModeratorCommand {
optional Command_RemoveUserAvatar ext = 1017;
}
optional string user_name = 1;
}

View file

@ -53,6 +53,7 @@ message Response {
RespClientUpdateRequired = 35; // Client is missing features that the server is requiring
RespServerFull = 36; // Server user limit reached
RespEmailBlackListed = 37; // Server has blocked the email address provided for registration for some reason
RespPasswordChangeRequired = 38; // Server requires the user to change their password before proceeding
}
// Type of response, used to route handling on the client

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_moderator_login.proto";
message Response_ModeratorLastLogins {
extend Response {
optional Response_ModeratorLastLogins ext = 1217;
}
repeated ServerInfo_ModeratorLogin logins = 1;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "response.proto";
message Response_RemoveUserAvatar {
extend Response {
optional Response_RemoveUserAvatar ext = 1219;
}
optional string user_name = 1;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
message Response_ReplayDownloadByGameId {
extend Response {
optional Response_ReplayDownloadByGameId ext = 1203;
}
optional bytes replay_data = 1;
optional sint32 replay_id = 2;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportDetails {
extend Response {
optional Response_ReportDetails ext = 1214;
}
optional ServerInfo_Report report = 1;
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportList {
extend Response {
optional Response_ReportList ext = 1210;
}
repeated ServerInfo_Report reports = 1;
optional uint32 total_count = 2;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportMyList {
extend Response {
optional Response_ReportMyList ext = 1213;
}
repeated ServerInfo_Report reports = 1;
}

View file

@ -0,0 +1,36 @@
syntax = "proto2";
import "response.proto";
message Response_ReportStats {
extend Response {
optional Response_ReportStats ext = 1212;
}
optional int32 total_reports = 1;
optional int32 total_pending = 2;
optional int32 total_assigned = 3;
optional int32 total_resolved = 4;
optional int32 reports_last_24h = 5;
optional int32 reports_last_7d = 6;
optional int32 reports_last_30d = 7;
optional double avg_resolution_hours = 8;
optional int32 reports_this_week = 9;
optional int32 reports_last_week = 10;
repeated ReportCategoryCount category_counts = 11;
repeated ReportTopUser top_reported_users = 12;
repeated ReportTopUser top_reporters = 13;
}
message ReportCategoryCount {
optional string category = 1;
optional int32 count = 2;
}
message ReportTopUser {
optional string user_name = 1;
optional int32 count = 2;
}

View file

@ -0,0 +1,21 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_report.proto";
message Response_ReportUserInfo {
extend Response {
optional Response_ReportUserInfo ext = 1211;
}
optional string user_name = 1;
optional int32 total_reports = 2;
optional int32 total_bans = 3;
optional int32 total_warns = 4;
optional int64 registration_time = 5;
optional bool is_admin = 6;
optional bool is_active = 7;
optional string admin_notes = 8;
repeated ServerInfo_Report recent_reports = 9;
// Last known login of the user, epoch seconds; 0 = unknown.
optional int64 last_login = 10;
}

View file

@ -0,0 +1,12 @@
syntax = "proto2";
import "response.proto";
message Response_ResetUserPassword {
extend Response {
optional Response_ResetUserPassword ext = 1218;
}
optional string user_name = 1;
// The generated temporary password, shown to the moderator who
// requested the reset. The affected user must change it on first login.
optional string temporary_password = 2;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_user_alt.proto";
message Response_UserAlts {
extend Response {
optional Response_UserAlts ext = 1216;
}
repeated ServerInfo_UserAlt alts = 1;
}

View file

@ -0,0 +1,10 @@
syntax = "proto2";
import "response.proto";
import "serverinfo_user_session.proto";
message Response_UserSessions {
extend Response {
optional Response_UserSessions ext = 1215;
}
repeated ServerInfo_UserSession sessions = 1;
}

View file

@ -8,4 +8,7 @@ message Response_WarnList {
repeated string warning = 1;
optional string user_name = 2;
optional string user_clientid = 3;
// Recommended starting intervention level per warning category,
// aligned by index with `warning`. Absent or shorter lists default to 1.
repeated uint32 warning_il = 4;
}

View file

@ -0,0 +1,11 @@
syntax = "proto2";
/**
* The last login date of a staff member (moderator/judge/admin).
* Used by the moderation "Moderator Last Logins" tool.
*/
message ServerInfo_ModeratorLogin {
optional string user_name = 1; // staff account name
optional uint64 last_login = 2; // last known login, epoch seconds; 0 = unknown
optional uint32 user_level = 3; // ServerInfo_User::UserLevelFlag mask (moderator/judge/admin)
}

View file

@ -0,0 +1,36 @@
syntax = "proto2";
message ServerInfo_ReportComment {
optional string author_name = 1;
optional string comment_text = 2;
optional int64 comment_time = 3;
optional bool is_moderator = 4;
}
message ServerInfo_Report {
optional int32 report_id = 1;
optional string reporter_name = 2;
optional string reported_user_name = 3;
optional int32 game_id = 4;
optional int32 replay_id = 5;
optional int32 room_id = 6;
optional string category = 7;
optional string status = 8;
optional string description = 9;
optional int64 report_time = 10;
optional string assigned_mod_name = 11;
optional int64 resolution_time = 12;
repeated ServerInfo_ReportComment comments = 13;
optional string chat_log = 14;
optional string resolution_note = 15;
}

View file

@ -0,0 +1,16 @@
syntax = "proto2";
/**
* An account that shares an IP address, client id or eMail address with
* the account being investigated. Used by the moderation "Get User Alts" tool.
*/
message ServerInfo_UserAlt {
optional string user_name = 1; // account name
optional string email = 2; // registration eMail
optional string clientid = 3; // client id
optional uint64 registration_time = 4; // account registration, epoch seconds
optional uint64 last_login = 5; // last known login, epoch seconds; 0 = unknown
optional uint32 warn_count = 6; // number of warnings on record
optional uint32 ban_count = 7; // number of bans on record
optional bool is_active = 8; // account is not deactivated/banned
}

View file

@ -0,0 +1,14 @@
syntax = "proto2";
/**
* A single login session of a user on the server, as stored in the
* sessions table. Used by the moderation "Get User Sessions" tool.
*/
message ServerInfo_UserSession {
optional string user_name = 1; // account that was logged in
optional string ip_address = 2; // IP address used for the session
optional string clientid = 3; // client id used for the session
optional uint64 start_time = 4; // session start, epoch seconds
optional uint64 end_time = 5; // session end, epoch seconds; 0 = still active
optional string connection_type = 6; // "tcp" or "websocket"
}

View file

@ -34,6 +34,11 @@ message SessionCommand {
REPLAY_DELETE_MATCH = 1103;
REPLAY_GET_CODE = 1104;
REPLAY_SUBMIT_CODE = 1105;
REPORT = 1200;
// 1201-1203 reserved: removed during squash
REPORT_MY_LIST = 1204;
REPORT_ADD_COMMENT = 1205;
REPORT_DETAILS = 1206;
}
extensions 100 to max;
}

View file

@ -96,6 +96,16 @@ void TabsSettings::setStartupRoomName(const QString &roomName)
emit startupRoomNameChanged(roomName);
}
bool TabsSettings::getTabReportOpen() const
{
return getValue("report", QString(), QString(), false).toBool();
}
bool TabsSettings::getTabModerationOpen() const
{
return getValue("moderation", QString(), QString(), false).toBool();
}
void TabsSettings::setTabVisualDeckStorageOpen(bool value)
{
setValue(value, "visualDeckStorage");
@ -130,3 +140,13 @@ void TabsSettings::setTabLogOpen(bool value)
{
setValue(value, "log");
}
void TabsSettings::setTabReportOpen(bool value)
{
setValue(value, "report");
}
void TabsSettings::setTabModerationOpen(bool value)
{
setValue(value, "moderation");
}

View file

@ -41,6 +41,8 @@ public:
[[nodiscard]] bool getTabReplaysOpen() const override;
[[nodiscard]] bool getTabAdminOpen() const override;
[[nodiscard]] bool getTabLogOpen() const override;
[[nodiscard]] bool getTabReportOpen() const override;
[[nodiscard]] bool getTabModerationOpen() const override;
void setStartupTabIndex(int value);
void setStartupServerHost(const QString &host);
@ -53,6 +55,8 @@ public:
void setTabReplaysOpen(bool value);
void setTabAdminOpen(bool value);
void setTabLogOpen(bool value);
void setTabReportOpen(bool value);
void setTabModerationOpen(bool value);
signals:
void startupTabIndexChanged(int index);

View file

@ -5,8 +5,9 @@ set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(UTILITY_SOURCES libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp
libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp
set(UTILITY_SOURCES
libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp libcockatrice/utility/passwordhasher.cpp
libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/warning_categories.cpp
)
set(UTILITY_HEADERS
@ -24,6 +25,7 @@ set(UTILITY_HEADERS
libcockatrice/utility/zone_names.h
libcockatrice/utility/days_years_between.h
libcockatrice/utility/server_rate_limiter.h
libcockatrice/utility/warning_categories.h
)
add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS})

View file

@ -22,6 +22,19 @@ inline QString textFromStdString(const std::string &_string)
{
return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_TEXT_LENGTH));
}
/** @brief Returns a QString from a std::string, truncated to at most MAX_TEXT_LENGTH bytes, keeping the tail. */
inline QString textTailFromStdString(const std::string &_string)
{
if (int(_string.size()) <= MAX_TEXT_LENGTH) {
return QString::fromUtf8(_string.data(), int(_string.size()));
}
int start = int(_string.size()) - MAX_TEXT_LENGTH;
while (start < int(_string.size()) && (static_cast<unsigned char>(_string[start]) & 0xC0) == 0x80) {
++start;
}
return QString::fromUtf8(_string.data() + start, int(_string.size()) - start);
}
/** @brief Returns a QString from a std::string, truncated to at most MAX_FILE_LENGTH bytes. */
inline QString fileFromStdString(const std::string &_string)
{

View file

@ -0,0 +1,24 @@
#include "warning_categories.h"
QList<WarningCategory> parseWarningCategories(const QString &value)
{
QList<WarningCategory> categories;
const QStringList entries = value.split(',', Qt::SkipEmptyParts);
for (const QString &entry : entries) {
const QStringList parts = entry.split('|');
WarningCategory category;
category.name = parts.first().trimmed();
if (category.name.isEmpty()) {
continue;
}
if (parts.size() > 1) {
bool ok = false;
const int il = parts.at(1).trimmed().toInt(&ok);
if (ok && il > 0) {
category.startingIl = il;
}
}
categories.append(category);
}
return categories;
}

View file

@ -0,0 +1,28 @@
#ifndef WARNING_CATEGORIES_H
#define WARNING_CATEGORIES_H
#include <QList>
#include <QString>
/**
* A warning category the server offers to moderators, optionally carrying a
* recommended starting intervention level (see the moderator guide).
*/
struct WarningCategory
{
QString name;
int startingIl = 1;
};
/**
* Parses the `server/officialwarnings` setting value into warning categories.
*
* Entries are separated by commas. Each entry is a category name, optionally
* followed by "|" and the recommended starting intervention level:
* "Abusive Language|1,Cheating|2,Spamming"
* Entries without an explicit level default to intervention level 1.
* Empty entries are skipped.
*/
QList<WarningCategory> parseWarningCategories(const QString &value);
#endif // WARNING_CATEGORIES_H

View file

@ -0,0 +1,53 @@
-- Servatrice db migration from version 35 to version 36
CREATE TABLE IF NOT EXISTS `cockatrice_reports` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`reporter_id` int(7) unsigned NULL,
`reporter_name` varchar(35) NOT NULL,
`reported_user_id` int(7) unsigned NULL,
`reported_user_name` varchar(35) NOT NULL,
`game_id` int(7) unsigned NULL,
`room_id` int(7) unsigned NULL,
`category` varchar(255) NOT NULL,
`description` text NOT NULL,
`chat_log` mediumtext NULL,
`created_at` datetime NOT NULL,
`resolution_time` datetime NULL,
`status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open',
`assigned_to` int(7) unsigned NULL,
`resolved_by` int(7) unsigned NULL,
`resolution_note` text,
`notified` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_status` (`status`),
INDEX `idx_created` (`created_at`),
INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`),
INDEX `idx_reported_user_name` (`reported_user_name`),
INDEX `idx_status_created` (`status`, `created_at`),
FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`report_id` int(11) unsigned NOT NULL,
`author_name` varchar(35) NOT NULL,
`author_id` int(7) unsigned NULL,
`comment_text` text NOT NULL,
`created_at` datetime NOT NULL,
`is_moderator` tinyint(1) NOT NULL DEFAULT 0,
`notified` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_report_id` (`report_id`),
INDEX `idx_notified` (`notified`),
FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
ALTER TABLE `cockatrice_users`
ADD COLUMN `force_password_change` tinyint(1) NOT NULL DEFAULT 0
AFTER `passwordLastChangedDate`;
UPDATE cockatrice_schema_version SET version=36 WHERE version=35;

View file

@ -79,8 +79,10 @@ requiredfeatures=""
; You can define custom warnings that users are sent when the moderation staff uses the right client warn user
; menu option. This list is comma seperated that each item will appear in the drop down list for staff members
; to choose from. Example: "Flaming,Foul Language"
officialwarnings="Flaming,Spamming,Causing Drama,Abusive Language"
; to choose from. Each entry may optionally carry a recommended starting intervention level (see the moderator
; guide) by appending "|" and the level number. Entries without an explicit level default to intervention
; level 1. Example: "Flaming,Foul Language"
officialwarnings="Abusive Language|1,Calling Out User|1,Causing Drama|1,Cheating|2,Disrespecting Staff|1,Disrupting a Draft|1,Inappropriate Avatar|3,Inappropriate Game Name|1,Kicking Without Valid Reason|1,Spamming|1,Targeted Harassment|2"
; Maximum time in seconds a player can stay connected but idle. Default is 3600 (0 = disabled)
; Clients will be notified at the 90% time period of pending disconnection if they do not take action.
@ -373,6 +375,13 @@ command_counting_interval=10
; Maximum number of game commands in an interval before new commands gets dropped; default is 20
max_command_count_per_interval=20
[reporting]
; Maximum number of user reports a single user can file per day; default is 10; set to 0 to disable the limit
max_reports_per_day=10
; Maximum number of report comments a single user can post per hour; default is 30; set to 0 to disable the limit
max_comments_per_hour=30
[logging]
; Admin/Moderators can query the stored logs for information when looking up reports by various players. This
; option can allow or disallow them from doing so.

View file

@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` (
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
INSERT INTO cockatrice_schema_version VALUES(35);
INSERT INTO cockatrice_schema_version VALUES(36);
-- users and user data tables
CREATE TABLE IF NOT EXISTS `cockatrice_users` (
@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` (
`privlevelStartDate` datetime NOT NULL,
`privlevelEndDate` datetime NOT NULL,
`passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`force_password_change` tinyint(1) NOT NULL DEFAULT 0,
`leftPawnColorOverride` varchar(255),
`rightPawnColorOverride` varchar(255),
`card_art_params` TEXT DEFAULT NULL,
@ -233,6 +234,52 @@ CREATE TABLE IF NOT EXISTS `cockatrice_warnings` (
INDEX `idx_user_name` (`user_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_reports` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`reporter_id` int(7) unsigned NULL,
`reporter_name` varchar(35) NOT NULL,
`reported_user_id` int(7) unsigned NULL,
`reported_user_name` varchar(35) NOT NULL,
`game_id` int(7) unsigned NULL,
`room_id` int(7) unsigned NULL,
`category` varchar(255) NOT NULL,
`description` text NOT NULL,
`chat_log` mediumtext NULL,
`created_at` datetime NOT NULL,
`resolution_time` datetime NULL,
`status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open',
`assigned_to` int(7) unsigned NULL,
`resolved_by` int(7) unsigned NULL,
`resolution_note` text,
`notified` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_status` (`status`),
INDEX `idx_created` (`created_at`),
INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`),
INDEX `idx_reported_user_name` (`reported_user_name`),
INDEX `idx_status_created` (`status`, `created_at`),
FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`report_id` int(11) unsigned NOT NULL,
`author_name` varchar(35) NOT NULL,
`author_id` int(7) unsigned NULL,
`comment_text` text NOT NULL,
`created_at` datetime NOT NULL,
`is_moderator` tinyint(1) NOT NULL DEFAULT 0,
`notified` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_report_id` (`report_id`),
INDEX `idx_notified` (`notified`),
FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `cockatrice_log` (
`log_time` datetime NOT NULL,
`sender_id` int(7) unsigned NULL,

View file

@ -514,6 +514,23 @@ QList<ServerProperties> Servatrice::getServerList() const
return result;
}
std::shared_ptr<const Response_ReportStats> Servatrice::getCachedReportStats() const
{
QMutexLocker locker(&reportStatsMutex);
if (!reportStatsTimestamp.isValid() ||
reportStatsTimestamp.secsTo(QDateTime::currentDateTime()) >= reportStatsCacheTtlSeconds) {
return nullptr;
}
return reportStatsCache;
}
void Servatrice::cacheReportStats(const Response_ReportStats &stats)
{
QMutexLocker locker(&reportStatsMutex);
reportStatsCache = std::make_shared<const Response_ReportStats>(stats);
reportStatsTimestamp = QDateTime::currentDateTime();
}
int Servatrice::getUsersWithAddress(const QHostAddress &address) const
{
int result = 0;

View file

@ -20,6 +20,7 @@
#ifndef SERVATRICE_H
#define SERVATRICE_H
#include <QDateTime>
#include <QHostAddress>
#include <QMetaType>
#include <QMutex>
@ -29,6 +30,8 @@
#include <QSslKey>
#include <QTcpServer>
#include <QWebSocketServer>
#include <libcockatrice/protocol/pb/response_report_stats.pb.h>
#include <memory>
#include <server.h>
#include <utility>
@ -173,6 +176,11 @@ private:
int nextShutdownMessageMinutes;
QTimer *shutdownTimer;
mutable QMutex reportStatsMutex;
QDateTime reportStatsTimestamp;
std::shared_ptr<const Response_ReportStats> reportStatsCache;
static constexpr int reportStatsCacheTtlSeconds = 60;
mutable QMutex serverListMutex;
QList<ServerProperties> serverList;
void updateServerList();
@ -283,6 +291,11 @@ public:
void removeIslInterface(int _serverId);
QReadWriteLock islLock;
// The moderation queue statistics are shared between all connected moderators and
// cached briefly to avoid re-running several full-table queries on every refresh.
std::shared_ptr<const Response_ReportStats> getCachedReportStats() const;
void cacheReportStats(const Response_ReportStats &stats);
QList<ServerProperties> getServerList() const;
};

View file

@ -14,6 +14,7 @@
#include <QSqlQuery>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/game_replay.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/utility/passwordhasher.h>
inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface");
@ -339,8 +340,8 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
return UserIsBanned;
}
QSqlQuery *passwordQuery =
prepareQuery("select password_sha512, active from {prefix}_users where name = :name");
QSqlQuery *passwordQuery = prepareQuery(
"select password_sha512, active, force_password_change from {prefix}_users where name = :name");
passwordQuery->bindValue(":name", user);
if (!execSqlQuery(passwordQuery)) {
qCWarning(DatabaseInterfaceLog) << "Login denied: SQL error";
@ -350,6 +351,7 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
if (passwordQuery->next()) {
const QString correctPasswordSha512 = passwordQuery->value(0).toString();
const bool userIsActive = passwordQuery->value(1).toBool();
const bool forceChange = passwordQuery->value(2).toBool();
if (!userIsActive) {
qCWarning(DatabaseInterfaceLog) << "Login denied: user not active";
return UserIsInactive;
@ -361,6 +363,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot
hashedPassword = password;
}
if (correctPasswordSha512 == hashedPassword) {
if (forceChange) {
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
return PasswordChangeRequired;
}
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
return PasswordRight;
} else {
@ -1084,11 +1090,22 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
"passwordLastChangedDate = NOW() where name = :name");
passwordQuery->bindValue(":password", passwordSha512);
passwordQuery->bindValue(":name", user);
if (execSqlQuery(passwordQuery)) {
return true;
if (!execSqlQuery(passwordQuery)) {
return false;
}
return passwordQuery->numRowsAffected() > 0;
}
return false;
void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force)
{
if (!checkSql()) {
return;
}
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET force_password_change = :force WHERE name = :name");
query->bindValue(":force", force ? 1 : 0);
query->bindValue(":name", user);
execSqlQuery(query);
}
bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
@ -1314,6 +1331,169 @@ QList<ServerInfo_Warning> Servatrice_DatabaseInterface::getUserWarnHistory(const
return results;
}
QList<ServerInfo_UserSession> Servatrice_DatabaseInterface::getUserSessions(const QString &userName, int limit)
{
QList<ServerInfo_UserSession> results;
if (!checkSql()) {
return results;
}
QSqlQuery *query = prepareQuery("SELECT user_name, ip_address, clientid, "
"UNIX_TIMESTAMP(start_time), UNIX_TIMESTAMP(end_time), connection_type "
"FROM {prefix}_sessions WHERE user_name = :user_name "
"ORDER BY start_time DESC LIMIT :limit");
query->bindValue(":user_name", userName);
query->bindValue(":limit", limit);
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Failed to collect session history information: SQL Error";
return results;
}
while (query->next()) {
ServerInfo_UserSession sessionDetails;
sessionDetails.set_user_name(query->value(0).toString().toStdString());
sessionDetails.set_ip_address(query->value(1).toString().toStdString());
sessionDetails.set_clientid(query->value(2).toString().toStdString());
sessionDetails.set_start_time(query->value(3).toLongLong());
if (!query->value(4).isNull()) {
sessionDetails.set_end_time(query->value(4).toLongLong());
}
sessionDetails.set_connection_type(query->value(5).toString().toStdString());
results << sessionDetails;
}
return results;
}
QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QString &userName)
{
QList<ServerInfo_UserAlt> results;
if (!checkSql()) {
return results;
}
// Seed account identifiers used to find related accounts
QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name");
seedQuery->bindValue(":user_name", userName);
if (!execSqlQuery(seedQuery) || !seedQuery->next()) {
return results;
}
const QString seedEmail = seedQuery->value(0).toString();
const QString seedClientId = seedQuery->value(1).toString();
QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), "
"UNIX_TIMESTAMP(a.last_login), "
"(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), "
"(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), "
"u.active "
"FROM {prefix}_users u "
"LEFT JOIN {prefix}_user_analytics a ON a.id = u.id "
"WHERE u.name = :user_name";
if (!seedEmail.isEmpty()) {
queryString.append(" OR u.email = :seed_email");
}
if (!seedClientId.isEmpty()) {
queryString.append(" OR u.clientid = :seed_clientid");
}
queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s "
"WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 "
"WHERE s2.user_name = :user_name"
" AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"
" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) "
"ORDER BY u.name LIMIT 200");
QSqlQuery *query = prepareQuery(queryString);
query->bindValue(":user_name", userName);
if (!seedEmail.isEmpty()) {
query->bindValue(":seed_email", seedEmail);
}
if (!seedClientId.isEmpty()) {
query->bindValue(":seed_clientid", seedClientId);
}
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error";
return results;
}
while (query->next()) {
ServerInfo_UserAlt altDetails;
altDetails.set_user_name(query->value(0).toString().toStdString());
altDetails.set_email(query->value(1).toString().toStdString());
altDetails.set_clientid(query->value(2).toString().toStdString());
altDetails.set_registration_time(query->value(3).toLongLong());
if (!query->value(4).isNull()) {
altDetails.set_last_login(query->value(4).toLongLong());
}
altDetails.set_warn_count(query->value(5).toInt());
altDetails.set_ban_count(query->value(6).toInt());
altDetails.set_is_active(query->value(7).toBool());
results << altDetails;
}
return results;
}
QList<ServerInfo_ModeratorLogin> Servatrice_DatabaseInterface::getModeratorLastLogins()
{
QList<ServerInfo_ModeratorLogin> results;
if (!checkSql()) {
return results;
}
QSqlQuery *query = prepareQuery("SELECT u.name, u.admin, UNIX_TIMESTAMP(a.last_login) "
"FROM {prefix}_users u "
"LEFT JOIN {prefix}_user_analytics a ON a.id = u.id "
"WHERE (u.admin & 7) <> 0 ORDER BY u.name");
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error";
return results;
}
while (query->next()) {
ServerInfo_ModeratorLogin loginDetails;
loginDetails.set_user_name(query->value(0).toString().toStdString());
const int isAdmin = query->value(1).toInt();
int userLevel = ServerInfo_User::IsUser | ServerInfo_User::IsRegistered;
if (isAdmin & 1) {
userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator;
} else if (isAdmin & 2) {
userLevel |= ServerInfo_User::IsModerator;
}
if (isAdmin & 4) {
userLevel |= ServerInfo_User::IsJudge;
}
loginDetails.set_user_level(userLevel);
if (!query->value(2).isNull()) {
loginDetails.set_last_login(query->value(2).toLongLong());
}
results << loginDetails;
}
return results;
}
bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName)
{
if (!checkSql()) {
return false;
}
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET avatar_bmp = '' WHERE name = :user_name");
query->bindValue(":user_name", userName);
if (!execSqlQuery(query)) {
return false;
}
return query->numRowsAffected() > 0;
}
QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user,
const QString &ipaddress,
const QString &gamename,

View file

@ -6,11 +6,14 @@
#include <QObject>
#include <QSqlDatabase>
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user_session.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
#include <server.h>
#include <server_database_interface.h>
#define DATABASE_SCHEMA_VERSION 35
#define DATABASE_SCHEMA_VERSION 36
class Servatrice;
@ -119,6 +122,7 @@ public:
bool oldPasswordNeedsHash,
const QString &newPassword,
bool newPasswordNeedsHash) override;
void setForcePasswordChange(const QString &user, bool force) override;
QList<ServerInfo_Ban> getUserBanHistory(const QString userName);
bool
addWarning(const QString userName, const QString adminName, const QString warningReason, const QString clientID);
@ -133,6 +137,10 @@ public:
bool &room,
int &range,
int &maxresults);
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName);
QList<ServerInfo_ModeratorLogin> getModeratorLastLogins();
bool removeUserAvatar(const QString &userName);
bool addForgotPassword(const QString &user);
bool removeForgotPassword(const QString &user) override;
bool doesForgotPasswordExist(const QString &user);

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,16 @@
#include <QMutex>
#include <QTcpSocket>
#include <QWebSocket>
#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>
#include <libcockatrice/protocol/pb/command_report_assign.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_my_list.pb.h>
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <server_protocolhandler.h>
class Servatrice;
@ -50,6 +60,7 @@ class Command_BanFromServer;
class Command_UpdateServerMessage;
class Command_ShutdownServer;
class Command_ReloadConfig;
class Command_ReplayDownloadByGameId;
class Command_AccountEdit;
class Command_AccountImage;
@ -67,7 +78,7 @@ signals:
void incTxBytes(qint64 amount);
protected:
void logDebugMessage(const QString &message);
void logDebugMessage(const QString &message) override;
bool tooManyRegistrationAttempts(const QString &ipAddress);
virtual void writeToSocket(QByteArray &data) = 0;
@ -102,6 +113,7 @@ private:
Response::ResponseCode cmdReplayGetCode(const Command_ReplayGetCode &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, ResponseContainer &rc);
Response::ResponseCode cmdBanFromServer(const Command_BanFromServer &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportList(const Command_ReportList &cmd, ResponseContainer &rc);
Response::ResponseCode cmdWarnUser(const Command_WarnUser &cmd, ResponseContainer &rc);
Response::ResponseCode cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc);
Response::ResponseCode cmdGetBanHistory(const Command_GetBanHistory &cmd, ResponseContainer &rc);
@ -109,6 +121,10 @@ private:
Response::ResponseCode cmdGetWarnHistory(const Command_GetWarnHistory &cmd, ResponseContainer &rc);
Response::ResponseCode cmdShutdownServer(const Command_ShutdownServer &cmd, ResponseContainer &rc);
Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &);
Response::ResponseCode cmdReportResolve(const Command_ReportResolve &cmd, ResponseContainer &);
Response::ResponseCode cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc);
Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc);
Response::ResponseCode cmdActivateAccount(const Command_Activate &cmd, ResponseContainer & /* rc */);
Response::ResponseCode cmdReloadConfig(const Command_ReloadConfig & /* cmd */, ResponseContainer & /*rc*/);
@ -122,10 +138,19 @@ private:
Response::ResponseCode cmdForgotPasswordChallenge(const Command_ForgotPasswordChallenge &cmd,
ResponseContainer &rc);
Response::ResponseCode cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd, ResponseContainer &rc);
Response::ResponseCode processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReport(const Command_Report &cmd, ResponseContainer &);
Response::ResponseCode cmdReportMyList(const Command_ReportMyList &cmd, ResponseContainer &rc);
void sendPendingReportNotifications(ResponseContainer &rc);
void onLogin(ResponseContainer &rc) override;
Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc);
Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc);
Response::ResponseCode
processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc);
Response::ResponseCode processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc);
processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override;
Response::ResponseCode
processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc) override;
Response::ResponseCode
processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc) override;
Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc);
Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc);
@ -141,6 +166,12 @@ private:
Response::ResponseCode cmdGetAdminNotes(const Command_GetAdminNotes &cmd, ResponseContainer &rc);
Response::ResponseCode cmdUpdateAdminNotes(const Command_UpdateAdminNotes &cmd, ResponseContainer &rc);
Response::ResponseCode cmdGetUserSessions(const Command_GetUserSessions &cmd, ResponseContainer &rc);
Response::ResponseCode cmdGetUserAlts(const Command_GetUserAlts &cmd, ResponseContainer &rc);
Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc);
Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc);
Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc);
bool addAdminFlagToUser(const QString &user, int flag);
bool removeAdminFlagFromUser(const QString &user, int flag);
@ -157,9 +188,9 @@ public:
bool initSession();
virtual QHostAddress getPeerAddress() const = 0;
virtual QString getAddress() const = 0;
QString getAddress() const override = 0;
void transmitProtocolItem(const ServerMessage &item);
void transmitProtocolItem(const ServerMessage &item) override;
};
class TcpServerSocketInterface : public AbstractServerSocketInterface

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 warning_categories_test COMMAND warning_categories_test)
add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test)
set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5)
@ -27,6 +28,7 @@ add_executable(deck_hash_performance_test deck_hash_performance_test.cpp)
add_executable(server_card_counter_test server_card_counter_test.cpp)
add_executable(server_counter_test server_counter_test.cpp)
add_executable(server_rate_limiter_test server_rate_limiter_test.cpp)
add_executable(warning_categories_test warning_categories_test.cpp)
find_package(GTest)
@ -63,6 +65,7 @@ if(NOT GTEST_FOUND)
add_dependencies(server_card_counter_test gtest)
add_dependencies(server_counter_test gtest)
add_dependencies(server_rate_limiter_test gtest)
add_dependencies(warning_categories_test gtest)
endif()
include_directories(${GTEST_INCLUDE_DIRS})
@ -94,6 +97,9 @@ target_link_libraries(
target_link_libraries(
server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
target_link_libraries(
warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}
)
add_subdirectory(card_zone_algorithms)
add_subdirectory(carddatabase)

View file

@ -232,6 +232,12 @@ TEST_F(SettingsDefaultsTest, Tabs_AllTabsOpen_Default)
ASSERT_EQ(s.getTabLogOpen(), true);
}
TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default)
{
TabsSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getTabModerationOpen(), false);
}
// --- ChatSettings ---
TEST_F(SettingsDefaultsTest, Chat_Mention_Default)

View file

@ -0,0 +1,89 @@
#include "gtest/gtest.h"
#include <QString>
#include <libcockatrice/utility/warning_categories.h>
TEST(WarningCategoriesTest, EmptyValueYieldsNoCategories)
{
EXPECT_TRUE(parseWarningCategories(QString()).isEmpty());
EXPECT_TRUE(parseWarningCategories(QString("")).isEmpty());
}
TEST(WarningCategoriesTest, PlainNamesDefaultToInterventionLevelOne)
{
const QList<WarningCategory> categories = parseWarningCategories("Flaming,Spamming,Causing Drama");
ASSERT_EQ(3, categories.size());
EXPECT_EQ("Flaming", categories.at(0).name);
EXPECT_EQ(1, categories.at(0).startingIl);
EXPECT_EQ("Spamming", categories.at(1).name);
EXPECT_EQ(1, categories.at(1).startingIl);
EXPECT_EQ("Causing Drama", categories.at(2).name);
EXPECT_EQ(1, categories.at(2).startingIl);
}
TEST(WarningCategoriesTest, ExplicitInterventionLevelsAreParsed)
{
const QList<WarningCategory> categories = parseWarningCategories("Cheating|2,Inappropriate Avatar|3");
ASSERT_EQ(2, categories.size());
EXPECT_EQ("Cheating", categories.at(0).name);
EXPECT_EQ(2, categories.at(0).startingIl);
EXPECT_EQ("Inappropriate Avatar", categories.at(1).name);
EXPECT_EQ(3, categories.at(1).startingIl);
}
TEST(WarningCategoriesTest, MixedEntriesKeepDefaultsForThoseWithoutLevels)
{
const QList<WarningCategory> categories = parseWarningCategories("Abusive Language|1,Cheating|2,Spamming");
ASSERT_EQ(3, categories.size());
EXPECT_EQ(1, categories.at(0).startingIl);
EXPECT_EQ(2, categories.at(1).startingIl);
EXPECT_EQ("Spamming", categories.at(2).name);
EXPECT_EQ(1, categories.at(2).startingIl);
}
TEST(WarningCategoriesTest, EmptyEntriesAreSkipped)
{
const QList<WarningCategory> categories = parseWarningCategories("Spamming,,Cheating|2,");
ASSERT_EQ(2, categories.size());
EXPECT_EQ("Spamming", categories.at(0).name);
EXPECT_EQ("Cheating", categories.at(1).name);
}
TEST(WarningCategoriesTest, WhitespaceIsTrimmed)
{
const QList<WarningCategory> categories = parseWarningCategories(" Abusive Language , Cheating | 2 ");
ASSERT_EQ(2, categories.size());
EXPECT_EQ("Abusive Language", categories.at(0).name);
EXPECT_EQ(1, categories.at(0).startingIl);
EXPECT_EQ("Cheating", categories.at(1).name);
EXPECT_EQ(2, categories.at(1).startingIl);
}
TEST(WarningCategoriesTest, InvalidInterventionLevelsFallBackToOne)
{
const QList<WarningCategory> categories = parseWarningCategories("Spamming|abc,Cheating|0,Targeted Harassment|-3");
ASSERT_EQ(3, categories.size());
EXPECT_EQ(1, categories.at(0).startingIl);
EXPECT_EQ(1, categories.at(1).startingIl);
EXPECT_EQ(1, categories.at(2).startingIl);
}
TEST(WarningCategoriesTest, EntryWithOnlyLevelIsSkipped)
{
const QList<WarningCategory> categories = parseWarningCategories("|2,Spamming|2");
ASSERT_EQ(1, categories.size());
EXPECT_EQ("Spamming", categories.at(0).name);
EXPECT_EQ(2, categories.at(0).startingIl);
}
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}