From a82d8f30216e2fa70a1dd149de5375a513fe9e5c 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 | 114 +++++++++++++----- .../src/servatrice_database_interface.h | 7 +- servatrice/src/serversocketinterface.cpp | 4 +- 7 files changed, 142 insertions(+), 45 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 833584a39..b13f67de4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -42,14 +42,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; @@ -94,6 +95,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); @@ -126,7 +128,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()); @@ -137,7 +139,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); @@ -145,6 +153,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); @@ -235,8 +245,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")); @@ -355,6 +368,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) { @@ -456,6 +473,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()))); @@ -513,7 +533,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 50aabf8f5..b0b241bf6 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); @@ -82,6 +83,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 19aec92c3..d03f93667 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -170,6 +170,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 e25ae5e6c..661a1b254 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1987,6 +1987,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) { @@ -2037,7 +2039,12 @@ QList cappedIdentifiers(const QList &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; @@ -2050,6 +2057,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), " @@ -2122,32 +2139,46 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin QSet clientIdKeys; QSet ipKeys; - QSqlQuery frontierQuery(sqlDatabase); - prepareDynamicQuery(frontierQuery, QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + - frontierClause); - altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(&frontierQuery)) { - while (frontierQuery.next()) { - // Email comparison is case-insensitive in SQL, so normalise the - // keys too or the membership tests below miss real matches. - appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); - appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + if (useEmail || useClientId) { + QSqlQuery frontierQuery(sqlDatabase); + prepareDynamicQuery(frontierQuery, + QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + + frontierClause); + altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&frontierQuery)) { + while (frontierQuery.next()) { + // Email comparison is case-insensitive in SQL, so normalise the + // keys too or the membership tests below miss real matches. + if (useEmail) { + appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + } + } } } // Recent session identifiers: IPs plus historic client ids, most recent // first so a capped list keeps what the account actually used lately. - QSqlQuery sessionQuery(sqlDatabase); - prepareDynamicQuery(sessionQuery, QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " - "FROM {prefix}_sessions WHERE user_name IN ") + - frontierClause + - QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " - "GROUP BY ip_address, clientid ORDER BY last_seen DESC")); - altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(&sessionQuery)) { - while (sessionQuery.next()) { - appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); - appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + if (useIp || useClientId) { + QSqlQuery sessionQuery(sqlDatabase); + prepareDynamicQuery(sessionQuery, + QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " + "FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + sessionWindow + + QStringLiteral(" GROUP BY ip_address, clientid " + "ORDER BY last_seen DESC")); + altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&sessionQuery)) { + while (sessionQuery.next()) { + if (useIp) { + appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + } + } } } @@ -2179,14 +2210,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 ")); // Already-visited accounts match their own identifiers at every level and @@ -2251,7 +2281,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(sqlDatabase); prepareDynamicQuery(evidenceQuery, evidenceString); @@ -2346,9 +2376,8 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin prepareDynamicQuery(countQuery, 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")); + altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow + + QStringLiteral(" GROUP BY ip_address")); altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList); if (execSqlQuery(&countQuery)) { while (countQuery.next()) { @@ -2385,6 +2414,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 327906b3c..c3cc57ab2 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -224,7 +224,12 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); 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 b49b94ec0..c32ccad49 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2596,7 +2596,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); }