This commit is contained in:
BruebachL 2026-11-04 10:30:37 -07:00 • committed by GitHub
commit 376a1d139a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 331 additions and 28 deletions

View file

@ -4,21 +4,31 @@
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/response_user_alts.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
#include <QAbstractItemView>
#include <QCheckBox>
#include <QDateTime>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QPushButton>
#include <QSpinBox>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextEdit>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/response_warn_history.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <string>
@ -38,6 +48,21 @@ constexpr int COL_ALTS_ACTIVE = 10;
constexpr int COL_ALTS_COUNT = 11;
constexpr int MAX_ALT_DEPTH = 3;
// Starting default only (adjustable 1-100000): shared VPN exits routinely
// cover dozens of users, so 10 hides the obvious VPN noise but keeps
// small shared networks visible.
constexpr int DEFAULT_VPN_THRESHOLD = 10;
void configureAltTable(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);
}
} // namespace
TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser)
@ -70,27 +95,80 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q
topBar->addWidget(depthSpin);
topBar->addWidget(investigateButton);
emailCheck = new QCheckBox;
emailCheck->setChecked(true);
connect(emailCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged);
clientIdCheck = new QCheckBox;
clientIdCheck->setChecked(true);
connect(clientIdCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged);
ipCheck = new QCheckBox;
ipCheck->setChecked(true);
connect(ipCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged);
vpnLabel = new QLabel;
vpnSpin = new QSpinBox;
vpnSpin->setMinimum(1);
vpnSpin->setMaximum(100000);
vpnSpin->setValue(DEFAULT_VPN_THRESHOLD);
connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged);
auto *filterBar = new QHBoxLayout;
filterBar->addWidget(emailCheck);
filterBar->addWidget(clientIdCheck);
filterBar->addWidget(ipCheck);
filterBar->addWidget(vpnLabel);
filterBar->addWidget(vpnSpin);
filterBar->addStretch();
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
altsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
altsTable->setSelectionMode(QAbstractItemView::SingleSelection);
altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
altsTable->verticalHeader()->setVisible(false);
altsTable->setAlternatingRowColors(true);
altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
altsTable->horizontalHeader()->setStretchLastSection(true);
configureAltTable(altsTable);
connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) {
auto *userItem = altsTable->item(row, COL_ALTS_USER);
if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) {
investigate(userItem->text());
}
});
connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected);
detailNameValue = new QLabel;
detailCountsValue = new QLabel;
detailNotesEdit = new QTextEdit;
detailNotesEdit->setReadOnly(true);
// Compact cap so the warn/ban tables stay visible without scrolling the tab.
detailNotesEdit->setMaximumHeight(60);
warnsTable = new QTableWidget(0, 3);
configureAltTable(warnsTable);
bansTable = new QTableWidget(0, 4);
configureAltTable(bansTable);
auto *detailGrid = new QGridLayout;
detailNameLabel = new QLabel;
detailCountsLabel = new QLabel;
detailNotesLabel = new QLabel;
detailGrid->addWidget(detailNameLabel, 0, 0);
detailGrid->addWidget(detailNameValue, 0, 1);
detailGrid->addWidget(detailCountsLabel, 0, 2);
detailGrid->addWidget(detailCountsValue, 0, 3);
detailGrid->addWidget(detailNotesLabel, 1, 0);
detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3);
auto *detailTables = new QHBoxLayout;
detailTables->addWidget(warnsTable);
detailTables->addWidget(bansTable);
detailsGroup = new QGroupBox;
auto *detailsLayout = new QVBoxLayout(detailsGroup);
detailsLayout->addLayout(detailGrid);
detailsLayout->addLayout(detailTables);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
mainLayout->addLayout(filterBar);
mainLayout->addWidget(statusLabel);
mainLayout->addWidget(altsTable);
mainLayout->addWidget(altsTable, 3);
mainLayout->addWidget(detailsGroup, 2);
retranslateUi();
clearDetails();
investigate(initialUser);
}
@ -106,9 +184,22 @@ void TabAlts::retranslateUi()
statusLabel->setText(tr("Enter a user name to investigate."));
}
emailCheck->setText(tr("Email"));
clientIdCheck->setText(tr("Client ID"));
ipCheck->setText(tr("IP"));
vpnLabel->setText(tr("Hide IPs shared by more than"));
vpnSpin->setSuffix(tr(" users"));
altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"),
tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"),
tr("Active")});
warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")});
bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")});
detailsGroup->setTitle(tr("Selected user"));
detailNameLabel->setText(tr("Name:"));
detailCountsLabel->setText(tr("Counts:"));
detailNotesLabel->setText(tr("Admin notes:"));
}
QString TabAlts::formatEpoch(quint64 ts) const
@ -152,6 +243,9 @@ void TabAlts::investigateUser()
return;
}
currentUser = userName;
lastAlts.clear();
selectedUser.clear();
clearDetails();
altsTable->setRowCount(0);
statusLabel->setText(tr("Loading alts for %1...").arg(currentUser));
requestAlts(userName, depthSpin->value());
@ -173,7 +267,7 @@ void TabAlts::requestAlts(const QString &userName, int depth)
void TabAlts::altsResponse(const Response &response)
{
altsTable->setRowCount(0);
lastAlts.clear();
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser));
return;
@ -186,31 +280,207 @@ void TabAlts::altsResponse(const Response &response)
return;
}
altsTable->setRowCount(resp.alts_size());
int relatedCount = 0;
for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i);
// The server appends the frontier user as a depth-0 seed row.
if (!alt.has_depth() || alt.depth() > 0) {
++relatedCount;
lastAlts << resp.alts(i);
}
renderTable();
}
bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const
{
const QString name = QString::fromStdString(alt.user_name());
if (name == currentUser) {
return true;
}
if (!alt.has_match_reason() || alt.match_reason().empty()) {
return true;
}
const QString reason = QString::fromStdString(alt.match_reason());
if (reason == QStringLiteral("email")) {
return emailCheck->isChecked();
}
if (reason.startsWith(QStringLiteral("clientid:"))) {
return clientIdCheck->isChecked();
}
if (reason.startsWith(QStringLiteral("ip:"))) {
return ipCheck->isChecked() &&
(!alt.has_shared_count() || alt.shared_count() <= static_cast<unsigned>(vpnSpin->value()));
}
return true;
}
void TabAlts::renderTable()
{
altsTable->setRowCount(0);
int hidden = 0;
int row = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
if (!rowVisible(alt)) {
++hidden;
continue;
}
altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name())));
altsTable->setItem(i, COL_ALTS_VIA,
altsTable->insertRow(row);
altsTable->setItem(row, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name())));
altsTable->setItem(row, COL_ALTS_VIA,
new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty()
? QString::fromStdString(alt.match_reason())
: QStringLiteral("-")));
altsTable->setItem(i, COL_ALTS_DEPTH,
altsTable->setItem(row, COL_ALTS_DEPTH,
new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-")));
altsTable->setItem(
i, COL_ALTS_SHARED,
row, COL_ALTS_SHARED,
new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-")));
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")));
altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login())));
altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count())));
altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count())));
altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")));
++row;
}
// The server appends the frontier user as a depth-0 seed row; it is the
// investigated user, not one of their related accounts.
int relatedCount = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
if (!alt.has_depth() || alt.depth() > 0) {
++relatedCount;
}
}
if (hidden == 0) {
statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser));
} else {
statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.")
.arg(row)
.arg(hidden)
.arg(relatedCount)
.arg(currentUser));
}
}
void TabAlts::onFilterChanged()
{
if (lastAlts.isEmpty()) {
return;
}
renderTable();
}
void TabAlts::onAltSelected()
{
const auto selected = altsTable->selectedItems();
if (selected.isEmpty()) {
selectedUser.clear();
clearDetails();
return;
}
auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER);
if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) {
return;
}
selectedUser = userItem->text();
requestDetails(selectedUser);
}
void TabAlts::requestDetails(const QString &userName)
{
detailNameValue->setText(userName);
detailCountsValue->setText(tr("Loading..."));
detailNotesEdit->clear();
warnsTable->setRowCount(0);
bansTable->setRowCount(0);
Command_ReportUserInfo infoCmd;
infoCmd.set_user_name(userName.toStdString());
PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd);
connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
userDetailsResponse(response);
}
});
client->sendCommand(infoPend);
Command_GetWarnHistory warnCmd;
warnCmd.set_user_name(userName.toStdString());
PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd);
connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
warnHistoryResponse(response);
}
});
client->sendCommand(warnPend);
Command_GetBanHistory banCmd;
banCmd.set_user_name(userName.toStdString());
PendingCommand *banPend = client->prepareModeratorCommand(banCmd);
connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
banHistoryResponse(response);
}
});
client->sendCommand(banPend);
}
void TabAlts::clearDetails()
{
detailNameValue->clear();
detailCountsValue->clear();
detailNotesEdit->clear();
warnsTable->setRowCount(0);
bansTable->setRowCount(0);
}
void TabAlts::userDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
detailCountsValue->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
detailCountsValue->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()) {
detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
detailNotesEdit->setPlainText(tr("(none)"));
}
}
void TabAlts::warnHistoryResponse(const Response &response)
{
warnsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext);
warnsTable->setRowCount(resp.warn_list_size());
for (int i = 0; i < resp.warn_list_size(); ++i) {
const ServerInfo_Warning &warn = resp.warn_list(i);
warnsTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(warn.time_of())));
warnsTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(warn.admin_name())));
warnsTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(warn.reason())));
}
}
void TabAlts::banHistoryResponse(const Response &response)
{
bansTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext);
bansTable->setRowCount(resp.ban_list_size());
for (int i = 0; i < resp.ban_list_size(); ++i) {
const ServerInfo_Ban &ban = resp.ban_list(i);
bansTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(ban.ban_time())));
bansTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(ban.admin_name())));
bansTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(ban.ban_length())));
bansTable->setItem(i, 3, new QTableWidgetItem(QString::fromStdString(ban.ban_reason())));
}
statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser));
}

View file

@ -3,16 +3,20 @@
#include "tab.h"
#include <QList>
#include <QString>
#include <QtGlobal>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <qtmetamacros.h>
class AbstractClient;
class QCheckBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QTableWidget;
class QTextEdit;
class Response;
class TabSupervisor;
@ -20,6 +24,7 @@ class TabSupervisor;
* Staff alt-graph investigation tool. Shows accounts related to the
* investigated user (shared email, client id, or recent IP address),
* including transitive alts-of-alts, with per-row match evidence.
* Selecting a row loads that account's warnings, bans, and admin notes.
*/
class TabAlts : public Tab
{
@ -37,15 +42,26 @@ private slots:
void investigateUser();
void goBack();
void altsResponse(const Response &response);
void onFilterChanged();
void onAltSelected();
void userDetailsResponse(const Response &response);
void warnHistoryResponse(const Response &response);
void banHistoryResponse(const Response &response);
private:
void loadUser(const QString &userName);
void requestAlts(const QString &userName, int depth);
void requestDetails(const QString &userName);
void clearDetails();
void renderTable();
[[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const;
[[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client;
QString currentUser;
QString selectedUser;
QList<QString> backStack;
QList<ServerInfo_UserAlt> lastAlts;
QLineEdit *searchEdit;
QSpinBox *depthSpin;
@ -53,6 +69,23 @@ private:
QPushButton *backButton;
QLabel *statusLabel;
QTableWidget *altsTable;
QCheckBox *emailCheck;
QCheckBox *clientIdCheck;
QCheckBox *ipCheck;
QLabel *vpnLabel;
QSpinBox *vpnSpin;
QLabel *detailNameValue;
QLabel *detailCountsValue;
QTextEdit *detailNotesEdit;
QTableWidget *warnsTable;
QTableWidget *bansTable;
QGroupBox *detailsGroup;
QLabel *detailNameLabel;
QLabel *detailCountsLabel;
QLabel *detailNotesLabel;
};
#endif // TAB_ALTS_H