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 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>
3084 lines
123 KiB
C++
3084 lines
123 KiB
C++
#include "servatrice_database_interface.h"
|
|
|
|
#include "deck_tag_serialization.h"
|
|
#include "libcockatrice/protocol/pb/response.pb.h"
|
|
#include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h"
|
|
#include "libcockatrice/protocol/pb/serverinfo_game.pb.h"
|
|
#include "libcockatrice/protocol/pb/serverinfo_moderator_login.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"
|
|
#include "private_chat_paging.h"
|
|
#include "servatrice.h"
|
|
#include "server.h"
|
|
#include "server_protocolhandler.h"
|
|
#include "settingscache.h"
|
|
|
|
#include <QByteArray>
|
|
#include <QChar>
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonValue>
|
|
#include <QLoggingCategory>
|
|
#include <QMap>
|
|
#include <QMessageLogger>
|
|
#include <QRegularExpression>
|
|
#include <QRegularExpressionMatch>
|
|
#include <QSet>
|
|
#include <QSetIterator>
|
|
#include <QSharedPointer>
|
|
#include <QSqlError>
|
|
#include <QSqlQuery>
|
|
#include <QStringList>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
#include <QtGlobal>
|
|
#include <algorithm>
|
|
#include <google/protobuf/stubs/common.h>
|
|
#include <libcockatrice/deck_list/deck_list.h>
|
|
#include <libcockatrice/protocol/pb/game_replay.pb.h>
|
|
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
|
|
#include <libcockatrice/utility/cryptoutil.h>
|
|
#include <libcockatrice/utility/passwordhasher.h>
|
|
#include <qalgorithms.h>
|
|
#include <qnamespace.h>
|
|
#include <string>
|
|
|
|
inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface");
|
|
|
|
// Oldest password_legacy_backup_used_at that is still inside the TTL. The value is
|
|
// compared against (and stored as) a UTC "yyyy-MM-dd HH:mm:ss" string, which sorts
|
|
// lexicographically, so no timezone-aware parsing is needed on either side.
|
|
static QString legacyBackupExpiryCutoff(int ttlDays)
|
|
{
|
|
return QDateTime::currentDateTimeUtc().addDays(-ttlDays).toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
|
|
}
|
|
|
|
// Width of the password_legacy_backup column; a sealed backup must fit inside it.
|
|
static constexpr int MAX_LEGACY_BACKUP_LENGTH = 255;
|
|
|
|
Servatrice_DatabaseInterface::Servatrice_DatabaseInterface(int _instanceId, Servatrice *_server)
|
|
: instanceId(_instanceId), sqlDatabase(QSqlDatabase()), server(_server)
|
|
{
|
|
}
|
|
|
|
Servatrice_DatabaseInterface::~Servatrice_DatabaseInterface()
|
|
{
|
|
// reset all prepared statements
|
|
qDeleteAll(preparedStatements);
|
|
preparedStatements.clear();
|
|
|
|
sqlDatabase.close();
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::initDatabase(const QSqlDatabase &_sqlDatabase)
|
|
{
|
|
if (_sqlDatabase.isValid()) {
|
|
sqlDatabase = QSqlDatabase::cloneDatabase(_sqlDatabase, "pool_" + QString::number(instanceId));
|
|
openDatabase();
|
|
}
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::initDatabase(const QString &type,
|
|
const QString &hostName,
|
|
const QString &databaseName,
|
|
const QString &userName,
|
|
const QString &password)
|
|
{
|
|
sqlDatabase = QSqlDatabase::addDatabase(type, "main");
|
|
sqlDatabase.setHostName(hostName);
|
|
sqlDatabase.setDatabaseName(databaseName);
|
|
sqlDatabase.setUserName(userName);
|
|
sqlDatabase.setPassword(password);
|
|
|
|
return openDatabase();
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::openDatabase()
|
|
{
|
|
if (sqlDatabase.isOpen()) {
|
|
sqlDatabase.close();
|
|
}
|
|
|
|
const QString poolStr = instanceId == -1 ? QString("main") : QString("pool %1").arg(instanceId);
|
|
qCDebug(DatabaseInterfaceLog).noquote() << poolStr << "Opening database...";
|
|
if (!sqlDatabase.open()) {
|
|
qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database:" << sqlDatabase.lastError().text();
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *versionQuery = prepareQuery("select version from {prefix}_schema_version limit 1");
|
|
if (!execSqlQuery(versionQuery)) {
|
|
qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database: unable to load database schema version"
|
|
<< "(hint: ensure the cockatrice_schema_version exists)";
|
|
return false;
|
|
}
|
|
|
|
if (versionQuery->next()) {
|
|
const int dbversion = versionQuery->value(0).toInt();
|
|
const int expectedversion = DATABASE_SCHEMA_VERSION;
|
|
if (dbversion < expectedversion) {
|
|
qCCritical(DatabaseInterfaceLog) << poolStr
|
|
<< "Error opening database: the database schema version is too old, you "
|
|
"need to run the migrations to update it from version"
|
|
<< dbversion << "to version" << expectedversion;
|
|
return false;
|
|
} else if (dbversion > expectedversion) {
|
|
qCCritical(DatabaseInterfaceLog)
|
|
<< poolStr << "Error opening database: the database schema version" << dbversion
|
|
<< "is too new, you need to update Servatrice" << "(Currently running Servatrice actually uses version"
|
|
<< expectedversion << ")";
|
|
return false;
|
|
}
|
|
} else {
|
|
qCCritical(DatabaseInterfaceLog) << poolStr
|
|
<< "Error opening database: unable to load database schema version (hint: "
|
|
"ensure the cockatrice_schema_version contains a single record)";
|
|
return false;
|
|
}
|
|
|
|
if (sqlDatabase.driverName() != "QMYSQL") {
|
|
qCCritical(DatabaseInterfaceLog)
|
|
<< poolStr
|
|
<< "Error opening database: connection is not a MySQL/MariaDB database, Servatrice only "
|
|
"supports the QMYSQL driver (actual driver:"
|
|
<< sqlDatabase.driverName() << ").";
|
|
return false;
|
|
}
|
|
|
|
bool strictModeCheckOk = false;
|
|
const bool strictModeEnabled = isStrictModeEnabled(strictModeCheckOk);
|
|
if (!strictModeCheckOk) {
|
|
qCCritical(DatabaseInterfaceLog) << poolStr
|
|
<< "Error opening database: unable to determine whether MySQL/MariaDB strict "
|
|
"mode is enabled";
|
|
return false;
|
|
}
|
|
if (strictModeEnabled) {
|
|
qCCritical(DatabaseInterfaceLog) << poolStr
|
|
<< "Error opening database: MySQL/MariaDB strict mode is enabled, which "
|
|
"breaks most Servatrice database operations. Please disable strict mode "
|
|
"by removing STRICT_TRANS_TABLES and STRICT_ALL_TABLES from sql_mode, "
|
|
"for example by adding 'sql_mode=NO_ENGINE_SUBSTITUTION' under [mysqld] "
|
|
"in your my.cnf (or my.ini on Windows) and restarting the database "
|
|
"server.";
|
|
return false;
|
|
}
|
|
|
|
// reset all prepared statements
|
|
qDeleteAll(preparedStatements);
|
|
preparedStatements.clear();
|
|
return true;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::isStrictModeEnabled(bool &ok) const
|
|
{
|
|
ok = true;
|
|
|
|
QSqlQuery query(sqlDatabase);
|
|
if (!query.exec("SELECT @@GLOBAL.sql_mode")) {
|
|
ok = false;
|
|
return false;
|
|
}
|
|
|
|
const QStringList modes = query.next() ? query.value(0).toString().split(',') : QStringList();
|
|
for (const QString &mode : modes) {
|
|
if (mode.trimmed() == "STRICT_TRANS_TABLES" || mode.trimmed() == "STRICT_ALL_TABLES") {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::checkSql()
|
|
{
|
|
if (!sqlDatabase.isValid()) {
|
|
return false;
|
|
}
|
|
|
|
auto query = QSqlQuery(sqlDatabase);
|
|
if (query.exec("select 1") && !query.isActive()) {
|
|
return openDatabase();
|
|
}
|
|
|
|
if (query.lastError().isValid()) {
|
|
const auto &poolStr = instanceId == -1 ? QString("main") : QString("pool %1").arg(instanceId);
|
|
qCCritical(DatabaseInterfaceLog) << poolStr << "Error executing query:" << query.lastError().text();
|
|
|
|
sqlDatabase.close();
|
|
return openDatabase();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
QSqlQuery *Servatrice_DatabaseInterface::prepareQuery(const QString &queryText)
|
|
{
|
|
if (preparedStatements.contains(queryText)) {
|
|
return preparedStatements.value(queryText);
|
|
}
|
|
|
|
QString prefixedQueryText = queryText;
|
|
prefixedQueryText.replace("{prefix}", server->getDbPrefix());
|
|
auto *query = new QSqlQuery(sqlDatabase);
|
|
query->prepare(prefixedQueryText);
|
|
|
|
preparedStatements.insert(queryText, query);
|
|
return query;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::execSqlQuery(QSqlQuery *query)
|
|
{
|
|
if (query->exec()) {
|
|
return true;
|
|
}
|
|
const QString poolStr = instanceId == -1 ? QString("main") : QString("pool %1").arg(instanceId);
|
|
qCCritical(DatabaseInterfaceLog) << poolStr << "Error executing query:" << query->lastError().text();
|
|
sqlDatabase.close();
|
|
openDatabase();
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::usernameIsValid(const QString &user, QString &error)
|
|
{
|
|
int minNameLength = settingsCache->value("users/minnamelength", 6).toInt();
|
|
if (minNameLength < 1) {
|
|
minNameLength = 1;
|
|
}
|
|
int maxNameLength = settingsCache->value("users/maxnamelength", 12).toInt();
|
|
bool allowLowercase = settingsCache->value("users/allowlowercase", true).toBool();
|
|
bool allowUppercase = settingsCache->value("users/allowuppercase", true).toBool();
|
|
bool allowNumerics = settingsCache->value("users/allownumerics", true).toBool();
|
|
bool allowPunctuationPrefix = settingsCache->value("users/allowpunctuationprefix", false).toBool();
|
|
QString allowedPunctuation = settingsCache->value("users/allowedpunctuation", "_").toString();
|
|
QString disallowedWordsStr = settingsCache->value("users/disallowedwords", "").toString();
|
|
QStringList disallowedWords = disallowedWordsStr.split(",", Qt::SkipEmptyParts);
|
|
disallowedWords.removeDuplicates();
|
|
QVariant displayDisallowedWords = settingsCache->value("users/displaydisallowedwords");
|
|
QString disallowedRegExpStr;
|
|
if (displayDisallowedWords.isValid()) {
|
|
disallowedWordsStr = displayDisallowedWords.toString().trimmed();
|
|
if (!disallowedWordsStr.isEmpty()) {
|
|
disallowedWordsStr.prepend("\n");
|
|
}
|
|
} else {
|
|
disallowedRegExpStr = settingsCache->value("users/disallowedregexp", "").toString();
|
|
}
|
|
|
|
error = QString("%1|%2|%3|%4|%5|%6|%7|%8|%9")
|
|
.arg(minNameLength)
|
|
.arg(maxNameLength)
|
|
.arg(allowLowercase)
|
|
.arg(allowUppercase)
|
|
.arg(allowNumerics)
|
|
.arg(allowPunctuationPrefix)
|
|
.arg(allowedPunctuation)
|
|
.arg(disallowedWordsStr)
|
|
.arg(disallowedRegExpStr);
|
|
|
|
if (user.length() < minNameLength || user.length() > maxNameLength) {
|
|
return false;
|
|
}
|
|
|
|
if (!allowPunctuationPrefix && allowedPunctuation.contains(user.at(0))) {
|
|
return false;
|
|
}
|
|
|
|
for (const QString &word : disallowedWords) {
|
|
if (user.contains(word, Qt::CaseInsensitive)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (const QRegularExpression ®Exp : settingsCache->disallowedRegExp) {
|
|
if (regExp.match(user).hasMatch()) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
QString regEx("\\A[");
|
|
if (allowLowercase) {
|
|
regEx.append("a-z");
|
|
}
|
|
if (allowUppercase) {
|
|
regEx.append("A-Z");
|
|
}
|
|
if (allowNumerics) {
|
|
regEx.append("0-9");
|
|
}
|
|
regEx.append(QRegularExpression::escape(allowedPunctuation));
|
|
regEx.append("]+\\z");
|
|
|
|
QRegularExpression re = QRegularExpression(regEx);
|
|
return re.match(user).hasMatch();
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::registerUser(const QString &userName,
|
|
const QString &realName,
|
|
const QString &password,
|
|
bool passwordNeedsHash,
|
|
const QString &emailAddress,
|
|
const QString &country,
|
|
bool active)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
QString passwordSha512;
|
|
if (passwordNeedsHash) {
|
|
passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt());
|
|
} else {
|
|
passwordSha512 = password;
|
|
}
|
|
QString token = active ? QString() : PasswordHasher::generateActivationToken();
|
|
|
|
QSqlQuery *query =
|
|
prepareQuery("insert into {prefix}_users "
|
|
"(name, realname, password_sha512, email, country, registrationDate, active, token, "
|
|
"admin, avatar_bmp, clientid, privlevel, privlevelStartDate, privlevelEndDate) "
|
|
"values "
|
|
"(:userName, :realName, :password_sha512, :email, :country, UTC_TIMESTAMP(), :active, "
|
|
":token, 0, '', '', 'NONE', UTC_TIMESTAMP(), UTC_TIMESTAMP())");
|
|
query->bindValue(":userName", userName);
|
|
query->bindValue(":realName", realName);
|
|
query->bindValue(":password_sha512", passwordSha512);
|
|
query->bindValue(":email", emailAddress);
|
|
query->bindValue(":country", country);
|
|
query->bindValue(":active", active ? 1 : 0);
|
|
query->bindValue(":token", token);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to insert user: " << query->lastError()
|
|
<< " sql: " << query->lastQuery();
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::activateUser(const QString &userName, const QString &token)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *activateQuery =
|
|
prepareQuery("select name from {prefix}_users where active=0 and name=:username and token=:token");
|
|
|
|
activateQuery->bindValue(":username", userName);
|
|
activateQuery->bindValue(":token", token);
|
|
if (!execSqlQuery(activateQuery)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Account activation failed: SQL error." << activateQuery->lastError()
|
|
<< " sql: " << activateQuery->lastQuery();
|
|
return false;
|
|
}
|
|
|
|
if (activateQuery->next()) {
|
|
const QString name = activateQuery->value(0).toString();
|
|
// redundant check
|
|
if (name == userName) {
|
|
|
|
QSqlQuery *query = prepareQuery("update {prefix}_users set active=1 where name = :userName");
|
|
query->bindValue(":userName", userName);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog)
|
|
<< "Failed to activate user: " << query->lastError() << " sql: " << query->lastQuery();
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_ProtocolHandler *handler,
|
|
const QString &user,
|
|
const QString &password,
|
|
const QString &clientId,
|
|
QString &reasonStr,
|
|
int &banSecondsLeft,
|
|
bool passwordNeedsHash)
|
|
{
|
|
switch (server->getAuthenticationMethod()) {
|
|
case Servatrice::AuthenticationNone:
|
|
return UnknownUser;
|
|
case Servatrice::AuthenticationPassword: {
|
|
QString configPassword = settingsCache->value("authentication/password").toString();
|
|
if (configPassword == password) {
|
|
return PasswordRight;
|
|
}
|
|
|
|
return NotLoggedIn;
|
|
}
|
|
case Servatrice::AuthenticationSql: {
|
|
if (!checkSql()) {
|
|
return UnknownUser;
|
|
}
|
|
|
|
if (!usernameIsValid(user, reasonStr)) {
|
|
return UsernameInvalid;
|
|
}
|
|
|
|
if (checkUserIsBanned(handler->getAddress(), user, clientId, reasonStr, banSecondsLeft)) {
|
|
return UserIsBanned;
|
|
}
|
|
|
|
QSqlQuery *passwordQuery = prepareQuery(
|
|
"select password_sha512, active, force_password_change from {prefix}_users where name = :name");
|
|
passwordQuery->bindValue(":name", user);
|
|
if (!execSqlQuery(passwordQuery)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Login denied: SQL error";
|
|
return NotLoggedIn;
|
|
}
|
|
|
|
if (passwordQuery->next()) {
|
|
const QString correctPasswordSha512 = passwordQuery->value(0).toString();
|
|
const bool userIsActive = passwordQuery->value(1).toBool();
|
|
const bool forceChange = passwordQuery->value(2).toBool();
|
|
if (!userIsActive) {
|
|
qCWarning(DatabaseInterfaceLog) << "Login denied: user not active";
|
|
return UserIsInactive;
|
|
}
|
|
|
|
// Fail closed on an absent stored credential: an empty key would
|
|
// otherwise authenticate anyone who can compute HMAC("", nonce).
|
|
if (correctPasswordSha512.isEmpty()) {
|
|
qCWarning(DatabaseInterfaceLog) << "Login denied: empty stored credential";
|
|
return NotLoggedIn;
|
|
}
|
|
|
|
if (password.startsWith("$challenge$")) {
|
|
// Challenge-response login: verify HMAC(stored_key, nonce) without
|
|
// ever transmitting the stored credential or password hash.
|
|
const QStringList parts = password.split("$");
|
|
if (parts.size() != 4) {
|
|
return NotLoggedIn;
|
|
}
|
|
const QByteArray nonce = QByteArray::fromBase64(parts.at(2).toUtf8());
|
|
const QByteArray response = QByteArray::fromBase64(parts.at(3).toUtf8());
|
|
if (nonce.isEmpty() || response.isEmpty() || !handler->isAuthNonceValid(nonce, user)) {
|
|
return NotLoggedIn;
|
|
}
|
|
|
|
QByteArray key;
|
|
if (PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
|
|
key = correctPasswordSha512.toUtf8();
|
|
} else {
|
|
// Design note: the stored scrypt verifier IS the challenge-response key, so a
|
|
// database dump yields credentials that can answer a login challenge directly.
|
|
// We deliberately accept this trade: it removes plaintext passwords from the
|
|
// client config and from the wire, but does not protect against DB compromise.
|
|
// A password-equivalent proof scheme (e.g. SRP-6a/OPAQUE) would be the proper
|
|
// escalation and is out of scope here.
|
|
const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(correctPasswordSha512);
|
|
if (!verifier.isValid) {
|
|
return NotLoggedIn;
|
|
}
|
|
key = verifier.verifier;
|
|
}
|
|
|
|
const QByteArray expected = PasswordHasher::computeResponse(key, nonce);
|
|
handler->clearAuthNonce();
|
|
if (PasswordHasher::constantTimeEquals(expected, response)) {
|
|
if (forceChange) {
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
|
return PasswordChangeRequired;
|
|
}
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right";
|
|
return PasswordRight;
|
|
}
|
|
qCDebug(DatabaseInterfaceLog) << "Login denied: challenge-response password wrong";
|
|
return NotLoggedIn;
|
|
}
|
|
|
|
// A migrated (scrypt) row distinguishes how the credential arrived:
|
|
// a plaintext password (passwordNeedsHash) is verified directly against
|
|
// the scrypt verifier, so email changes (cmdAccountEdit) and old clients
|
|
// that still send `password` keep working without a legacy backup key.
|
|
// A pre-hashed legacy value is accepted against the encrypted legacy
|
|
// backup while that backup is fresh. Without one there is no downgrade
|
|
// path. The backup window is refreshed only when the backup is what
|
|
// actually matched, so a plaintext proof never keeps the
|
|
// fast-crackable hash alive.
|
|
if (!PasswordHasher::isLegacyFormat(correctPasswordSha512)) {
|
|
if (passwordNeedsHash) {
|
|
// Cost is bounded by costParamsAreSane inside parsePasswordVerifier.
|
|
if (PasswordHasher::verifyPassword(password, correctPasswordSha512)) {
|
|
if (forceChange) {
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
|
return PasswordChangeRequired;
|
|
}
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
|
|
return PasswordRight;
|
|
}
|
|
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
|
|
return NotLoggedIn;
|
|
}
|
|
const QString backupCredential = getUsableLegacyBackup(user);
|
|
if (backupCredential.isEmpty()) {
|
|
qCDebug(DatabaseInterfaceLog)
|
|
<< "Login denied: migrated account without a usable legacy backup";
|
|
return NotLoggedIn;
|
|
}
|
|
if (PasswordHasher::constantTimeEquals(backupCredential.toUtf8(), password.toUtf8())) {
|
|
touchLegacyBackup(user);
|
|
if (forceChange) {
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
|
return PasswordChangeRequired;
|
|
}
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
|
|
return PasswordRight;
|
|
}
|
|
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
|
|
return NotLoggedIn;
|
|
}
|
|
|
|
QString storedCredential = correctPasswordSha512;
|
|
QString hashedPassword;
|
|
if (passwordNeedsHash) {
|
|
hashedPassword = PasswordHasher::computeHash(password, storedCredential.left(16));
|
|
} else {
|
|
hashedPassword = password;
|
|
}
|
|
if (PasswordHasher::constantTimeEquals(storedCredential.toUtf8(), hashedPassword.toUtf8())) {
|
|
if (forceChange) {
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required";
|
|
return PasswordChangeRequired;
|
|
}
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted: password right";
|
|
return PasswordRight;
|
|
} else {
|
|
qCDebug(DatabaseInterfaceLog) << "Login denied: password wrong";
|
|
return NotLoggedIn;
|
|
}
|
|
} else {
|
|
qCDebug(DatabaseInterfaceLog) << "Login accepted: unknown user";
|
|
return UnknownUser;
|
|
}
|
|
}
|
|
}
|
|
return UnknownUser;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::checkUserIsBanned(const QString &ipAddress,
|
|
const QString &userName,
|
|
const QString &clientId,
|
|
QString &banReason,
|
|
int &banSecondsRemaining)
|
|
{
|
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
|
return false;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to check if user is banned. Database invalid.";
|
|
return false;
|
|
}
|
|
|
|
return checkUserIsIpBanned(ipAddress, banReason, banSecondsRemaining) ||
|
|
checkUserIsNameBanned(userName, banReason, banSecondsRemaining) ||
|
|
checkUserIsIdBanned(clientId, banReason, banSecondsRemaining);
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::checkUserIsIdBanned(const QString &clientId,
|
|
QString &banReason,
|
|
int &banSecondsRemaining)
|
|
{
|
|
if (clientId.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *idBanQuery =
|
|
prepareQuery("select"
|
|
" timestampdiff(second, now(), date_add(b.time_from, interval b.minutes minute)),"
|
|
" b.minutes <=> 0,"
|
|
" b.visible_reason"
|
|
" from {prefix}_bans b"
|
|
" where"
|
|
" b.time_from = (select max(c.time_from)"
|
|
" from {prefix}_bans c"
|
|
" where c.clientid = :id)"
|
|
" and b.clientid = :id2");
|
|
|
|
idBanQuery->bindValue(":id", clientId);
|
|
idBanQuery->bindValue(":id2", clientId);
|
|
if (!execSqlQuery(idBanQuery)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Id ban check failed: SQL error." << idBanQuery->lastError();
|
|
return false;
|
|
}
|
|
|
|
if (idBanQuery->next()) {
|
|
const int secondsLeft = idBanQuery->value(0).toInt();
|
|
const bool permanentBan = idBanQuery->value(1).toInt();
|
|
if ((secondsLeft > 0) || permanentBan) {
|
|
banReason = idBanQuery->value(2).toString();
|
|
banSecondsRemaining = permanentBan ? 0 : secondsLeft;
|
|
qCDebug(DatabaseInterfaceLog) << "User is banned by client id" << clientId;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::checkUserIsNameBanned(const QString &userName,
|
|
QString &banReason,
|
|
int &banSecondsRemaining)
|
|
{
|
|
QSqlQuery *nameBanQuery =
|
|
prepareQuery("select timestampdiff(second, now(), date_add(b.time_from, interval b.minutes minute)), b.minutes "
|
|
"<=> 0, b.visible_reason from {prefix}_bans b where b.time_from = (select max(c.time_from) from "
|
|
"{prefix}_bans c where c.user_name = :name2) and b.user_name = :name1");
|
|
nameBanQuery->bindValue(":name1", userName);
|
|
nameBanQuery->bindValue(":name2", userName);
|
|
if (!execSqlQuery(nameBanQuery)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Name ban check failed: SQL error" << nameBanQuery->lastError();
|
|
return false;
|
|
}
|
|
|
|
if (nameBanQuery->next()) {
|
|
const int secondsLeft = nameBanQuery->value(0).toInt();
|
|
const bool permanentBan = nameBanQuery->value(1).toInt();
|
|
if ((secondsLeft > 0) || permanentBan) {
|
|
banReason = nameBanQuery->value(2).toString();
|
|
banSecondsRemaining = permanentBan ? 0 : secondsLeft;
|
|
qCDebug(DatabaseInterfaceLog) << "Username" << userName << "is banned by name";
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::checkUserIsIpBanned(const QString &ipAddress,
|
|
QString &banReason,
|
|
int &banSecondsRemaining)
|
|
{
|
|
QSqlQuery *ipBanQuery =
|
|
prepareQuery("select"
|
|
" timestampdiff(second, now(), date_add(b.time_from, interval b.minutes minute)),"
|
|
" b.minutes <=> 0,"
|
|
" b.visible_reason"
|
|
" from {prefix}_bans b"
|
|
" where"
|
|
" b.time_from = (select max(c.time_from)"
|
|
" from {prefix}_bans c"
|
|
" where c.ip_address = :address)"
|
|
" and b.ip_address = :address2");
|
|
|
|
ipBanQuery->bindValue(":address", ipAddress);
|
|
ipBanQuery->bindValue(":address2", ipAddress);
|
|
if (!execSqlQuery(ipBanQuery)) {
|
|
qCWarning(DatabaseInterfaceLog) << "IP ban check failed: SQL error." << ipBanQuery->lastError();
|
|
return false;
|
|
}
|
|
|
|
if (ipBanQuery->next()) {
|
|
const int secondsLeft = ipBanQuery->value(0).toInt();
|
|
const bool permanentBan = ipBanQuery->value(1).toInt();
|
|
if ((secondsLeft > 0) || permanentBan) {
|
|
banReason = ipBanQuery->value(2).toString();
|
|
banSecondsRemaining = permanentBan ? 0 : secondsLeft;
|
|
qCDebug(DatabaseInterfaceLog) << "User is banned by address" << ipAddress;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::activeUserExists(const QString &user)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("select 1 from {prefix}_users where name = :name and active = 1");
|
|
query->bindValue(":name", user);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
return query->next();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::userExists(const QString &user)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("select 1 from {prefix}_users where name = :name");
|
|
query->bindValue(":name", user);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
return query->next();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
QString Servatrice_DatabaseInterface::getUserSalt(const QString &user)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
|
checkSql();
|
|
|
|
QSqlQuery *query =
|
|
prepareQuery("SELECT SUBSTRING(password_sha512, 1, 16) FROM {prefix}_users WHERE name = :name");
|
|
|
|
query->bindValue(":name", user);
|
|
if (!execSqlQuery(query)) {
|
|
return {};
|
|
}
|
|
|
|
if (!query->next()) {
|
|
return {};
|
|
}
|
|
|
|
return query->value(0).toString();
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user)
|
|
{
|
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
|
return {};
|
|
}
|
|
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("SELECT password_sha512 FROM {prefix}_users WHERE name = :name");
|
|
query->bindValue(":name", user);
|
|
if (!execSqlQuery(query)) {
|
|
return {};
|
|
}
|
|
|
|
if (!query->next()) {
|
|
return {};
|
|
}
|
|
|
|
return query->value(0).toString();
|
|
}
|
|
|
|
QString Servatrice_DatabaseInterface::sealLegacyBackup(const QString &storedName, const QString &credential)
|
|
{
|
|
const QByteArray key = server->getLegacyBackupKey();
|
|
if (key.isEmpty()) {
|
|
return {};
|
|
}
|
|
const QByteArray aad = storedName.simplified().toUtf8();
|
|
const QString sealed = CryptoUtil::encryptSecret(credential.toUtf8(), key, aad);
|
|
if (sealed.isEmpty() || sealed.length() > MAX_LEGACY_BACKUP_LENGTH) {
|
|
return {};
|
|
}
|
|
return sealed;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier)
|
|
{
|
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
|
return false;
|
|
}
|
|
|
|
checkSql();
|
|
|
|
// Read the credential that is about to be replaced: while it is still in the
|
|
// legacy format it becomes the encrypted downgrade backup, sealed before the
|
|
// row is overwritten with the scrypt verifier.
|
|
QSqlQuery *currentQuery = prepareQuery("SELECT name, password_sha512 FROM {prefix}_users WHERE name = :name");
|
|
currentQuery->bindValue(":name", user);
|
|
if (!execSqlQuery(currentQuery) || !currentQuery->next()) {
|
|
return false;
|
|
}
|
|
// Use the stored row name (not the caller-supplied one) as the AAD: name is
|
|
// utf8mb4_unicode_ci, so `alice` logs in as `Alice` and the SELECT still finds
|
|
// the row while a client-typed name would fail the GCM tag check on open.
|
|
const QString storedName = currentQuery->value(0).toString();
|
|
const QString currentCredential = currentQuery->value(1).toString();
|
|
|
|
QString sealedBackup;
|
|
QDateTime backupUsedAt;
|
|
if (PasswordHasher::isLegacyFormat(currentCredential) && !server->getLegacyBackupKey().isEmpty()) {
|
|
sealedBackup = sealLegacyBackup(storedName, currentCredential);
|
|
if (sealedBackup.isEmpty()) {
|
|
qCWarning(DatabaseInterfaceLog)
|
|
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
|
|
} else {
|
|
backupUsedAt = QDateTime::currentDateTimeUtc();
|
|
}
|
|
}
|
|
|
|
// Only migrate accounts that still use the credential this backup was taken from;
|
|
// the guard makes a re-migration (or a raced second submission) a no-op.
|
|
QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512 = :verifier, "
|
|
"password_legacy_backup = :backup, password_legacy_backup_used_at = :used_at "
|
|
"where name = :user and password_sha512 = :expected and password_sha512 not like "
|
|
"'$%'");
|
|
query->bindValue(":verifier", passwordVerifier);
|
|
query->bindValue(":backup", sealedBackup);
|
|
query->bindValue(":used_at", backupUsedAt.isValid()
|
|
? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")))
|
|
: QVariant());
|
|
query->bindValue(":user", user);
|
|
query->bindValue(":expected", currentCredential);
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to submit password verifier for user" << user << query->lastError();
|
|
return false;
|
|
}
|
|
// The guard makes a re-migration a no-op; only report success when a row was actually updated.
|
|
return query->numRowsAffected() > 0;
|
|
}
|
|
|
|
QString Servatrice_DatabaseInterface::getUsableLegacyBackup(const QString &user)
|
|
{
|
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
|
return {};
|
|
}
|
|
|
|
// Without a configured key the backup cannot be sealed or opened; do not touch
|
|
// (and never purge on this path) so a temporarily missing key cannot destroy it.
|
|
const QByteArray key = server->getLegacyBackupKey();
|
|
if (key.isEmpty()) {
|
|
return {};
|
|
}
|
|
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery(
|
|
"SELECT name, password_legacy_backup, password_legacy_backup_used_at FROM {prefix}_users WHERE name = :name");
|
|
query->bindValue(":name", user);
|
|
if (!execSqlQuery(query) || !query->next()) {
|
|
return {};
|
|
}
|
|
|
|
// Open with the stored row name as the AAD: sealing used the canonical DB name
|
|
// while the caller passes the client-typed name, and the two differ whenever
|
|
// the login case does not match the stored case (utf8mb4_unicode_ci).
|
|
const QString storedName = query->value(0).toString();
|
|
const QString sealed = query->value(1).toString();
|
|
const QString usedAt = query->value(2).toString();
|
|
if (sealed.isEmpty()) {
|
|
return {};
|
|
}
|
|
|
|
// Age out: a backup unused for the whole TTL is purged on sight, so a database
|
|
// dump taken after the window closes never carries a crackable legacy hash.
|
|
if (usedAt.isEmpty() || usedAt < legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays())) {
|
|
qCDebug(DatabaseInterfaceLog) << "Purging expired legacy backup for user" << user;
|
|
clearLegacyBackup(user);
|
|
return {};
|
|
}
|
|
|
|
const QByteArray legacyCredential = CryptoUtil::decryptSecret(sealed, key, storedName.simplified().toUtf8());
|
|
if (legacyCredential.isEmpty()) {
|
|
qCWarning(DatabaseInterfaceLog) << "Legacy backup for user" << user << "failed to decrypt"
|
|
<< "- check security/legacy_backup_key";
|
|
return {};
|
|
}
|
|
return QString::fromUtf8(legacyCredential);
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::touchLegacyBackup(const QString &user)
|
|
{
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup_used_at = UTC_TIMESTAMP() "
|
|
"WHERE name = :name AND password_legacy_backup <> ''");
|
|
query->bindValue(":name", user);
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::clearLegacyBackup(const QString &user)
|
|
{
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
|
|
"password_legacy_backup_used_at = NULL WHERE name = :name");
|
|
query->bindValue(":name", user);
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::purgeExpiredLegacyBackups()
|
|
{
|
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
|
return;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET password_legacy_backup = '', "
|
|
"password_legacy_backup_used_at = NULL "
|
|
"WHERE password_legacy_backup <> '' AND "
|
|
"(password_legacy_backup_used_at IS NULL OR password_legacy_backup_used_at < "
|
|
":cutoff)");
|
|
query->bindValue(":cutoff", legacyBackupExpiryCutoff(server->getLegacyBackupTtlDays()));
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Legacy backup sweep failed:" << query->lastError();
|
|
return;
|
|
}
|
|
if (query->numRowsAffected() > 0) {
|
|
qCDebug(DatabaseInterfaceLog) << "Purged" << query->numRowsAffected() << "expired legacy password backups";
|
|
}
|
|
}
|
|
|
|
int Servatrice_DatabaseInterface::getUserIdInDB(const QString &name)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
|
QSqlQuery *query = prepareQuery("select id from {prefix}_users where name = :name and active = 1");
|
|
query->bindValue(":name", name);
|
|
if (!execSqlQuery(query)) {
|
|
return -1;
|
|
}
|
|
if (!query->next()) {
|
|
return -1;
|
|
}
|
|
return query->value(0).toInt();
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::isInBuddyList(const QString &whoseList, const QString &who)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationNone) {
|
|
return false;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
int id1 = getUserIdInDB(whoseList);
|
|
int id2 = getUserIdInDB(who);
|
|
|
|
QSqlQuery *query =
|
|
prepareQuery("select 1 from {prefix}_buddylist where id_user1 = :id_user1 and id_user2 = :id_user2");
|
|
query->bindValue(":id_user1", id1);
|
|
query->bindValue(":id_user2", id2);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
return query->next();
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::isInIgnoreList(const QString &whoseList, const QString &who)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationNone) {
|
|
return false;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
int id1 = getUserIdInDB(whoseList);
|
|
int id2 = getUserIdInDB(who);
|
|
|
|
QSqlQuery *query =
|
|
prepareQuery("select 1 from {prefix}_ignorelist where id_user1 = :id_user1 and id_user2 = :id_user2");
|
|
query->bindValue(":id_user1", id1);
|
|
query->bindValue(":id_user2", id2);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
return query->next();
|
|
}
|
|
|
|
ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuery *query, bool complete, bool withId)
|
|
{
|
|
ServerInfo_User result;
|
|
|
|
if (withId) {
|
|
result.set_id(query->value(0).toInt());
|
|
}
|
|
result.set_name(query->value(1).toString().toStdString());
|
|
|
|
const int is_admin = query->value(2).toInt();
|
|
int userLevel = ServerInfo_User::IsUser | ServerInfo_User::IsRegistered;
|
|
if (is_admin & 1) {
|
|
userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator;
|
|
} else if (is_admin & 2) {
|
|
userLevel |= ServerInfo_User::IsModerator;
|
|
}
|
|
|
|
if (is_admin & 4) {
|
|
userLevel |= ServerInfo_User::IsJudge;
|
|
}
|
|
|
|
if (is_admin & 8) {
|
|
userLevel |= ServerInfo_User::IsDeveloper;
|
|
}
|
|
|
|
result.set_user_level(userLevel);
|
|
|
|
const QString country = query->value(3).toString();
|
|
if (!country.isEmpty()) {
|
|
result.set_country(country.toStdString());
|
|
}
|
|
|
|
const QString privlevel = query->value(4).toString();
|
|
if (!privlevel.isEmpty()) {
|
|
result.set_privlevel(privlevel.toStdString());
|
|
}
|
|
|
|
const auto &pawn_left_override = query->value(5).toString();
|
|
const auto &pawn_right_override = query->value(6).toString();
|
|
if (!pawn_left_override.isEmpty()) {
|
|
result.mutable_pawn_colors()->set_left_side(pawn_left_override.toStdString());
|
|
}
|
|
if (!pawn_right_override.isEmpty()) {
|
|
result.mutable_pawn_colors()->set_right_side(pawn_right_override.toStdString());
|
|
}
|
|
|
|
if (complete) {
|
|
const QString realName = query->value(7).toString();
|
|
if (!realName.isEmpty()) {
|
|
result.set_real_name(realName.toStdString());
|
|
}
|
|
|
|
const QByteArray avatarBmp = query->value(8).toByteArray();
|
|
if (avatarBmp.size()) {
|
|
result.set_avatar_bmp(avatarBmp.data(), avatarBmp.size());
|
|
}
|
|
|
|
const QDateTime regDate = query->value(9).toDateTime();
|
|
if (!regDate.toString(Qt::ISODate).isEmpty()) {
|
|
// the registration date is in utc
|
|
qint64 accountAgeInSeconds = regDate.secsTo(QDateTime::currentDateTimeUtc());
|
|
result.set_accountage_secs(accountAgeInSeconds);
|
|
}
|
|
|
|
const QString email = query->value(10).toString();
|
|
if (!email.isEmpty()) {
|
|
result.set_email(email.toStdString());
|
|
}
|
|
|
|
const QString clientid = query->value(11).toString();
|
|
if (!clientid.isEmpty()) {
|
|
result.set_clientid(clientid.toStdString());
|
|
}
|
|
|
|
const QString cardArtParamsJson = query->value(12).toString();
|
|
if (!cardArtParamsJson.isEmpty()) {
|
|
const QJsonDocument doc = QJsonDocument::fromJson(cardArtParamsJson.toUtf8());
|
|
if (doc.isObject()) {
|
|
const QJsonObject obj = doc.object();
|
|
auto *cap = result.mutable_card_art_params();
|
|
if (obj.contains("card_name")) {
|
|
cap->set_card_name(obj["card_name"].toString().toStdString());
|
|
}
|
|
if (obj.contains("card_provider_id")) {
|
|
cap->set_card_provider_id(obj["card_provider_id"].toString().toStdString());
|
|
}
|
|
if (obj.contains("marginPctL")) {
|
|
cap->set_margin_pct_l(obj["marginPctL"].toDouble(0.33));
|
|
}
|
|
if (obj.contains("marginPctR")) {
|
|
cap->set_margin_pct_r(obj["marginPctR"].toDouble(0.02));
|
|
}
|
|
if (obj.contains("verticalOffset")) {
|
|
cap->set_vertical_offset(obj["verticalOffset"].toDouble(0.35));
|
|
}
|
|
if (obj.contains("zoom")) {
|
|
cap->set_zoom(obj["zoom"].toDouble(1.0));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
ServerInfo_User Servatrice_DatabaseInterface::getUserData(const QString &name, bool withId)
|
|
{
|
|
ServerInfo_User result;
|
|
result.set_name(name.toStdString());
|
|
result.set_user_level(ServerInfo_User::IsUser);
|
|
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
|
if (!checkSql()) {
|
|
return result;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("select id, name, admin, country, privlevel, leftPawnColorOverride, "
|
|
"rightPawnColorOverride, realname, avatar_bmp, registrationDate, "
|
|
"email, clientid, card_art_params from {prefix}_users where "
|
|
"name = :name and active = 1");
|
|
query->bindValue(":name", name);
|
|
if (!execSqlQuery(query)) {
|
|
return result;
|
|
}
|
|
|
|
if (query->next()) {
|
|
return evalUserQueryResult(query, true, withId);
|
|
} else {
|
|
return result;
|
|
}
|
|
} else {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::clearSessionTables()
|
|
{
|
|
lockSessionTables();
|
|
QSqlQuery *query =
|
|
prepareQuery("update {prefix}_sessions set end_time=now() where end_time is null and id_server = :id_server");
|
|
query->bindValue(":id_server", server->getServerID());
|
|
execSqlQuery(query);
|
|
unlockSessionTables();
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::lockSessionTables()
|
|
{
|
|
QSqlQuery *query = prepareQuery("lock tables {prefix}_sessions write, {prefix}_users read");
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::unlockSessionTables()
|
|
{
|
|
QSqlQuery *query = prepareQuery("unlock tables");
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::userSessionExists(const QString &userName)
|
|
{
|
|
// Call only after lockSessionTables().
|
|
|
|
QSqlQuery *query = prepareQuery(
|
|
"select 1 from {prefix}_sessions where user_name = :user_name and id_server = :id_server and end_time is null");
|
|
query->bindValue(":id_server", server->getServerID());
|
|
query->bindValue(":user_name", userName);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
};
|
|
return query->next();
|
|
}
|
|
|
|
qint64 Servatrice_DatabaseInterface::startSession(const QString &userName,
|
|
const QString &address,
|
|
const QString &clientId,
|
|
const QString &connectionType)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationNone) {
|
|
return -1;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return -1;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_sessions (user_name, id_server, ip_address, start_time, "
|
|
"clientid, connection_type) values(:user_name, :id_server, :ip_address, NOW(), "
|
|
":client_id, :connection_type)");
|
|
query->bindValue(":user_name", userName);
|
|
query->bindValue(":id_server", server->getServerID());
|
|
query->bindValue(":ip_address", address);
|
|
query->bindValue(":client_id", clientId);
|
|
query->bindValue(":connection_type", connectionType);
|
|
if (execSqlQuery(query)) {
|
|
return query->lastInsertId().toInt();
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::endSession(qint64 sessionId)
|
|
{
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationNone) {
|
|
return;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
auto *query = prepareQuery("update {prefix}_sessions set end_time=NOW() where id = :id_session");
|
|
query->bindValue(":id_session", sessionId);
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
QMap<QString, ServerInfo_User> Servatrice_DatabaseInterface::getBuddyList(const QString &name)
|
|
{
|
|
QMap<QString, ServerInfo_User> result;
|
|
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("select a.id, a.name, a.admin, a.country, a.privlevel, "
|
|
"a.leftPawnColorOverride, a.rightPawnColorOverride from {prefix}_users a "
|
|
"left join {prefix}_buddylist b on a.id = b.id_user2 left join {prefix}_users "
|
|
"c on b.id_user1 = c.id where c.name = :name");
|
|
query->bindValue(":name", name);
|
|
if (!execSqlQuery(query)) {
|
|
return result;
|
|
}
|
|
|
|
while (query->next()) {
|
|
const ServerInfo_User &temp = evalUserQueryResult(query, false);
|
|
result.insert(QString::fromStdString(temp.name()), temp);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QMap<QString, ServerInfo_User> Servatrice_DatabaseInterface::getIgnoreList(const QString &name)
|
|
{
|
|
QMap<QString, ServerInfo_User> result;
|
|
|
|
if (server->getAuthenticationMethod() == Servatrice::AuthenticationSql) {
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("select a.id, a.name, a.admin, a.country, a.privlevel, "
|
|
"a.leftPawnColorOverride, a.rightPawnColorOverride from {prefix}_users a "
|
|
"left join {prefix}_ignorelist b on a.id = b.id_user2 left join {prefix}_users "
|
|
"c on b.id_user1 = c.id where c.name = :name");
|
|
query->bindValue(":name", name);
|
|
if (!execSqlQuery(query)) {
|
|
return result;
|
|
}
|
|
|
|
while (query->next()) {
|
|
ServerInfo_User temp = evalUserQueryResult(query, false);
|
|
result.insert(QString::fromStdString(temp.name()), temp);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
int Servatrice_DatabaseInterface::getNextGameId()
|
|
{
|
|
if (!sqlDatabase.isValid()) {
|
|
return server->getNextLocalGameId();
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return -1;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_games (time_started) values (now())");
|
|
|
|
if (!execSqlQuery(query)) {
|
|
return -1;
|
|
}
|
|
|
|
return query->lastInsertId().toInt();
|
|
}
|
|
|
|
int Servatrice_DatabaseInterface::getNextReplayId()
|
|
{
|
|
if (!checkSql()) {
|
|
return -1;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_replays (id_game) values (NULL)");
|
|
|
|
if (!execSqlQuery(query)) {
|
|
return -1;
|
|
}
|
|
|
|
return query->lastInsertId().toInt();
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::storeGameInformation(const QString &roomName,
|
|
const QStringList &roomGameTypes,
|
|
const ServerInfo_Game &gameInfo,
|
|
const QSet<QString> &allPlayersEver,
|
|
const QSet<QString> &allSpectatorsEver,
|
|
const QList<GameReplay *> &replayList)
|
|
{
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
if (!settingsCache->value("game/store_replays", 1).toBool()) {
|
|
return;
|
|
}
|
|
|
|
QVariantList gameIds1, playerNames, gameIds2, userIds, replayNames;
|
|
QSetIterator<QString> playerIterator(allPlayersEver);
|
|
while (playerIterator.hasNext()) {
|
|
gameIds1.append(gameInfo.game_id());
|
|
const QString &playerName = playerIterator.next();
|
|
playerNames.append(playerName);
|
|
}
|
|
QSet<QString> allUsersInGame = allPlayersEver + allSpectatorsEver;
|
|
QSetIterator<QString> allUsersIterator(allUsersInGame);
|
|
while (allUsersIterator.hasNext()) {
|
|
int id = getUserIdInDB(allUsersIterator.next());
|
|
if (id == -1) {
|
|
continue;
|
|
}
|
|
gameIds2.append(gameInfo.game_id());
|
|
userIds.append(id);
|
|
replayNames.append(QString::fromStdString(gameInfo.description()));
|
|
}
|
|
|
|
QVariantList replayIds, replayGameIds, replayDurations, replayBlobs;
|
|
for (int i = 0; i < replayList.size(); ++i) {
|
|
QByteArray blob;
|
|
#if GOOGLE_PROTOBUF_VERSION > 3001000
|
|
const unsigned int size = static_cast<unsigned int>(replayList[i]->ByteSizeLong());
|
|
#else
|
|
const unsigned int size = static_cast<unsigned int>(replayList[i]->ByteSize());
|
|
#endif
|
|
blob.resize(size);
|
|
qulonglong replayId = replayList[i]->replay_id();
|
|
if (replayList[i]->SerializeToArray(blob.data(), size)) {
|
|
|
|
replayIds.append(QVariant(replayId));
|
|
replayGameIds.append(gameInfo.game_id());
|
|
replayDurations.append(replayList[i]->duration_seconds());
|
|
replayBlobs.append(blob);
|
|
} else {
|
|
qCWarning(DatabaseInterfaceLog)
|
|
<< "failed to serialise replay, id:" << replayId << "game:" << gameInfo.game_id();
|
|
}
|
|
}
|
|
|
|
{
|
|
QSqlQuery *query = prepareQuery("update {prefix}_games set room_name=:room_name, descr=:descr, "
|
|
"creator_name=:creator_name, password=:password, game_types=:game_types, "
|
|
"player_count=:player_count, time_finished=now() where id=:id_game");
|
|
query->bindValue(":room_name", roomName);
|
|
query->bindValue(":id_game", gameInfo.game_id());
|
|
query->bindValue(":descr", QString::fromStdString(gameInfo.description()));
|
|
query->bindValue(":creator_name", QString::fromStdString(gameInfo.creator_info().name()));
|
|
query->bindValue(":password", gameInfo.with_password() ? 1 : 0);
|
|
query->bindValue(":game_types", roomGameTypes.isEmpty() ? QString("") : roomGameTypes.join(", "));
|
|
query->bindValue(":player_count", gameInfo.max_players());
|
|
if (!execSqlQuery(query)) {
|
|
return;
|
|
}
|
|
}
|
|
{
|
|
QSqlQuery *query =
|
|
prepareQuery("insert into {prefix}_games_players (id_game, player_name) values (:id_game, :player_name)");
|
|
query->bindValue(":id_game", gameIds1);
|
|
query->bindValue(":player_name", playerNames);
|
|
query->execBatch();
|
|
}
|
|
{
|
|
QSqlQuery *query = prepareQuery(
|
|
"update {prefix}_replays set id_game=:id_game, duration=:duration, replay=:replay where id=:id_replay");
|
|
query->bindValue(":id_replay", replayIds);
|
|
query->bindValue(":id_game", replayGameIds);
|
|
query->bindValue(":duration", replayDurations);
|
|
query->bindValue(":replay", replayBlobs);
|
|
query->execBatch();
|
|
}
|
|
{
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_replays_access (id_game, id_player, replay_name) values "
|
|
"(:id_game, :id_player, :replay_name)");
|
|
query->bindValue(":id_game", gameIds2);
|
|
query->bindValue(":id_player", userIds);
|
|
query->bindValue(":replay_name", replayNames);
|
|
query->execBatch();
|
|
}
|
|
}
|
|
|
|
DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int userId)
|
|
{
|
|
checkSql();
|
|
|
|
QSqlQuery *query =
|
|
prepareQuery("select content from {prefix}_decklist_files where id = :id and id_user = :id_user");
|
|
query->bindValue(":id", deckId);
|
|
query->bindValue(":id_user", userId);
|
|
execSqlQuery(query);
|
|
if (!query->next()) {
|
|
throw Response::RespNameNotFound;
|
|
}
|
|
|
|
DeckList *deck = new DeckList;
|
|
deck->loadFromString_Native(query->value(0).toString());
|
|
|
|
return deck;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::createDeckShare(const QString &token,
|
|
const QString &name,
|
|
int userId,
|
|
const QList<DeckShareItemRecord> &items,
|
|
int expiryDays,
|
|
qint64 &expiresAt)
|
|
{
|
|
checkSql();
|
|
|
|
if (items.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
if (!sqlDatabase.transaction()) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_deck_share (token, name, created_by, created_at, expires_at) "
|
|
"values (:token, :name, :created_by, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))");
|
|
query->bindValue(":token", token);
|
|
query->bindValue(":name", name);
|
|
query->bindValue(":created_by", userId < 1 ? QVariant() : userId);
|
|
query->bindValue(":days", expiryDays);
|
|
if (!execSqlQuery(query)) {
|
|
// A failed execSqlQuery has already closed and reopened the connection,
|
|
// which implicitly discards the transaction; rollback below is a no-op.
|
|
sqlDatabase.rollback();
|
|
return false;
|
|
}
|
|
|
|
const int shareId = query->lastInsertId().toInt();
|
|
|
|
// Read the expiry back from the database so the value returned to the client
|
|
// matches the server clock rather than being approximated client-side.
|
|
QSqlQuery *expiryQuery = prepareQuery("select UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where id = :id");
|
|
expiryQuery->bindValue(":id", shareId);
|
|
if (!execSqlQuery(expiryQuery) || !expiryQuery->next()) {
|
|
// See the note above: after a failed execSqlQuery the transaction is
|
|
// already gone because the connection was torn down.
|
|
sqlDatabase.rollback();
|
|
return false;
|
|
}
|
|
expiresAt = expiryQuery->value(0).toLongLong();
|
|
|
|
for (int i = 0; i < items.size(); ++i) {
|
|
const DeckShareItemRecord &item = items.at(i);
|
|
QSqlQuery *itemQuery = prepareQuery("insert into {prefix}_deck_share_item (share_id, name, tags, banner_card, "
|
|
"game_format, color_identity, content, position) values (:share_id, :name, "
|
|
":tags, :banner_card, :game_format, :color_identity, :content, :position)");
|
|
itemQuery->bindValue(":share_id", shareId);
|
|
itemQuery->bindValue(":name", item.name);
|
|
itemQuery->bindValue(":tags", serializeDeckTags(item.tags));
|
|
itemQuery->bindValue(":banner_card", item.bannerCard);
|
|
itemQuery->bindValue(":game_format", item.gameFormat);
|
|
itemQuery->bindValue(":color_identity", item.colorIdentity);
|
|
itemQuery->bindValue(":content", item.content);
|
|
itemQuery->bindValue(":position", i);
|
|
if (!execSqlQuery(itemQuery)) {
|
|
// See the note above: the transaction is already gone after the
|
|
// reconnect performed by a failed execSqlQuery.
|
|
sqlDatabase.rollback();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (!sqlDatabase.commit()) {
|
|
sqlDatabase.rollback();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token,
|
|
QString &name,
|
|
qint64 &expiresAt,
|
|
QList<DeckShareItemRecord> &items)
|
|
{
|
|
checkSql();
|
|
|
|
QSqlQuery *query =
|
|
prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = "
|
|
":token and expires_at > now()");
|
|
query->bindValue(":token", token);
|
|
execSqlQuery(query);
|
|
if (!query->next()) {
|
|
return false;
|
|
}
|
|
|
|
const int shareId = query->value(0).toInt();
|
|
name = query->value(1).toString();
|
|
expiresAt = query->value(2).toLongLong();
|
|
items.clear();
|
|
|
|
QSqlQuery *itemQuery =
|
|
prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item "
|
|
"where share_id = :share_id order by position");
|
|
itemQuery->bindValue(":share_id", shareId);
|
|
execSqlQuery(itemQuery);
|
|
while (itemQuery->next()) {
|
|
DeckShareItemRecord item;
|
|
item.id = itemQuery->value(0).toInt();
|
|
item.name = itemQuery->value(1).toString();
|
|
item.tags = deserializeDeckTags(itemQuery->value(2).toString());
|
|
item.bannerCard = itemQuery->value(3).toString();
|
|
item.gameFormat = itemQuery->value(4).toString();
|
|
item.colorIdentity = itemQuery->value(5).toString();
|
|
items.append(item);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::getDeckShareItem(const QString &token, int itemId, QString &content)
|
|
{
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("select i.content from {prefix}_deck_share_item i join {prefix}_deck_share s on "
|
|
"s.id = i.share_id where s.token = :token and s.expires_at > now() and i.id = :id");
|
|
query->bindValue(":token", token);
|
|
query->bindValue(":id", itemId);
|
|
execSqlQuery(query);
|
|
if (!query->next()) {
|
|
return false;
|
|
}
|
|
|
|
content = query->value(0).toString();
|
|
return true;
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::cleanupExpiredDeckShares()
|
|
{
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where expires_at < now()");
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList<DeckShareSummaryRecord> &shares)
|
|
{
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("select s.id, s.name, UNIX_TIMESTAMP(s.created_at), "
|
|
"UNIX_TIMESTAMP(s.expires_at), count(i.id) from {prefix}_deck_share s left join "
|
|
"{prefix}_deck_share_item i on i.share_id = s.id "
|
|
"where s.created_by = :created_by and s.expires_at > now() "
|
|
"group by s.id, s.name, s.created_at, s.expires_at order by s.created_at desc");
|
|
query->bindValue(":created_by", userId);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
|
|
shares.clear();
|
|
while (query->next()) {
|
|
DeckShareSummaryRecord summary;
|
|
summary.id = query->value(0).toInt();
|
|
summary.name = query->value(1).toString();
|
|
summary.creationTime = query->value(2).toLongLong();
|
|
summary.expiresAt = query->value(3).toLongLong();
|
|
summary.itemCount = query->value(4).toInt();
|
|
shares.append(summary);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::deleteDeckShare(int shareId, int userId)
|
|
{
|
|
checkSql();
|
|
|
|
QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where id = :id and created_by = :created_by");
|
|
query->bindValue(":id", shareId);
|
|
query->bindValue(":created_by", userId);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
return query->numRowsAffected() > 0;
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::logMessage(const int senderId,
|
|
const QString &senderName,
|
|
const QString &senderIp,
|
|
const QString &logMessage,
|
|
LogMessage_TargetType targetType,
|
|
const int targetId,
|
|
const QString &targetName,
|
|
const QString &channel)
|
|
{
|
|
QString targetTypeString;
|
|
switch (targetType) {
|
|
case MessageTargetRoom:
|
|
if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) {
|
|
return;
|
|
}
|
|
targetTypeString = "room";
|
|
break;
|
|
case MessageTargetRoomChannel:
|
|
if (!settingsCache->value("logging/log_user_msg_room", 0).toBool()) {
|
|
return;
|
|
}
|
|
targetTypeString = "room_channel";
|
|
break;
|
|
case MessageTargetGame:
|
|
if (!settingsCache->value("logging/log_user_msg_game", 0).toBool()) {
|
|
return;
|
|
}
|
|
targetTypeString = "game";
|
|
break;
|
|
case MessageTargetChat:
|
|
if (!settingsCache->value("logging/log_user_msg_chat", 0).toBool()) {
|
|
return;
|
|
}
|
|
targetTypeString = "chat";
|
|
break;
|
|
case MessageTargetIslRoom:
|
|
if (!settingsCache->value("logging/log_user_msg_isl", 0).toBool()) {
|
|
return;
|
|
}
|
|
targetTypeString = "room";
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_log (log_time, sender_id, sender_name, sender_ip, "
|
|
"log_message, target_type, target_id, target_name, channel) values (now(), "
|
|
":sender_id, :sender_name, :sender_ip, :log_message, :target_type, :target_id, "
|
|
":target_name, :channel)");
|
|
query->bindValue(":sender_id", senderId < 1 ? QVariant() : senderId);
|
|
query->bindValue(":sender_name", senderName);
|
|
query->bindValue(":sender_ip", senderIp);
|
|
query->bindValue(":log_message", logMessage);
|
|
query->bindValue(":target_type", targetTypeString);
|
|
query->bindValue(":target_id", (targetType == MessageTargetChat && targetId < 1) ? QVariant() : targetId);
|
|
query->bindValue(":target_name", targetName);
|
|
query->bindValue(":channel", channel.isEmpty() ? QVariant() : channel);
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
|
const QString &password,
|
|
bool passwordNeedsHash)
|
|
{
|
|
QString passwordSha512 = password;
|
|
if (passwordNeedsHash) {
|
|
passwordSha512 = PasswordHasher::computeHash(password, PasswordHasher::generateRandomSalt());
|
|
}
|
|
|
|
// The backup authenticates the credential it was taken from; a new credential
|
|
// invalidates it (the old password is dead, and the new one has no legacy form).
|
|
QSqlQuery *passwordQuery = prepareQuery("update {prefix}_users set password_sha512=:password, "
|
|
"password_legacy_backup='', password_legacy_backup_used_at=NULL, "
|
|
"passwordLastChangedDate = NOW() where name = :name");
|
|
passwordQuery->bindValue(":password", passwordSha512);
|
|
passwordQuery->bindValue(":name", user);
|
|
if (!execSqlQuery(passwordQuery)) {
|
|
return false;
|
|
}
|
|
return passwordQuery->numRowsAffected() > 0;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::changeUserPasswordWithLegacyBackup(const QString &user,
|
|
const QString &passwordVerifier,
|
|
const QString &legacyCredential)
|
|
{
|
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
|
return false;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
// Seal against the stored row name, not the caller-supplied one: the name column is
|
|
// case-insensitive, so a differently-cased name would fail the tag check on open.
|
|
QSqlQuery *nameQuery = prepareQuery("SELECT name FROM {prefix}_users WHERE name = :name");
|
|
nameQuery->bindValue(":name", user);
|
|
if (!execSqlQuery(nameQuery) || !nameQuery->next()) {
|
|
return false;
|
|
}
|
|
const QString storedName = nameQuery->value(0).toString();
|
|
|
|
const QString sealedBackup = sealLegacyBackup(storedName, legacyCredential);
|
|
const QDateTime backupUsedAt = sealedBackup.isEmpty() ? QDateTime() : QDateTime::currentDateTimeUtc();
|
|
if (sealedBackup.isEmpty()) {
|
|
// Without a backup a pre-challenge client has nothing to authenticate against: the
|
|
// row holds a verifier and the legacy hash it would send cannot match it. Say so,
|
|
// because the symptom - the temporary password appearing not to work - shows up on
|
|
// a different machine from the one that reset it.
|
|
qCWarning(DatabaseInterfaceLog) << "Stored a verifier without a legacy backup for user" << user
|
|
<< "- pre-challenge clients cannot use this password"
|
|
<< "- check security/legacy_backup_key";
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512=:password, "
|
|
"password_legacy_backup=:backup, password_legacy_backup_used_at=:used_at, "
|
|
"passwordLastChangedDate = NOW() where name = :name");
|
|
query->bindValue(":password", passwordVerifier);
|
|
query->bindValue(":backup", sealedBackup);
|
|
query->bindValue(":used_at", backupUsedAt.isValid()
|
|
? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")))
|
|
: QVariant());
|
|
query->bindValue(":name", storedName);
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
return query->numRowsAffected() > 0;
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force)
|
|
{
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET force_password_change = :force WHERE name = :name");
|
|
query->bindValue(":force", force ? 1 : 0);
|
|
query->bindValue(":name", user);
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user,
|
|
const QString &oldPassword,
|
|
bool oldPasswordNeedsHash,
|
|
const QString &newPassword,
|
|
bool newPasswordNeedsHash)
|
|
{
|
|
if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) {
|
|
return false;
|
|
}
|
|
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
QString error;
|
|
if (!usernameIsValid(user, error)) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *passwordQuery = prepareQuery("select password_sha512 from {prefix}_users where name = :name");
|
|
passwordQuery->bindValue(":name", user);
|
|
|
|
if (!execSqlQuery(passwordQuery)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Change password denied: SQL error";
|
|
return false;
|
|
}
|
|
|
|
if (!passwordQuery->next()) {
|
|
return false;
|
|
}
|
|
|
|
const QString storedPassword = passwordQuery->value(0).toString();
|
|
// oldPasswordNeedsHash means the client sent the old password in plaintext. Verify it
|
|
// against whatever is stored: legacy salt+hash rows or already-migrated scrypt rows
|
|
// (which must NOT be re-hashed with a salt torn out of the "$scrypt$..." string).
|
|
const bool oldPasswordMatches = oldPasswordNeedsHash ? PasswordHasher::verifyPassword(oldPassword, storedPassword)
|
|
: (oldPassword == storedPassword);
|
|
if (!oldPasswordMatches) {
|
|
return false;
|
|
}
|
|
|
|
return changeUserPassword(user, newPassword, newPasswordNeedsHash);
|
|
}
|
|
|
|
int Servatrice_DatabaseInterface::getActiveUserCount(QString connectionType)
|
|
{
|
|
int userCount = 0;
|
|
|
|
if (!checkSql()) {
|
|
return userCount;
|
|
}
|
|
|
|
QString text = "select count(*) from {prefix}_sessions where id_server = :serverid AND end_time is NULL";
|
|
if (!connectionType.isEmpty()) {
|
|
text += " AND connection_type = :connection_type";
|
|
}
|
|
QSqlQuery *query = prepareQuery(text);
|
|
|
|
query->bindValue(":serverid", server->getServerID());
|
|
if (!connectionType.isEmpty()) {
|
|
query->bindValue(":connection_type", connectionType);
|
|
}
|
|
|
|
if (!execSqlQuery(query)) {
|
|
return userCount;
|
|
}
|
|
|
|
if (query->next()) {
|
|
userCount = query->value(0).toInt();
|
|
}
|
|
|
|
return userCount;
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::updateUsersClientID(const QString &userName, const QString &userClientID)
|
|
{
|
|
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("update {prefix}_users set clientid = :clientid where name = :username");
|
|
query->bindValue(":clientid", userClientID);
|
|
query->bindValue(":username", userName);
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::updateUsersLastLoginData(const QString &userName, const QString &clientVersion)
|
|
{
|
|
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
int usersID = 0;
|
|
|
|
QSqlQuery *query = prepareQuery("select id from {prefix}_users where name = :user_name");
|
|
query->bindValue(":user_name", userName);
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to locate user id when updating users last login data: SQL Error";
|
|
return;
|
|
}
|
|
|
|
if (query->next()) {
|
|
usersID = query->value(0).toInt();
|
|
}
|
|
|
|
if (usersID) {
|
|
int userCount = 0;
|
|
query = prepareQuery("select count(id) from {prefix}_user_analytics where id = :user_id");
|
|
query->bindValue(":user_id", usersID);
|
|
if (!execSqlQuery(query)) {
|
|
return;
|
|
}
|
|
|
|
if (query->next()) {
|
|
userCount = query->value(0).toInt();
|
|
}
|
|
|
|
if (!userCount) {
|
|
query = prepareQuery(
|
|
"insert into {prefix}_user_analytics (id,client_ver,last_login) values (:user_id,:client_ver,NOW())");
|
|
query->bindValue(":user_id", usersID);
|
|
query->bindValue(":client_ver", clientVersion);
|
|
execSqlQuery(query);
|
|
} else {
|
|
query = prepareQuery(
|
|
"update {prefix}_user_analytics set last_login = NOW(), client_ver = :client_ver where id = :user_id");
|
|
query->bindValue(":client_ver", clientVersion);
|
|
query->bindValue(":user_id", usersID);
|
|
execSqlQuery(query);
|
|
}
|
|
}
|
|
}
|
|
|
|
QList<ServerInfo_Ban> Servatrice_DatabaseInterface::getUserBanHistory(const QString userName)
|
|
{
|
|
QList<ServerInfo_Ban> results;
|
|
ServerInfo_Ban banDetails;
|
|
|
|
if (!checkSql()) {
|
|
return results;
|
|
}
|
|
|
|
QSqlQuery *query =
|
|
prepareQuery("SELECT A.id_admin, A.time_from, A.minutes, A.reason, A.visible_reason, B.name AS name_admin FROM "
|
|
"{prefix}_bans A LEFT JOIN {prefix}_users B ON A.id_admin=B.id WHERE A.user_name = :user_name");
|
|
query->bindValue(":user_name", userName);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to collect ban history information: SQL Error";
|
|
return results;
|
|
}
|
|
|
|
while (query->next()) {
|
|
banDetails.set_admin_id(QString(query->value(0).toString()).toStdString());
|
|
banDetails.set_admin_name(QString(query->value(5).toString()).toStdString());
|
|
banDetails.set_ban_time(QString(query->value(1).toString()).toStdString());
|
|
banDetails.set_ban_length(QString(query->value(2).toString()).toStdString());
|
|
banDetails.set_ban_reason(QString(query->value(3).toString()).toStdString());
|
|
banDetails.set_visible_reason(QString(query->value(4).toString()).toStdString());
|
|
results << banDetails;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::addWarning(const QString userName,
|
|
const QString adminName,
|
|
const QString warningReason,
|
|
const QString clientID)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
int userID = getUserIdInDB(userName);
|
|
QSqlQuery *query =
|
|
prepareQuery("insert into {prefix}_warnings (user_id,user_name,mod_name,reason,time_of,clientid) values "
|
|
"(:user_id,:user_name,:mod_name,:warn_reason,NOW(),:client_id)");
|
|
query->bindValue(":user_id", userID);
|
|
query->bindValue(":user_name", userName);
|
|
query->bindValue(":mod_name", adminName);
|
|
query->bindValue(":warn_reason", warningReason);
|
|
query->bindValue(":client_id", clientID);
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to collect create warning history information: SQL Error";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
QList<ServerInfo_Warning> Servatrice_DatabaseInterface::getUserWarnHistory(const QString userName)
|
|
{
|
|
QList<ServerInfo_Warning> results;
|
|
ServerInfo_Warning warnDetails;
|
|
|
|
if (!checkSql()) {
|
|
return results;
|
|
}
|
|
|
|
int userID = getUserIdInDB(userName);
|
|
QSqlQuery *query =
|
|
prepareQuery("SELECT user_name, mod_name, reason, time_of FROM {prefix}_warnings WHERE user_id = :user_id");
|
|
query->bindValue(":user_id", userID);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to collect warning history information: SQL Error";
|
|
return results;
|
|
}
|
|
|
|
while (query->next()) {
|
|
warnDetails.set_user_name(QString(query->value(0).toString()).toStdString());
|
|
warnDetails.set_admin_name(QString(query->value(1).toString()).toStdString());
|
|
warnDetails.set_reason(QString(query->value(2).toString()).toStdString());
|
|
warnDetails.set_time_of(QString(query->value(3).toString()).toStdString());
|
|
results << warnDetails;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
QList<ServerInfo_UserSession> Servatrice_DatabaseInterface::getUserSessions(const QString &userName, int limit)
|
|
{
|
|
QList<ServerInfo_UserSession> 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;
|
|
}
|
|
|
|
namespace
|
|
{
|
|
constexpr int MAX_USER_ALT_DEPTH = 3;
|
|
constexpr int MAX_USER_ALTS_PER_LEVEL = 200;
|
|
// Bounds per-level work and the size of each dynamic IN-list.
|
|
// Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are
|
|
// truncated: this tool is deliberately a first-pass sweep, not an audit.
|
|
constexpr int MAX_USER_ALT_IDENTIFIERS = 200;
|
|
constexpr int MIN_USER_ALT_MONTHS = 1;
|
|
constexpr int MAX_USER_ALT_MONTHS = 24;
|
|
|
|
QString altInClause(const QString &prefix, const QList<QString> &values)
|
|
{
|
|
QStringList placeholders;
|
|
placeholders.reserve(values.size());
|
|
for (int i = 0; i < values.size(); ++i) {
|
|
placeholders << prefix + QString::number(i);
|
|
}
|
|
return QStringLiteral("(") + placeholders.join(QStringLiteral(",")) + QStringLiteral(")");
|
|
}
|
|
|
|
void altBindValues(QSqlQuery *query, const QString &prefix, const QList<QString> &values)
|
|
{
|
|
for (int i = 0; i < values.size(); ++i) {
|
|
query->bindValue(prefix + QString::number(i), values.at(i));
|
|
}
|
|
}
|
|
|
|
struct AltCandidate
|
|
{
|
|
QString name;
|
|
QString email;
|
|
QString clientId;
|
|
qint64 registrationTime = 0;
|
|
qint64 lastLogin = 0;
|
|
bool hasLastLogin = false;
|
|
int warnCount = 0;
|
|
int banCount = 0;
|
|
bool isActive = false;
|
|
};
|
|
|
|
// Keeps the first-seen order: callers add the strongest evidence first so the
|
|
// cap below drops the least relevant identifiers rather than an arbitrary slice.
|
|
void appendIdentifier(QList<QString> &list, QSet<QString> &seen, const QString &value)
|
|
{
|
|
if (!value.isEmpty() && !seen.contains(value)) {
|
|
seen.insert(value);
|
|
list << value;
|
|
}
|
|
}
|
|
|
|
QList<QString> cappedIdentifiers(const QList<QString> &values)
|
|
{
|
|
if (values.size() <= MAX_USER_ALT_IDENTIFIERS) {
|
|
return values;
|
|
}
|
|
return values.mid(0, MAX_USER_ALT_IDENTIFIERS);
|
|
}
|
|
} // namespace
|
|
|
|
QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QString &userName,
|
|
int depth,
|
|
bool useEmail,
|
|
bool useClientId,
|
|
bool useIp,
|
|
int sessionMonths)
|
|
{
|
|
QList<ServerInfo_UserAlt> results;
|
|
|
|
if (!checkSql()) {
|
|
return results;
|
|
}
|
|
|
|
if (depth < 1) {
|
|
depth = 1;
|
|
} else if (depth > MAX_USER_ALT_DEPTH) {
|
|
depth = MAX_USER_ALT_DEPTH;
|
|
}
|
|
if (sessionMonths < MIN_USER_ALT_MONTHS) {
|
|
sessionMonths = MIN_USER_ALT_MONTHS;
|
|
} else if (sessionMonths > MAX_USER_ALT_MONTHS) {
|
|
sessionMonths = MAX_USER_ALT_MONTHS;
|
|
}
|
|
|
|
// Interpolated, not bound: placeholders are not allowed in INTERVAL
|
|
// expressions, and the clamped integer cannot inject SQL.
|
|
const QString sessionWindow = QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL ") +
|
|
QString::number(sessionMonths) + QStringLiteral(" MONTH)");
|
|
|
|
// Seed must exist; its row anchors the result at depth 0.
|
|
QSqlQuery *seedQuery = prepareQuery("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");
|
|
seedQuery->bindValue(":user_name", userName);
|
|
if (!execSqlQuery(seedQuery) || !seedQuery->next()) {
|
|
return results;
|
|
}
|
|
|
|
auto appendAlt = [&results](const AltCandidate &candidate, const QString &matchReason, int matchDepth,
|
|
int sharedCount) {
|
|
ServerInfo_UserAlt altDetails;
|
|
altDetails.set_user_name(candidate.name.toStdString());
|
|
altDetails.set_email(candidate.email.toStdString());
|
|
altDetails.set_clientid(candidate.clientId.toStdString());
|
|
altDetails.set_registration_time(candidate.registrationTime);
|
|
if (candidate.hasLastLogin) {
|
|
altDetails.set_last_login(candidate.lastLogin);
|
|
}
|
|
altDetails.set_warn_count(candidate.warnCount);
|
|
altDetails.set_ban_count(candidate.banCount);
|
|
altDetails.set_is_active(candidate.isActive);
|
|
altDetails.set_match_reason(matchReason.toStdString());
|
|
altDetails.set_depth(static_cast<google::protobuf::uint32>(matchDepth));
|
|
altDetails.set_shared_count(static_cast<google::protobuf::uint32>(sharedCount));
|
|
results << altDetails;
|
|
};
|
|
|
|
AltCandidate seed{.name = seedQuery->value(0).toString(),
|
|
.email = seedQuery->value(1).toString(),
|
|
.clientId = seedQuery->value(2).toString(),
|
|
.registrationTime = seedQuery->value(3).toLongLong(),
|
|
.lastLogin = seedQuery->value(4).toLongLong(),
|
|
.hasLastLogin = !seedQuery->value(4).isNull(),
|
|
.warnCount = seedQuery->value(5).toInt(),
|
|
.banCount = seedQuery->value(6).toInt(),
|
|
.isActive = seedQuery->value(7).toBool()};
|
|
appendAlt(seed, QStringLiteral("self"), 0, 1);
|
|
|
|
// Identifier lookups are case-insensitive in SQL, so the seed must be keyed by
|
|
// its stored casing: otherwise the level-1 query re-lists it as an alt.
|
|
QSet<QString> visited{seed.name};
|
|
QList<QString> frontier{seed.name};
|
|
|
|
// Dynamic IN-lists bypass prepareQuery(): it caches by query text, so every
|
|
// distinct list size would leave a prepared statement in the long-lived cache
|
|
// and can exhaust MySQL's max_prepared_stmt_count.
|
|
const QString dbPrefix = server->getDbPrefix();
|
|
auto prepareDynamicQuery = [&dbPrefix](QSqlQuery &query, const QString &queryText) {
|
|
QString text = queryText;
|
|
text.replace(QStringLiteral("{prefix}"), dbPrefix);
|
|
query.prepare(text);
|
|
};
|
|
|
|
for (int level = 1; level <= depth && !frontier.isEmpty(); ++level) {
|
|
const QString frontierClause = altInClause(QStringLiteral(":alt_name"), frontier);
|
|
|
|
// Current identifiers of the frontier: registration email + client id.
|
|
// Kept strongest-first so cappedIdentifiers() drops the least useful ones.
|
|
QList<QString> emails;
|
|
QList<QString> clientIds;
|
|
QList<QString> ips;
|
|
QSet<QString> emailKeys;
|
|
QSet<QString> clientIdKeys;
|
|
QSet<QString> ipKeys;
|
|
|
|
if (useEmail || useClientId) {
|
|
QSqlQuery frontierQuery(sqlDatabase);
|
|
prepareDynamicQuery(frontierQuery,
|
|
QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") +
|
|
frontierClause);
|
|
altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier);
|
|
if (execSqlQuery(&frontierQuery)) {
|
|
while (frontierQuery.next()) {
|
|
// Email comparison is case-insensitive in SQL, so normalise the
|
|
// keys too or the membership tests below miss real matches.
|
|
if (useEmail) {
|
|
appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower());
|
|
}
|
|
if (useClientId) {
|
|
appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Recent session identifiers: IPs plus historic client ids, most recent
|
|
// first so a capped list keeps what the account actually used lately.
|
|
if (useIp || useClientId) {
|
|
QSqlQuery sessionQuery(sqlDatabase);
|
|
prepareDynamicQuery(sessionQuery,
|
|
QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen "
|
|
"FROM {prefix}_sessions WHERE user_name IN ") +
|
|
frontierClause + sessionWindow +
|
|
QStringLiteral(" GROUP BY ip_address, clientid "
|
|
"ORDER BY last_seen DESC"));
|
|
altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier);
|
|
if (execSqlQuery(&sessionQuery)) {
|
|
while (sessionQuery.next()) {
|
|
if (useIp) {
|
|
appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString());
|
|
}
|
|
if (useClientId) {
|
|
appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (emails.isEmpty() && clientIds.isEmpty() && ips.isEmpty()) {
|
|
break;
|
|
}
|
|
|
|
// Accounts sharing any frontier identifier. Historic client ids are matched
|
|
// through past sessions so identifier changes do not hide older alts.
|
|
QString candidateString =
|
|
QStringLiteral("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 ");
|
|
QStringList conditions;
|
|
const QList<QString> emailList = cappedIdentifiers(emails);
|
|
const QList<QString> clientIdList = cappedIdentifiers(clientIds);
|
|
const QList<QString> ipList = cappedIdentifiers(ips);
|
|
if (!emailList.isEmpty()) {
|
|
conditions << QStringLiteral("u.email IN ") + altInClause(QStringLiteral(":alt_email"), emailList);
|
|
}
|
|
if (!clientIdList.isEmpty()) {
|
|
conditions << QStringLiteral("u.clientid IN ") + altInClause(QStringLiteral(":alt_cid"), clientIdList);
|
|
}
|
|
if (!ipList.isEmpty()) {
|
|
conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE "
|
|
"s.ip_address IN ") +
|
|
altInClause(QStringLiteral(":alt_ip"), ipList) + sessionWindow + QStringLiteral(")");
|
|
}
|
|
if (!clientIdList.isEmpty()) {
|
|
conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE "
|
|
"s.clientid IN ") +
|
|
altInClause(QStringLiteral(":alt_hcid"), clientIdList) + sessionWindow +
|
|
QStringLiteral(")");
|
|
}
|
|
candidateString += conditions.join(QStringLiteral(" OR "));
|
|
// Already-visited accounts match their own identifiers at every level and
|
|
// are dropped below, so widen the row limit by that count: otherwise a
|
|
// second level can return only visited rows and no new candidates.
|
|
candidateString +=
|
|
QStringLiteral(" ORDER BY u.name LIMIT ") + QString::number(MAX_USER_ALTS_PER_LEVEL + visited.size());
|
|
|
|
QSqlQuery candidateQuery(sqlDatabase);
|
|
prepareDynamicQuery(candidateQuery, candidateString);
|
|
altBindValues(&candidateQuery, QStringLiteral(":alt_email"), emailList);
|
|
altBindValues(&candidateQuery, QStringLiteral(":alt_cid"), clientIdList);
|
|
altBindValues(&candidateQuery, QStringLiteral(":alt_ip"), ipList);
|
|
altBindValues(&candidateQuery, QStringLiteral(":alt_hcid"), clientIdList);
|
|
if (!execSqlQuery(&candidateQuery)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error";
|
|
return results;
|
|
}
|
|
|
|
QList<AltCandidate> candidates;
|
|
while (candidateQuery.next()) {
|
|
AltCandidate candidate;
|
|
candidate.name = candidateQuery.value(0).toString();
|
|
if (visited.contains(candidate.name)) {
|
|
continue;
|
|
}
|
|
candidate.email = candidateQuery.value(1).toString();
|
|
candidate.clientId = candidateQuery.value(2).toString();
|
|
candidate.registrationTime = candidateQuery.value(3).toLongLong();
|
|
candidate.hasLastLogin = !candidateQuery.value(4).isNull();
|
|
candidate.lastLogin = candidateQuery.value(4).toLongLong();
|
|
candidate.warnCount = candidateQuery.value(5).toInt();
|
|
candidate.banCount = candidateQuery.value(6).toInt();
|
|
candidate.isActive = candidateQuery.value(7).toBool();
|
|
candidates << candidate;
|
|
if (candidates.size() >= MAX_USER_ALTS_PER_LEVEL) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Resolve session-level evidence (shared IP or historic client id) for
|
|
// candidates that do not match on current email or client id.
|
|
QMap<QString, QString> candidateIp;
|
|
QMap<QString, QString> candidateHistoricClientId;
|
|
QList<QString> unexplained;
|
|
for (const AltCandidate &candidate : candidates) {
|
|
const bool emailMatch = !candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower());
|
|
const bool clientIdMatch = !candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId);
|
|
if (!emailMatch && !clientIdMatch) {
|
|
unexplained << candidate.name;
|
|
}
|
|
}
|
|
if (!unexplained.isEmpty() && (!ipKeys.isEmpty() || !clientIdKeys.isEmpty())) {
|
|
QString evidenceString =
|
|
QStringLiteral("SELECT DISTINCT user_name, ip_address, clientid FROM {prefix}_sessions WHERE "
|
|
"user_name IN ") +
|
|
altInClause(QStringLiteral(":alt_ev"), unexplained) + QStringLiteral(" AND (");
|
|
QStringList evidenceConditions;
|
|
if (!ipList.isEmpty()) {
|
|
evidenceConditions << QStringLiteral("ip_address IN ") +
|
|
altInClause(QStringLiteral(":alt_eip"), ipList);
|
|
}
|
|
if (!clientIdList.isEmpty()) {
|
|
evidenceConditions << QStringLiteral("clientid IN ") +
|
|
altInClause(QStringLiteral(":alt_ecid"), clientIdList);
|
|
}
|
|
evidenceString += evidenceConditions.join(QStringLiteral(" OR "));
|
|
evidenceString += QStringLiteral(")") + sessionWindow;
|
|
|
|
QSqlQuery evidenceQuery(sqlDatabase);
|
|
prepareDynamicQuery(evidenceQuery, evidenceString);
|
|
altBindValues(&evidenceQuery, QStringLiteral(":alt_ev"), unexplained);
|
|
altBindValues(&evidenceQuery, QStringLiteral(":alt_eip"), ipList);
|
|
altBindValues(&evidenceQuery, QStringLiteral(":alt_ecid"), clientIdList);
|
|
if (execSqlQuery(&evidenceQuery)) {
|
|
while (evidenceQuery.next()) {
|
|
const QString name = evidenceQuery.value(0).toString();
|
|
const QString ip = evidenceQuery.value(1).toString();
|
|
const QString clientId = evidenceQuery.value(2).toString();
|
|
if (!ip.isEmpty() && ipKeys.contains(ip) && !candidateIp.contains(name)) {
|
|
candidateIp.insert(name, ip);
|
|
}
|
|
if (!clientId.isEmpty() && clientIdKeys.contains(clientId) &&
|
|
!candidateHistoricClientId.contains(name)) {
|
|
candidateHistoricClientId.insert(name, clientId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fan-out counts so moderators can spot VPN/shared-network false positives.
|
|
QMap<QString, int> emailCounts;
|
|
QMap<QString, int> clientIdCounts;
|
|
QMap<QString, int> historicClientIdCounts;
|
|
QMap<QString, int> ipCounts;
|
|
QSet<QString> neededEmails;
|
|
QSet<QString> neededClientIds;
|
|
QSet<QString> neededHistoricClientIds;
|
|
QSet<QString> neededIps;
|
|
for (const AltCandidate &candidate : candidates) {
|
|
if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) {
|
|
neededEmails << candidate.email.toLower();
|
|
} else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) {
|
|
neededClientIds << candidate.clientId;
|
|
} else if (candidateHistoricClientId.contains(candidate.name)) {
|
|
neededHistoricClientIds << candidateHistoricClientId.value(candidate.name);
|
|
} else if (candidateIp.contains(candidate.name)) {
|
|
neededIps << candidateIp.value(candidate.name);
|
|
}
|
|
}
|
|
if (!neededEmails.isEmpty()) {
|
|
const QList<QString> neededEmailList = neededEmails.values();
|
|
QSqlQuery countQuery(sqlDatabase);
|
|
prepareDynamicQuery(countQuery, QStringLiteral("SELECT email, COUNT(*) FROM {prefix}_users WHERE "
|
|
"email IN ") +
|
|
altInClause(QStringLiteral(":alt_ce"), neededEmailList) +
|
|
QStringLiteral(" GROUP BY email"));
|
|
altBindValues(&countQuery, QStringLiteral(":alt_ce"), neededEmailList);
|
|
if (execSqlQuery(&countQuery)) {
|
|
while (countQuery.next()) {
|
|
emailCounts.insert(countQuery.value(0).toString().toLower(), countQuery.value(1).toInt());
|
|
}
|
|
}
|
|
}
|
|
if (!neededClientIds.isEmpty()) {
|
|
const QList<QString> neededClientIdList = neededClientIds.values();
|
|
QSqlQuery countQuery(sqlDatabase);
|
|
prepareDynamicQuery(countQuery, QStringLiteral("SELECT clientid, COUNT(*) FROM {prefix}_users WHERE "
|
|
"clientid IN ") +
|
|
altInClause(QStringLiteral(":alt_cc"), neededClientIdList) +
|
|
QStringLiteral(" GROUP BY clientid"));
|
|
altBindValues(&countQuery, QStringLiteral(":alt_cc"), neededClientIdList);
|
|
if (execSqlQuery(&countQuery)) {
|
|
while (countQuery.next()) {
|
|
clientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt());
|
|
}
|
|
}
|
|
}
|
|
// Historic client ids are matched through sessions, so their fan-out must
|
|
// come from _sessions too: _users only knows each account's current id.
|
|
if (!neededHistoricClientIds.isEmpty()) {
|
|
const QList<QString> neededHistoricClientIdList = neededHistoricClientIds.values();
|
|
QSqlQuery countQuery(sqlDatabase);
|
|
prepareDynamicQuery(countQuery,
|
|
QStringLiteral("SELECT clientid, COUNT(DISTINCT user_name) FROM {prefix}_sessions "
|
|
"WHERE clientid IN ") +
|
|
altInClause(QStringLiteral(":alt_hcc"), neededHistoricClientIdList) +
|
|
QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) "
|
|
"GROUP BY clientid"));
|
|
altBindValues(&countQuery, QStringLiteral(":alt_hcc"), neededHistoricClientIdList);
|
|
if (execSqlQuery(&countQuery)) {
|
|
while (countQuery.next()) {
|
|
historicClientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt());
|
|
}
|
|
}
|
|
}
|
|
if (!neededIps.isEmpty()) {
|
|
const QList<QString> neededIpList = neededIps.values();
|
|
QSqlQuery countQuery(sqlDatabase);
|
|
prepareDynamicQuery(countQuery,
|
|
QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions "
|
|
"WHERE ip_address IN ") +
|
|
altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow +
|
|
QStringLiteral(" GROUP BY ip_address"));
|
|
altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList);
|
|
if (execSqlQuery(&countQuery)) {
|
|
while (countQuery.next()) {
|
|
ipCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt());
|
|
}
|
|
}
|
|
}
|
|
|
|
QList<QString> nextFrontier;
|
|
for (const AltCandidate &candidate : candidates) {
|
|
QString matchReason;
|
|
int sharedCount = 1;
|
|
if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) {
|
|
matchReason = QStringLiteral("email");
|
|
sharedCount = qMax(1, emailCounts.value(candidate.email.toLower(), 1));
|
|
} else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) {
|
|
matchReason = QStringLiteral("clientid:") + candidate.clientId;
|
|
sharedCount = qMax(1, clientIdCounts.value(candidate.clientId, 1));
|
|
} else if (candidateHistoricClientId.contains(candidate.name)) {
|
|
const QString historicId = candidateHistoricClientId.value(candidate.name);
|
|
matchReason = QStringLiteral("clientid:") + historicId;
|
|
sharedCount = qMax(1, historicClientIdCounts.value(historicId, 1));
|
|
} else if (candidateIp.contains(candidate.name)) {
|
|
const QString ip = candidateIp.value(candidate.name);
|
|
matchReason = QStringLiteral("ip:") + ip;
|
|
sharedCount = qMax(1, ipCounts.value(ip, 1));
|
|
} else {
|
|
continue;
|
|
}
|
|
appendAlt(candidate, matchReason, level, sharedCount);
|
|
visited.insert(candidate.name);
|
|
nextFrontier << candidate.name;
|
|
}
|
|
frontier = nextFrontier;
|
|
}
|
|
|
|
// Distinct recent IP counts per account: cheap group signal without
|
|
// shipping full address histories to the client.
|
|
if (!results.isEmpty()) {
|
|
QList<QString> resultNames;
|
|
resultNames.reserve(results.size());
|
|
for (const ServerInfo_UserAlt &alt : results) {
|
|
resultNames << QString::fromStdString(alt.user_name());
|
|
}
|
|
QSqlQuery *ipCountQuery = prepareQuery(
|
|
QStringLiteral("SELECT user_name, COUNT(DISTINCT ip_address) FROM {prefix}_sessions WHERE user_name IN ") +
|
|
altInClause(QStringLiteral(":alt_ic"), resultNames) + sessionWindow +
|
|
QStringLiteral(" GROUP BY user_name"));
|
|
altBindValues(ipCountQuery, QStringLiteral(":alt_ic"), resultNames);
|
|
QMap<QString, int> ipCountsByUser;
|
|
if (execSqlQuery(ipCountQuery)) {
|
|
while (ipCountQuery->next()) {
|
|
ipCountsByUser.insert(ipCountQuery->value(0).toString(), ipCountQuery->value(1).toInt());
|
|
}
|
|
}
|
|
for (ServerInfo_UserAlt &alt : results) {
|
|
alt.set_ip_count(static_cast<google::protobuf::uint32>(
|
|
ipCountsByUser.value(QString::fromStdString(alt.user_name()), 0)));
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
QList<ServerInfo_ModeratorLogin> Servatrice_DatabaseInterface::getModeratorLastLogins()
|
|
{
|
|
QList<ServerInfo_ModeratorLogin> 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 & 15) <> 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;
|
|
}
|
|
if (isAdmin & 8) {
|
|
userLevel |= ServerInfo_User::IsDeveloper;
|
|
}
|
|
loginDetails.set_user_level(userLevel);
|
|
|
|
if (!query->value(2).isNull()) {
|
|
loginDetails.set_last_login(query->value(2).toLongLong());
|
|
}
|
|
results << loginDetails;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
Servatrice_DatabaseInterface::UptimeSnapshot Servatrice_DatabaseInterface::getLatestUptimeSnapshot(int serverId)
|
|
{
|
|
UptimeSnapshot snapshot;
|
|
|
|
if (!checkSql()) {
|
|
return snapshot;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, "
|
|
"UNIX_TIMESTAMP(timest) FROM {prefix}_uptime "
|
|
"WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1");
|
|
query->bindValue(":id_server", serverId);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to collect server stats snapshot: SQL Error";
|
|
return snapshot;
|
|
}
|
|
|
|
if (query->next()) {
|
|
snapshot.valid = true;
|
|
snapshot.usersCount = query->value(0).toULongLong();
|
|
snapshot.modsCount = query->value(1).toULongLong();
|
|
snapshot.gamesCount = query->value(2).toULongLong();
|
|
snapshot.txBytes = query->value(3).toULongLong();
|
|
snapshot.rxBytes = query->value(4).toULongLong();
|
|
snapshot.uptimeSecs = query->value(5).toULongLong();
|
|
snapshot.timest = query->value(6).toULongLong();
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
Servatrice_DatabaseInterface::RemoveUserBannerResult
|
|
Servatrice_DatabaseInterface::removeUserBanner(const QString &userName)
|
|
{
|
|
if (!checkSql()) {
|
|
return RemoveUserBannerResult::DatabaseError;
|
|
}
|
|
|
|
// The UPDATE alone cannot tell a missing user from a user without a banner,
|
|
// so read the row first and let the moderator know which case they hit.
|
|
QSqlQuery *lookupQuery = prepareQuery("SELECT card_art_params FROM {prefix}_users WHERE name = :user_name");
|
|
lookupQuery->bindValue(":user_name", userName);
|
|
if (!execSqlQuery(lookupQuery)) {
|
|
return RemoveUserBannerResult::DatabaseError;
|
|
}
|
|
if (!lookupQuery->next()) {
|
|
return RemoveUserBannerResult::NoSuchUser;
|
|
}
|
|
if (lookupQuery->value(0).toString().isEmpty()) {
|
|
return RemoveUserBannerResult::NothingToRemove;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE name = :user_name");
|
|
query->bindValue(":user_name", userName);
|
|
if (!execSqlQuery(query)) {
|
|
return RemoveUserBannerResult::DatabaseError;
|
|
}
|
|
return RemoveUserBannerResult::Removed;
|
|
}
|
|
|
|
// isCardArtAllowed() compares against the rule table with the database's
|
|
// case-insensitive collation, so the sweep has to fold case the same way or it
|
|
// leaves behind banners the server already treats as blacklisted.
|
|
static QPair<QString, QString> bannerKey(const QString &cardName, const QString &cardProviderId)
|
|
{
|
|
return {cardName.toCaseFolded(), cardProviderId.toCaseFolded()};
|
|
}
|
|
|
|
int Servatrice_DatabaseInterface::clearBlacklistedBanners()
|
|
{
|
|
if (!checkSql()) {
|
|
return -1;
|
|
}
|
|
|
|
QSqlQuery *ruleQuery = prepareQuery("SELECT card_name, card_provider_id FROM {prefix}_card_art_name_rules "
|
|
"WHERE mode = 'DENY'");
|
|
if (!execSqlQuery(ruleQuery)) {
|
|
return -1;
|
|
}
|
|
|
|
QSet<QPair<QString, QString>> denied;
|
|
while (ruleQuery->next()) {
|
|
denied.insert(bannerKey(ruleQuery->value(0).toString(), ruleQuery->value(1).toString()));
|
|
}
|
|
if (denied.isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
QSqlQuery *userQuery =
|
|
prepareQuery("SELECT id, card_art_params FROM {prefix}_users WHERE card_art_params IS NOT NULL");
|
|
if (!execSqlQuery(userQuery)) {
|
|
return -1;
|
|
}
|
|
|
|
QList<QVariant> matchingIds;
|
|
while (userQuery->next()) {
|
|
const QJsonDocument doc = QJsonDocument::fromJson(userQuery->value(1).toString().toUtf8());
|
|
if (!doc.isObject()) {
|
|
continue;
|
|
}
|
|
const QJsonObject obj = doc.object();
|
|
if (denied.contains(bannerKey(obj.value("card_name").toString(), obj.value("card_provider_id").toString()))) {
|
|
matchingIds.append(userQuery->value(0));
|
|
}
|
|
}
|
|
|
|
if (matchingIds.isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
// Chunk the UPDATE so very large user tables do not exceed driver placeholder limits.
|
|
int cleared = 0;
|
|
constexpr int BATCH_SIZE = 500;
|
|
for (int i = 0; i < matchingIds.size(); i += BATCH_SIZE) {
|
|
const QList<QVariant> batch = matchingIds.mid(i, BATCH_SIZE);
|
|
QStringList placeholders;
|
|
placeholders.reserve(batch.size());
|
|
for (int j = 0; j < batch.size(); ++j) {
|
|
placeholders.append(QString(":id%1").arg(j));
|
|
}
|
|
QSqlQuery *clearQuery = prepareQuery(
|
|
QString("UPDATE {prefix}_users SET card_art_params = NULL WHERE id IN (%1)").arg(placeholders.join(",")));
|
|
for (int j = 0; j < batch.size(); ++j) {
|
|
clearQuery->bindValue(placeholders.at(j), batch.at(j));
|
|
}
|
|
if (!execSqlQuery(clearQuery)) {
|
|
return -1;
|
|
}
|
|
cleared += clearQuery->numRowsAffected();
|
|
}
|
|
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,
|
|
const QString &gameid,
|
|
const QString &message,
|
|
bool &chat,
|
|
bool &game,
|
|
bool &room,
|
|
int &range,
|
|
int &maxresults,
|
|
int offset,
|
|
const QString &beforeTime,
|
|
bool *hasMore)
|
|
{
|
|
if (hasMore != nullptr) {
|
|
*hasMore = false;
|
|
}
|
|
|
|
QList<ServerInfo_ChatMessage> results;
|
|
ServerInfo_ChatMessage chatMessage;
|
|
|
|
if (!checkSql()) {
|
|
return results;
|
|
}
|
|
|
|
// Page size guard: always bound the scan so a filter-less browse of room
|
|
// history cannot become a 50M-row fetch. Zero means "client did not set a
|
|
// size", so fall back to one smooth-scroll page.
|
|
const int pageSize = maxresults <= 0 ? 200 : qBound(1, maxresults, 1000);
|
|
maxresults = pageSize;
|
|
const int safeOffset = qMax(0, offset);
|
|
|
|
const bool hasIndexedFilter = !user.isEmpty() || !ipaddress.isEmpty() || !gameid.isEmpty() || !gamename.isEmpty();
|
|
const bool hasLocationFilter = chat || game || room;
|
|
if (!hasLocationFilter) {
|
|
// No target_type restriction would mix private chat into a room/game
|
|
// browse, so refuse rather than leak.
|
|
return results;
|
|
}
|
|
// A browse narrowed only by a message substring has no index to serve it, and `range`
|
|
// has no upper bound on the wire: the client offers 20 days, but a hand-built command
|
|
// can ask for any value and every LIKE candidate in it is a row the server examines.
|
|
// Clamping here keeps the guard independent of what the client happens to send.
|
|
constexpr int maxFilterlessRangeHours = 20 * 24;
|
|
if (!hasIndexedFilter && range > maxFilterlessRangeHours) {
|
|
range = maxFilterlessRangeHours;
|
|
}
|
|
if (!hasIndexedFilter && range <= 0) {
|
|
// Filter-less browse must stay time-bounded (idx_log_time) so the
|
|
// query stays an index range scan instead of a full table scan.
|
|
return results;
|
|
}
|
|
|
|
// BUILD QUERY STRING BASED ON PASSED IN VALUES
|
|
QString queryString = "SELECT * FROM {prefix}_log WHERE `sender_ip` IS NOT NULL";
|
|
if (!user.isEmpty()) {
|
|
queryString.append(" AND (`sender_name` = :user_name OR `target_name` = :user_name)");
|
|
}
|
|
|
|
if (!ipaddress.isEmpty()) {
|
|
queryString.append(" AND `sender_ip` = :ip_to_find");
|
|
}
|
|
|
|
if (!gameid.isEmpty()) {
|
|
queryString.append(" AND (`target_id` = :game_id AND `target_type` = 'game')");
|
|
}
|
|
|
|
if (!gamename.isEmpty()) {
|
|
queryString.append(" AND (`target_name` = :game_name AND `target_type` = 'game')");
|
|
}
|
|
|
|
if (!message.isEmpty()) {
|
|
queryString.append(" AND `log_message` LIKE :log_message");
|
|
}
|
|
|
|
if (chat || game || room) {
|
|
queryString.append(" AND (");
|
|
|
|
if (chat) {
|
|
queryString.append("`target_type` = 'chat'");
|
|
}
|
|
|
|
if (game) {
|
|
if (chat) {
|
|
queryString.append(" OR `target_type` = 'game'");
|
|
} else {
|
|
queryString.append("`target_type` = 'game'");
|
|
}
|
|
}
|
|
|
|
if (room) {
|
|
if (game || chat) {
|
|
queryString.append(" OR `target_type` = 'room' OR `target_type` = 'room_channel'");
|
|
} else {
|
|
queryString.append("`target_type` = 'room' OR `target_type` = 'room_channel'");
|
|
}
|
|
}
|
|
queryString.append(")");
|
|
}
|
|
|
|
if (range) {
|
|
queryString.append(" AND log_time >= DATE_SUB(now(), INTERVAL :range_time HOUR)");
|
|
}
|
|
|
|
if (!beforeTime.isEmpty()) {
|
|
// Pages after the first run against the snapshot fixed by the first one, so rows
|
|
// logged while the moderator scrolls can no longer push the OFFSET window down and
|
|
// repeat a row already on screen.
|
|
queryString.append(" AND log_time <= :before_time");
|
|
}
|
|
|
|
// Newest-first keeps "going back from today" stable across pages and lets
|
|
// the client append older rows at the bottom for smooth infinite scroll.
|
|
queryString.append(" ORDER BY log_time DESC");
|
|
|
|
// Fetch one extra row when the caller wants a has_more cursor so a full
|
|
// page does not need a second empty round-trip to prove exhaustion.
|
|
const bool detectMore = (hasMore != nullptr);
|
|
const int fetchSize = detectMore ? pageSize + 1 : pageSize;
|
|
queryString.append(" LIMIT :limit_size OFFSET :offset_size");
|
|
|
|
QSqlQuery *query = prepareQuery(queryString);
|
|
if (!user.isEmpty()) {
|
|
query->bindValue(":user_name", user);
|
|
}
|
|
if (!ipaddress.isEmpty()) {
|
|
query->bindValue(":ip_to_find", ipaddress);
|
|
}
|
|
if (!gameid.isEmpty()) {
|
|
query->bindValue(":game_id", gameid);
|
|
}
|
|
if (!gamename.isEmpty()) {
|
|
query->bindValue(":game_name", gamename);
|
|
}
|
|
if (!message.isEmpty()) {
|
|
query->bindValue(":log_message", message);
|
|
}
|
|
if (range) {
|
|
query->bindValue(":range_time", range);
|
|
}
|
|
if (!beforeTime.isEmpty()) {
|
|
query->bindValue(":before_time", beforeTime);
|
|
}
|
|
query->bindValue(":limit_size", fetchSize);
|
|
query->bindValue(":offset_size", safeOffset);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog) << "Failed to collect log history information: SQL Error";
|
|
return results;
|
|
}
|
|
|
|
while (query->next()) {
|
|
chatMessage.set_time(QString(query->value(0).toString()).toStdString());
|
|
chatMessage.set_sender_id(QString(query->value(1).toString()).toStdString());
|
|
chatMessage.set_sender_name(QString(query->value(2).toString()).toStdString());
|
|
chatMessage.set_sender_ip(QString(query->value(3).toString()).toStdString());
|
|
chatMessage.set_message(QString(query->value(4).toString()).toStdString());
|
|
chatMessage.set_target_type(QString(query->value(5).toString()).toStdString());
|
|
chatMessage.set_target_id(QString(query->value(6).toString()).toStdString());
|
|
chatMessage.set_target_name(QString(query->value(7).toString()).toStdString());
|
|
chatMessage.set_channel(QString(query->value(8).toString()).toStdString());
|
|
results << chatMessage;
|
|
}
|
|
|
|
if (detectMore && results.size() > pageSize) {
|
|
results.removeLast();
|
|
*hasMore = true;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
int Servatrice_DatabaseInterface::checkNumberOfUserAccounts(const QString &email)
|
|
{
|
|
if (!checkSql()) {
|
|
return 0;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("SELECT count(email) FROM {prefix}_users WHERE email = :user_email");
|
|
query->bindValue(":user_email", email);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
qCWarning(DatabaseInterfaceLog)
|
|
<< "Failed to identify the number of users accounts for users email address: SQL Error";
|
|
return 0;
|
|
}
|
|
|
|
if (query->next()) {
|
|
return query->value(0).toInt();
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::addForgotPassword(const QString &user)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
if (!updateUserToken(PasswordHasher::generateActivationToken(), user)) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_forgot_password (name,requestDate) values (:username,NOW())");
|
|
query->bindValue(":username", user);
|
|
if (execSqlQuery(query)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::removeForgotPassword(const QString &user)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("delete from {prefix}_forgot_password where name = :username");
|
|
query->bindValue(":username", user);
|
|
if (execSqlQuery(query)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::doesForgotPasswordExist(const QString &user)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("select count(name) from {prefix}_forgot_password where name = :user_name AND "
|
|
"requestDate > (now() - interval :minutes minute)");
|
|
query->bindValue(":user_name", user);
|
|
query->bindValue(":minutes", QString::number(server->getForgotPasswordTokenLife()));
|
|
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
|
|
if (query->next()) {
|
|
if (query->value("count(name)").toInt() > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::updateUserToken(const QString &token, const QString &user)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
if (token.isEmpty() || user.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("update {prefix}_users set token = :token where name = :user_name");
|
|
query->bindValue(":user_name", user);
|
|
query->bindValue(":token", token);
|
|
|
|
if (execSqlQuery(query)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::validateTableColumnStringData(const QString &table,
|
|
const QString &column,
|
|
const QString &_user,
|
|
const QString &_datatocheck)
|
|
{
|
|
if (!checkSql()) {
|
|
return false;
|
|
}
|
|
|
|
if (table.isEmpty() || column.isEmpty() || _user.isEmpty() || _datatocheck.isEmpty()) {
|
|
return false;
|
|
}
|
|
|
|
QString formatedQuery = QString("select %1 from %2 where name = :user_name").arg(column).arg(table);
|
|
QSqlQuery *query = prepareQuery(formatedQuery);
|
|
query->bindValue(":user_name", _user);
|
|
|
|
if (!execSqlQuery(query)) {
|
|
return false;
|
|
}
|
|
|
|
if (query->next()) {
|
|
if (query->value(column).toString().toLower() == _datatocheck.toLower()) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void Servatrice_DatabaseInterface::addAuditRecord(const QString &user,
|
|
const QString &ipaddress,
|
|
const QString &clientid,
|
|
const QString &action,
|
|
const QString &details,
|
|
const bool &results = false)
|
|
{
|
|
if (!checkSql()) {
|
|
return;
|
|
}
|
|
|
|
if (!server->getEnableAudit()) {
|
|
return;
|
|
}
|
|
|
|
if (user.isEmpty() || ipaddress.isEmpty() || clientid.isEmpty() || action.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = prepareQuery("insert into {prefix}_audit "
|
|
"(id_server,name,ip_address,clientid,incidentDate,action,results,details) values "
|
|
"(:idserver,:username,:ipaddress,:clientid,NOW(),:action,:results,:details)");
|
|
query->bindValue(":idserver", server->getServerID());
|
|
query->bindValue(":username", user);
|
|
query->bindValue(":ipaddress", ipaddress);
|
|
query->bindValue(":clientid", clientid);
|
|
query->bindValue(":action", action);
|
|
query->bindValue(":results", results ? "success" : "fail");
|
|
|
|
query->bindValue(":details", details);
|
|
execSqlQuery(query);
|
|
}
|
|
|
|
bool Servatrice_DatabaseInterface::isCardArtAllowed(const QString &cardName, const QString &cardProviderId)
|
|
{
|
|
if (cardName.isEmpty() || !checkSql()) {
|
|
return true;
|
|
}
|
|
QSqlQuery *q = prepareQuery(
|
|
"SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider");
|
|
q->bindValue(":name", cardName);
|
|
q->bindValue(":provider", cardProviderId);
|
|
|
|
if (!execSqlQuery(q)) {
|
|
qWarning() << "Card art rule lookup failed; failing open for" << cardName;
|
|
return true;
|
|
}
|
|
|
|
if (!q->next()) {
|
|
return true; // default allow
|
|
}
|
|
|
|
return q->value(0).toString() != "DENY";
|
|
}
|