diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 78b9d955f..fccec85ef 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -804,10 +804,13 @@ Response::ResponseCode Server_ProtocolHandler::cmdMessage(const Command_Message userInterface->sendProtocolItem(*se); rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, se); - databaseInterface->logMessage(userInfo->id(), QString::fromStdString(userInfo->name()), - QString::fromStdString(userInfo->address()), QString::fromStdString(cmd.message()), - Server_DatabaseInterface::MessageTargetChat, userInterface->getUserInfo()->id(), - receiver); + databaseInterface->logMessage( + userInfo->id(), QString::fromStdString(userInfo->name()), QString::fromStdString(userInfo->address()), + QString::fromStdString(cmd.message()), Server_DatabaseInterface::MessageTargetChat, + // A federated user's id belongs to the remote server's + // database, not ours. Logging it would collide with a + // local id and corrupt local history lookups. + userInterface->getUserInfo()->server_id() == -1 ? userInterface->getUserInfo()->id() : 0, receiver); resetIdleTimer(); return Response::RespOk; } 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/CMakeLists.txt b/servatrice/CMakeLists.txt index 9d8dd46d2..0204b15a6 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -10,6 +10,7 @@ set(servatrice_SOURCES src/forwarded_address.cpp src/main.cpp src/metrics_registry.cpp + src/private_chat_paging.cpp src/ratelimiter.cpp src/servatrice.cpp src/servatrice_connection_pool.cpp 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/private_chat_paging.cpp b/servatrice/src/private_chat_paging.cpp new file mode 100644 index 000000000..872a1e733 --- /dev/null +++ b/servatrice/src/private_chat_paging.cpp @@ -0,0 +1,31 @@ +#include "private_chat_paging.h" + +#include + +PrivateChatPage assemblePrivateChatPage(const QList &newestFirst, int limit) +{ + PrivateChatPage page; + page.messages = newestFirst; + page.hasMore = newestFirst.size() > limit; + if (!page.hasMore) { + std::reverse(page.messages.begin(), page.messages.end()); + return page; + } + + // The (limit+1)th message is the first one that does not fit. If its second + // appears anywhere in the page, that whole second would be split, so drop + // every message in that second and leave it for the next page. + const quint64 boundary = newestFirst.at(limit).time(); + while (!page.messages.isEmpty() && page.messages.last().time() == boundary) { + page.messages.removeLast(); + } + + if (page.messages.isEmpty()) { + page.needsWholeSecond = true; + page.boundarySecond = boundary; + return page; + } + + std::reverse(page.messages.begin(), page.messages.end()); + return page; +} diff --git a/servatrice/src/private_chat_paging.h b/servatrice/src/private_chat_paging.h new file mode 100644 index 000000000..ee503f2d9 --- /dev/null +++ b/servatrice/src/private_chat_paging.h @@ -0,0 +1,30 @@ +#ifndef PRIVATE_CHAT_PAGING_H +#define PRIVATE_CHAT_PAGING_H + +#include +#include + +/** One page of private chat history, ready for the response. */ +struct PrivateChatPage +{ + /** Messages for the response, oldest first. */ + QList messages; + /** Whether messages older than this page may still exist. */ + bool hasMore = false; + /** + * Set when every fetched message belongs to the same second, so the caller + * has to fetch that whole second before it can answer. messages is empty in + * that case and boundarySecond names the second to fetch. + */ + bool needsWholeSecond = false; + quint64 boundarySecond = 0; +}; + +/** + * Assembles one newest-first page from up to limit + 1 messages ordered newest + * first. A second is never split across pages: when the limit would cut into a + * second, that whole second is deferred to the next page instead. + */ +PrivateChatPage assemblePrivateChatPage(const QList &newestFirst, int limit); + +#endif // PRIVATE_CHAT_PAGING_H diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 1327c85d5..c11f63363 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -8,6 +8,7 @@ #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" #include "libcockatrice/protocol/pb/serverinfo_warning.pb.h" +#include "private_chat_paging.h" #include "servatrice.h" #include "server.h" #include "server_protocolhandler.h" @@ -2638,6 +2639,92 @@ int Servatrice_DatabaseInterface::clearBlacklistedBanners() return cleared; } +bool Servatrice_DatabaseInterface::getPrivateChatHistory(int callerId, + const QString &callerName, + int peerId, + const QString &peerName, + quint64 before, + int limit, + QList &messages, + bool &hasMore) +{ + messages.clear(); + hasMore = false; + if (callerId < 1 || peerId < 1 || callerName.isEmpty() || peerName.isEmpty() || !checkSql()) { + return false; + } + + // Match both participants by account id, and verify their names as well. + // Names can be reused (guests, re-registration), so ids are what separate + // earlier holders; names are what reject a federated user's remote id that + // happens to collide with a local one in the shared log table. + const QString conversationFilter = " FROM {prefix}_log WHERE target_type = 'chat'" + " AND ((sender_id = :caller_sender_id AND sender_name = :caller_name" + " AND target_id = :peer_target_id AND target_name = :peer_name)" + " OR (target_id = :caller_target_id AND target_name = :caller_name" + " AND sender_id = :peer_sender_id AND sender_name = :peer_name))"; + 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(":caller_name", callerName); + query->bindValue(":peer_target_id", peerId); + query->bindValue(":peer_name", peerName); + query->bindValue(":peer_sender_id", peerId); + query->bindValue(":caller_target_id", callerId); + }; + const auto readMessages = [&](QSqlQuery *query, QList &out) { + 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()); + out.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; + } + + QList newestFirst; + readMessages(query, newestFirst); + PrivateChatPage page = assemblePrivateChatPage(newestFirst, limit); + + if (page.needsWholeSecond) { + query = prepareQuery(selectMessages + conversationFilter + " AND log_time = FROM_UNIXTIME(:second)"); + bindParticipants(query); + query->bindValue(":second", page.boundarySecond); + if (!execSqlQuery(query)) { + return false; + } + readMessages(query, page.messages); + std::reverse(page.messages.begin(), page.messages.end()); + + query = prepareQuery("SELECT 1" + conversationFilter + " AND log_time < FROM_UNIXTIME(:before) LIMIT 1"); + bindParticipants(query); + query->bindValue(":before", page.boundarySecond); + if (!execSqlQuery(query)) { + return false; + } + page.hasMore = query->next(); + } + + messages = page.messages; + hasMore = page.hasMore; + 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..13a581673 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,14 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); + bool getPrivateChatHistory(int callerId, + const QString &callerName, + int peerId, + const QString &peerName, + 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..49cc053b3 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,49 @@ 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(), QString::fromStdString(userInfo->name()), peerId, peer, + 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, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ae90ee896..6eb59fc72 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -426,6 +426,19 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + private_chat_paging_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/private_chat_paging.cpp + private_chat_paging_test.cpp + LIBS + libcockatrice_protocol + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME latency_tracker_test diff --git a/tests/private_chat_paging_test.cpp b/tests/private_chat_paging_test.cpp new file mode 100644 index 000000000..4e5919517 --- /dev/null +++ b/tests/private_chat_paging_test.cpp @@ -0,0 +1,115 @@ +#include "private_chat_paging.h" + +#include + +namespace +{ +ServerInfo_PrivateChatMessage messageAt(quint64 time, const QString &text) +{ + ServerInfo_PrivateChatMessage message; + message.set_time(time); + message.set_sender_name("sender"); + message.set_message(text.toStdString()); + return message; +} + +QList newestFirst(std::initializer_list times) +{ + QList messages; + for (const quint64 time : times) { + messages.append(messageAt(time, QString::number(time))); + } + return messages; +} + +QList timesOf(const QList &messages) +{ + QList times; + for (const auto &message : messages) { + times.append(message.time()); + } + return times; +} +} // namespace + +TEST(PrivateChatPaging, EmptyInputProducesEmptyPage) +{ + const PrivateChatPage page = assemblePrivateChatPage({}, 50); + + EXPECT_TRUE(page.messages.isEmpty()); + EXPECT_FALSE(page.hasMore); + EXPECT_FALSE(page.needsWholeSecond); +} + +TEST(PrivateChatPaging, PageShorterThanLimitIsReturnedOldestFirst) +{ + const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 100}), 50); + + EXPECT_EQ(timesOf(page.messages), QList({100, 200, 300})); + EXPECT_FALSE(page.hasMore); + EXPECT_FALSE(page.needsWholeSecond); +} + +TEST(PrivateChatPaging, PageExactlyAtLimitIsReturnedWithoutMore) +{ + const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 100}), 3); + + EXPECT_EQ(timesOf(page.messages), QList({100, 200, 300})); + EXPECT_FALSE(page.hasMore); +} + +TEST(PrivateChatPaging, DistinctSecondsKeepWholePage) +{ + const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 100}), 2); + + EXPECT_EQ(timesOf(page.messages), QList({200, 300})); + EXPECT_TRUE(page.hasMore); + EXPECT_FALSE(page.needsWholeSecond); +} + +TEST(PrivateChatPaging, BoundarySecondIsDeferredToNextPage) +{ + const PrivateChatPage page = assemblePrivateChatPage(newestFirst({300, 200, 200}), 2); + + EXPECT_EQ(timesOf(page.messages), QList({300})); + EXPECT_TRUE(page.hasMore); + EXPECT_FALSE(page.needsWholeSecond); +} + +TEST(PrivateChatPaging, WholeSecondIsRequestedWhenNothingElseFits) +{ + const PrivateChatPage page = assemblePrivateChatPage(newestFirst({200, 200, 200}), 2); + + EXPECT_TRUE(page.messages.isEmpty()); + EXPECT_TRUE(page.hasMore); + EXPECT_TRUE(page.needsWholeSecond); + EXPECT_EQ(page.boundarySecond, 200); +} + +TEST(PrivateChatPaging, WholeSecondIsRequestedForSingleMessageLimit) +{ + const PrivateChatPage page = assemblePrivateChatPage(newestFirst({200, 200}), 1); + + EXPECT_TRUE(page.messages.isEmpty()); + EXPECT_TRUE(page.needsWholeSecond); + EXPECT_EQ(page.boundarySecond, 200); +} + +TEST(PrivateChatPaging, BoundarySecondAtPageEdgeIsDropped) +{ + const PrivateChatPage page = assemblePrivateChatPage(newestFirst({400, 400, 300, 300}), 3); + + EXPECT_EQ(timesOf(page.messages), QList({400, 400})); + EXPECT_TRUE(page.hasMore); + EXPECT_FALSE(page.needsWholeSecond); +} + +TEST(PrivateChatPaging, ReturnedMessagesKeepSenderAndText) +{ + const PrivateChatPage page = assemblePrivateChatPage({messageAt(200, "hello"), messageAt(100, "world")}, 50); + + ASSERT_EQ(page.messages.size(), 2); + EXPECT_EQ(page.messages.at(0).message(), "world"); + EXPECT_EQ(page.messages.at(1).message(), "hello"); + EXPECT_EQ(page.messages.at(0).sender_name(), "sender"); +}