From ae58cdd2dee20f4ca2d391aa66048ba1a364b689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 11 Aug 2026 21:10:46 +0200 Subject: [PATCH] [Server] Add moderation investigation tools Implements the server side of the moderation suite: - getUserSessions/getUserAlts/getModeratorLastLogins/removeUserAvatar DB methods - Handlers for all five new commands with audit records (PASSWORD_RESET, REMOVE_USER_AVATAR); password resets return a generated temporary password - cmdGetWarnList now reports per-category infraction levels from the officialwarnings setting; cmdReportUserInfo reports last_login - Update servatrice.ini.example with the warning taxonomy - Password/avatar mutations report RespNameNotFound when the user does not exist --- servatrice/servatrice.ini.example | 6 +- .../src/servatrice_database_interface.cpp | 169 +++++++++++++++++- .../src/servatrice_database_interface.h | 7 + servatrice/src/serversocketinterface.cpp | 146 ++++++++++++++- servatrice/src/serversocketinterface.h | 6 + 5 files changed, 325 insertions(+), 9 deletions(-) diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index d733de543..c1940c22f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -79,8 +79,10 @@ requiredfeatures="" ; You can define custom warnings that users are sent when the moderation staff uses the right client warn user ; menu option. This list is comma seperated that each item will appear in the drop down list for staff members -; to choose from. Example: "Flaming,Foul Language" -officialwarnings="Flaming,Spamming,Causing Drama,Abusive Language" +; to choose from. Each entry may optionally carry a recommended starting intervention level (see the moderator +; guide) by appending "|" and the level number. Entries without an explicit level default to intervention +; level 1. Example: "Flaming,Foul Language" +officialwarnings="Abusive Language|1,Calling Out User|1,Causing Drama|1,Cheating|2,Disrespecting Staff|1,Disrupting a Draft|1,Inappropriate Avatar|3,Inappropriate Game Name|1,Kicking Without Valid Reason|1,Spamming|1,Targeted Harassment|2" ; Maximum time in seconds a player can stay connected but idle. Default is 3600 (0 = disabled) ; Clients will be notified at the 90% time period of pending disconnection if they do not take action. diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index d5e1f13ef..41c7eea61 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); @@ -1084,11 +1085,10 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, "passwordLastChangedDate = NOW() where name = :name"); passwordQuery->bindValue(":password", passwordSha512); passwordQuery->bindValue(":name", user); - if (execSqlQuery(passwordQuery)) { - return true; + if (!execSqlQuery(passwordQuery)) { + return false; } - - return false; + return passwordQuery->numRowsAffected() > 0; } bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, @@ -1314,6 +1314,167 @@ QList Servatrice_DatabaseInterface::getUserWarnHistory(const return results; } +QList Servatrice_DatabaseInterface::getUserSessions(const QString &userName, int limit) +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT user_name, ip_address, clientid, " + "UNIX_TIMESTAMP(start_time), UNIX_TIMESTAMP(end_time), connection_type " + "FROM {prefix}_sessions WHERE user_name = :user_name " + "ORDER BY start_time DESC LIMIT :limit"); + query->bindValue(":user_name", userName); + query->bindValue(":limit", limit); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect session history information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserSession sessionDetails; + sessionDetails.set_user_name(query->value(0).toString().toStdString()); + sessionDetails.set_ip_address(query->value(1).toString().toStdString()); + sessionDetails.set_clientid(query->value(2).toString().toStdString()); + sessionDetails.set_start_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + sessionDetails.set_end_time(query->value(4).toLongLong()); + } + sessionDetails.set_connection_type(query->value(5).toString().toStdString()); + results << sessionDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +{ + QList results; + + if (!checkSql()) { + return results; + } + + // Seed account identifiers used to find related accounts + QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + seedQuery->bindValue(":user_name", userName); + if (!execSqlQuery(seedQuery) || !seedQuery->next()) { + return results; + } + const QString seedEmail = seedQuery->value(0).toString(); + const QString seedClientId = seedQuery->value(1).toString(); + + QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE u.name = :user_name"; + if (!seedEmail.isEmpty()) { + queryString.append(" OR u.email = :seed_email"); + } + if (!seedClientId.isEmpty()) { + queryString.append(" OR u.clientid = :seed_clientid"); + } + queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s " + "WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 " + "WHERE s2.user_name = :user_name)) " + "ORDER BY u.name"); + + QSqlQuery *query = prepareQuery(queryString); + query->bindValue(":user_name", userName); + if (!seedEmail.isEmpty()) { + query->bindValue(":seed_email", seedEmail); + } + if (!seedClientId.isEmpty()) { + query->bindValue(":seed_clientid", seedClientId); + } + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserAlt altDetails; + altDetails.set_user_name(query->value(0).toString().toStdString()); + altDetails.set_email(query->value(1).toString().toStdString()); + altDetails.set_clientid(query->value(2).toString().toStdString()); + altDetails.set_registration_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + altDetails.set_last_login(query->value(4).toLongLong()); + } + altDetails.set_warn_count(query->value(5).toInt()); + altDetails.set_ban_count(query->value(6).toInt()); + altDetails.set_is_active(query->value(7).toBool()); + results << altDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getModeratorLastLogins() +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT u.name, u.admin, UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE (u.admin & 7) <> 0 ORDER BY u.name"); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_ModeratorLogin loginDetails; + loginDetails.set_user_name(query->value(0).toString().toStdString()); + + const int isAdmin = query->value(1).toInt(); + int userLevel = ServerInfo_User::IsUser | ServerInfo_User::IsRegistered; + if (isAdmin & 1) { + userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator; + } else if (isAdmin & 2) { + userLevel |= ServerInfo_User::IsModerator; + } + if (isAdmin & 4) { + userLevel |= ServerInfo_User::IsJudge; + } + loginDetails.set_user_level(userLevel); + + if (!query->value(2).isNull()) { + loginDetails.set_last_login(query->value(2).toLongLong()); + } + results << loginDetails; + } + + return results; +} + +bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) +{ + if (!checkSql()) { + return false; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET avatar_bmp = '' WHERE name = :user_name"); + query->bindValue(":user_name", userName); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + QList Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 5e35aeead..6937ee8f6 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -6,6 +6,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -133,6 +136,10 @@ public: bool &room, int &range, int &maxresults); + QList getUserSessions(const QString &userName, int limit); + QList getUserAlts(const QString &userName); + QList getModeratorLastLogins(); + bool removeUserAvatar(const QString &userName); bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; bool doesForgotPasswordExist(const QString &user); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 7eac84873..95da90a71 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -80,8 +80,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -91,15 +93,23 @@ #include #include #include +#include +#include +#include #include #include #include #include #include #include +#include #include #include +#include +#include +#include #include +#include #include #include #include @@ -295,6 +305,16 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdReportUserInfo(cmd.GetExtension(Command_ReportUserInfo::ext), rc); case ModeratorCommand::REPORT_STATS: return cmdReportStats(cmd.GetExtension(Command_ReportStats::ext), rc); + case ModeratorCommand::GET_USER_SESSIONS: + return cmdGetUserSessions(cmd.GetExtension(Command_GetUserSessions::ext), rc); + case ModeratorCommand::GET_USER_ALTS: + return cmdGetUserAlts(cmd.GetExtension(Command_GetUserAlts::ext), rc); + case ModeratorCommand::GET_MODERATOR_LAST_LOGINS: + return cmdGetModeratorLastLogins(cmd.GetExtension(Command_GetModeratorLastLogins::ext), rc); + case ModeratorCommand::RESET_USER_PASSWORD: + return cmdResetUserPassword(cmd.GetExtension(Command_ResetUserPassword::ext), rc); + case ModeratorCommand::REMOVE_USER_AVATAR: + return cmdRemoveUserAvatar(cmd.GetExtension(Command_RemoveUserAvatar::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -1103,9 +1123,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetWarnList(const Comma Response_WarnList *re = new Response_WarnList; QString officialWarnings = settingsCache->value("server/officialwarnings").toString(); - QStringList warningsList = officialWarnings.split(",", Qt::SkipEmptyParts); - for (const QString &warning : warningsList) { - re->add_warning(warning.toStdString()); + const QList categories = parseWarningCategories(officialWarnings); + for (const WarningCategory &category : categories) { + re->add_warning(category.name.toStdString()); + re->add_warning_il(category.startingIl); } re->set_user_name(nameFromStdString(cmd.user_name()).toStdString()); re->set_user_clientid(nameFromStdString(cmd.user_clientid()).toStdString()); @@ -1550,6 +1571,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportUserInfo(const Co re->set_total_warns(warnCountQuery->value(0).toInt()); } + QSqlQuery *lastLoginQuery = sqlInterface->prepareQuery("SELECT UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_user_analytics a " + "JOIN {prefix}_users u ON u.id = a.id " + "WHERE u.name = :name"); + lastLoginQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(lastLoginQuery) && lastLoginQuery->next() && !lastLoginQuery->value(0).isNull()) { + re->set_last_login(lastLoginQuery->value(0).toLongLong()); + } + QSqlQuery *recentQuery = sqlInterface->prepareQuery("SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " "r.category, r.description, r.created_at, r.status, " @@ -1697,6 +1727,116 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Comma return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserSessions(const Command_GetUserSessions &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserSessions *re = new Response_UserSessions; + const QList sessions = sqlInterface->getUserSessions(userName, cmd.limit()); + for (const ServerInfo_UserSession &session : sessions) { + re->add_sessions()->CopyFrom(session); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Command_GetUserAlts &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserAlts *re = new Response_UserAlts; + const QList alts = sqlInterface->getUserAlts(userName); + for (const ServerInfo_UserAlt &alt : alts) { + re->add_alts()->CopyFrom(alt); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + Response_ModeratorLastLogins *re = new Response_ModeratorLastLogins; + const QList logins = sqlInterface->getModeratorLastLogins(); + for (const ServerInfo_ModeratorLogin &login : logins) { + re->add_logins()->CopyFrom(login); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const Command_ResetUserPassword &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + const QString tempPassword = PasswordHasher::generateRandomSalt(); + if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "PASSWORD_RESET", "Moderator password reset", true); + + Response_ResetUserPassword *re = new Response_ResetUserPassword; + re->set_user_name(userName.toStdString()); + re->set_temporary_password(tempPassword.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + if (!sqlInterface->removeUserAvatar(userName)) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_AVATAR", "Moderator removed user avatar", true); + + Response_RemoveUserAvatar *re = new Response_RemoveUserAvatar; + re->set_user_name(userName.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) { if (!sqlInterface->checkSql()) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 8a3cf97df..600796b5f 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -166,6 +166,12 @@ private: Response::ResponseCode cmdGetAdminNotes(const Command_GetAdminNotes &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateAdminNotes(const Command_UpdateAdminNotes &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserSessions(const Command_GetUserSessions &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserAlts(const Command_GetUserAlts &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc); + Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); + Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + bool addAdminFlagToUser(const QString &user, int flag); bool removeAdminFlagFromUser(const QString &user, int flag);