From a7fa3f0852057e3a692264e00d57314976229c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:01:32 +0200 Subject: [PATCH 1/4] [Protocol] Add alt match evidence and search depth ServerInfo_UserAlt gains match_reason, depth, and shared_count so moderators can see why an account was flagged and how far it sits from the investigated user. Command_GetUserAlts gains a depth parameter (default 1) for transitive alt-of-alt searches. No behavior change: server still answers depth 1 and leaves the new fields empty until the follow-up server/client layers land. --- .../libcockatrice/protocol/pb/moderator_commands.proto | 1 + .../libcockatrice/protocol/pb/serverinfo_user_alt.proto | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 5298d3dad..7963a97f2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -165,6 +165,7 @@ message Command_GetUserAlts { optional Command_GetUserAlts ext = 1014; } 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 } 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 191dd5063..ce5e4fce2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -13,4 +13,8 @@ message ServerInfo_UserAlt { 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 + optional string match_reason = 9; // why flagged, e.g. "email", "clientid:abc", "ip:1.2.3.4"; empty = seed/legacy + 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 } From 3113289dbe96e3d07496773bcfb94c5ca7a21196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:08:25 +0200 Subject: [PATCH 2/4] [Server] Recursive alt search with match evidence getUserAlts() now walks a breadth-first graph to the requested depth (clamped 1-3): each level expands frontier accounts over registration email, current client id, recent session IPs (6-month window), and historic session client ids, so identifier changes no longer hide older alts. Every row carries match_reason (email / clientid: / ip:), depth, and shared_count fan-out so moderators can distinguish real alts from VPN/shared-network overlap. Command_GetUserAlts depth is plumbed through; unset depth defaults to 1, preserving previous single-hop behavior for old clients. --- .../src/servatrice_database_interface.cpp | 390 +++++++++++++++--- .../src/servatrice_database_interface.h | 2 +- servatrice/src/serversocketinterface.cpp | 3 +- 3 files changed, 343 insertions(+), 52 deletions(-) diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index ac3a19c8e..83a9df35d 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -1621,7 +1622,58 @@ QList Servatrice_DatabaseInterface::getUserSessions(cons return results; } -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +namespace +{ +constexpr int MAX_USER_ALT_DEPTH = 3; +constexpr int MAX_USER_ALTS_PER_LEVEL = 200; +// Bounds per-level work and the prepared-statement cache (keyed by query text, +// so every distinct IN-list size is a new entry on the long-lived server). +// 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; + +QString altInClause(const QString &prefix, const QList &values) +{ + QStringList placeholders; + placeholders.reserve(values.size()); + for (int i = 0; i < values.size(); ++i) { + placeholders << prefix + QString::number(i); + } + return QStringLiteral("(") + placeholders.join(QStringLiteral(",")) + QStringLiteral(")"); +} + +void altBindValues(QSqlQuery *query, const QString &prefix, const QList &values) +{ + for (int i = 0; i < values.size(); ++i) { + query->bindValue(prefix + QString::number(i), values.at(i)); + } +} + +struct AltCandidate +{ + QString name; + QString email; + QString clientId; + qint64 registrationTime = 0; + qint64 lastLogin = 0; + bool hasLastLogin = false; + int warnCount = 0; + int banCount = 0; + bool isActive = false; +}; + +QList cappedIdentifiers(QSet values) +{ + QList list = values.values(); + std::sort(list.begin(), list.end()); + if (list.size() > MAX_USER_ALT_IDENTIFIERS) { + list.erase(list.begin() + MAX_USER_ALT_IDENTIFIERS, list.end()); + } + return list; +} +} // namespace + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, int depth) { QList results; @@ -1629,63 +1681,301 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin return results; } - // Seed account identifiers used to find related accounts - QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + if (depth < 1) { + depth = 1; + } else if (depth > MAX_USER_ALT_DEPTH) { + depth = MAX_USER_ALT_DEPTH; + } + + // 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), " + "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"); 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()) { + auto appendAlt = [&results](const AltCandidate &candidate, const QString &matchReason, int matchDepth, + int sharedCount) { 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_user_name(candidate.name.toStdString()); + altDetails.set_email(candidate.email.toStdString()); + altDetails.set_clientid(candidate.clientId.toStdString()); + altDetails.set_registration_time(candidate.registrationTime); + if (candidate.hasLastLogin) { + altDetails.set_last_login(candidate.lastLogin); } - altDetails.set_warn_count(query->value(5).toInt()); - altDetails.set_ban_count(query->value(6).toInt()); - altDetails.set_is_active(query->value(7).toBool()); + altDetails.set_warn_count(candidate.warnCount); + altDetails.set_ban_count(candidate.banCount); + altDetails.set_is_active(candidate.isActive); + altDetails.set_match_reason(matchReason.toStdString()); + altDetails.set_depth(static_cast(matchDepth)); + altDetails.set_shared_count(static_cast(sharedCount)); results << altDetails; + }; + + AltCandidate seed{.name = seedQuery->value(0).toString(), + .email = seedQuery->value(1).toString(), + .clientId = seedQuery->value(2).toString(), + .registrationTime = seedQuery->value(3).toLongLong(), + .lastLogin = seedQuery->value(4).toLongLong(), + .hasLastLogin = !seedQuery->value(4).isNull(), + .warnCount = seedQuery->value(5).toInt(), + .banCount = seedQuery->value(6).toInt(), + .isActive = seedQuery->value(7).toBool()}; + appendAlt(seed, QStringLiteral("self"), 0, 1); + + QSet visited{userName}; + QList frontier{userName}; + for (int level = 1; level <= depth && !frontier.isEmpty(); ++level) { + const QString frontierClause = altInClause(QStringLiteral(":alt_name"), frontier); + + // Current identifiers of the frontier: registration email + client id. + 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; + } + } + } + + // 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 (emails.isEmpty() && clientIds.isEmpty() && ips.isEmpty()) { + break; + } + + // Accounts sharing any frontier identifier. Historic client ids are matched + // through past sessions so identifier changes do not hide older alts. + QString candidateString = + QStringLiteral("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 "); + QStringList conditions; + QList emailList = cappedIdentifiers(emails); + QList clientIdList = cappedIdentifiers(clientIds); + QList ipList = cappedIdentifiers(ips); + if (!emailList.isEmpty()) { + conditions << QStringLiteral("u.email IN ") + altInClause(QStringLiteral(":alt_email"), emailList); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.clientid IN ") + altInClause(QStringLiteral(":alt_cid"), clientIdList); + } + 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))"); + } + 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))"); + } + candidateString += conditions.join(QStringLiteral(" OR ")); + candidateString += QStringLiteral(" ORDER BY u.name LIMIT ") + QString::number(MAX_USER_ALTS_PER_LEVEL); + + QSqlQuery *candidateQuery = prepareQuery(candidateString); + altBindValues(candidateQuery, QStringLiteral(":alt_email"), emailList); + altBindValues(candidateQuery, QStringLiteral(":alt_cid"), clientIdList); + altBindValues(candidateQuery, QStringLiteral(":alt_ip"), ipList); + altBindValues(candidateQuery, QStringLiteral(":alt_hcid"), clientIdList); + if (!execSqlQuery(candidateQuery)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + QList candidates; + while (candidateQuery->next()) { + AltCandidate candidate; + candidate.name = candidateQuery->value(0).toString(); + if (visited.contains(candidate.name)) { + continue; + } + candidate.email = candidateQuery->value(1).toString(); + candidate.clientId = candidateQuery->value(2).toString(); + candidate.registrationTime = candidateQuery->value(3).toLongLong(); + candidate.hasLastLogin = !candidateQuery->value(4).isNull(); + candidate.lastLogin = candidateQuery->value(4).toLongLong(); + candidate.warnCount = candidateQuery->value(5).toInt(); + candidate.banCount = candidateQuery->value(6).toInt(); + candidate.isActive = candidateQuery->value(7).toBool(); + candidates << candidate; + } + + // Resolve session-level evidence (shared IP or historic client id) for + // candidates that do not match on current email or client id. + QMap candidateIp; + QMap candidateHistoricClientId; + QList unexplained; + for (const AltCandidate &candidate : candidates) { + const bool emailMatch = !candidate.email.isEmpty() && emails.contains(candidate.email); + const bool clientIdMatch = !candidate.clientId.isEmpty() && clientIds.contains(candidate.clientId); + if (!emailMatch && !clientIdMatch) { + unexplained << candidate.name; + } + } + if (!unexplained.isEmpty() && (!ips.isEmpty() || !clientIds.isEmpty())) { + QString evidenceString = + QStringLiteral("SELECT DISTINCT user_name, ip_address, clientid FROM {prefix}_sessions WHERE " + "user_name IN ") + + altInClause(QStringLiteral(":alt_ev"), unexplained) + QStringLiteral(" AND ("); + QStringList evidenceConditions; + if (!ipList.isEmpty()) { + evidenceConditions << QStringLiteral("ip_address IN ") + + altInClause(QStringLiteral(":alt_eip"), ipList); + } + if (!clientIdList.isEmpty()) { + evidenceConditions << QStringLiteral("clientid IN ") + + altInClause(QStringLiteral(":alt_ecid"), clientIdList); + } + evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); + evidenceString += QStringLiteral(") AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)"); + + QSqlQuery *evidenceQuery = prepareQuery(evidenceString); + altBindValues(evidenceQuery, QStringLiteral(":alt_ev"), unexplained); + altBindValues(evidenceQuery, QStringLiteral(":alt_eip"), ipList); + altBindValues(evidenceQuery, QStringLiteral(":alt_ecid"), clientIdList); + if (execSqlQuery(evidenceQuery)) { + while (evidenceQuery->next()) { + const QString name = evidenceQuery->value(0).toString(); + const QString ip = evidenceQuery->value(1).toString(); + const QString clientId = evidenceQuery->value(2).toString(); + if (!ip.isEmpty() && ips.contains(ip) && !candidateIp.contains(name)) { + candidateIp.insert(name, ip); + } + if (!clientId.isEmpty() && clientIds.contains(clientId) && + !candidateHistoricClientId.contains(name)) { + candidateHistoricClientId.insert(name, clientId); + } + } + } + } + + // Fan-out counts so moderators can spot VPN/shared-network false positives. + QMap emailCounts; + QMap clientIdCounts; + QMap ipCounts; + QSet neededEmails; + QSet neededClientIds; + QSet neededIps; + for (const AltCandidate &candidate : candidates) { + if (!candidate.email.isEmpty() && emails.contains(candidate.email)) { + neededEmails << candidate.email; + } else if (!candidate.clientId.isEmpty() && clientIds.contains(candidate.clientId)) { + neededClientIds << candidate.clientId; + } else if (candidateHistoricClientId.contains(candidate.name)) { + neededClientIds << candidateHistoricClientId.value(candidate.name); + } else if (candidateIp.contains(candidate.name)) { + neededIps << candidateIp.value(candidate.name); + } + } + if (!neededEmails.isEmpty()) { + const QList neededEmailList = neededEmails.values(); + QSqlQuery *countQuery = prepareQuery(QStringLiteral("SELECT email, COUNT(*) FROM {prefix}_users WHERE " + "email IN ") + + altInClause(QStringLiteral(":alt_ce"), neededEmailList) + + QStringLiteral(" GROUP BY email")); + altBindValues(countQuery, QStringLiteral(":alt_ce"), neededEmailList); + if (execSqlQuery(countQuery)) { + while (countQuery->next()) { + emailCounts.insert(countQuery->value(0).toString(), countQuery->value(1).toInt()); + } + } + } + if (!neededClientIds.isEmpty()) { + const QList neededClientIdList = neededClientIds.values(); + QSqlQuery *countQuery = prepareQuery(QStringLiteral("SELECT clientid, COUNT(*) FROM {prefix}_users WHERE " + "clientid IN ") + + altInClause(QStringLiteral(":alt_cc"), neededClientIdList) + + QStringLiteral(" GROUP BY clientid")); + altBindValues(countQuery, QStringLiteral(":alt_cc"), neededClientIdList); + if (execSqlQuery(countQuery)) { + while (countQuery->next()) { + clientIdCounts.insert(countQuery->value(0).toString(), countQuery->value(1).toInt()); + } + } + } + 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")); + altBindValues(countQuery, QStringLiteral(":alt_ci"), neededIpList); + if (execSqlQuery(countQuery)) { + while (countQuery->next()) { + ipCounts.insert(countQuery->value(0).toString(), countQuery->value(1).toInt()); + } + } + } + + QList nextFrontier; + for (const AltCandidate &candidate : candidates) { + QString matchReason; + int sharedCount = 1; + if (!candidate.email.isEmpty() && emails.contains(candidate.email)) { + matchReason = QStringLiteral("email"); + sharedCount = qMax(1, emailCounts.value(candidate.email, 1)); + } else if (!candidate.clientId.isEmpty() && clientIds.contains(candidate.clientId)) { + matchReason = QStringLiteral("clientid:") + candidate.clientId; + sharedCount = qMax(1, clientIdCounts.value(candidate.clientId, 1)); + } else if (candidateHistoricClientId.contains(candidate.name)) { + const QString historicId = candidateHistoricClientId.value(candidate.name); + matchReason = QStringLiteral("clientid:") + historicId; + sharedCount = qMax(1, clientIdCounts.value(historicId, 1)); + } else if (candidateIp.contains(candidate.name)) { + const QString ip = candidateIp.value(candidate.name); + matchReason = QStringLiteral("ip:") + ip; + sharedCount = qMax(1, ipCounts.value(ip, 1)); + } else { + continue; + } + appendAlt(candidate, matchReason, level, sharedCount); + visited.insert(candidate.name); + nextFrontier << candidate.name; + } + frontier = nextFrontier; } return results; diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 0509d1a03..f76ca27f5 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -200,7 +200,7 @@ public: int &range, int &maxresults); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName); + QList getUserAlts(const QString &userName, int depth = 1); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 5f5d1ced5..a394e6bfd 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2565,7 +2565,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma } Response_UserAlts *re = new Response_UserAlts; - const QList alts = sqlInterface->getUserAlts(userName); + const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; + const QList alts = sqlInterface->getUserAlts(userName, depth); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); } From f468315826c5107c38fe3961e8e2aa8fda809cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:18:14 +0200 Subject: [PATCH 3/4] [Client] Transient User Alts investigation tab New TabAlts tab with user search, depth selector (1-3), and an alt table showing per-row match evidence (Via, Depth, Shared fan-out) alongside the account data. Sends Command_GetUserAlts with the requested depth and degrades gracefully against old servers (missing evidence fields render as dashes). Wired as a transient single-reuse tab via TabSupervisor::openTabAlts with no menu action or persisted setting; closed on disconnect like the other staff tabs. --- cockatrice/CMakeLists.txt | 1 + .../src/interface/widgets/tabs/tab_alts.cpp | 170 ++++++++++++++++++ .../src/interface/widgets/tabs/tab_alts.h | 54 ++++++ .../interface/widgets/tabs/tab_supervisor.cpp | 22 ++- .../interface/widgets/tabs/tab_supervisor.h | 3 + 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 cockatrice/src/interface/widgets/tabs/tab_alts.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_alts.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 63ce7d1af..3b4350e24 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -393,6 +393,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp + src/interface/widgets/tabs/tab_alts.cpp src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp new file mode 100644 index 000000000..d43f5cfc6 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -0,0 +1,170 @@ +#include "tab_alts.h" + +#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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +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 MAX_ALT_DEPTH = 3; +} // namespace + +TabAlts::TabAlts(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, &TabAlts::investigateUser); + + depthSpin = new QSpinBox; + depthSpin->setMinimum(1); + depthSpin->setMaximum(MAX_ALT_DEPTH); + depthSpin->setValue(1); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + + statusLabel = new QLabel; + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(depthSpin); + topBar->addWidget(investigateButton); + + 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); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addWidget(statusLabel); + mainLayout->addWidget(altsTable); + + retranslateUi(); + investigate(initialUser); +} + +void TabAlts::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + depthSpin->setPrefix(tr("Depth: ")); + depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); + investigateButton->setText(tr("Investigate")); + if (currentUser.isEmpty()) { + statusLabel->setText(tr("Enter a user name to investigate.")); + } + + 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")}); +} + +QString TabAlts::formatEpoch(quint64 ts) const +{ + if (ts == 0) { + return tr("Unknown"); + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); +} + +void TabAlts::investigate(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + searchEdit->setText(userName); + investigateUser(); +} + +void TabAlts::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + currentUser = userName; + altsTable->setRowCount(0); + statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); + requestAlts(userName, depthSpin->value()); +} + +void TabAlts::requestAlts(const QString &userName, int depth) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_depth(static_cast(depth)); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabAlts::altsResponse); + client->sendCommand(pend); +} + +void TabAlts::altsResponse(const Response &response) +{ + altsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + 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_VIA, + new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() + ? QString::fromStdString(alt.match_reason()) + : QStringLiteral("-"))); + altsTable->setItem(i, COL_ALTS_DEPTH, + new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); + altsTable->setItem( + i, 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"))); + } + statusLabel->setText(tr("%1 account(s) related to %2.").arg(resp.alts_size()).arg(currentUser)); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h new file mode 100644 index 000000000..3b83cc4a9 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -0,0 +1,54 @@ +#ifndef TAB_ALTS_H +#define TAB_ALTS_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class Response; +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. + */ +class TabAlts : public Tab +{ + Q_OBJECT +public: + explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("User Alts"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void altsResponse(const Response &response); + +private: + void requestAlts(const QString &userName, int depth); + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + + QLineEdit *searchEdit; + QSpinBox *depthSpin; + QPushButton *investigateButton; + QLabel *statusLabel; + QTableWidget *altsTable; +}; + +#endif // TAB_ALTS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 2352d7d7b..29a19ba91 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -25,6 +25,7 @@ #include "tab.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_alts.h" #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" @@ -156,7 +157,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), - tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) + tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -300,6 +301,7 @@ void TabSupervisor::retranslateUi() tabs.append(tabLog); tabs.append(tabReport); tabs.append(tabModeration); + tabs.append(tabAlts); tabs.append(tabCardArtRules); tabs.append(tabDeveloper); QMapIterator roomIterator(roomTabs); @@ -600,6 +602,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabAlts = nullptr; tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; @@ -648,6 +651,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabAlts) { + tabAlts->close(); + } if (tabCardArtRules) { tabCardArtRules->close(); } @@ -956,6 +962,20 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::openTabAlts(const QString &userName) +{ + if (tabAlts) { + setCurrentWidget(tabAlts); + if (!userName.isEmpty()) { + tabAlts->investigate(userName); + } + return; + } + tabAlts = new TabAlts(this, client, userName); + myAddTab(tabAlts); + connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; }); +} + void TabSupervisor::actTabDeveloper(bool checked) { if (checked && !tabDeveloper) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 9c7dc48d1..d529c5022 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -50,6 +50,7 @@ class TabAdmin; class TabMessage; class TabReport; class TabModeration; +class TabAlts; class TabAccount; class TabDeckEditor; class TabDeveloper; @@ -116,6 +117,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabAlts *tabAlts; TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; @@ -209,6 +211,7 @@ public slots: /** Joins or spectates a game listed on a user's profile hover card. */ void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); void openTabModeration(const QString &userName = {}); + void openTabAlts(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); From b5fe417da2a27a2d014134bf783b9ec85a3074c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 05:57:17 +0100 Subject: [PATCH 4/4] Fix remaining review threads on the user alts tab - Switch to the User Alts tab when it is created, so the first investigate click does not open it in the background. - Treat an empty RespOk as "user not found" and use a generic message for error codes, matching what the server actually returns. - Exclude the depth-0 seed row from the related-account count. - Guard the reply with the requested user name so a stale response cannot overwrite a newer one. - Move the duplicated formatEpoch body into widgets/utility/epoch_format.h. --- cockatrice/CMakeLists.txt | 1 + .../src/interface/widgets/tabs/tab_alts.cpp | 27 ++++++++++++++----- .../interface/widgets/tabs/tab_moderation.cpp | 6 ++--- .../interface/widgets/tabs/tab_supervisor.cpp | 1 + .../interface/widgets/utility/epoch_format.h | 27 +++++++++++++++++++ 5 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 cockatrice/src/interface/widgets/utility/epoch_format.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 3b4350e24..8bfc32ffe 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -302,6 +302,7 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/storage_settings_page.cpp src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp + src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index d43f5cfc6..b3b31b8da 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -1,5 +1,6 @@ #include "tab_alts.h" +#include "../utility/epoch_format.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -99,10 +100,7 @@ void TabAlts::retranslateUi() QString TabAlts::formatEpoch(quint64 ts) const { - if (ts == 0) { - return tr("Unknown"); - } - return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); + return formatEpochTimestamp(ts, tr("Unknown")); } void TabAlts::investigate(const QString &userName) @@ -132,7 +130,11 @@ void TabAlts::requestAlts(const QString &userName, int depth) cmd.set_user_name(userName.toStdString()); cmd.set_depth(static_cast(depth)); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabAlts::altsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); client->sendCommand(pend); } @@ -140,14 +142,25 @@ void TabAlts::altsResponse(const Response &response) { altsTable->setRowCount(0); if (response.response_code() != Response::RespOk) { - statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser)); return; } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server answers RespOk with no rows when the user does not exist. + if (resp.alts_size() == 0) { + statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + 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; + } altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); altsTable->setItem(i, COL_ALTS_VIA, new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() @@ -166,5 +179,5 @@ void TabAlts::altsResponse(const Response &response) 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"))); } - statusLabel->setText(tr("%1 account(s) related to %2.").arg(resp.alts_size()).arg(currentUser)); + statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 09a640540..3a6401dd2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,5 +1,6 @@ #include "tab_moderation.h" +#include "../utility/epoch_format.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -208,10 +209,7 @@ void TabModeration::retranslateUi() QString TabModeration::formatEpoch(quint64 ts) const { - if (ts == 0) { - return tr("Unknown"); - } - return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); + return formatEpochTimestamp(ts, tr("Unknown")); } void TabModeration::clearUserData() diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 29a19ba91..6a5bf8fd7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -973,6 +973,7 @@ void TabSupervisor::openTabAlts(const QString &userName) } tabAlts = new TabAlts(this, client, userName); myAddTab(tabAlts); + setCurrentWidget(tabAlts); connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; }); } diff --git a/cockatrice/src/interface/widgets/utility/epoch_format.h b/cockatrice/src/interface/widgets/utility/epoch_format.h new file mode 100644 index 000000000..977053997 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/epoch_format.h @@ -0,0 +1,27 @@ +/** + * @file epoch_format.h + * @brief Shared timestamp formatting for the moderation tables. + */ + +#ifndef EPOCH_FORMAT_H +#define EPOCH_FORMAT_H + +#include +#include + +/** + * @brief Format a unix timestamp the way the moderation tabs show dates. + * + * @param ts Seconds since the epoch; 0 means "unknown". + * @param unknownText Label returned for 0, translated by the caller so the + * translation context stays with the tab. + */ +inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText) +{ + if (ts == 0) { + return unknownText; + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")); +} + +#endif // EPOCH_FORMAT_H