[Server] Let users fetch their own private chat history (#7506)

* [Server] Let users fetch their own private chat history

Players lose their private conversations on reconnect or on another
device. Servatrice can already log private messages, but only
moderators can read the logs.

Add Command_GetPrivateChatHistory / Response_PrivateChatHistory so a
registered user can page through their own logged conversation with one
other user. Operators opt in with logging/allow_user_chat_history
(default off, and log_user_msg_chat must be on). The caller is matched
by account id so reused guest or deleted-account names can't read older
messages; only time, sender name and text are returned. Pages hold
1..200 messages and never split a second, since log_time has 1-second
resolution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* [Server] Harden private chat history identity matching

Match both participants by account id and name so a federated user's remote id cannot collide with a local account, and stop storing foreign ids in the local chat log. Extract the paging assembly into a pure helper and cover the whole-second boundary rules with gtests.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com>
This commit is contained in:
SwitchOCE 2026-11-09 23:21:43 +11:00 • committed by GitHub
parent 3d8c729517
commit 7709184ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 395 additions and 4 deletions

View file

@ -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<ServerInfo_PrivateChatMessage> &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<ServerInfo_PrivateChatMessage> &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<ServerInfo_PrivateChatMessage> 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<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user,
const QString &ipaddress,
const QString &gamename,