mirror of https://github.com/qTox/qTox.git
31 changed files with 1045 additions and 455 deletions
@ -0,0 +1,466 @@ |
|||||||
|
#include "rawdatabase.h" |
||||||
|
#include <QDebug> |
||||||
|
#include <QMetaObject> |
||||||
|
#include <QMutexLocker> |
||||||
|
#include <QCoreApplication> |
||||||
|
#include <QFile> |
||||||
|
#include <cassert> |
||||||
|
#include <tox/toxencryptsave.h> |
||||||
|
|
||||||
|
/// The two following defines are required to use SQLCipher
|
||||||
|
/// They are used by the sqlite3.h header
|
||||||
|
#define SQLITE_HAS_CODEC |
||||||
|
#define SQLITE_TEMP_STORE 2 |
||||||
|
|
||||||
|
#include <sqlcipher/sqlite3.h> |
||||||
|
|
||||||
|
RawDatabase::RawDatabase(const QString &path, const QString& password) |
||||||
|
: workerThread{new QThread}, path{path}, currentHexKey{deriveKey(password)} |
||||||
|
{ |
||||||
|
workerThread->setObjectName("qTox Database"); |
||||||
|
moveToThread(workerThread.get()); |
||||||
|
workerThread->start(); |
||||||
|
|
||||||
|
if (!open(path, currentHexKey)) |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
RawDatabase::~RawDatabase() |
||||||
|
{ |
||||||
|
close(); |
||||||
|
workerThread->exit(0); |
||||||
|
while (workerThread->isRunning()) |
||||||
|
workerThread->wait(50); |
||||||
|
} |
||||||
|
|
||||||
|
bool RawDatabase::open(const QString& path, const QString &hexKey) |
||||||
|
{ |
||||||
|
if (QThread::currentThread() != workerThread.get()) |
||||||
|
{ |
||||||
|
bool ret; |
||||||
|
QMetaObject::invokeMethod(this, "open", Qt::BlockingQueuedConnection, Q_RETURN_ARG(bool, ret), |
||||||
|
Q_ARG(const QString&, path), Q_ARG(const QString&, hexKey)); |
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
if (!QFile::exists(path) && QFile::exists(path+".tmp")) |
||||||
|
{ |
||||||
|
qWarning() << "Restoring database from temporary export file! Did we crash while changing the password?"; |
||||||
|
QFile::rename(path+".tmp", path); |
||||||
|
} |
||||||
|
|
||||||
|
if (sqlite3_open_v2(path.toUtf8().data(), &sqlite, |
||||||
|
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_NOMUTEX, nullptr) != SQLITE_OK) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to open database"<<path<<"with error:"<<sqlite3_errmsg(sqlite); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (!hexKey.isEmpty()) |
||||||
|
{ |
||||||
|
if (!execNow("PRAGMA key = \"x'"+hexKey+"'\"")) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to set encryption key"; |
||||||
|
close(); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (!execNow("SELECT count(*) FROM sqlite_master")) |
||||||
|
{ |
||||||
|
qWarning() << "Database is unusable, check that the password is correct"; |
||||||
|
close(); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
void RawDatabase::close() |
||||||
|
{ |
||||||
|
if (QThread::currentThread() != workerThread.get()) |
||||||
|
return (void)QMetaObject::invokeMethod(this, "close", Qt::BlockingQueuedConnection); |
||||||
|
|
||||||
|
// We assume we're in the ctor or dtor, so we just need to finish processing our transactions
|
||||||
|
process(); |
||||||
|
|
||||||
|
if (sqlite3_close(sqlite) == SQLITE_OK) |
||||||
|
sqlite = nullptr; |
||||||
|
else |
||||||
|
qWarning() << "Error closing database:"<<sqlite3_errmsg(sqlite); |
||||||
|
} |
||||||
|
|
||||||
|
bool RawDatabase::isOpen() |
||||||
|
{ |
||||||
|
// We don't need thread safety since only the ctor/dtor can write this pointer
|
||||||
|
return sqlite != nullptr; |
||||||
|
} |
||||||
|
|
||||||
|
bool RawDatabase::execNow(const QString& statement) |
||||||
|
{ |
||||||
|
return execNow(Query{statement}); |
||||||
|
} |
||||||
|
|
||||||
|
bool RawDatabase::execNow(const RawDatabase::Query &statement) |
||||||
|
{ |
||||||
|
return execNow(QVector<Query>{statement}); |
||||||
|
} |
||||||
|
|
||||||
|
bool RawDatabase::execNow(const QVector<RawDatabase::Query> &statements) |
||||||
|
{ |
||||||
|
if (!sqlite) |
||||||
|
{ |
||||||
|
qWarning() << "Trying to exec, but the database is not open"; |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
std::atomic_bool done{false}; |
||||||
|
std::atomic_bool success{false}; |
||||||
|
|
||||||
|
Transaction trans; |
||||||
|
trans.queries = statements; |
||||||
|
trans.done = &done; |
||||||
|
trans.success = &success; |
||||||
|
{ |
||||||
|
QMutexLocker locker{&transactionsMutex}; |
||||||
|
pendingTransactions.enqueue(trans); |
||||||
|
} |
||||||
|
|
||||||
|
// We can't use blocking queued here, otherwise we might process future transactions
|
||||||
|
// before returning, but we only want to wait until this transaction is done.
|
||||||
|
QMetaObject::invokeMethod(this, "process"); |
||||||
|
while (!done.load(std::memory_order_acquire)) |
||||||
|
QThread::msleep(10); |
||||||
|
|
||||||
|
return success.load(std::memory_order_acquire); |
||||||
|
} |
||||||
|
|
||||||
|
void RawDatabase::execLater(const QString &statement) |
||||||
|
{ |
||||||
|
execLater(Query{statement}); |
||||||
|
} |
||||||
|
|
||||||
|
void RawDatabase::execLater(const RawDatabase::Query &statement) |
||||||
|
{ |
||||||
|
execLater(QVector<Query>{statement}); |
||||||
|
} |
||||||
|
|
||||||
|
void RawDatabase::execLater(const QVector<RawDatabase::Query> &statements) |
||||||
|
{ |
||||||
|
if (!sqlite) |
||||||
|
{ |
||||||
|
qWarning() << "Trying to exec, but the database is not open"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Transaction trans; |
||||||
|
trans.queries = statements; |
||||||
|
{ |
||||||
|
QMutexLocker locker{&transactionsMutex}; |
||||||
|
pendingTransactions.enqueue(trans); |
||||||
|
} |
||||||
|
|
||||||
|
QMetaObject::invokeMethod(this, "process"); |
||||||
|
} |
||||||
|
|
||||||
|
void RawDatabase::sync() |
||||||
|
{ |
||||||
|
QMetaObject::invokeMethod(this, "process", Qt::BlockingQueuedConnection); |
||||||
|
} |
||||||
|
|
||||||
|
bool RawDatabase::setPassword(const QString& password) |
||||||
|
{ |
||||||
|
if (!sqlite) |
||||||
|
{ |
||||||
|
qWarning() << "Trying to change the password, but the database is not open"; |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (QThread::currentThread() != workerThread.get()) |
||||||
|
{ |
||||||
|
bool ret; |
||||||
|
QMetaObject::invokeMethod(this, "setPassword", Qt::BlockingQueuedConnection, |
||||||
|
Q_RETURN_ARG(bool, ret), Q_ARG(const QString&, password)); |
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
// If we need to decrypt or encrypt, we'll need to sync and close,
|
||||||
|
// so we always process the pending queue before rekeying for consistency
|
||||||
|
process(); |
||||||
|
|
||||||
|
if (QFile::exists(path+".tmp")) |
||||||
|
{ |
||||||
|
qWarning() << "Found old temporary export file while rekeying, deleting it"; |
||||||
|
QFile::remove(path+".tmp"); |
||||||
|
} |
||||||
|
|
||||||
|
if (!password.isEmpty()) |
||||||
|
{ |
||||||
|
QString newHexKey = deriveKey(password); |
||||||
|
if (!currentHexKey.isEmpty()) |
||||||
|
{ |
||||||
|
if (!execNow("PRAGMA rekey = \"x'"+newHexKey+"'\"")) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to change encryption key"; |
||||||
|
close(); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// Need to encrypt the database
|
||||||
|
if (!execNow("ATTACH DATABASE '"+path+".tmp' AS encrypted KEY \"x'"+newHexKey+"'\";" |
||||||
|
"SELECT sqlcipher_export('encrypted');" |
||||||
|
"DETACH DATABASE encrypted;")) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to export encrypted database"; |
||||||
|
close(); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// This is racy as hell, but nobody will race with us since we hold the profile lock
|
||||||
|
// If we crash or die here, the rename should be atomic, so we can recover no matter what
|
||||||
|
close(); |
||||||
|
QFile::remove(path); |
||||||
|
QFile::rename(path+".tmp", path); |
||||||
|
currentHexKey = newHexKey; |
||||||
|
if (!open(path, currentHexKey)) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to open encrypted database"; |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
if (currentHexKey.isEmpty()) |
||||||
|
return true; |
||||||
|
|
||||||
|
// Need to decrypt the database
|
||||||
|
if (!execNow("ATTACH DATABASE '"+path+".tmp' AS plaintext KEY '';" |
||||||
|
"SELECT sqlcipher_export('plaintext');" |
||||||
|
"DETACH DATABASE plaintext;")) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to export decrypted database"; |
||||||
|
close(); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// This is racy as hell, but nobody will race with us since we hold the profile lock
|
||||||
|
// If we crash or die here, the rename should be atomic, so we can recover no matter what
|
||||||
|
close(); |
||||||
|
QFile::remove(path); |
||||||
|
QFile::rename(path+".tmp", path); |
||||||
|
currentHexKey.clear(); |
||||||
|
if (!open(path)) |
||||||
|
{ |
||||||
|
qCritical() << "Failed to open decrypted database"; |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
bool RawDatabase::rename(const QString &newPath) |
||||||
|
{ |
||||||
|
if (!sqlite) |
||||||
|
{ |
||||||
|
qWarning() << "Trying to change the password, but the database is not open"; |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (QThread::currentThread() != workerThread.get()) |
||||||
|
{ |
||||||
|
bool ret; |
||||||
|
QMetaObject::invokeMethod(this, "rename", Qt::BlockingQueuedConnection, |
||||||
|
Q_RETURN_ARG(bool, ret), Q_ARG(const QString&, newPath)); |
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
process(); |
||||||
|
|
||||||
|
if (path == newPath) |
||||||
|
return true; |
||||||
|
|
||||||
|
if (QFile::exists(newPath)) |
||||||
|
return false; |
||||||
|
|
||||||
|
close(); |
||||||
|
if (!QFile::rename(path, newPath)) |
||||||
|
return false; |
||||||
|
path = newPath; |
||||||
|
return open(path, currentHexKey); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
QString RawDatabase::deriveKey(QString password) |
||||||
|
{ |
||||||
|
if (password.isEmpty()) |
||||||
|
return {}; |
||||||
|
|
||||||
|
QByteArray passData = password.toUtf8(); |
||||||
|
|
||||||
|
static_assert(TOX_PASS_KEY_LENGTH >= 32, "toxcore must provide 256bit or longer keys"); |
||||||
|
|
||||||
|
static const uint8_t expandConstant[TOX_PASS_SALT_LENGTH+1] = "L'ignorance est le pire des maux"; |
||||||
|
TOX_PASS_KEY key; |
||||||
|
tox_derive_key_with_salt((uint8_t*)passData.data(), passData.size(), expandConstant, &key, nullptr); |
||||||
|
return QByteArray((char*)key.key, 32).toHex(); |
||||||
|
} |
||||||
|
|
||||||
|
void RawDatabase::process() |
||||||
|
{ |
||||||
|
assert(QThread::currentThread() == workerThread.get()); |
||||||
|
|
||||||
|
if (!sqlite) |
||||||
|
return; |
||||||
|
|
||||||
|
forever |
||||||
|
{ |
||||||
|
// Fetch the next transaction
|
||||||
|
Transaction trans; |
||||||
|
{ |
||||||
|
QMutexLocker locker{&transactionsMutex}; |
||||||
|
if (pendingTransactions.isEmpty()) |
||||||
|
return; |
||||||
|
trans = pendingTransactions.dequeue(); |
||||||
|
} |
||||||
|
|
||||||
|
// In case we exit early, prepare to signal errors
|
||||||
|
if (trans.success != nullptr) |
||||||
|
trans.success->store(false, std::memory_order_release); |
||||||
|
|
||||||
|
// Add transaction commands if necessary
|
||||||
|
if (trans.queries.size() > 1) |
||||||
|
{ |
||||||
|
trans.queries.prepend(Query{"BEGIN;"}); |
||||||
|
trans.queries.append({"COMMIT;"}); |
||||||
|
} |
||||||
|
|
||||||
|
// Compile queries
|
||||||
|
for (Query& query : trans.queries) |
||||||
|
{ |
||||||
|
assert(query.statements.isEmpty()); |
||||||
|
// sqlite3_prepare_v2 only compiles one statement at a time in the query, we need to loop over them all
|
||||||
|
int curParam=0; |
||||||
|
const char* compileTail = query.query.data(); |
||||||
|
do { |
||||||
|
// Compile the next statement
|
||||||
|
sqlite3_stmt* stmt; |
||||||
|
int r; |
||||||
|
if ((r = sqlite3_prepare_v2(sqlite, compileTail, |
||||||
|
query.query.size() - static_cast<int>(compileTail - query.query.data()), |
||||||
|
&stmt, &compileTail)) != SQLITE_OK) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to prepare statement"<<query.query<<"with error"<<r; |
||||||
|
goto cleanupStatements; |
||||||
|
} |
||||||
|
query.statements += stmt; |
||||||
|
|
||||||
|
// Now we can bind our params to this statement
|
||||||
|
int nParams = sqlite3_bind_parameter_count(stmt); |
||||||
|
if (query.blobs.size() < curParam+nParams) |
||||||
|
{ |
||||||
|
qWarning() << "Not enough parameters to bind to query "<<query.query; |
||||||
|
goto cleanupStatements; |
||||||
|
} |
||||||
|
for (int i=0; i<nParams; ++i) |
||||||
|
{ |
||||||
|
const QByteArray& blob = query.blobs[curParam+i]; |
||||||
|
if (sqlite3_bind_blob(stmt, i+1, blob.data(), blob.size(), SQLITE_STATIC) != SQLITE_OK) |
||||||
|
{ |
||||||
|
qWarning() << "Failed to bind param"<<curParam+i<<"to query "<<query.query; |
||||||
|
goto cleanupStatements; |
||||||
|
} |
||||||
|
} |
||||||
|
curParam += nParams; |
||||||
|
} while (compileTail != query.query.data()+query.query.size()); |
||||||
|
} |
||||||
|
|
||||||
|
// Execute each statement of each query of our transaction
|
||||||
|
for (Query& query : trans.queries) |
||||||
|
{ |
||||||
|
for (sqlite3_stmt* stmt : query.statements) |
||||||
|
{ |
||||||
|
int column_count = sqlite3_column_count(stmt); |
||||||
|
int result; |
||||||
|
do { |
||||||
|
result = sqlite3_step(stmt); |
||||||
|
|
||||||
|
// Execute our row callback
|
||||||
|
if (result == SQLITE_ROW && query.rowCallback) |
||||||
|
{ |
||||||
|
QVector<QVariant> row; |
||||||
|
for (int i=0; i<column_count; ++i) |
||||||
|
row += extractData(stmt, i); |
||||||
|
|
||||||
|
query.rowCallback(row); |
||||||
|
} |
||||||
|
} while (result == SQLITE_ROW); |
||||||
|
if (result == SQLITE_ERROR) |
||||||
|
{ |
||||||
|
qWarning() << "Error executing query "<<query.query; |
||||||
|
goto cleanupStatements; |
||||||
|
} |
||||||
|
else if (result == SQLITE_MISUSE) |
||||||
|
{ |
||||||
|
qWarning() << "Misuse executing query "<<query.query; |
||||||
|
goto cleanupStatements; |
||||||
|
} |
||||||
|
else if (result == SQLITE_CONSTRAINT) |
||||||
|
{ |
||||||
|
qWarning() << "Constraint error executing query "<<query.query; |
||||||
|
goto cleanupStatements; |
||||||
|
} |
||||||
|
else if (result != SQLITE_DONE) |
||||||
|
{ |
||||||
|
qWarning() << "Unknown error"<<result<<"executing query "<<query.query; |
||||||
|
goto cleanupStatements; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (query.insertCallback) |
||||||
|
query.insertCallback(sqlite3_last_insert_rowid(sqlite)); |
||||||
|
} |
||||||
|
|
||||||
|
if (trans.success != nullptr) |
||||||
|
trans.success->store(true, std::memory_order_release); |
||||||
|
|
||||||
|
// Free our statements
|
||||||
|
cleanupStatements: |
||||||
|
for (Query& query : trans.queries) |
||||||
|
{ |
||||||
|
for (sqlite3_stmt* stmt : query.statements) |
||||||
|
sqlite3_finalize(stmt); |
||||||
|
query.statements.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
// Signal transaction results
|
||||||
|
if (trans.done != nullptr) |
||||||
|
trans.done->store(true, std::memory_order_release); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
QVariant RawDatabase::extractData(sqlite3_stmt *stmt, int col) |
||||||
|
{ |
||||||
|
int type = sqlite3_column_type(stmt, col); |
||||||
|
if (type == SQLITE_INTEGER) |
||||||
|
{ |
||||||
|
return sqlite3_column_int64(stmt, col); |
||||||
|
} |
||||||
|
else if (type == SQLITE_TEXT) |
||||||
|
{ |
||||||
|
const char* str = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col)); |
||||||
|
int len = sqlite3_column_bytes(stmt, col); |
||||||
|
return QString::fromUtf8(str, len); |
||||||
|
} |
||||||
|
else if (type == SQLITE_NULL) |
||||||
|
{ |
||||||
|
return QVariant{}; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
const char* data = reinterpret_cast<const char*>(sqlite3_column_blob(stmt, col)); |
||||||
|
int len = sqlite3_column_bytes(stmt, col); |
||||||
|
return QByteArray::fromRawData(data, len); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,115 @@ |
|||||||
|
#ifndef RAWDATABASE_H |
||||||
|
#define RAWDATABASE_H |
||||||
|
|
||||||
|
#include <QString> |
||||||
|
#include <QByteArray> |
||||||
|
#include <QThread> |
||||||
|
#include <QQueue> |
||||||
|
#include <QVector> |
||||||
|
#include <QPair> |
||||||
|
#include <QMutex> |
||||||
|
#include <QVariant> |
||||||
|
#include <memory> |
||||||
|
#include <atomic> |
||||||
|
|
||||||
|
struct sqlite3; |
||||||
|
struct sqlite3_stmt; |
||||||
|
|
||||||
|
/// Implements a low level RAII interface to a SQLCipher (SQlite3) database
|
||||||
|
/// Thread-safe, does all database operations on a worker thread
|
||||||
|
/// The queries must not contain transaction commands (BEGIN/COMMIT/...) or the behavior is undefined
|
||||||
|
class RawDatabase : QObject |
||||||
|
{ |
||||||
|
Q_OBJECT |
||||||
|
|
||||||
|
public: |
||||||
|
/// A query to be executed by the database. Can be composed of one or more SQL statements in the query,
|
||||||
|
/// optional BLOB parameters to be bound, and callbacks fired when the query is executed
|
||||||
|
/// Calling any database method from a query callback is undefined behavior
|
||||||
|
class Query |
||||||
|
{ |
||||||
|
public: |
||||||
|
Query(QString query, QVector<QByteArray> blobs = {}, std::function<void(int64_t)> insertCallback={}) |
||||||
|
: query{query.toUtf8()}, blobs{blobs}, insertCallback{insertCallback} {} |
||||||
|
Query(QString query, std::function<void(int64_t)> insertCallback) |
||||||
|
: query{query.toUtf8()}, insertCallback{insertCallback} {} |
||||||
|
Query(QString query, std::function<void(const QVector<QVariant>&)> rowCallback) |
||||||
|
: query{query.toUtf8()}, rowCallback{rowCallback} {} |
||||||
|
Query() = default; |
||||||
|
private: |
||||||
|
QByteArray query; ///< UTF-8 query string
|
||||||
|
QVector<QByteArray> blobs; ///< Bound data blobs
|
||||||
|
std::function<void(int64_t)> insertCallback; ///< Called after execution with the last insert rowid
|
||||||
|
std::function<void(const QVector<QVariant>&)> rowCallback; ///< Called during execution for each row
|
||||||
|
QVector<sqlite3_stmt*> statements; ///< Statements to be compiled from the query
|
||||||
|
|
||||||
|
friend class RawDatabase; |
||||||
|
}; |
||||||
|
|
||||||
|
public: |
||||||
|
/// Tries to open a database
|
||||||
|
/// If password is empty, the database will be opened unencrypted
|
||||||
|
/// Otherwise we will use toxencryptsave to derive a key and encrypt the database
|
||||||
|
RawDatabase(const QString& path, const QString& password); |
||||||
|
~RawDatabase(); |
||||||
|
bool isOpen(); ///< Returns true if the database was opened successfully
|
||||||
|
/// Executes a SQL transaction synchronously.
|
||||||
|
/// Returns whether the transaction was successful.
|
||||||
|
bool execNow(const QString& statement); |
||||||
|
bool execNow(const Query& statement); |
||||||
|
bool execNow(const QVector<Query>& statements); |
||||||
|
/// Executes a SQL transaction asynchronously.
|
||||||
|
void execLater(const QString& statement); |
||||||
|
void execLater(const Query& statement); |
||||||
|
void execLater(const QVector<Query>& statements); |
||||||
|
/// Waits until all the pending transactions are executed
|
||||||
|
void sync(); |
||||||
|
|
||||||
|
public slots: |
||||||
|
/// Changes the database password, encrypting or decrypting if necessary
|
||||||
|
/// If password is empty, the database will be decrypted
|
||||||
|
/// Will process all transactions before changing the password
|
||||||
|
bool setPassword(const QString& password); |
||||||
|
/// Moves the database file on disk to match the new path
|
||||||
|
/// /// Will process all transactions before renaming
|
||||||
|
bool rename(const QString& newPath); |
||||||
|
|
||||||
|
protected slots: |
||||||
|
/// Should only be called from the constructor, runs on the caller's thread
|
||||||
|
bool open(const QString& path, const QString& hexKey = {}); |
||||||
|
/// Should only be called from the destructor, runs on the caller's thread
|
||||||
|
void close(); |
||||||
|
/// Implements the actual processing of pending transactions
|
||||||
|
/// Unqueues, compiles, binds and executes queries, then notifies of results
|
||||||
|
/// MUST only be called from the worker thread
|
||||||
|
void process(); |
||||||
|
/// Extracts a variant from one column of a result row depending on the column type
|
||||||
|
QVariant extractData(sqlite3_stmt* stmt, int col); |
||||||
|
|
||||||
|
protected: |
||||||
|
/// Derives a 256bit key from the password and returns it hex-encoded
|
||||||
|
static QString deriveKey(QString password); |
||||||
|
|
||||||
|
private: |
||||||
|
/// SQL transactions to be processed
|
||||||
|
/// A transaction is made of queries, which can have bound BLOBs
|
||||||
|
struct Transaction |
||||||
|
{ |
||||||
|
QVector<Query> queries; |
||||||
|
/// If not a nullptr, the result of the transaction will be set
|
||||||
|
std::atomic_bool* success = nullptr; |
||||||
|
/// If not a nullptr, will be set to true when the transaction has been executed
|
||||||
|
std::atomic_bool* done = nullptr; |
||||||
|
}; |
||||||
|
|
||||||
|
private: |
||||||
|
sqlite3* sqlite; |
||||||
|
std::unique_ptr<QThread> workerThread; |
||||||
|
QQueue<Transaction> pendingTransactions; |
||||||
|
/// Protects pendingTransactions
|
||||||
|
QMutex transactionsMutex; |
||||||
|
QString path; |
||||||
|
QString currentHexKey; |
||||||
|
}; |
||||||
|
|
||||||
|
#endif // RAWDATABASE_H
|
@ -0,0 +1,231 @@ |
|||||||
|
#include "history.h" |
||||||
|
#include "src/persistence/profile.h" |
||||||
|
#include "src/persistence/settings.h" |
||||||
|
#include "src/persistence/db/rawdatabase.h" |
||||||
|
#include "src/persistence/historykeeper.h" |
||||||
|
#include <QDebug> |
||||||
|
#include <cassert> |
||||||
|
|
||||||
|
using namespace std; |
||||||
|
|
||||||
|
History::History(const QString &profileName, const QString &password) |
||||||
|
: db{getDbPath(profileName), password} |
||||||
|
{ |
||||||
|
init(); |
||||||
|
} |
||||||
|
|
||||||
|
History::History(const QString &profileName, const QString &password, const HistoryKeeper &oldHistory) |
||||||
|
: History{profileName, password} |
||||||
|
{ |
||||||
|
import(oldHistory); |
||||||
|
} |
||||||
|
|
||||||
|
History::~History() |
||||||
|
{ |
||||||
|
// We could have execLater requests pending with a lambda attached,
|
||||||
|
// so clear the pending transactions first
|
||||||
|
db.sync(); |
||||||
|
} |
||||||
|
|
||||||
|
bool History::isValid() |
||||||
|
{ |
||||||
|
return db.isOpen(); |
||||||
|
} |
||||||
|
|
||||||
|
void History::setPassword(const QString& password) |
||||||
|
{ |
||||||
|
db.setPassword(password); |
||||||
|
} |
||||||
|
|
||||||
|
void History::rename(const QString &newName) |
||||||
|
{ |
||||||
|
db.rename(getDbPath(newName)); |
||||||
|
} |
||||||
|
|
||||||
|
void History::eraseHistory() |
||||||
|
{ |
||||||
|
db.execNow("DELETE FROM faux_offline_pending;" |
||||||
|
"DELETE FROM history;" |
||||||
|
"DELETE FROM aliases;" |
||||||
|
"DELETE FROM peers;" |
||||||
|
"VACUUM;"); |
||||||
|
} |
||||||
|
|
||||||
|
void History::removeFriendHistory(const QString &friendPk) |
||||||
|
{ |
||||||
|
if (!peers.contains(friendPk)) |
||||||
|
return; |
||||||
|
int64_t id = peers[friendPk]; |
||||||
|
|
||||||
|
if (db.execNow(QString("DELETE FROM faux_offline_pending " |
||||||
|
"WHERE faux_offline_pending.id IN ( " |
||||||
|
"SELECT faux_offline_pending.id FROM faux_offline_pending " |
||||||
|
"LEFT JOIN history ON faux_offline_pending.id = history.id " |
||||||
|
"WHERE chat_id=%1 " |
||||||
|
"); " |
||||||
|
"DELETE FROM history WHERE chat_id=%1; " |
||||||
|
"DELETE FROM aliases WHERE owner=%1; " |
||||||
|
"DELETE FROM peers WHERE id=%1; " |
||||||
|
"VACUUM;").arg(id))) |
||||||
|
{ |
||||||
|
peers.remove(friendPk); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
qWarning() << "Failed to remove friend's history"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
QVector<RawDatabase::Query> History::generateNewMessageQueries(const QString &friendPk, const QString &message, |
||||||
|
const QString &sender, const QDateTime &time, bool isSent, QString dispName, |
||||||
|
std::function<void(int64_t)> insertIdCallback) |
||||||
|
{ |
||||||
|
QVector<RawDatabase::Query> queries; |
||||||
|
|
||||||
|
// Get the db id of the peer we're chatting with
|
||||||
|
int64_t peerId; |
||||||
|
if (peers.contains(friendPk)) |
||||||
|
{ |
||||||
|
peerId = peers[friendPk]; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
if (peers.isEmpty()) |
||||||
|
peerId = 0; |
||||||
|
else |
||||||
|
peerId = *max_element(begin(peers), end(peers))+1; |
||||||
|
peers[friendPk] = peerId; |
||||||
|
queries += RawDatabase::Query{("INSERT INTO peers (id, public_key) VALUES (%1, '"+friendPk+"');").arg(peerId)}; |
||||||
|
} |
||||||
|
|
||||||
|
// Get the db id of the sender of the message
|
||||||
|
int64_t senderId; |
||||||
|
if (peers.contains(sender)) |
||||||
|
{ |
||||||
|
senderId = peers[sender]; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
if (peers.isEmpty()) |
||||||
|
senderId = 0; |
||||||
|
else |
||||||
|
senderId = *max_element(begin(peers), end(peers))+1; |
||||||
|
peers[sender] = senderId; |
||||||
|
queries += RawDatabase::Query{("INSERT INTO peers (id, public_key) VALUES (%1, '"+sender+"');").arg(senderId)}; |
||||||
|
} |
||||||
|
|
||||||
|
queries += RawDatabase::Query(QString("INSERT OR IGNORE INTO aliases (owner, display_name) VALUES (%1, ?);") |
||||||
|
.arg(senderId), {dispName.toUtf8()}); |
||||||
|
|
||||||
|
// If the alias already existed, the insert will ignore the conflict and last_insert_rowid() will return garbage,
|
||||||
|
// so we have to check changes() and manually fetch the row ID in this case
|
||||||
|
queries += RawDatabase::Query(QString("INSERT INTO history (timestamp, chat_id, message, sender_alias) " |
||||||
|
"VALUES (%1, %2, ?, (" |
||||||
|
" CASE WHEN changes() IS 0 THEN (" |
||||||
|
" SELECT id FROM aliases WHERE owner=%3 AND display_name=?)" |
||||||
|
" ELSE last_insert_rowid() END" |
||||||
|
"));") |
||||||
|
.arg(time.toMSecsSinceEpoch()).arg(peerId).arg(senderId), |
||||||
|
{message.toUtf8(), dispName.toUtf8()}, insertIdCallback); |
||||||
|
|
||||||
|
if (!isSent) |
||||||
|
queries += RawDatabase::Query{"INSERT INTO faux_offline_pending (id) VALUES (last_insert_rowid());"}; |
||||||
|
|
||||||
|
return queries; |
||||||
|
} |
||||||
|
|
||||||
|
void History::addNewMessage(const QString &friendPk, const QString &message, const QString &sender, |
||||||
|
const QDateTime &time, bool isSent, QString dispName, std::function<void(int64_t)> insertIdCallback) |
||||||
|
{ |
||||||
|
db.execLater(generateNewMessageQueries(friendPk, message, sender, time, isSent, dispName, insertIdCallback)); |
||||||
|
} |
||||||
|
|
||||||
|
QList<History::HistMessage> History::getChatHistory(const QString &friendPk, const QDateTime &from, const QDateTime &to) |
||||||
|
{ |
||||||
|
QList<HistMessage> messages; |
||||||
|
|
||||||
|
auto rowCallback = [&messages](const QVector<QVariant>& row) |
||||||
|
{ |
||||||
|
messages += {row[0].toLongLong(), |
||||||
|
row[1].isNull(), |
||||||
|
QDateTime::fromMSecsSinceEpoch(row[2].toLongLong()), |
||||||
|
row[3].toString(), |
||||||
|
row[4].toString(), |
||||||
|
row[5].toString(), |
||||||
|
row[6].toString()}; |
||||||
|
}; |
||||||
|
|
||||||
|
// Don't forget to update the rowCallback if you change the selected columns!
|
||||||
|
db.execNow({QString("SELECT history.id, faux_offline_pending.id, timestamp, chat.public_key, " |
||||||
|
"aliases.display_name, sender.public_key, message FROM history " |
||||||
|
"LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id " |
||||||
|
"JOIN peers chat ON chat_id = chat.id " |
||||||
|
"JOIN aliases ON sender_alias = aliases.id " |
||||||
|
"JOIN peers sender ON aliases.owner = sender.id " |
||||||
|
"WHERE timestamp BETWEEN %1 AND %2 AND chat.public_key='%3';") |
||||||
|
.arg(from.toMSecsSinceEpoch()).arg(to.toMSecsSinceEpoch()).arg(friendPk), rowCallback}); |
||||||
|
|
||||||
|
return messages; |
||||||
|
} |
||||||
|
|
||||||
|
void History::markAsSent(qint64 id) |
||||||
|
{ |
||||||
|
db.execLater(QString("DELETE FROM faux_offline_pending WHERE id=%1;").arg(id)); |
||||||
|
} |
||||||
|
|
||||||
|
QString History::getDbPath(const QString &profileName) |
||||||
|
{ |
||||||
|
return Settings::getInstance().getSettingsDirPath() + profileName + ".db"; |
||||||
|
} |
||||||
|
|
||||||
|
void History::init() |
||||||
|
{ |
||||||
|
if (!isValid()) |
||||||
|
{ |
||||||
|
qWarning() << "Database not open, init failed"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
db.execLater("CREATE TABLE IF NOT EXISTS peers (id INTEGER PRIMARY KEY, public_key TEXT NOT NULL UNIQUE);" |
||||||
|
"CREATE TABLE IF NOT EXISTS aliases (id INTEGER PRIMARY KEY, owner INTEGER," |
||||||
|
"display_name BLOB NOT NULL, UNIQUE(owner, display_name));" |
||||||
|
"CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, " |
||||||
|
"chat_id INTEGER NOT NULL, sender_alias INTEGER NOT NULL, " |
||||||
|
"message BLOB NOT NULL);" |
||||||
|
"CREATE TABLE IF NOT EXISTS faux_offline_pending (id INTEGER PRIMARY KEY);"); |
||||||
|
|
||||||
|
// Cache our current peers
|
||||||
|
db.execLater(RawDatabase::Query{"SELECT id, public_key FROM peers;", [this](const QVector<QVariant>& row) |
||||||
|
{ |
||||||
|
peers[row[1].toString()] = row[0].toInt(); |
||||||
|
}}); |
||||||
|
} |
||||||
|
|
||||||
|
void History::import(const HistoryKeeper &oldHistory) |
||||||
|
{ |
||||||
|
if (!isValid()) |
||||||
|
{ |
||||||
|
qWarning() << "New database not open, import failed"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
qDebug() << "Importing old database..."; |
||||||
|
QTime t=QTime::currentTime(); |
||||||
|
t.start(); |
||||||
|
QVector<RawDatabase::Query> queries; |
||||||
|
constexpr int batchSize = 1000; |
||||||
|
queries.reserve(batchSize); |
||||||
|
QList<HistoryKeeper::HistMessage> oldMessages = oldHistory.exportMessagesDeleteFile(); |
||||||
|
for (const HistoryKeeper::HistMessage& msg : oldMessages) |
||||||
|
{ |
||||||
|
queries += generateNewMessageQueries(msg.chat, msg.message, msg.sender, msg.timestamp, true, msg.dispName); |
||||||
|
if (queries.size() == batchSize) |
||||||
|
{ |
||||||
|
db.execLater(queries); |
||||||
|
queries.clear(); |
||||||
|
} |
||||||
|
} |
||||||
|
db.execLater(queries); |
||||||
|
db.sync(); |
||||||
|
qDebug() << "Imported old database in"<<t.elapsed()<<"ms"; |
||||||
|
} |
@ -0,0 +1,77 @@ |
|||||||
|
#ifndef HISTORY_H |
||||||
|
#define HISTORY_H |
||||||
|
|
||||||
|
#include <tox/toxencryptsave.h> |
||||||
|
#include <QDateTime> |
||||||
|
#include <QVector> |
||||||
|
#include <QHash> |
||||||
|
#include <cstdint> |
||||||
|
#include "src/persistence/db/rawdatabase.h" |
||||||
|
|
||||||
|
class Profile; |
||||||
|
class HistoryKeeper; |
||||||
|
class RawDatabase; |
||||||
|
|
||||||
|
/// Interacts with the profile database to save the chat history
|
||||||
|
class History |
||||||
|
{ |
||||||
|
public: |
||||||
|
struct HistMessage |
||||||
|
{ |
||||||
|
HistMessage(qint64 id, bool isSent, QDateTime timestamp, QString chat, QString dispName, QString sender, QString message) : |
||||||
|
chat{chat}, sender{sender}, message{message}, dispName{dispName}, timestamp{timestamp}, id{id}, isSent{isSent} {} |
||||||
|
|
||||||
|
QString chat; |
||||||
|
QString sender; |
||||||
|
QString message; |
||||||
|
QString dispName; |
||||||
|
QDateTime timestamp; |
||||||
|
qint64 id; |
||||||
|
bool isSent; |
||||||
|
}; |
||||||
|
|
||||||
|
public: |
||||||
|
/// Opens the profile database and prepares to work with the history
|
||||||
|
/// If password is empty, the database will be opened unencrypted
|
||||||
|
History(const QString& profileName, const QString& password); |
||||||
|
/// Opens the profile database, and import from the old database
|
||||||
|
/// If password is empty, the database will be opened unencrypted
|
||||||
|
History(const QString& profileName, const QString& password, const HistoryKeeper& oldHistory); |
||||||
|
~History(); |
||||||
|
/// Checks if the database was opened successfully
|
||||||
|
bool isValid(); |
||||||
|
/// Imports messages from the old history file
|
||||||
|
void import(const HistoryKeeper& oldHistory); |
||||||
|
/// Changes the database password, will encrypt or decrypt if necessary
|
||||||
|
void setPassword(const QString& password); |
||||||
|
/// Moves the database file on disk to match the new name
|
||||||
|
void rename(const QString& newName); |
||||||
|
|
||||||
|
/// Erases all the chat history from the database
|
||||||
|
void eraseHistory(); |
||||||
|
/// Erases the chat history with one friend
|
||||||
|
void removeFriendHistory(const QString& friendPk); |
||||||
|
/// Saves a chat message in the database
|
||||||
|
void addNewMessage(const QString& friendPk, const QString& message, const QString& sender, |
||||||
|
const QDateTime &time, bool isSent, QString dispName, |
||||||
|
std::function<void(int64_t)> insertIdCallback={}); |
||||||
|
/// Fetches chat messages from the database
|
||||||
|
QList<HistMessage> getChatHistory(const QString& friendPk, const QDateTime &from, const QDateTime &to); |
||||||
|
/// Marks a message as sent, removing it from the faux-offline pending messages list
|
||||||
|
void markAsSent(qint64 id); |
||||||
|
|
||||||
|
protected: |
||||||
|
/// Makes sure the history tables are created
|
||||||
|
void init(); |
||||||
|
static QString getDbPath(const QString& profileName); |
||||||
|
QVector<RawDatabase::Query> generateNewMessageQueries(const QString& friendPk, const QString& message, |
||||||
|
const QString& sender, const QDateTime &time, bool isSent, QString dispName, |
||||||
|
std::function<void(int64_t)> insertIdCallback={}); |
||||||
|
|
||||||
|
private: |
||||||
|
RawDatabase db; |
||||||
|
// Cached mappings to speed up message saving
|
||||||
|
QHash<QString, int64_t> peers; ///< Maps friend public keys to unique IDs by index
|
||||||
|
}; |
||||||
|
|
||||||
|
#endif // HISTORY_H
|
Loading…
Reference in new issue