diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 5fba2631d..9c1f8ba28 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -33,6 +33,7 @@ set(PROTO_FILES command_dump_zone.proto command_flip_card.proto command_game_say.proto + command_get_private_chat_history.proto command_get_server_stats.proto command_inc_card_counter.proto command_inc_counter.proto @@ -164,6 +165,7 @@ set(PROTO_FILES response_list_users.proto response_login.proto response_password_salt.proto + response_private_chat_history.proto response_register.proto response_replay_download.proto response_replay_download_by_game_id.proto @@ -202,6 +204,7 @@ set(PROTO_FILES serverinfo_player.proto serverinfo_playerping.proto serverinfo_playerproperties.proto + serverinfo_private_chat_message.proto serverinfo_replay.proto serverinfo_replay_match.proto serverinfo_report.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_private_chat_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_private_chat_history.proto new file mode 100644 index 000000000..ae9508d18 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_private_chat_history.proto @@ -0,0 +1,18 @@ +syntax = "proto2"; +import "session_commands.proto"; + +/** + * Fetch the caller's private conversation with user_name. + * user_name must be an active registered account; guest and deleted-account + * names have no stable account id, so they are rejected with RespNameNotFound. + * before is exclusive Unix seconds; zero or unset applies no upper time bound. + * limit is clamped to 1..200; a whole second may exceed it to preserve pagination. + */ +message Command_GetPrivateChatHistory { + extend SessionCommand { + optional Command_GetPrivateChatHistory ext = 1035; + } + optional string user_name = 1; + optional uint64 before = 2; + optional uint32 limit = 3 [default = 50]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index caf9febde..59f96e98c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -78,6 +78,7 @@ message Response { PASSWORD_SALT = 1017; // Response containing password salt GET_ADMIN_NOTES = 1018; // Response with admin notes GET_SERVER_STATS = 1019; // Response with server status statistics + PRIVATE_CHAT_HISTORY = 1020; // Response with the caller's private conversation REPLAY_LIST = 1100; // Response listing replays REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_GET_CODE = 1102; // Response containing replay code diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_private_chat_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_private_chat_history.proto new file mode 100644 index 000000000..829df695c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_private_chat_history.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_private_chat_message.proto"; + +/** + * Private conversation messages, oldest first, without account IDs or IP addresses. + * When has_more is true, use the oldest returned time as before on the next request. + * Seconds are never split across pages; ordering within a second is unspecified. + */ +message Response_PrivateChatHistory { + extend Response { + optional Response_PrivateChatHistory ext = 1020; + } + repeated ServerInfo_PrivateChatMessage messages = 1; + optional bool has_more = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_private_chat_message.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_private_chat_message.proto new file mode 100644 index 000000000..1827f54cc --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_private_chat_message.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +/** + * A private conversation message with its time in Unix seconds. + */ +message ServerInfo_PrivateChatMessage { + optional uint64 time = 1; + optional string sender_name = 2; + optional string message = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 10aefe7b5..24e147951 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -37,6 +37,7 @@ message SessionCommand { DECK_SHARE_LIST_MINE = 1032; DECK_SHARE_REMOVE = 1033; SUBMIT_PASSWORD_VERIFIER = 1034; + GET_PRIVATE_CHAT_HISTORY = 1035; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index ac291fd7f..47dc1407f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -534,6 +534,10 @@ log_user_msg_chat=false ; Log user messages coming from other servers in the network log_user_msg_isl=false +; Allow registered users to read their retained private messages; requires log_user_msg_chat=true +; Privacy: exposes previously logged conversations to their participants on any device +allow_user_chat_history=false + [audit] ; Servatrice can record certain actions being performed in the database for server operators to better understand diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 1327c85d5..1c7246919 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -2638,6 +2638,87 @@ int Servatrice_DatabaseInterface::clearBlacklistedBanners() return cleared; } +bool Servatrice_DatabaseInterface::getPrivateChatHistory(int callerId, + int peerId, + quint64 before, + int limit, + QList &messages, + bool &hasMore) +{ + messages.clear(); + hasMore = false; + if (callerId < 1 || peerId < 1 || !checkSql()) { + return false; + } + + // Both participants are matched by account id, never by name: names can be + // reused by guests or re-registered after deletion, and matching by name + // would merge conversations from different people. + const QString conversationFilter = " FROM {prefix}_log WHERE target_type = 'chat'" + " AND ((sender_id = :caller_sender_id AND target_id = :peer_target_id)" + " OR (target_id = :caller_target_id AND sender_id = :peer_sender_id))"; + const QString selectMessages = "SELECT UNIX_TIMESTAMP(log_time), sender_name, log_message"; + const auto bindParticipants = [&](QSqlQuery *query) { + query->bindValue(":caller_sender_id", callerId); + query->bindValue(":peer_target_id", peerId); + query->bindValue(":peer_sender_id", peerId); + query->bindValue(":caller_target_id", callerId); + }; + const auto readMessages = [&](QSqlQuery *query) { + while (query->next()) { + ServerInfo_PrivateChatMessage message; + message.set_time(query->value(0).toULongLong()); + message.set_sender_name(query->value(1).toString().toStdString()); + message.set_message(query->value(2).toString().toStdString()); + messages.append(message); + } + }; + + QString queryString = selectMessages + conversationFilter; + if (before) { + queryString.append(" AND log_time < FROM_UNIXTIME(:before)"); + } + queryString.append(" ORDER BY log_time DESC LIMIT :limit_size"); + QSqlQuery *query = prepareQuery(queryString); + bindParticipants(query); + if (before) { + query->bindValue(":before", before); + } + query->bindValue(":limit_size", limit + 1); + if (!execSqlQuery(query)) { + return false; + } + readMessages(query); + + hasMore = messages.size() > limit; + if (hasMore) { + const quint64 boundary = messages.at(limit).time(); + while (!messages.isEmpty() && messages.last().time() == boundary) { + messages.removeLast(); + } + if (messages.isEmpty()) { + query = prepareQuery(selectMessages + conversationFilter + " AND log_time = FROM_UNIXTIME(:second)"); + bindParticipants(query); + query->bindValue(":second", boundary); + if (!execSqlQuery(query)) { + return false; + } + readMessages(query); + + query = prepareQuery("SELECT 1" + conversationFilter + " AND log_time < FROM_UNIXTIME(:before) LIMIT 1"); + bindParticipants(query); + query->bindValue(":before", boundary); + if (!execSqlQuery(query)) { + return false; + } + hasMore = query->next(); + } + } + + std::reverse(messages.begin(), messages.end()); + return true; +} + 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 c3cc57ab2..548b3fbb2 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -224,6 +225,12 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); + bool getPrivateChatHistory(int callerId, + int peerId, + quint64 before, + int limit, + QList &messages, + bool &hasMore); QList getUserAlts(const QString &userName, int depth = 1, bool useEmail = true, diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index c32ccad49..d5dbb7266 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -98,6 +98,7 @@ #include #include #include +#include #include #include #include @@ -139,6 +140,7 @@ #include #include #include +#include #include #include #include @@ -414,6 +416,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc); case SessionCommand::DECK_SHARE_DOWNLOAD: return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc); + case SessionCommand::GET_PRIVATE_CHAT_HISTORY: + return cmdGetPrivateChatHistory(cmd.GetExtension(Command_GetPrivateChatHistory::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: @@ -1702,6 +1706,48 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdGetPrivateChatHistory(const Command_GetPrivateChatHistory &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + if (!userInfo || !(userInfo->user_level() & ServerInfo_User::IsRegistered)) { + return Response::RespFunctionNotAllowed; + } + if (!settingsCache->value("logging/log_user_msg_chat", false).toBool() || + !settingsCache->value("logging/allow_user_chat_history", false).toBool()) { + return Response::RespFunctionNotAllowed; + } + + const QString peer = nameFromStdString(cmd.user_name()); + if (peer.isEmpty()) { + return Response::RespInvalidData; + } + + // Resolve the peer to an account id. Matching by name alone would merge the + // conversations of everyone who has ever used that name. + const int peerId = sqlInterface->getUserIdInDB(peer); + if (peerId < 1) { + return Response::RespNameNotFound; + } + + const int limit = qBound(1, cmd.limit(), 200); + QList messages; + bool hasMore = false; + if (!sqlInterface->getPrivateChatHistory(userInfo->id(), peerId, cmd.before(), limit, messages, hasMore)) { + return Response::RespInternalError; + } + + Response_PrivateChatHistory *re = new Response_PrivateChatHistory; + for (const auto &message : messages) { + re->add_messages()->CopyFrom(message); + } + re->set_has_more(hasMore); + rc.setResponseExtension(re); + return Response::RespOk; +} + // MODERATOR FUNCTIONS. // May be called by admins and moderators. Permission is checked by the calling function. Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index cd37286c6..0e77d7458 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -81,6 +81,7 @@ class Command_ForgotPasswordReset; class Command_GetAdminNotes; class Command_GetBanHistory; class Command_GetModeratorLastLogins; +class Command_GetPrivateChatHistory; class Command_GetServerStats; class Command_GetUserAlts; class Command_GetUserSessions; @@ -142,6 +143,7 @@ private: Response::ResponseCode cmdAddToList(const Command_AddToList &cmd, ResponseContainer &rc); Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetPrivateChatHistory(const Command_GetPrivateChatHistory &cmd, ResponseContainer &rc); int getDeckPathId(int basePathId, QStringList path); int getDeckPathId(const QString &path); bool deckListHelper(int folderId,