mirror of https://github.com/qTox/qTox
1417 lines
58 KiB
C++
1417 lines
58 KiB
C++
/*
|
|
Copyright © 2015-2019 by The qTox Project Contributors
|
|
|
|
This file is part of qTox, a Qt-based graphical interface for Tox.
|
|
|
|
qTox is libre software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
qTox is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with qTox. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include <QDebug>
|
|
#include <cassert>
|
|
|
|
#include "history.h"
|
|
#include "profile.h"
|
|
#include "settings.h"
|
|
#include "db/rawdatabase.h"
|
|
#include "src/core/toxpk.h"
|
|
|
|
namespace {
|
|
static constexpr int SCHEMA_VERSION = 9;
|
|
|
|
bool createCurrentSchema(RawDatabase& db)
|
|
{
|
|
QVector<RawDatabase::Query> queries;
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"CREATE TABLE peers (id INTEGER PRIMARY KEY, "
|
|
"public_key TEXT NOT NULL UNIQUE);"
|
|
"CREATE TABLE aliases (id INTEGER PRIMARY KEY, "
|
|
"owner INTEGER, "
|
|
"display_name BLOB NOT NULL, "
|
|
"UNIQUE(owner, display_name), "
|
|
"FOREIGN KEY (owner) REFERENCES peers(id));"
|
|
"CREATE TABLE history "
|
|
"(id INTEGER PRIMARY KEY, "
|
|
"message_type CHAR(1) NOT NULL DEFAULT 'T' CHECK (message_type in ('T','F','S')), "
|
|
"timestamp INTEGER NOT NULL, "
|
|
"chat_id INTEGER NOT NULL, "
|
|
// Message subtypes want to reference the following as a foreign key. Foreign keys must be
|
|
// guaranteed to be unique. Since an ID is already unique, id + message type is also unique
|
|
"UNIQUE (id, message_type), "
|
|
"FOREIGN KEY (chat_id) REFERENCES peers(id)); "
|
|
"CREATE TABLE text_messages "
|
|
"(id INTEGER PRIMARY KEY, "
|
|
"message_type CHAR(1) NOT NULL CHECK (message_type = 'T'), "
|
|
"sender_alias INTEGER NOT NULL, "
|
|
// even though technically a message can be null for file transfer, we've opted
|
|
// to just insert an empty string when there's no content, this moderately simplifies
|
|
// implementation as currently our database doesn't have support for optional fields.
|
|
// We would either have to insert "?" or "null" based on if message exists and then
|
|
// ensure that our blob vector always has the right number of fields. Better to just
|
|
// leave this as NOT NULL for now.
|
|
"message BLOB NOT NULL, "
|
|
"FOREIGN KEY (id, message_type) REFERENCES history(id, message_type), "
|
|
"FOREIGN KEY (sender_alias) REFERENCES aliases(id)); "
|
|
"CREATE TABLE file_transfers "
|
|
"(id INTEGER PRIMARY KEY, "
|
|
"message_type CHAR(1) NOT NULL CHECK (message_type = 'F'), "
|
|
"sender_alias INTEGER NOT NULL, "
|
|
"file_restart_id BLOB NOT NULL, "
|
|
"file_name BLOB NOT NULL, "
|
|
"file_path BLOB NOT NULL, "
|
|
"file_hash BLOB NOT NULL, "
|
|
"file_size INTEGER NOT NULL, "
|
|
"direction INTEGER NOT NULL, "
|
|
"file_state INTEGER NOT NULL, "
|
|
"FOREIGN KEY (id, message_type) REFERENCES history(id, message_type), "
|
|
"FOREIGN KEY (sender_alias) REFERENCES aliases(id)); "
|
|
"CREATE TABLE system_messages "
|
|
"(id INTEGER PRIMARY KEY, "
|
|
"message_type CHAR(1) NOT NULL CHECK (message_type = 'S'), "
|
|
"system_message_type INTEGER NOT NULL, "
|
|
"arg1 BLOB, "
|
|
"arg2 BLOB, "
|
|
"arg3 BLOB, "
|
|
"arg4 BLOB, "
|
|
"FOREIGN KEY (id, message_type) REFERENCES history(id, message_type)); "
|
|
"CREATE TABLE faux_offline_pending (id INTEGER PRIMARY KEY, "
|
|
"required_extensions INTEGER NOT NULL DEFAULT 0, "
|
|
"FOREIGN KEY (id) REFERENCES history(id));"
|
|
"CREATE TABLE broken_messages (id INTEGER PRIMARY KEY, "
|
|
"reason INTEGER NOT NULL DEFAULT 0, "
|
|
"FOREIGN KEY (id) REFERENCES history(id));"));
|
|
// sqlite doesn't support including the index as part of the CREATE TABLE statement, so add a second query
|
|
queries += RawDatabase::Query(
|
|
"CREATE INDEX chat_id_idx on history (chat_id);");
|
|
queries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = %1;").arg(SCHEMA_VERSION));
|
|
return db.execNow(queries);
|
|
}
|
|
|
|
bool isNewDb(std::shared_ptr<RawDatabase>& db, bool& success)
|
|
{
|
|
bool newDb;
|
|
if (!db->execNow(RawDatabase::Query("SELECT COUNT(*) FROM sqlite_master;",
|
|
[&](const QVector<QVariant>& row) {
|
|
newDb = row[0].toLongLong() == 0;
|
|
}))) {
|
|
db.reset();
|
|
success = false;
|
|
return false;
|
|
}
|
|
success = true;
|
|
return newDb;
|
|
}
|
|
|
|
bool dbSchema0to1(RawDatabase& db)
|
|
{
|
|
QVector<RawDatabase::Query> queries;
|
|
queries += RawDatabase::Query(QStringLiteral("CREATE TABLE file_transfers "
|
|
"(id INTEGER PRIMARY KEY, "
|
|
"chat_id INTEGER NOT NULL, "
|
|
"file_restart_id BLOB NOT NULL, "
|
|
"file_name BLOB NOT NULL, "
|
|
"file_path BLOB NOT NULL, "
|
|
"file_hash BLOB NOT NULL, "
|
|
"file_size INTEGER NOT NULL, "
|
|
"direction INTEGER NOT NULL, "
|
|
"file_state INTEGER NOT NULL);"));
|
|
queries += RawDatabase::Query(QStringLiteral("ALTER TABLE history ADD file_id INTEGER;"));
|
|
queries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 1;"));
|
|
return db.execNow(queries);
|
|
}
|
|
|
|
bool dbSchema1to2(RawDatabase& db)
|
|
{
|
|
// Any faux_offline_pending message, in a chat that has newer delivered
|
|
// message is decided to be broken. It must be moved from
|
|
// faux_offline_pending to broken_messages
|
|
|
|
// the last non-pending message in each chat
|
|
QString lastDeliveredQuery =
|
|
QString("SELECT chat_id, MAX(history.id) FROM "
|
|
"history JOIN peers chat ON chat_id = chat.id "
|
|
"LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id "
|
|
"WHERE faux_offline_pending.id IS NULL "
|
|
"GROUP BY chat_id;");
|
|
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("CREATE TABLE broken_messages "
|
|
"(id INTEGER PRIMARY KEY);"));
|
|
|
|
auto rowCallback = [&upgradeQueries](const QVector<QVariant>& row) {
|
|
auto chatId = row[0].toLongLong();
|
|
auto lastDeliveredHistoryId = row[1].toLongLong();
|
|
|
|
upgradeQueries += QString("INSERT INTO broken_messages "
|
|
"SELECT faux_offline_pending.id FROM "
|
|
"history JOIN faux_offline_pending "
|
|
"ON faux_offline_pending.id = history.id "
|
|
"WHERE history.chat_id=%1 "
|
|
"AND history.id < %2;")
|
|
.arg(chatId)
|
|
.arg(lastDeliveredHistoryId);
|
|
};
|
|
// note this doesn't modify the db, just generate new queries, so is safe
|
|
// to run outside of our upgrade transaction
|
|
if (!db.execNow({lastDeliveredQuery, rowCallback})) {
|
|
return false;
|
|
}
|
|
|
|
upgradeQueries += QString("DELETE FROM faux_offline_pending "
|
|
"WHERE id in ("
|
|
"SELECT id FROM broken_messages);");
|
|
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 2;"));
|
|
|
|
return db.execNow(upgradeQueries);
|
|
}
|
|
|
|
bool dbSchema2to3(RawDatabase& db)
|
|
{
|
|
// Any faux_offline_pending message with the content "/me " are action
|
|
// messages that qTox previously let a user enter, but that will cause an
|
|
// action type message to be sent to toxcore, with 0 length, which will
|
|
// always fail. They must be be moved from faux_offline_pending to broken_messages
|
|
// to avoid qTox from erroring trying to send them on every connect
|
|
|
|
const QString emptyActionMessageString = "/me ";
|
|
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
upgradeQueries += RawDatabase::Query{QString("INSERT INTO broken_messages "
|
|
"SELECT faux_offline_pending.id FROM "
|
|
"history JOIN faux_offline_pending "
|
|
"ON faux_offline_pending.id = history.id "
|
|
"WHERE history.message = ?;"),
|
|
{emptyActionMessageString.toUtf8()}};
|
|
|
|
upgradeQueries += QString("DELETE FROM faux_offline_pending "
|
|
"WHERE id in ("
|
|
"SELECT id FROM broken_messages);");
|
|
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 3;"));
|
|
|
|
return db.execNow(upgradeQueries);
|
|
}
|
|
|
|
bool dbSchema3to4(RawDatabase& db)
|
|
{
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
upgradeQueries += RawDatabase::Query{QString(
|
|
"CREATE INDEX chat_id_idx on history (chat_id);")};
|
|
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 4;"));
|
|
|
|
return db.execNow(upgradeQueries);
|
|
}
|
|
|
|
void addForeignKeyToAlias(QVector<RawDatabase::Query>& queries)
|
|
{
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"CREATE TABLE aliases_new (id INTEGER PRIMARY KEY, owner INTEGER, "
|
|
"display_name BLOB NOT NULL, UNIQUE(owner, display_name), "
|
|
"FOREIGN KEY (owner) REFERENCES peers(id));"));
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"INSERT INTO aliases_new (id, owner, display_name) "
|
|
"SELECT id, owner, display_name "
|
|
"FROM aliases;"));
|
|
queries += RawDatabase::Query(QStringLiteral("DROP TABLE aliases;"));
|
|
queries += RawDatabase::Query(QStringLiteral("ALTER TABLE aliases_new RENAME TO aliases;"));
|
|
}
|
|
|
|
void addForeignKeyToHistory(QVector<RawDatabase::Query>& queries)
|
|
{
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"CREATE TABLE history_new "
|
|
"(id INTEGER PRIMARY KEY, "
|
|
"timestamp INTEGER NOT NULL, "
|
|
"chat_id INTEGER NOT NULL, "
|
|
"sender_alias INTEGER NOT NULL, "
|
|
"message BLOB NOT NULL, "
|
|
"file_id INTEGER, "
|
|
"FOREIGN KEY (file_id) REFERENCES file_transfers(id), "
|
|
"FOREIGN KEY (chat_id) REFERENCES peers(id), "
|
|
"FOREIGN KEY (sender_alias) REFERENCES aliases(id));"));
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"INSERT INTO history_new (id, timestamp, chat_id, sender_alias, message, file_id) "
|
|
"SELECT id, timestamp, chat_id, sender_alias, message, file_id "
|
|
"FROM history;"));
|
|
queries += RawDatabase::Query(QStringLiteral("DROP TABLE history;"));
|
|
queries += RawDatabase::Query(QStringLiteral("ALTER TABLE history_new RENAME TO history;"));
|
|
}
|
|
|
|
void addForeignKeyToFauxOfflinePending(QVector<RawDatabase::Query>& queries)
|
|
{
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"CREATE TABLE new_faux_offline_pending (id INTEGER PRIMARY KEY, "
|
|
"FOREIGN KEY (id) REFERENCES history(id));"));
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"INSERT INTO new_faux_offline_pending (id) "
|
|
"SELECT id "
|
|
"FROM faux_offline_pending;"));
|
|
queries += RawDatabase::Query(QStringLiteral("DROP TABLE faux_offline_pending;"));
|
|
queries += RawDatabase::Query(QStringLiteral("ALTER TABLE new_faux_offline_pending RENAME TO faux_offline_pending;"));
|
|
}
|
|
|
|
void addForeignKeyToBrokenMessages(QVector<RawDatabase::Query>& queries)
|
|
{
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"CREATE TABLE new_broken_messages (id INTEGER PRIMARY KEY, "
|
|
"FOREIGN KEY (id) REFERENCES history(id));"));
|
|
queries += RawDatabase::Query(QStringLiteral(
|
|
"INSERT INTO new_broken_messages (id) "
|
|
"SELECT id "
|
|
"FROM broken_messages;"));
|
|
queries += RawDatabase::Query(QStringLiteral("DROP TABLE broken_messages;"));
|
|
queries += RawDatabase::Query(QStringLiteral("ALTER TABLE new_broken_messages RENAME TO broken_messages;"));
|
|
}
|
|
|
|
bool dbSchema4to5(RawDatabase& db)
|
|
{
|
|
// add foreign key contrains to database tables. sqlite doesn't support advanced alter table commands, so instead we
|
|
// need to copy data to new tables with the foreign key contraints: http://www.sqlitetutorial.net/sqlite-alter-table/
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
addForeignKeyToAlias(upgradeQueries);
|
|
addForeignKeyToHistory(upgradeQueries);
|
|
addForeignKeyToFauxOfflinePending(upgradeQueries);
|
|
addForeignKeyToBrokenMessages(upgradeQueries);
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 5;"));
|
|
auto transactionPass = db.execNow(upgradeQueries);
|
|
if (transactionPass) {
|
|
db.execNow("VACUUM;"); // after copying all the tables and deleting the old ones, our db file is half empty.
|
|
}
|
|
return transactionPass;
|
|
}
|
|
|
|
bool dbSchema5to6(RawDatabase& db)
|
|
{
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
|
|
upgradeQueries += RawDatabase::Query{QString("ALTER TABLE faux_offline_pending "
|
|
"ADD COLUMN required_extensions INTEGER NOT NULL "
|
|
"DEFAULT 0;")};
|
|
|
|
upgradeQueries += RawDatabase::Query{QString("ALTER TABLE broken_messages "
|
|
"ADD COLUMN reason INTEGER NOT NULL "
|
|
"DEFAULT 0;")};
|
|
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 6;"));
|
|
return db.execNow(upgradeQueries);
|
|
}
|
|
|
|
bool dbSchema6to7(RawDatabase& db)
|
|
{
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
|
|
// Cannot add UNIQUE(id, message_type) to history table without creating a new one. Create a new history table
|
|
upgradeQueries += RawDatabase::Query(
|
|
"CREATE TABLE history_new (id INTEGER PRIMARY KEY, message_type CHAR(1) NOT NULL DEFAULT "
|
|
"'T' CHECK (message_type in ('T','F','S')), timestamp INTEGER NOT NULL, chat_id INTEGER "
|
|
"NOT NULL, UNIQUE (id, message_type), FOREIGN KEY (chat_id) REFERENCES peers(id))");
|
|
|
|
// Create new text_messages table. We will split messages out of history and insert them into this new table
|
|
upgradeQueries += RawDatabase::Query(
|
|
"CREATE TABLE text_messages (id INTEGER PRIMARY KEY, message_type CHAR(1) NOT NULL CHECK "
|
|
"(message_type = 'T'), sender_alias INTEGER NOT NULL, message BLOB NOT NULL, FOREIGN KEY "
|
|
"(id, message_type) REFERENCES history_new(id, message_type), FOREIGN KEY (sender_alias) "
|
|
"REFERENCES aliases(id))");
|
|
|
|
// Cannot add a FOREIGN KEY to the file_transfers table without creating a new one. Create a new file_transfers table
|
|
upgradeQueries += RawDatabase::Query(
|
|
"CREATE TABLE file_transfers_new (id INTEGER PRIMARY KEY, message_type CHAR(1) NOT NULL "
|
|
"CHECK (message_type = 'F'), sender_alias INTEGER NOT NULL, file_restart_id BLOB NOT NULL, "
|
|
"file_name BLOB NOT NULL, file_path BLOB NOT NULL, file_hash BLOB NOT NULL, file_size "
|
|
"INTEGER NOT NULL, direction INTEGER NOT NULL, file_state INTEGER NOT NULL, FOREIGN KEY "
|
|
"(id, message_type) REFERENCES history_new(id, message_type), FOREIGN KEY (sender_alias) "
|
|
"REFERENCES aliases(id))");
|
|
|
|
upgradeQueries +=
|
|
RawDatabase::Query("INSERT INTO history_new SELECT id, 'T' AS message_type, timestamp, "
|
|
"chat_id FROM history WHERE history.file_id IS NULL");
|
|
|
|
upgradeQueries +=
|
|
RawDatabase::Query("INSERT INTO text_messages SELECT id, 'T' AS message_type, "
|
|
"sender_alias, message FROM history WHERE history.file_id IS NULL");
|
|
|
|
upgradeQueries +=
|
|
RawDatabase::Query("INSERT INTO history_new SELECT id, 'F' AS message_type, timestamp, "
|
|
"chat_id FROM history WHERE history.file_id IS NOT NULL");
|
|
|
|
upgradeQueries += RawDatabase::Query(
|
|
"INSERT INTO file_transfers_new (id, message_type, sender_alias, file_restart_id, "
|
|
"file_name, file_path, file_hash, file_size, direction, file_state) SELECT history.id, 'F' "
|
|
"as message_type, history.sender_alias, file_transfers.file_restart_id, "
|
|
"file_transfers.file_name, file_transfers.file_path, file_transfers.file_hash, "
|
|
"file_transfers.file_size, file_transfers.direction, file_transfers.file_state FROM "
|
|
"history INNER JOIN file_transfers on history.file_id = file_transfers.id WHERE "
|
|
"history.file_id IS NOT NULL");
|
|
|
|
upgradeQueries += RawDatabase::Query(
|
|
"CREATE TABLE system_messages (id INTEGER PRIMARY KEY, message_type CHAR(1) NOT NULL CHECK "
|
|
"(message_type = 'S'), system_message_type INTEGER NOT NULL, arg1 BLOB, arg2 BLOB, arg3 BLOB, arg4 BLOB, "
|
|
"FOREIGN KEY (id, message_type) REFERENCES history_new(id, message_type))");
|
|
|
|
// faux_offline_pending needs to be re-created to reference the new history table
|
|
upgradeQueries += RawDatabase::Query(
|
|
"CREATE TABLE faux_offline_pending_new (id INTEGER PRIMARY KEY, required_extensions "
|
|
"INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (id) REFERENCES history_new(id))");
|
|
upgradeQueries += RawDatabase::Query("INSERT INTO faux_offline_pending_new SELECT id, "
|
|
"required_extensions FROM faux_offline_pending");
|
|
upgradeQueries += RawDatabase::Query("DROP TABLE faux_offline_pending");
|
|
upgradeQueries +=
|
|
RawDatabase::Query("ALTER TABLE faux_offline_pending_new RENAME TO faux_offline_pending");
|
|
|
|
// broken_messages needs to be re-created to reference the new history table
|
|
upgradeQueries += RawDatabase::Query(
|
|
"CREATE TABLE broken_messages_new (id INTEGER PRIMARY KEY, reason INTEGER NOT NULL DEFAULT "
|
|
"0, FOREIGN KEY (id) REFERENCES history_new(id))");
|
|
upgradeQueries += RawDatabase::Query(
|
|
"INSERT INTO broken_messages_new SELECT id, reason FROM broken_messages");
|
|
upgradeQueries += RawDatabase::Query("DROP TABLE broken_messages");
|
|
upgradeQueries +=
|
|
RawDatabase::Query("ALTER TABLE broken_messages_new RENAME TO broken_messages");
|
|
|
|
// Everything referencing old history should now be gone
|
|
upgradeQueries += RawDatabase::Query("DROP TABLE history");
|
|
upgradeQueries += RawDatabase::Query("ALTER TABLE history_new RENAME TO history");
|
|
|
|
// Drop file transfers late since history depends on it
|
|
upgradeQueries += RawDatabase::Query("DROP TABLE file_transfers");
|
|
upgradeQueries += RawDatabase::Query("ALTER TABLE file_transfers_new RENAME TO file_transfers");
|
|
|
|
upgradeQueries += RawDatabase::Query("CREATE INDEX chat_id_idx on history (chat_id);");
|
|
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 7;"));
|
|
|
|
return db.execNow(upgradeQueries);
|
|
}
|
|
|
|
bool dbSchema7to8(RawDatabase& db)
|
|
{
|
|
// Dummy upgrade. This upgrade does not change the schema, however on
|
|
// version 7 if qtox saw a system message it would assert and crash. This
|
|
// upgrade ensures that old versions of qtox do not try to load the new
|
|
// database
|
|
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 8;"));
|
|
|
|
return db.execNow(upgradeQueries);
|
|
}
|
|
|
|
struct BadEntry {
|
|
BadEntry(int64_t row, QString toxId) :
|
|
row{row},
|
|
toxId{toxId} {}
|
|
RowId row;
|
|
QString toxId;
|
|
};
|
|
|
|
std::vector<BadEntry> getInvalidPeers(RawDatabase& db)
|
|
{
|
|
std::vector<BadEntry> badPeerIds;
|
|
db.execNow(RawDatabase::Query("SELECT id, public_key FROM peers WHERE LENGTH(public_key) != 64", [&](const QVector<QVariant>& row) {
|
|
badPeerIds.emplace_back(BadEntry{row[0].toInt(), row[1].toString()});
|
|
}));
|
|
return badPeerIds;
|
|
}
|
|
|
|
RowId getValidPeerRow(RawDatabase& db, const ToxPk& friendPk)
|
|
{
|
|
bool validPeerExists{false};
|
|
RowId validPeerRow;
|
|
db.execNow(RawDatabase::Query(QStringLiteral("SELECT id FROM peers WHERE public_key='%1';")
|
|
.arg(friendPk.toString()), [&](const QVector<QVariant>& row) {
|
|
validPeerRow = RowId{row[0].toLongLong()};
|
|
validPeerExists = true;
|
|
}));
|
|
if (validPeerExists) {
|
|
return validPeerRow;
|
|
}
|
|
|
|
db.execNow(RawDatabase::Query(("SELECT id FROM peers ORDER BY id DESC LIMIT 1;"), [&](const QVector<QVariant>& row) {
|
|
int64_t maxPeerId = row[0].toInt();
|
|
validPeerRow = RowId{maxPeerId + 1};
|
|
}));
|
|
db.execNow(RawDatabase::Query(QStringLiteral("INSERT INTO peers (id, public_key) VALUES (%1, '%2');").arg(validPeerRow.get()).arg(friendPk.toString())));
|
|
return validPeerRow;
|
|
}
|
|
|
|
struct DuplicateAlias {
|
|
DuplicateAlias(RowId goodAliasRow, std::vector<RowId> badAliasRows) :
|
|
goodAliasRow{goodAliasRow},
|
|
badAliasRows{badAliasRows} {}
|
|
DuplicateAlias() {};
|
|
RowId goodAliasRow{-1};
|
|
std::vector<RowId> badAliasRows;
|
|
};
|
|
|
|
DuplicateAlias getDuplicateAliasRows(RawDatabase& db, RowId goodPeerRow, RowId badPeerRow)
|
|
{
|
|
std::vector<RowId> badAliasRows;
|
|
RowId goodAliasRow;
|
|
bool hasGoodEntry{false};
|
|
db.execNow(RawDatabase::Query(QStringLiteral("SELECT good.id, bad.id FROM aliases good INNER JOIN aliases bad ON good.display_name=bad.display_name WHERE good.owner=%1 AND bad.owner=%2;").arg(goodPeerRow.get()).arg(badPeerRow.get()),
|
|
[&](const QVector<QVariant>& row) {
|
|
hasGoodEntry = true;
|
|
goodAliasRow = RowId{row[0].toInt()};
|
|
badAliasRows.emplace_back(RowId{row[1].toLongLong()});
|
|
}));
|
|
|
|
if (hasGoodEntry) {
|
|
return {goodAliasRow, badAliasRows};
|
|
} else {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
void mergeAndDeleteAlias(QVector<RawDatabase::Query>& upgradeQueries, RowId goodAlias, std::vector<RowId> badAliases)
|
|
{
|
|
for (const auto badAliasId : badAliases) {
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("UPDATE text_messages SET sender_alias = %1 WHERE sender_alias = %2;").arg(goodAlias.get()).arg(badAliasId.get()));
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("UPDATE file_transfers SET sender_alias = %1 WHERE sender_alias = %2;").arg(goodAlias.get()).arg(badAliasId.get()));
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("DELETE FROM aliases WHERE id = %1;").arg(badAliasId.get()));
|
|
}
|
|
}
|
|
|
|
void mergeAndDeletePeer(QVector<RawDatabase::Query>& upgradeQueries, RowId goodPeerId, RowId badPeerId)
|
|
{
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("UPDATE aliases SET owner = %1 WHERE owner = %2").arg(goodPeerId.get()).arg(badPeerId.get()));
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("UPDATE history SET chat_id = %1 WHERE chat_id = %2;").arg(goodPeerId.get()).arg(badPeerId.get()));
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("DELETE FROM peers WHERE id = %1").arg(badPeerId.get()));
|
|
}
|
|
|
|
void mergeDuplicatePeers(QVector<RawDatabase::Query>& upgradeQueries, RawDatabase& db, std::vector<BadEntry> badPeers)
|
|
{
|
|
for (const auto& badPeer : badPeers) {
|
|
const RowId goodPeerId = getValidPeerRow(db, ToxPk{badPeer.toxId.left(64)});
|
|
const auto aliasDuplicates = getDuplicateAliasRows(db, goodPeerId, badPeer.row);
|
|
mergeAndDeleteAlias(upgradeQueries, aliasDuplicates.goodAliasRow, aliasDuplicates.badAliasRows);
|
|
mergeAndDeletePeer(upgradeQueries, goodPeerId, badPeer.row);
|
|
}
|
|
}
|
|
|
|
bool dbSchema8to9(RawDatabase& db)
|
|
{
|
|
// not technically a schema update, but still a database version update based on healing invalid user data
|
|
// we added ourself in the peers table by ToxId isntead of ToxPk. Heal this over-length entry.
|
|
QVector<RawDatabase::Query> upgradeQueries;
|
|
const auto badPeers = getInvalidPeers(db);
|
|
mergeDuplicatePeers(upgradeQueries, db, badPeers);
|
|
upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 9;"));
|
|
return db.execNow(upgradeQueries);
|
|
}
|
|
|
|
/**
|
|
* @brief Upgrade the db schema
|
|
* @note On future alterations of the database all you have to do is bump the SCHEMA_VERSION
|
|
* variable and add another case to the switch statement below. Make sure to fall through on each case.
|
|
*/
|
|
bool dbSchemaUpgrade(std::shared_ptr<RawDatabase>& db)
|
|
{
|
|
// If we're a new dB we can just make a new one and call it a day
|
|
bool success = false;
|
|
const bool newDb = isNewDb(db, success);
|
|
if (!success) {
|
|
qCritical() << "Failed to create current db schema";
|
|
return false;
|
|
}
|
|
|
|
if (newDb) {
|
|
if (!createCurrentSchema(*db)) {
|
|
qCritical() << "Failed to create current db schema";
|
|
return false;
|
|
}
|
|
qDebug() << "Database created at schema version" << SCHEMA_VERSION;
|
|
return true;
|
|
}
|
|
|
|
// Otherwise we have to do upgrades from our current version to the latest version
|
|
|
|
int64_t databaseSchemaVersion;
|
|
|
|
if (!db->execNow(RawDatabase::Query("PRAGMA user_version", [&](const QVector<QVariant>& row) {
|
|
databaseSchemaVersion = row[0].toLongLong();
|
|
}))) {
|
|
qCritical() << "History failed to read user_version";
|
|
return false;
|
|
}
|
|
|
|
if (databaseSchemaVersion > SCHEMA_VERSION) {
|
|
qWarning().nospace() << "Database version (" << databaseSchemaVersion
|
|
<< ") is newer than we currently support (" << SCHEMA_VERSION
|
|
<< "). Please upgrade qTox";
|
|
// We don't know what future versions have done, we have to disable db access until we re-upgrade
|
|
return false;
|
|
} else if (databaseSchemaVersion == SCHEMA_VERSION) {
|
|
// No work to do
|
|
return true;
|
|
}
|
|
|
|
using DbSchemaUpgradeFn = bool (*)(RawDatabase&);
|
|
std::vector<DbSchemaUpgradeFn> upgradeFns = {dbSchema0to1, dbSchema1to2, dbSchema2to3,
|
|
dbSchema3to4, dbSchema4to5, dbSchema5to6,
|
|
dbSchema6to7, dbSchema7to8, dbSchema8to9};
|
|
|
|
assert(databaseSchemaVersion < static_cast<int>(upgradeFns.size()));
|
|
assert(upgradeFns.size() == SCHEMA_VERSION);
|
|
|
|
for (int64_t i = databaseSchemaVersion; i < static_cast<int>(upgradeFns.size()); ++i) {
|
|
auto const newDbVersion = i + 1;
|
|
if (!upgradeFns[i](*db)) {
|
|
qCritical() << "Failed to upgrade db to schema version " << newDbVersion << " aborting";
|
|
return false;
|
|
}
|
|
qDebug() << "Database upgraded incrementally to schema version " << newDbVersion;
|
|
}
|
|
|
|
qInfo() << "Database upgrade finished (databaseSchemaVersion" << databaseSchemaVersion << "->"
|
|
<< SCHEMA_VERSION << ")";
|
|
return true;
|
|
}
|
|
|
|
MessageState getMessageState(bool isPending, bool isBroken)
|
|
{
|
|
assert(!(isPending && isBroken));
|
|
MessageState messageState;
|
|
|
|
if (isPending) {
|
|
messageState = MessageState::pending;
|
|
} else if (isBroken) {
|
|
messageState = MessageState::broken;
|
|
} else {
|
|
messageState = MessageState::complete;
|
|
}
|
|
return messageState;
|
|
}
|
|
|
|
QString generatePeerIdString(ToxPk const& pk)
|
|
{
|
|
return QString("(SELECT id FROM peers WHERE public_key = '%1')").arg(pk.toString());
|
|
|
|
}
|
|
|
|
RawDatabase::Query generateEnsurePkInPeers(ToxPk const& pk)
|
|
{
|
|
return RawDatabase::Query{QStringLiteral("INSERT OR IGNORE INTO peers (public_key) "
|
|
"VALUES ('%1')").arg(pk.toString())};
|
|
}
|
|
|
|
RawDatabase::Query generateUpdateAlias(ToxPk const& pk, QString const& dispName)
|
|
{
|
|
return RawDatabase::Query(
|
|
QString("INSERT OR IGNORE INTO aliases (owner, display_name) VALUES (%1, ?);").arg(generatePeerIdString(pk)),
|
|
{dispName.toUtf8()});
|
|
}
|
|
|
|
RawDatabase::Query generateHistoryTableInsertion(char type, const QDateTime& time, const ToxPk& friendPk)
|
|
{
|
|
return RawDatabase::Query(QString("INSERT INTO history (message_type, timestamp, chat_id) "
|
|
"VALUES ('%1', %2, %3);")
|
|
.arg(type)
|
|
.arg(time.toMSecsSinceEpoch())
|
|
.arg(generatePeerIdString(friendPk)));
|
|
}
|
|
|
|
/**
|
|
* @brief Generate query to insert new message in database
|
|
* @param friendPk Friend publick key to save.
|
|
* @param message Message to save.
|
|
* @param sender Sender to save.
|
|
* @param time Time of message sending.
|
|
* @param isDelivered True if message was already delivered.
|
|
* @param dispName Name, which should be displayed.
|
|
* @param insertIdCallback Function, called after query execution.
|
|
*/
|
|
QVector<RawDatabase::Query>
|
|
generateNewTextMessageQueries(const ToxPk& friendPk, const QString& message, const ToxPk& sender,
|
|
const QDateTime& time, bool isDelivered, ExtensionSet extensionSet,
|
|
QString dispName, std::function<void(RowId)> insertIdCallback)
|
|
{
|
|
QVector<RawDatabase::Query> queries;
|
|
|
|
queries += generateEnsurePkInPeers(friendPk);
|
|
queries += generateEnsurePkInPeers(sender);
|
|
queries += generateUpdateAlias(sender, dispName);
|
|
queries += generateHistoryTableInsertion('T', time, friendPk);
|
|
|
|
queries += RawDatabase::Query(
|
|
QString("INSERT INTO text_messages (id, message_type, sender_alias, message) "
|
|
"VALUES ( "
|
|
" last_insert_rowid(), "
|
|
" 'T', "
|
|
" (SELECT id FROM aliases WHERE owner=%1 and display_name=?), "
|
|
" ?"
|
|
");")
|
|
.arg(generatePeerIdString(sender)),
|
|
{dispName.toUtf8(), message.toUtf8()}, insertIdCallback);
|
|
|
|
if (!isDelivered) {
|
|
queries += RawDatabase::Query{
|
|
QString("INSERT INTO faux_offline_pending (id, required_extensions) VALUES ("
|
|
" last_insert_rowid(), %1"
|
|
");")
|
|
.arg(extensionSet.to_ulong())};
|
|
}
|
|
|
|
return queries;
|
|
}
|
|
|
|
QVector<RawDatabase::Query> generateNewSystemMessageQueries(const ToxPk& friendPk,
|
|
const SystemMessage& systemMessage)
|
|
{
|
|
QVector<RawDatabase::Query> queries;
|
|
|
|
queries += generateEnsurePkInPeers(friendPk);
|
|
queries += generateHistoryTableInsertion('S', systemMessage.timestamp, friendPk);
|
|
|
|
QVector<QByteArray> blobs;
|
|
std::transform(systemMessage.args.begin(), systemMessage.args.end(), std::back_inserter(blobs),
|
|
[](const QString& s) { return s.toUtf8(); });
|
|
|
|
queries += RawDatabase::Query(QString("INSERT INTO system_messages (id, message_type, "
|
|
"system_message_type, arg1, arg2, arg3, arg4)"
|
|
"VALUES (last_insert_rowid(), 'S', %1, ?, ?, ?, ?)")
|
|
.arg(static_cast<int>(systemMessage.messageType)),
|
|
blobs);
|
|
|
|
return queries;
|
|
}
|
|
} // namespace
|
|
|
|
/**
|
|
* @class History
|
|
* @brief Interacts with the profile database to save the chat history.
|
|
*
|
|
* @var QHash<QString, int64_t> History::peers
|
|
* @brief Maps friend public keys to unique IDs by index.
|
|
* Caches mappings to speed up message saving.
|
|
*/
|
|
|
|
FileDbInsertionData::FileDbInsertionData()
|
|
{
|
|
static int id = qRegisterMetaType<FileDbInsertionData>();
|
|
(void)id;
|
|
}
|
|
|
|
/**
|
|
* @brief Prepares the database to work with the history.
|
|
* @param db This database will be prepared for use with the history.
|
|
*/
|
|
History::History(std::shared_ptr<RawDatabase> db_)
|
|
: db(db_)
|
|
{
|
|
if (!isValid()) {
|
|
qWarning() << "Database not open, init failed";
|
|
return;
|
|
}
|
|
|
|
// foreign key support is not enabled by default, so needs to be enabled on every connection
|
|
// support was added in sqlite 3.6.19, which is qTox's minimum supported version
|
|
db->execNow(
|
|
"PRAGMA foreign_keys = ON;");
|
|
|
|
const auto upgradeSucceeded = dbSchemaUpgrade(db);
|
|
|
|
// dbSchemaUpgrade may have put us in an invalid state
|
|
if (!upgradeSucceeded) {
|
|
db.reset();
|
|
return;
|
|
}
|
|
|
|
connect(this, &History::fileInserted, this, &History::onFileInserted);
|
|
}
|
|
|
|
History::~History()
|
|
{
|
|
if (!isValid()) {
|
|
return;
|
|
}
|
|
|
|
// We could have execLater requests pending with a lambda attached,
|
|
// so clear the pending transactions first
|
|
db->sync();
|
|
}
|
|
|
|
/**
|
|
* @brief Checks if the database was opened successfully
|
|
* @return True if database if opened, false otherwise.
|
|
*/
|
|
bool History::isValid()
|
|
{
|
|
return db && db->isOpen();
|
|
}
|
|
|
|
/**
|
|
* @brief Checks if a friend has chat history
|
|
* @param friendPk
|
|
* @return True if has, false otherwise.
|
|
*/
|
|
bool History::historyExists(const ToxPk& friendPk)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return false;
|
|
}
|
|
|
|
return !getMessagesForFriend(friendPk, 0, 1).empty();
|
|
}
|
|
|
|
/**
|
|
* @brief Erases all the chat history from the database.
|
|
*/
|
|
void History::eraseHistory()
|
|
{
|
|
if (!isValid()) {
|
|
return;
|
|
}
|
|
|
|
db->execNow("DELETE FROM faux_offline_pending;"
|
|
"DELETE FROM broken_messages;"
|
|
"DELETE FROM text_messages;"
|
|
"DELETE FROM file_transfers;"
|
|
"DELETE FROM system_messages;"
|
|
"DELETE FROM history;"
|
|
"DELETE FROM aliases;"
|
|
"DELETE FROM peers;"
|
|
"VACUUM;");
|
|
}
|
|
|
|
/**
|
|
* @brief Erases the chat history with one friend.
|
|
* @param friendPk Friend public key to erase.
|
|
*/
|
|
void History::removeFriendHistory(const ToxPk& friendPk)
|
|
{
|
|
if (!isValid()) {
|
|
return;
|
|
}
|
|
|
|
QString queryText = 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 broken_messages "
|
|
"WHERE broken_messages.id IN ( "
|
|
" SELECT broken_messages.id FROM broken_messages "
|
|
" LEFT JOIN history ON broken_messages.id = history.id "
|
|
" WHERE chat_id=%1 "
|
|
"); "
|
|
"DELETE FROM text_messages "
|
|
"WHERE id IN ("
|
|
" SELECT id from history "
|
|
" WHERE message_type = 'T' AND chat_id=%1);"
|
|
"DELETE FROM file_transfers "
|
|
"WHERE id IN ( "
|
|
" SELECT id from history "
|
|
" WHERE message_type = 'F' AND chat_id=%1);"
|
|
"DELETE FROM system_messages "
|
|
"WHERE id IN ( "
|
|
" SELECT id from history "
|
|
" WHERE message_type = 'S' AND chat_id=%1);"
|
|
"DELETE FROM history WHERE chat_id=%1; "
|
|
"DELETE FROM aliases WHERE owner=%1; "
|
|
"DELETE FROM peers WHERE id=%1; "
|
|
"VACUUM;")
|
|
.arg(generatePeerIdString(friendPk));
|
|
|
|
if (!db->execNow(queryText)) {
|
|
qWarning() << "Failed to remove friend's history";
|
|
}
|
|
}
|
|
|
|
void History::onFileInserted(RowId dbId, QString fileId)
|
|
{
|
|
auto& fileInfo = fileInfos[fileId];
|
|
if (fileInfo.finished) {
|
|
db->execLater(
|
|
generateFileFinished(dbId, fileInfo.success, fileInfo.filePath, fileInfo.fileHash));
|
|
fileInfos.remove(fileId);
|
|
} else {
|
|
fileInfo.finished = false;
|
|
fileInfo.fileId = dbId;
|
|
}
|
|
}
|
|
|
|
QVector<RawDatabase::Query>
|
|
History::generateNewFileTransferQueries(const ToxPk& friendPk, const ToxPk& sender,
|
|
const QDateTime& time, const QString& dispName,
|
|
const FileDbInsertionData& insertionData)
|
|
{
|
|
QVector<RawDatabase::Query> queries;
|
|
|
|
queries += generateEnsurePkInPeers(friendPk);
|
|
queries += generateEnsurePkInPeers(sender);
|
|
queries += generateUpdateAlias(sender, dispName);
|
|
queries += generateHistoryTableInsertion('F', time, friendPk);
|
|
|
|
std::weak_ptr<History> weakThis = shared_from_this();
|
|
auto fileId = insertionData.fileId;
|
|
|
|
queries +=
|
|
RawDatabase::Query(QString(
|
|
"INSERT INTO file_transfers "
|
|
" (id, message_type, sender_alias, "
|
|
" file_restart_id, file_name, file_path, "
|
|
" file_hash, file_size, direction, file_state) "
|
|
"VALUES ( "
|
|
" last_insert_rowid(), "
|
|
" 'F', "
|
|
" (SELECT id FROM aliases WHERE owner=%1 AND display_name=?), "
|
|
" ?, "
|
|
" ?, "
|
|
" ?, "
|
|
" ?, "
|
|
" %2, "
|
|
" %3, "
|
|
" %4 "
|
|
");")
|
|
.arg(generatePeerIdString(sender))
|
|
.arg(insertionData.size)
|
|
.arg(insertionData.direction)
|
|
.arg(ToxFile::CANCELED),
|
|
{dispName.toUtf8(), insertionData.fileId.toUtf8(),
|
|
insertionData.fileName.toUtf8(), insertionData.filePath.toUtf8(),
|
|
QByteArray()},
|
|
[weakThis, fileId](RowId id) {
|
|
auto pThis = weakThis.lock();
|
|
if (pThis)
|
|
emit pThis->fileInserted(id, fileId);
|
|
});
|
|
|
|
return queries;
|
|
}
|
|
|
|
RawDatabase::Query History::generateFileFinished(RowId id, bool success, const QString& filePath,
|
|
const QByteArray& fileHash)
|
|
{
|
|
auto file_state = success ? ToxFile::FINISHED : ToxFile::CANCELED;
|
|
if (filePath.length()) {
|
|
return RawDatabase::Query(QStringLiteral("UPDATE file_transfers "
|
|
"SET file_state = %1, file_path = ?, file_hash = ?"
|
|
"WHERE id = %2")
|
|
.arg(file_state)
|
|
.arg(id.get()),
|
|
{filePath.toUtf8(), fileHash});
|
|
} else {
|
|
return RawDatabase::Query(QStringLiteral("UPDATE file_transfers "
|
|
"SET finished = %1 "
|
|
"WHERE id = %2")
|
|
.arg(file_state)
|
|
.arg(id.get()));
|
|
}
|
|
}
|
|
|
|
void History::addNewFileMessage(const ToxPk& friendPk, const QString& fileId,
|
|
const QString& fileName, const QString& filePath, int64_t size,
|
|
const ToxPk& sender, const QDateTime& time, QString const& dispName)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return;
|
|
}
|
|
|
|
// This is an incredibly far from an optimal way of implementing this,
|
|
// but given the frequency that people are going to be initiating a file
|
|
// transfer we can probably live with it.
|
|
|
|
// Since both inserting an alias for a user and inserting a file transfer
|
|
// will generate new ids, there is no good way to inject both new ids into the
|
|
// history query without refactoring our RawDatabase::Query and processor loops.
|
|
|
|
// What we will do instead is chain callbacks to try to get reasonable behavior.
|
|
// We can call the generateNewMessageQueries() fn to insert a message with an empty
|
|
// message in it, and get the id with the callbck. Once we have the id we can ammend
|
|
// the data to have our newly inserted file_id as well
|
|
|
|
ToxFile::FileDirection direction;
|
|
if (sender == friendPk) {
|
|
direction = ToxFile::RECEIVING;
|
|
} else {
|
|
direction = ToxFile::SENDING;
|
|
}
|
|
|
|
std::weak_ptr<History> weakThis = shared_from_this();
|
|
FileDbInsertionData insertionData;
|
|
insertionData.friendPk = friendPk;
|
|
insertionData.fileId = fileId;
|
|
insertionData.fileName = fileName;
|
|
insertionData.filePath = filePath;
|
|
insertionData.size = size;
|
|
insertionData.direction = direction;
|
|
|
|
auto queries = generateNewFileTransferQueries(friendPk, sender, time, dispName, insertionData);
|
|
|
|
db->execLater(queries);
|
|
}
|
|
|
|
void History::addNewSystemMessage(const ToxPk& friendPk, const SystemMessage& systemMessage)
|
|
{
|
|
if (historyAccessBlocked())
|
|
return;
|
|
|
|
const auto queries = generateNewSystemMessageQueries(friendPk, systemMessage);
|
|
|
|
db->execLater(queries);
|
|
}
|
|
|
|
/**
|
|
* @brief Saves a chat message in the database.
|
|
* @param friendPk Friend publick key to save.
|
|
* @param message Message to save.
|
|
* @param sender Sender to save.
|
|
* @param time Time of message sending.
|
|
* @param isDelivered True if message was already delivered.
|
|
* @param dispName Name, which should be displayed.
|
|
* @param insertIdCallback Function, called after query execution.
|
|
*/
|
|
void History::addNewMessage(const ToxPk& friendPk, const QString& message, const ToxPk& sender,
|
|
const QDateTime& time, bool isDelivered, ExtensionSet extensionSet,
|
|
QString dispName, const std::function<void(RowId)>& insertIdCallback)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return;
|
|
}
|
|
|
|
db->execLater(generateNewTextMessageQueries(friendPk, message, sender, time, isDelivered,
|
|
extensionSet, dispName, insertIdCallback));
|
|
}
|
|
|
|
void History::setFileFinished(const QString& fileId, bool success, const QString& filePath,
|
|
const QByteArray& fileHash)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return;
|
|
}
|
|
|
|
auto& fileInfo = fileInfos[fileId];
|
|
if (fileInfo.fileId.get() == -1) {
|
|
fileInfo.finished = true;
|
|
fileInfo.success = success;
|
|
fileInfo.filePath = filePath;
|
|
fileInfo.fileHash = fileHash;
|
|
} else {
|
|
db->execLater(generateFileFinished(fileInfo.fileId, success, filePath, fileHash));
|
|
}
|
|
|
|
fileInfos.remove(fileId);
|
|
}
|
|
|
|
size_t History::getNumMessagesForFriend(const ToxPk& friendPk)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return 0;
|
|
}
|
|
|
|
return getNumMessagesForFriendBeforeDate(friendPk, QDateTime());
|
|
}
|
|
|
|
size_t History::getNumMessagesForFriendBeforeDate(const ToxPk& friendPk, const QDateTime& date)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return 0;
|
|
}
|
|
|
|
QString queryText = QString("SELECT COUNT(history.id) "
|
|
"FROM history "
|
|
"JOIN peers chat ON chat_id = chat.id "
|
|
"WHERE chat.public_key='%1'")
|
|
.arg(friendPk.toString());
|
|
|
|
if (date.isNull()) {
|
|
queryText += ";";
|
|
} else {
|
|
queryText += QString(" AND timestamp < %1;").arg(date.toMSecsSinceEpoch());
|
|
}
|
|
|
|
size_t numMessages = 0;
|
|
auto rowCallback = [&numMessages](const QVector<QVariant>& row) {
|
|
numMessages = row[0].toLongLong();
|
|
};
|
|
|
|
db->execNow({queryText, rowCallback});
|
|
|
|
return numMessages;
|
|
}
|
|
|
|
QList<History::HistMessage> History::getMessagesForFriend(const ToxPk& friendPk, size_t firstIdx,
|
|
size_t lastIdx)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return {};
|
|
}
|
|
|
|
QList<HistMessage> messages;
|
|
|
|
// Don't forget to update the rowCallback if you change the selected columns!
|
|
QString queryText =
|
|
QString(
|
|
"SELECT history.id, history.message_type, history.timestamp, faux_offline_pending.id, "
|
|
" faux_offline_pending.required_extensions, broken_messages.id, text_messages.message, "
|
|
" file_restart_id, file_name, file_path, file_size, file_transfers.direction, "
|
|
" file_state, peers.public_key as sender_key, aliases.display_name, "
|
|
" system_messages.system_message_type, system_messages.arg1, system_messages.arg2, "
|
|
" system_messages.arg3, system_messages.arg4 "
|
|
"FROM history "
|
|
"LEFT JOIN text_messages ON history.id = text_messages.id "
|
|
"LEFT JOIN file_transfers ON history.id = file_transfers.id "
|
|
"LEFT JOIN system_messages ON system_messages.id == history.id "
|
|
"LEFT JOIN aliases ON text_messages.sender_alias = aliases.id OR "
|
|
"file_transfers.sender_alias = aliases.id "
|
|
"LEFT JOIN peers ON aliases.owner = peers.id "
|
|
"LEFT JOIN faux_offline_pending ON faux_offline_pending.id = history.id "
|
|
"LEFT JOIN broken_messages ON broken_messages.id = history.id "
|
|
"WHERE history.chat_id = %1 "
|
|
"LIMIT %2 OFFSET %3;")
|
|
.arg(generatePeerIdString(friendPk))
|
|
.arg(lastIdx - firstIdx)
|
|
.arg(firstIdx);
|
|
|
|
auto rowCallback = [&friendPk, &messages](const QVector<QVariant>& row) {
|
|
// If the select statement is changed please update these constants
|
|
constexpr auto messageOffset = 6;
|
|
constexpr auto fileOffset = 7;
|
|
constexpr auto senderOffset = 13;
|
|
constexpr auto systemOffset = 15;
|
|
|
|
auto it = row.begin();
|
|
|
|
const auto id = RowId{(*it++).toLongLong()};
|
|
const auto messageType = (*it++).toString();
|
|
const auto timestamp = QDateTime::fromMSecsSinceEpoch((*it++).toLongLong());
|
|
const auto isPending = !(*it++).isNull();
|
|
// If NULL this should just reutrn 0 which is an empty extension set, good enough for now
|
|
const auto requiredExtensions = ExtensionSet((*it++).toLongLong());
|
|
const auto isBroken = !(*it++).isNull();
|
|
const auto messageState = getMessageState(isPending, isBroken);
|
|
|
|
// Intentionally arrange query so message types are at the end so we don't have to think
|
|
// about the iterator jumping around after handling the different types.
|
|
assert(messageType.size() == 1);
|
|
switch (messageType[0].toLatin1()) {
|
|
case 'T': {
|
|
it = std::next(row.begin(), messageOffset);
|
|
assert(!it->isNull());
|
|
const auto messageContent = (*it++).toString();
|
|
it = std::next(row.begin(), senderOffset);
|
|
const auto senderKey = (*it++).toString();
|
|
const auto senderName = QString::fromUtf8((*it++).toByteArray().replace('\0', ""));
|
|
messages += HistMessage(id, messageState, requiredExtensions, timestamp,
|
|
friendPk.toString(), senderName, senderKey, messageContent);
|
|
break;
|
|
}
|
|
case 'F': {
|
|
it = std::next(row.begin(), fileOffset);
|
|
assert(!it->isNull());
|
|
const auto fileKind = TOX_FILE_KIND_DATA;
|
|
const auto resumeFileId = (*it++).toString().toUtf8();
|
|
const auto fileName = (*it++).toString();
|
|
const auto filePath = (*it++).toString();
|
|
const auto filesize = (*it++).toLongLong();
|
|
const auto direction = static_cast<ToxFile::FileDirection>((*it++).toLongLong());
|
|
const auto status = static_cast<ToxFile::FileStatus>((*it++).toLongLong());
|
|
|
|
ToxFile file(0, 0, fileName, filePath, filesize, direction);
|
|
file.fileKind = fileKind;
|
|
file.resumeFileId = resumeFileId;
|
|
file.status = status;
|
|
|
|
it = std::next(row.begin(), senderOffset);
|
|
const auto senderKey = (*it++).toString();
|
|
const auto senderName = QString::fromUtf8((*it++).toByteArray().replace('\0', ""));
|
|
messages += HistMessage(id, messageState, timestamp, friendPk.toString(), senderName,
|
|
senderKey, file);
|
|
break;
|
|
}
|
|
default:
|
|
case 'S':
|
|
it = std::next(row.begin(), systemOffset);
|
|
assert(!it->isNull());
|
|
SystemMessage systemMessage;
|
|
systemMessage.messageType = static_cast<SystemMessageType>((*it++).toLongLong());
|
|
|
|
auto argEnd = std::next(it, systemMessage.args.size());
|
|
std::transform(it, argEnd, systemMessage.args.begin(), [](const QVariant& arg) {
|
|
return QString::fromUtf8(arg.toByteArray().replace('\0', ""));
|
|
});
|
|
it = argEnd;
|
|
|
|
messages += HistMessage(id, timestamp, friendPk.toString(), systemMessage);
|
|
break;
|
|
}
|
|
};
|
|
|
|
db->execNow({queryText, rowCallback});
|
|
|
|
return messages;
|
|
}
|
|
|
|
QList<History::HistMessage> History::getUndeliveredMessagesForFriend(const ToxPk& friendPk)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return {};
|
|
}
|
|
|
|
auto queryText =
|
|
QString(
|
|
"SELECT history.id, history.timestamp, faux_offline_pending.id, "
|
|
" faux_offline_pending.required_extensions, broken_messages.id, text_messages.message, "
|
|
" peers.public_key as sender_key, aliases.display_name "
|
|
"FROM history "
|
|
"JOIN text_messages ON history.id = text_messages.id "
|
|
"JOIN aliases ON text_messages.sender_alias = aliases.id "
|
|
"JOIN peers ON aliases.owner = peers.id "
|
|
"JOIN faux_offline_pending ON faux_offline_pending.id = history.id "
|
|
"LEFT JOIN broken_messages ON broken_messages.id = history.id "
|
|
"WHERE history.chat_id = %1 AND history.message_type = 'T';")
|
|
.arg(generatePeerIdString(friendPk));
|
|
|
|
QList<History::HistMessage> ret;
|
|
auto rowCallback = [&friendPk, &ret](const QVector<QVariant>& row) {
|
|
auto it = row.begin();
|
|
// dispName and message could have null bytes, QString::fromUtf8
|
|
// truncates on null bytes so we strip them
|
|
auto id = RowId{(*it++).toLongLong()};
|
|
auto timestamp = QDateTime::fromMSecsSinceEpoch((*it++).toLongLong());
|
|
auto isPending = !(*it++).isNull();
|
|
auto extensionSet = ExtensionSet((*it++).toLongLong());
|
|
auto isBroken = !(*it++).isNull();
|
|
auto messageContent = (*it++).toString();
|
|
auto senderKey = (*it++).toString();
|
|
auto displayName = QString::fromUtf8((*it++).toByteArray().replace('\0', ""));
|
|
|
|
MessageState messageState = getMessageState(isPending, isBroken);
|
|
|
|
ret += {id, messageState, extensionSet, timestamp, friendPk.toString(),
|
|
displayName, senderKey, messageContent};
|
|
};
|
|
|
|
db->execNow({queryText, rowCallback});
|
|
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* @brief Search phrase in chat messages
|
|
* @param friendPk Friend public key
|
|
* @param from a date message where need to start a search
|
|
* @param phrase what need to find
|
|
* @param parameter for search
|
|
* @return date of the message where the phrase was found
|
|
*/
|
|
QDateTime History::getDateWhereFindPhrase(const ToxPk& friendPk, const QDateTime& from,
|
|
QString phrase, const ParameterSearch& parameter)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return QDateTime();
|
|
}
|
|
|
|
QDateTime result;
|
|
auto rowCallback = [&result](const QVector<QVariant>& row) {
|
|
result = QDateTime::fromMSecsSinceEpoch(row[0].toLongLong());
|
|
};
|
|
|
|
phrase.replace("'", "''");
|
|
|
|
QString message;
|
|
|
|
switch (parameter.filter) {
|
|
case FilterSearch::Register:
|
|
message = QStringLiteral("text_messages.message LIKE '%%1%'").arg(phrase);
|
|
break;
|
|
case FilterSearch::WordsOnly:
|
|
message = QStringLiteral("text_messages.message REGEXP '%1'")
|
|
.arg(SearchExtraFunctions::generateFilterWordsOnly(phrase).toLower());
|
|
break;
|
|
case FilterSearch::RegisterAndWordsOnly:
|
|
message = QStringLiteral("REGEXPSENSITIVE(text_messages.message, '%1')")
|
|
.arg(SearchExtraFunctions::generateFilterWordsOnly(phrase));
|
|
break;
|
|
case FilterSearch::Regular:
|
|
message = QStringLiteral("text_messages.message REGEXP '%1'").arg(phrase);
|
|
break;
|
|
case FilterSearch::RegisterAndRegular:
|
|
message = QStringLiteral("REGEXPSENSITIVE(text_messages.message '%1')").arg(phrase);
|
|
break;
|
|
default:
|
|
message = QStringLiteral("LOWER(text_messages.message) LIKE '%%1%'").arg(phrase.toLower());
|
|
break;
|
|
}
|
|
|
|
QDateTime date = from;
|
|
|
|
if (!date.isValid()) {
|
|
date = QDateTime::currentDateTime();
|
|
}
|
|
|
|
if (parameter.period == PeriodSearch::AfterDate || parameter.period == PeriodSearch::BeforeDate) {
|
|
|
|
#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0))
|
|
date = parameter.date.startOfDay();
|
|
#else
|
|
date = QDateTime(parameter.date);
|
|
#endif
|
|
}
|
|
|
|
QString period;
|
|
switch (parameter.period) {
|
|
case PeriodSearch::WithTheFirst:
|
|
period = QStringLiteral("ORDER BY timestamp ASC LIMIT 1;");
|
|
break;
|
|
case PeriodSearch::AfterDate:
|
|
period = QStringLiteral("AND timestamp > '%1' ORDER BY timestamp ASC LIMIT 1;")
|
|
.arg(date.toMSecsSinceEpoch());
|
|
break;
|
|
case PeriodSearch::BeforeDate:
|
|
period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;")
|
|
.arg(date.toMSecsSinceEpoch());
|
|
break;
|
|
default:
|
|
period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;")
|
|
.arg(date.toMSecsSinceEpoch());
|
|
break;
|
|
}
|
|
|
|
QString queryText =
|
|
QStringLiteral("SELECT timestamp "
|
|
"FROM history "
|
|
"JOIN peers chat ON chat_id = chat.id "
|
|
"JOIN text_messages ON history.id = text_messages.id "
|
|
"WHERE chat.public_key='%1' "
|
|
"AND %2 "
|
|
"%3")
|
|
.arg(friendPk.toString())
|
|
.arg(message)
|
|
.arg(period);
|
|
|
|
db->execNow({queryText, rowCallback});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @brief Gets date boundaries in conversation with friendPk. History doesn't model conversation indexes,
|
|
* but we can count messages between us and friendPk to effectively give us an index. This function
|
|
* returns how many messages have happened between us <-> friendPk each time the date changes
|
|
* @param[in] friendPk ToxPk of conversation to retrieve
|
|
* @param[in] from Start date to look from
|
|
* @param[in] maxNum Maximum number of date boundaries to retrieve
|
|
* @note This API may seem a little strange, why not use QDate from and QDate to? The intent is to
|
|
* have an API that can be used to get the first item after a date (for search) and to get a list
|
|
* of date changes (for loadHistory). We could write two separate queries but the query is fairly
|
|
* intricate compared to our other ones so reducing duplication of it is preferable.
|
|
*/
|
|
QList<History::DateIdx> History::getNumMessagesForFriendBeforeDateBoundaries(const ToxPk& friendPk,
|
|
const QDate& from,
|
|
size_t maxNum)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return {};
|
|
}
|
|
|
|
auto friendPkString = friendPk.toString();
|
|
|
|
// No guarantee that this is the most efficient way to do this...
|
|
// We want to count messages that happened for a friend before a
|
|
// certain date. We do this by re-joining our table a second time
|
|
// but this time with the only filter being that our id is less than
|
|
// the ID of the corresponding row in the table that is grouped by day
|
|
auto countMessagesForFriend =
|
|
QString("SELECT COUNT(*) - 1 " // Count - 1 corresponds to 0 indexed message id for friend
|
|
"FROM history countHistory " // Import unfiltered table as countHistory
|
|
"JOIN peers chat ON chat_id = chat.id " // link chat_id to chat.id
|
|
"WHERE chat.public_key = '%1'" // filter this conversation
|
|
"AND countHistory.id <= history.id") // and filter that our unfiltered table history id only has elements up to history.id
|
|
.arg(friendPkString);
|
|
|
|
auto limitString = (maxNum) ? QString("LIMIT %1").arg(maxNum) : QString("");
|
|
|
|
auto queryString = QString("SELECT (%1), (timestamp / 1000 / 60 / 60 / 24) AS day "
|
|
"FROM history "
|
|
"JOIN peers chat ON chat_id = chat.id "
|
|
"WHERE chat.public_key = '%2' "
|
|
"AND timestamp >= %3 "
|
|
"GROUP by day "
|
|
"%4;")
|
|
.arg(countMessagesForFriend)
|
|
.arg(friendPkString)
|
|
#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0))
|
|
.arg(QDateTime(from.startOfDay()).toMSecsSinceEpoch())
|
|
#else
|
|
.arg(QDateTime(from).toMSecsSinceEpoch())
|
|
#endif
|
|
.arg(limitString);
|
|
|
|
QList<DateIdx> dateIdxs;
|
|
auto rowCallback = [&dateIdxs](const QVector<QVariant>& row) {
|
|
DateIdx dateIdx;
|
|
dateIdx.numMessagesIn = row[0].toLongLong();
|
|
dateIdx.date =
|
|
QDateTime::fromMSecsSinceEpoch(row[1].toLongLong() * 24 * 60 * 60 * 1000).date();
|
|
dateIdxs.append(dateIdx);
|
|
};
|
|
|
|
db->execNow({queryString, rowCallback});
|
|
|
|
return dateIdxs;
|
|
}
|
|
|
|
/**
|
|
* @brief Marks a message as delivered.
|
|
* Removing message from the faux-offline pending messages list.
|
|
*
|
|
* @param id Message ID.
|
|
*/
|
|
void History::markAsDelivered(RowId messageId)
|
|
{
|
|
if (historyAccessBlocked()) {
|
|
return;
|
|
}
|
|
|
|
db->execLater(QString("DELETE FROM faux_offline_pending WHERE id=%1;").arg(messageId.get()));
|
|
}
|
|
|
|
/**
|
|
* @brief Determines if history access should be blocked
|
|
* @return True if history should not be accessed
|
|
*/
|
|
bool History::historyAccessBlocked()
|
|
{
|
|
if (!Settings::getInstance().getEnableLogging()) {
|
|
assert(false);
|
|
qCritical() << "Blocked history access while history is disabled";
|
|
return true;
|
|
}
|
|
|
|
if (!isValid()) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void History::markAsBroken(RowId messageId, BrokenMessageReason reason)
|
|
{
|
|
if (!isValid()) {
|
|
return;
|
|
}
|
|
|
|
QVector<RawDatabase::Query> queries;
|
|
queries += RawDatabase::Query(QString("DELETE FROM faux_offline_pending WHERE id=%1;").arg(messageId.get()));
|
|
queries += RawDatabase::Query(QString("INSERT INTO broken_messages (id, reason) "
|
|
"VALUES (%1, %2);")
|
|
.arg(messageId.get())
|
|
.arg(static_cast<int64_t>(reason)));
|
|
|
|
db->execLater(queries);
|
|
}
|