mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-09 11:33:25 -08:00
[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:
parent
3d8c729517
commit
7709184ecc
17 changed files with 395 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
31
servatrice/src/private_chat_paging.cpp
Normal file
31
servatrice/src/private_chat_paging.cpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#include "private_chat_paging.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
PrivateChatPage assemblePrivateChatPage(const QList<ServerInfo_PrivateChatMessage> &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;
|
||||
}
|
||||
30
servatrice/src/private_chat_paging.h
Normal file
30
servatrice/src/private_chat_paging.h
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#ifndef PRIVATE_CHAT_PAGING_H
|
||||
#define PRIVATE_CHAT_PAGING_H
|
||||
|
||||
#include <QList>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_private_chat_message.pb.h>
|
||||
|
||||
/** One page of private chat history, ready for the response. */
|
||||
struct PrivateChatPage
|
||||
{
|
||||
/** Messages for the response, oldest first. */
|
||||
QList<ServerInfo_PrivateChatMessage> 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<ServerInfo_PrivateChatMessage> &newestFirst, int limit);
|
||||
|
||||
#endif // PRIVATE_CHAT_PAGING_H
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_chat_message.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_private_chat_message.pb.h>
|
||||
#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>
|
||||
|
|
@ -224,6 +225,14 @@ public:
|
|||
const QString &beforeTime = QString(),
|
||||
bool *hasMore = nullptr);
|
||||
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
|
||||
bool getPrivateChatHistory(int callerId,
|
||||
const QString &callerName,
|
||||
int peerId,
|
||||
const QString &peerName,
|
||||
quint64 before,
|
||||
int limit,
|
||||
QList<ServerInfo_PrivateChatMessage> &messages,
|
||||
bool &hasMore);
|
||||
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName,
|
||||
int depth = 1,
|
||||
bool useEmail = true,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
#include <libcockatrice/protocol/pb/command_deck_share_list_mine.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_share_remove.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_get_private_chat_history.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_get_server_stats.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_replay_delete_match.pb.h>
|
||||
#include <libcockatrice/protocol/pb/command_replay_download.pb.h>
|
||||
|
|
@ -139,6 +140,7 @@
|
|||
#include <libcockatrice/protocol/pb/response_get_server_stats.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_moderator_last_logins.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_password_salt.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_private_chat_history.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_register.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_remove_user_avatar.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_remove_user_banner.pb.h>
|
||||
|
|
@ -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<quint32>(1, cmd.limit(), 200);
|
||||
QList<ServerInfo_PrivateChatMessage> 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue