Cockatrice/servatrice/src/servatrice_database_interface.cpp
BruebachL a139084ad6
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
[Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords (#7193)
* [Security] Add challenge-response auth with scrypt verifiers and stop storing plaintext passwords

Challenge-response authentication: the client derives a scrypt verifier
(RFC 7914, EVP_PBE_scrypt, N=32768, r=8, p=1) and authenticates with
HMAC-SHA256(key, nonce), so neither the password nor its hash is
transmitted. The stored format becomes
"$scrypt$<n>$<r>$<p>$<salt>$<verifier>" and Response_PasswordSalt
now carries the cost parameters. Strict servers only accept scrypt
verifiers; legacy accounts are migrated after a successful login.

Fix #344 for challenge-response servers: a saved profile stores the
derived verifier under the password key instead of the plaintext password.
The connect dialog loads it without revealing it, autoconnect passes it
through, the change-password dialog no longer prefills the old password
field with it, and the client only persists the verifier when
"Save password" is checked.

Took 3 minutes

Took 1 minute

Took 10 seconds

Took 7 minutes

* [Security] Harden credential format acceptance and challenge nonce validation

* [Security] Refuse KDF downgrade; trim profile name and warn on silent credential drop

* [Security] Add SupportsAll ServerOptions enumerator to fix enum validation assert

The server advertised both SupportsPasswordHash (1) and
SupportsChallengeResponseAuth (2) via a bitwise-OR that produced the
value 3, which no enumerator declared. Protobuf's proto2 enum
validation rejects the value in _internal_set_server_options, crashing
servatrice on the debug assertion. Add a SupportsAll = 3 enumerator with
an identical wire value so the bitmask reads unchanged while the value
is now a documented, valid member of the enum.

* [Security] Normalize username when issuing challenge nonce

cmdRequestPasswordSalt bound the challenge nonce to the raw, un-simplified
username while cmdLogin verifies it against the simplified name. A user
who typed leading or trailing whitespace in the username box would be
silently rejected during challenge-response login even with the correct
password. Simplify at the nonce-issuing site so both commands agree on
the normalized name.

Also drive-by formatting: collapse the now single-expression
serverOptions initializer onto one line.

* [Security] Keep an encrypted legacy password backup for old-client logins

Migrating an account to scrypt stranded pre-challenge clients, which can
only present the legacy salt+hash: after migration the server served them
a scrypt salt they cannot use, so the only recovery was a password reset.

When security/legacy_backup_key is configured (64 hex chars,
openssl rand -hex 32), submitPasswordVerifier now seals the legacy
credential with AES-256-GCM under that key before overwriting the row,
storing it in password_legacy_backup with the time it was last used. Old
clients keep working: cmdRequestPasswordSalt serves the backup's 16-char
salt in password_salt so they can derive the legacy hash they were
always able to compute, while new clients derive the verifier from the
new scrypt_salt field, which always carries the verifier salt. The
non-challenge login path accepts the plaintext or legacy hash against
the decrypted backup and refreshes its window; a migrated row with no
usable backup fails closed.

Backups age out so a database dump taken after the window closes never
carries a crackable legacy hash: getUsableLegacyBackup purges on first
use past security/legacy_backup_ttl_days (default 90, clamped 1..3650),
and a six-hourly sweep drops backups idle accounts never touch.
changeUserPassword clears the backup since a new credential invalidates
it. Without a configured key nothing is sealed or purged, so a
temporarily missing key cannot destroy existing backups.

The AAD is the simplified user name on both sides, sealing the blob to
its row so it cannot be moved between accounts. Sealing failures
degrade to the previous lockout behaviour rather than blocking
migration. The columns extend the unmerged 0036->0037 migration, so the
schema version stays 37.

* [Security] Address review: proto compat, plaintext verify, AAD case, strict scope

Fix the two blockers and follow-ups from the 2026-10-16 review:

- Advertise challenge-response in a new supports_challenge_response
  field instead of the ServerOptions enum and drop SupportsAll:
  proto2 enums are closed, so already-released clients parsed
  server_options = 3 as NoOptions and fell back to plaintext
  passwords. server_options stays exactly SupportsPasswordHash
  on the wire; old clients ignore the new field.
- Verify a plaintext password directly against a migrated scrypt
  row with verifyPassword() so email changes (cmdAccountEdit)
  and old clients sending password keep working without a
  legacy backup key. Pre-hashed values are still checked
  against the backup, and its TTL window is refreshed only
  when the backup is what actually matched.
- Use the stored row name as the AES-GCM AAD on both seal
  and open so a login whose case differs from the stored
  name (utf8mb4_unicode_ci) still opens its backup.
- Require challenge-response in strict mode only for
  registered SQL accounts (userExists gate), leaving
  guest/unregistered logins and none/password auth
  methods unaffected.
- Document in servatrice.ini.example that a captured
  legacy hash can take over a not-yet-migrated account
  and that strict only constrains registered SQL
  accounts. Schema version is 38.

* [Security] Raise legacy backup TTL default to 180 days

Gives operators and slow-to-upgrade users a longer downgrade
window for pre-challenge clients before unused encrypted
legacy password backups age out.

* [Security] Fix servatrice.cpp formatting per clang-format-18

* [Security] Add missing forward declaration for Command_SubmitPasswordVerifier

The cmdSubmitPasswordVerifier declaration references the proto-generated
type, which otherwise does not name a type in this header and breaks
every C++ build.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: BruebachL <bruebachl@cockatrice.example>
2026-10-16 22:52:26 +02:00

2387 lines
90 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 "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 <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 &regExp : 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)) {
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();
}
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)) {
const QByteArray key = server->getLegacyBackupKey();
if (!key.isEmpty()) {
const QByteArray aad = storedName.simplified().toUtf8();
sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad);
if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) {
qCWarning(DatabaseInterfaceLog)
<< "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path";
sealedBackup.clear();
} 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;
}
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;
}
QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QString &userName)
{
QList<ServerInfo_UserAlt> results;
if (!checkSql()) {
return results;
}
// Seed account identifiers used to find related accounts
QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name");
seedQuery->bindValue(":user_name", userName);
if (!execSqlQuery(seedQuery) || !seedQuery->next()) {
return results;
}
const QString seedEmail = seedQuery->value(0).toString();
const QString seedClientId = seedQuery->value(1).toString();
QString queryString = "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";
if (!seedEmail.isEmpty()) {
queryString.append(" OR u.email = :seed_email");
}
if (!seedClientId.isEmpty()) {
queryString.append(" OR u.clientid = :seed_clientid");
}
queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s "
"WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 "
"WHERE s2.user_name = :user_name"
" AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"
" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) "
"ORDER BY u.name LIMIT 200");
QSqlQuery *query = prepareQuery(queryString);
query->bindValue(":user_name", userName);
if (!seedEmail.isEmpty()) {
query->bindValue(":seed_email", seedEmail);
}
if (!seedClientId.isEmpty()) {
query->bindValue(":seed_clientid", seedClientId);
}
if (!execSqlQuery(query)) {
qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error";
return results;
}
while (query->next()) {
ServerInfo_UserAlt altDetails;
altDetails.set_user_name(query->value(0).toString().toStdString());
altDetails.set_email(query->value(1).toString().toStdString());
altDetails.set_clientid(query->value(2).toString().toStdString());
altDetails.set_registration_time(query->value(3).toLongLong());
if (!query->value(4).isNull()) {
altDetails.set_last_login(query->value(4).toLongLong());
}
altDetails.set_warn_count(query->value(5).toInt());
altDetails.set_ban_count(query->value(6).toInt());
altDetails.set_is_active(query->value(7).toBool());
results << altDetails;
}
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;
}
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)
{
QList<ServerInfo_ChatMessage> results;
ServerInfo_ChatMessage chatMessage;
if (!checkSql()) {
return results;
}
if (user.isEmpty() && ipaddress.isEmpty() && gameid.isEmpty() && gamename.isEmpty()) {
// To ensure quick results and minimal lag, require an indexed field
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 (maxresults) {
queryString.append(" LIMIT :limit_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 (maxresults) {
query->bindValue(":limit_size", maxresults);
}
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;
}
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";
}