From 2de068270d72269a4804c15847a301b455203a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:31:50 +0200 Subject: [PATCH] [Alts] Server-side search controls, IP counts, adjustable window Plumbs the match-type chips through to the server: include_email/include_clientid/include_ip skip disabled identifier types in both matching and frontier expansion, so excluded types (e.g. throwaway domains) never enter the graph at any depth. Session lookback becomes adjustable (session_months, clamped 1-24, default 6). Each alt row carries ip_count, the distinct recent session IP count shown in a new IPs column. All proto additions are optional with old defaults, so old clients/servers interoperate. --- .../src/interface/widgets/tabs/tab_alts.cpp | 52 ++++++-- .../src/interface/widgets/tabs/tab_alts.h | 5 +- .../protocol/pb/moderator_commands.proto | 4 + .../protocol/pb/serverinfo_user_alt.proto | 1 + .../src/servatrice_database_interface.cpp | 117 ++++++++++++------ .../src/servatrice_database_interface.h | 7 +- servatrice/src/serversocketinterface.cpp | 4 +- 7 files changed, 139 insertions(+), 51 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 54d565b5d..97ecbdad3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -40,14 +40,15 @@ constexpr int COL_ALTS_USER = 0; constexpr int COL_ALTS_VIA = 1; constexpr int COL_ALTS_DEPTH = 2; constexpr int COL_ALTS_SHARED = 3; -constexpr int COL_ALTS_EMAIL = 4; -constexpr int COL_ALTS_CLIENTID = 5; -constexpr int COL_ALTS_REGISTERED = 6; -constexpr int COL_ALTS_LAST_LOGIN = 7; -constexpr int COL_ALTS_WARNS = 8; -constexpr int COL_ALTS_BANS = 9; -constexpr int COL_ALTS_ACTIVE = 10; -constexpr int COL_ALTS_COUNT = 11; +constexpr int COL_ALTS_IPS = 4; +constexpr int COL_ALTS_EMAIL = 5; +constexpr int COL_ALTS_CLIENTID = 6; +constexpr int COL_ALTS_REGISTERED = 7; +constexpr int COL_ALTS_LAST_LOGIN = 8; +constexpr int COL_ALTS_WARNS = 9; +constexpr int COL_ALTS_BANS = 10; +constexpr int COL_ALTS_ACTIVE = 11; +constexpr int COL_ALTS_COUNT = 12; constexpr int COL_WARNS_TIME = 0; constexpr int COL_WARNS_MODERATOR = 1; @@ -92,6 +93,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q depthSpin->setMinimum(1); depthSpin->setMaximum(MAX_ALT_DEPTH); depthSpin->setValue(1); + connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); investigateButton = new QPushButton; connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); @@ -121,7 +123,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q auto *chip = new QPushButton(text); chip->setCheckable(true); chip->setChecked(true); - connect(chip, &QPushButton::toggled, this, &TabAlts::onFilterChanged); + connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged); return chip; }; emailChip = makeChip(QString()); @@ -132,7 +134,13 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q vpnSpin->setMinimum(1); vpnSpin->setMaximum(100000); vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); - connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged); + windowLabel = new QLabel; + monthsSpin = new QSpinBox; + monthsSpin->setMinimum(1); + monthsSpin->setMaximum(24); + monthsSpin->setValue(6); + connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); auto *filterBar = new QHBoxLayout; filterBar->addWidget(emailChip); @@ -140,6 +148,8 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q filterBar->addWidget(ipChip); filterBar->addWidget(vpnLabel); filterBar->addWidget(vpnSpin); + filterBar->addWidget(windowLabel); + filterBar->addWidget(monthsSpin); filterBar->addStretch(); altsTable = new QTableWidget(0, COL_ALTS_COUNT); @@ -232,8 +242,11 @@ void TabAlts::retranslateUi() ipChip->setToolTip(tr("Show or hide IP address matches")); vpnLabel->setText(tr("Hide IPs shared by more than")); vpnSpin->setSuffix(tr(" users")); + windowLabel->setText(tr("Session window")); + monthsSpin->setSuffix(tr(" months")); + monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered.")); - altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account")); @@ -334,6 +347,10 @@ void TabAlts::requestAlts(const QString &userName, int depth) Command_GetUserAlts cmd; cmd.set_user_name(userName.toStdString()); cmd.set_depth(static_cast(depth)); + cmd.set_include_email(emailChip->isChecked()); + cmd.set_include_clientid(clientIdChip->isChecked()); + cmd.set_include_ip(ipChip->isChecked()); + cmd.set_session_months(static_cast(monthsSpin->value())); PendingCommand *pend = client->prepareModeratorCommand(cmd); connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { if (userName == currentUser) { @@ -430,6 +447,9 @@ void TabAlts::renderTable() altsTable->setItem( row, COL_ALTS_SHARED, new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem( + row, COL_ALTS_IPS, + new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-"))); 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()))); @@ -477,7 +497,15 @@ void TabAlts::renderTable() } } -void TabAlts::onFilterChanged() +void TabAlts::onSearchFilterChanged() +{ + if (currentUser.isEmpty()) { + return; + } + investigateUser(); +} + +void TabAlts::onDisplayFilterChanged() { if (lastAlts.isEmpty()) { return; diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 4ba355447..150f32a58 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -44,7 +44,8 @@ private slots: void goBack(); void investigateSelected(); void altsResponse(const Response &response); - void onFilterChanged(); + void onSearchFilterChanged(); + void onDisplayFilterChanged(); void onAltSelected(); void userDetailsResponse(const Response &response); void warnHistoryResponse(const Response &response); @@ -81,6 +82,8 @@ private: QPushButton *ipChip; QLabel *vpnLabel; QSpinBox *vpnSpin; + QLabel *windowLabel; + QSpinBox *monthsSpin; QLabel *detailNameValue; QLabel *detailCountsValue; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 7963a97f2..91e265a6e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -166,6 +166,10 @@ message Command_GetUserAlts { } optional string user_name = 1; optional uint32 depth = 2 [default = 1]; // BFS depth: 1 = direct matches only, up to 3 for transitive alts-of-alts + optional bool include_email = 3 [default = true]; // match and expand over registration email + optional bool include_clientid = 4 [default = true]; // match and expand over current and historic client ids + optional bool include_ip = 5 [default = true]; // match and expand over recent session IPs + optional uint32 session_months = 6 [default = 6]; // session lookback window in months, 1-24 } message Command_GetModeratorLastLogins { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto index ce5e4fce2..c9ec9aa9a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -17,4 +17,5 @@ message ServerInfo_UserAlt { optional uint32 depth = 10; // BFS distance from investigated account; 1 = direct, 2+ = alt-of-alt optional uint32 shared_count = 11; // distinct users sharing the matched identifier; high values suggest VPN/shared network + optional uint32 ip_count = 12; // distinct recent session IPs of this account in the search window } diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 83a9df35d..44a5bec20 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1631,6 +1631,8 @@ constexpr int MAX_USER_ALTS_PER_LEVEL = 200; // Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are // truncated: this tool is deliberately a first-pass sweep, not an audit. constexpr int MAX_USER_ALT_IDENTIFIERS = 200; +constexpr int MIN_USER_ALT_MONTHS = 1; +constexpr int MAX_USER_ALT_MONTHS = 24; QString altInClause(const QString &prefix, const QList &values) { @@ -1673,7 +1675,12 @@ QList cappedIdentifiers(QSet values) } } // namespace -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, int depth) +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, + int depth, + bool useEmail, + bool useClientId, + bool useIp, + int sessionMonths) { QList results; @@ -1686,6 +1693,16 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin } else if (depth > MAX_USER_ALT_DEPTH) { depth = MAX_USER_ALT_DEPTH; } + if (sessionMonths < MIN_USER_ALT_MONTHS) { + sessionMonths = MIN_USER_ALT_MONTHS; + } else if (sessionMonths > MAX_USER_ALT_MONTHS) { + sessionMonths = MAX_USER_ALT_MONTHS; + } + + // Interpolated, not bound: placeholders are not allowed in INTERVAL + // expressions, and the clamped integer cannot inject SQL. + const QString sessionWindow = QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL ") + + QString::number(sessionMonths) + QStringLiteral(" MONTH)"); // Seed must exist; its row anchors the result at depth 0. QSqlQuery *seedQuery = prepareQuery("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " @@ -1740,36 +1757,40 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin QSet emails; QSet clientIds; QSet ips; - QSqlQuery *frontierQuery = - prepareQuery(QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + frontierClause); - altBindValues(frontierQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(frontierQuery)) { - while (frontierQuery->next()) { - const QString email = frontierQuery->value(0).toString(); - const QString clientId = frontierQuery->value(1).toString(); - if (!email.isEmpty()) { - emails << email; - } - if (!clientId.isEmpty()) { - clientIds << clientId; + if (useEmail || useClientId) { + QSqlQuery *frontierQuery = prepareQuery( + QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + frontierClause); + altBindValues(frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(frontierQuery)) { + while (frontierQuery->next()) { + const QString email = frontierQuery->value(0).toString(); + const QString clientId = frontierQuery->value(1).toString(); + if (useEmail && !email.isEmpty()) { + emails << email; + } + if (useClientId && !clientId.isEmpty()) { + clientIds << clientId; + } } } } // Recent session identifiers: IPs plus historic client ids. - QSqlQuery *sessionQuery = prepareQuery( - QStringLiteral("SELECT DISTINCT ip_address, clientid FROM {prefix}_sessions WHERE user_name IN ") + - frontierClause + QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)")); - altBindValues(sessionQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(sessionQuery)) { - while (sessionQuery->next()) { - const QString ip = sessionQuery->value(0).toString(); - const QString clientId = sessionQuery->value(1).toString(); - if (!ip.isEmpty()) { - ips << ip; - } - if (!clientId.isEmpty()) { - clientIds << clientId; + if (useIp || useClientId) { + QSqlQuery *sessionQuery = prepareQuery( + QStringLiteral("SELECT DISTINCT ip_address, clientid FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + sessionWindow); + altBindValues(sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(sessionQuery)) { + while (sessionQuery->next()) { + const QString ip = sessionQuery->value(0).toString(); + const QString clientId = sessionQuery->value(1).toString(); + if (useIp && !ip.isEmpty()) { + ips << ip; + } + if (useClientId && !clientId.isEmpty()) { + clientIds << clientId; + } } } } @@ -1802,14 +1823,13 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin if (!ipList.isEmpty()) { conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " "s.ip_address IN ") + - altInClause(QStringLiteral(":alt_ip"), ipList) + - QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + altInClause(QStringLiteral(":alt_ip"), ipList) + sessionWindow + QStringLiteral(")"); } if (!clientIdList.isEmpty()) { conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " "s.clientid IN ") + - altInClause(QStringLiteral(":alt_hcid"), clientIdList) + - QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + altInClause(QStringLiteral(":alt_hcid"), clientIdList) + sessionWindow + + QStringLiteral(")"); } candidateString += conditions.join(QStringLiteral(" OR ")); candidateString += QStringLiteral(" ORDER BY u.name LIMIT ") + QString::number(MAX_USER_ALTS_PER_LEVEL); @@ -1869,7 +1889,7 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin altInClause(QStringLiteral(":alt_ecid"), clientIdList); } evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); - evidenceString += QStringLiteral(") AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)"); + evidenceString += QStringLiteral(")") + sessionWindow; QSqlQuery *evidenceQuery = prepareQuery(evidenceString); altBindValues(evidenceQuery, QStringLiteral(":alt_ev"), unexplained); @@ -1937,11 +1957,11 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin } if (!neededIps.isEmpty()) { const QList neededIpList = neededIps.values(); - QSqlQuery *countQuery = prepareQuery( - QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions WHERE " - "ip_address IN ") + - altInClause(QStringLiteral(":alt_ci"), neededIpList) + - QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) GROUP BY ip_address")); + QSqlQuery *countQuery = + prepareQuery(QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions WHERE " + "ip_address IN ") + + altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow + + QStringLiteral(" GROUP BY ip_address")); altBindValues(countQuery, QStringLiteral(":alt_ci"), neededIpList); if (execSqlQuery(countQuery)) { while (countQuery->next()) { @@ -1978,6 +1998,31 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin frontier = nextFrontier; } + // Distinct recent IP counts per account: cheap group signal without + // shipping full address histories to the client. + if (!results.isEmpty()) { + QList resultNames; + resultNames.reserve(results.size()); + for (const ServerInfo_UserAlt &alt : results) { + resultNames << QString::fromStdString(alt.user_name()); + } + QSqlQuery *ipCountQuery = prepareQuery( + QStringLiteral("SELECT user_name, COUNT(DISTINCT ip_address) FROM {prefix}_sessions WHERE user_name IN ") + + altInClause(QStringLiteral(":alt_ic"), resultNames) + sessionWindow + + QStringLiteral(" GROUP BY user_name")); + altBindValues(ipCountQuery, QStringLiteral(":alt_ic"), resultNames); + QMap ipCountsByUser; + if (execSqlQuery(ipCountQuery)) { + while (ipCountQuery->next()) { + ipCountsByUser.insert(ipCountQuery->value(0).toString(), ipCountQuery->value(1).toInt()); + } + } + for (ServerInfo_UserAlt &alt : results) { + alt.set_ip_count(static_cast( + ipCountsByUser.value(QString::fromStdString(alt.user_name()), 0))); + } + } + return results; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index f76ca27f5..90ab7267a 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -200,7 +200,12 @@ public: int &range, int &maxresults); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName, int depth = 1); + QList getUserAlts(const QString &userName, + int depth = 1, + bool useEmail = true, + bool useClientId = true, + bool useIp = true, + int sessionMonths = 6); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index a394e6bfd..5cbb289a6 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2566,7 +2566,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma Response_UserAlts *re = new Response_UserAlts; const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; - const QList alts = sqlInterface->getUserAlts(userName, depth); + const int sessionMonths = cmd.has_session_months() ? static_cast(cmd.session_months()) : 6; + const QList alts = sqlInterface->getUserAlts( + userName, depth, cmd.include_email(), cmd.include_clientid(), cmd.include_ip(), sessionMonths); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); }