1 /*
2 Copyright © 2015-2019 by The qTox Project Contributors
3
4 This file is part of qTox, a Qt-based graphical interface for Tox.
5
6 qTox is libre software: you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation, either version 3 of the License, or
9 (at your option) any later version.
10
11 qTox is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with qTox. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20 #include <QDebug>
21 #include <cassert>
22
23 #include "history.h"
24 #include "profile.h"
25 #include "settings.h"
26 #include "db/rawdatabase.h"
27
28 namespace {
29 static constexpr int SCHEMA_VERSION = 4;
30
createCurrentSchema(RawDatabase & db)31 bool createCurrentSchema(RawDatabase& db)
32 {
33 QVector<RawDatabase::Query> queries;
34 queries += RawDatabase::Query(QStringLiteral(
35 "CREATE TABLE peers (id INTEGER PRIMARY KEY, "
36 "public_key TEXT NOT NULL UNIQUE);"
37 "CREATE TABLE aliases (id INTEGER PRIMARY KEY, "
38 "owner INTEGER, "
39 "display_name BLOB NOT NULL, "
40 "UNIQUE(owner, display_name));"
41 "CREATE TABLE history "
42 "(id INTEGER PRIMARY KEY, "
43 "timestamp INTEGER NOT NULL, "
44 "chat_id INTEGER NOT NULL, "
45 "sender_alias INTEGER NOT NULL, "
46 // even though technically a message can be null for file transfer, we've opted
47 // to just insert an empty string when there's no content, this moderately simplifies
48 // implementating to leakon as currently our database doesn't have support for optional
49 // fields. We would either have to insert "?" or "null" based on if message exists and then
50 // ensure that our blob vector always has the right number of fields. Better to just
51 // leave this as NOT NULL for now.
52 "message BLOB NOT NULL, "
53 "file_id INTEGER);"
54 "CREATE TABLE file_transfers "
55 "(id INTEGER PRIMARY KEY, "
56 "chat_id INTEGER NOT NULL, "
57 "file_restart_id BLOB NOT NULL, "
58 "file_name BLOB NOT NULL, "
59 "file_path BLOB NOT NULL, "
60 "file_hash BLOB NOT NULL, "
61 "file_size INTEGER NOT NULL, "
62 "direction INTEGER NOT NULL, "
63 "file_state INTEGER NOT NULL);"
64 "CREATE TABLE faux_offline_pending (id INTEGER PRIMARY KEY);"
65 "CREATE TABLE broken_messages (id INTEGER PRIMARY KEY);"));
66 // sqlite doesn't support including the index as part of the CREATE TABLE statement, so add a second query
67 queries += RawDatabase::Query(
68 "CREATE INDEX chat_id_idx on history (chat_id);");
69 queries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = %1;").arg(SCHEMA_VERSION));
70 return db.execNow(queries);
71 }
72
isNewDb(std::shared_ptr<RawDatabase> & db,bool & success)73 bool isNewDb(std::shared_ptr<RawDatabase>& db, bool& success)
74 {
75 bool newDb;
76 if (!db->execNow(RawDatabase::Query("SELECT COUNT(*) FROM sqlite_master;",
77 [&](const QVector<QVariant>& row) {
78 newDb = row[0].toLongLong() == 0;
79 }))) {
80 db.reset();
81 success = false;
82 return false;
83 }
84 success = true;
85 return newDb;
86 }
87
dbSchema0to1(RawDatabase & db)88 bool dbSchema0to1(RawDatabase& db)
89 {
90 QVector<RawDatabase::Query> queries;
91 queries +=
92 RawDatabase::Query(QStringLiteral(
93 "CREATE TABLE file_transfers "
94 "(id INTEGER PRIMARY KEY, "
95 "chat_id INTEGER NOT NULL, "
96 "file_restart_id BLOB NOT NULL, "
97 "file_name BLOB NOT NULL, "
98 "file_path BLOB NOT NULL, "
99 "file_hash BLOB NOT NULL, "
100 "file_size INTEGER NOT NULL, "
101 "direction INTEGER NOT NULL, "
102 "file_state INTEGER NOT NULL);"));
103 queries +=
104 RawDatabase::Query(QStringLiteral("ALTER TABLE history ADD file_id INTEGER;"));
105 queries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 1;"));
106 return db.execNow(queries);
107 }
108
dbSchema1to2(RawDatabase & db)109 bool dbSchema1to2(RawDatabase& db)
110 {
111 // Any faux_offline_pending message, in a chat that has newer delivered
112 // message is decided to be broken. It must be moved from
113 // faux_offline_pending to broken_messages
114
115 // the last non-pending message in each chat
116 QString lastDeliveredQuery = QString(
117 "SELECT chat_id, MAX(history.id) FROM "
118 "history JOIN peers chat ON chat_id = chat.id "
119 "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id "
120 "WHERE faux_offline_pending.id IS NULL "
121 "GROUP BY chat_id;");
122
123 QVector<RawDatabase::Query> upgradeQueries;
124 upgradeQueries +=
125 RawDatabase::Query(QStringLiteral(
126 "CREATE TABLE broken_messages "
127 "(id INTEGER PRIMARY KEY);"));
128
129 auto rowCallback = [&upgradeQueries](const QVector<QVariant>& row) {
130 auto chatId = row[0].toLongLong();
131 auto lastDeliveredHistoryId = row[1].toLongLong();
132
133 upgradeQueries += QString("INSERT INTO broken_messages "
134 "SELECT faux_offline_pending.id FROM "
135 "history JOIN faux_offline_pending "
136 "ON faux_offline_pending.id = history.id "
137 "WHERE history.chat_id=%1 "
138 "AND history.id < %2;").arg(chatId).arg(lastDeliveredHistoryId);
139 };
140 // note this doesn't modify the db, just generate new queries, so is safe
141 // to run outside of our upgrade transaction
142 if (!db.execNow({lastDeliveredQuery, rowCallback})) {
143 return false;
144 }
145
146 upgradeQueries += QString(
147 "DELETE FROM faux_offline_pending "
148 "WHERE id in ("
149 "SELECT id FROM broken_messages);");
150
151 upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 2;"));
152
153 return db.execNow(upgradeQueries);
154 }
155
dbSchema2to3(RawDatabase & db)156 bool dbSchema2to3(RawDatabase& db)
157 {
158 // Any faux_offline_pending message with the content "/me " are action
159 // messages that qTox previously let a user enter, but that will cause an
160 // action type message to be sent to toxcore, with 0 length, which will
161 // always fail. They must be be moved from faux_offline_pending to broken_messages
162 // to avoid qTox from erroring trying to send them on every connect
163
164 const QString emptyActionMessageString = "/me ";
165
166 QVector<RawDatabase::Query> upgradeQueries;
167 upgradeQueries += RawDatabase::Query{QString("INSERT INTO broken_messages "
168 "SELECT faux_offline_pending.id FROM "
169 "history JOIN faux_offline_pending "
170 "ON faux_offline_pending.id = history.id "
171 "WHERE history.message = ?;"),
172 {emptyActionMessageString.toUtf8()}};
173
174 upgradeQueries += QString(
175 "DELETE FROM faux_offline_pending "
176 "WHERE id in ("
177 "SELECT id FROM broken_messages);");
178
179 upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 3;"));
180
181 return db.execNow(upgradeQueries);
182 }
183
dbSchema3to4(RawDatabase & db)184 bool dbSchema3to4(RawDatabase& db)
185 {
186 QVector<RawDatabase::Query> upgradeQueries;
187 upgradeQueries += RawDatabase::Query{QString(
188 "CREATE INDEX chat_id_idx on history (chat_id);")};
189
190 upgradeQueries += RawDatabase::Query(QStringLiteral("PRAGMA user_version = 4;"));
191
192 return db.execNow(upgradeQueries);
193 }
194
195
196 /**
197 * @brief Upgrade the db schema
198 * @return True if the schema upgrade succeded, false otherwise
199 * @note On future alterations of the database all you have to do is bump the SCHEMA_VERSION
200 * variable and add another case to the switch statement below. Make sure to fall through on each case.
201 */
dbSchemaUpgrade(std::shared_ptr<RawDatabase> & db)202 bool dbSchemaUpgrade(std::shared_ptr<RawDatabase>& db)
203 {
204 int64_t databaseSchemaVersion;
205
206 if (!db->execNow(RawDatabase::Query("PRAGMA user_version", [&](const QVector<QVariant>& row) {
207 databaseSchemaVersion = row[0].toLongLong();
208 }))) {
209 qCritical() << "History failed to read user_version";
210 return false;
211 }
212
213 if (databaseSchemaVersion > SCHEMA_VERSION) {
214 qWarning().nospace() << "Database version (" << databaseSchemaVersion <<
215 ") is newer than we currently support (" << SCHEMA_VERSION << "). Please upgrade qTox";
216 // We don't know what future versions have done, we have to disable db access until we re-upgrade
217 return false;
218 } else if (databaseSchemaVersion == SCHEMA_VERSION) {
219 // No work to do
220 return true;
221 }
222
223 switch (databaseSchemaVersion) {
224 case 0: {
225 // Note: 0 is a special version that is actually two versions.
226 // possibility 1) it is a newly created database and it neesds the current schema to be created.
227 // possibility 2) it is a old existing database, before version 1 and before we saved schema version,
228 // and needs to be updated.
229 bool success = false;
230 const bool newDb = isNewDb(db, success);
231 if (!success) {
232 qCritical() << "Failed to create current db schema";
233 return false;
234 }
235 if (newDb) {
236 if (!createCurrentSchema(*db)) {
237 qCritical() << "Failed to create current db schema";
238 return false;
239 }
240 qDebug() << "Database created at schema version" << SCHEMA_VERSION;
241 break; // new db is the only case where we don't incrementally upgrade through each version
242 } else {
243 if (!dbSchema0to1(*db)) {
244 qCritical() << "Failed to upgrade db to schema version 1, aborting";
245 return false;
246 }
247 qDebug() << "Database upgraded incrementally to schema version 1";
248 }
249 }
250 // fallthrough
251 case 1:
252 if (!dbSchema1to2(*db)) {
253 qCritical() << "Failed to upgrade db to schema version 2, aborting";
254 return false;
255 }
256 qDebug() << "Database upgraded incrementally to schema version 2";
257 //fallthrough
258 case 2:
259 if (!dbSchema2to3(*db)) {
260 qCritical() << "Failed to upgrade db to schema version 3, aborting";
261 return false;
262 }
263 qDebug() << "Database upgraded incrementally to schema version 3";
264 case 3:
265 if (!dbSchema3to4(*db)) {
266 qCritical() << "Failed to upgrade db to schema version 4, aborting";
267 return false;
268 }
269 qDebug() << "Database upgraded incrementally to schema version 4";
270 // etc.
271 default:
272 qInfo() << "Database upgrade finished (databaseSchemaVersion" << databaseSchemaVersion
273 << "->" << SCHEMA_VERSION << ")";
274 }
275
276 return true;
277 }
278
getMessageState(bool isPending,bool isBroken)279 MessageState getMessageState(bool isPending, bool isBroken)
280 {
281 assert(!(isPending && isBroken));
282 MessageState messageState;
283 if (isPending) {
284 messageState = MessageState::pending;
285 } else if (isBroken) {
286 messageState = MessageState::broken;
287 } else {
288 messageState = MessageState::complete;
289 }
290 return messageState;
291 }
292 } // namespace
293
294 /**
295 * @class History
296 * @brief Interacts with the profile database to save the chat history.
297 *
298 * @var QHash<QString, int64_t> History::peers
299 * @brief Maps friend public keys to unique IDs by index.
300 * Caches mappings to speed up message saving.
301 */
302
303 static constexpr int NUM_MESSAGES_DEFAULT =
304 100; // arbitrary number of messages loaded when not loading by date
305
FileDbInsertionData()306 FileDbInsertionData::FileDbInsertionData()
307 {
308 static int id = qRegisterMetaType<FileDbInsertionData>();
309 (void)id;
310 }
311
312 /**
313 * @brief Prepares the database to work with the history.
314 * @param db This database will be prepared for use with the history.
315 */
History(std::shared_ptr<RawDatabase> db_)316 History::History(std::shared_ptr<RawDatabase> db_)
317 : db(db_)
318 {
319 if (!isValid()) {
320 qWarning() << "Database not open, init failed";
321 return;
322 }
323
324 const auto upgradeSucceeded = dbSchemaUpgrade(db);
325
326 // dbSchemaUpgrade may have put us in an invalid state
327 if (!upgradeSucceeded) {
328 db.reset();
329 return;
330 }
331
332 connect(this, &History::fileInsertionReady, this, &History::onFileInsertionReady);
333 connect(this, &History::fileInserted, this, &History::onFileInserted);
334
335 // Cache our current peers
336 db->execLater(RawDatabase::Query{"SELECT public_key, id FROM peers;",
337 [this](const QVector<QVariant>& row) {
338 peers[row[0].toString()] = row[1].toInt();
339 }});
340 }
341
~History()342 History::~History()
343 {
344 if (!isValid()) {
345 return;
346 }
347
348 // We could have execLater requests pending with a lambda attached,
349 // so clear the pending transactions first
350 db->sync();
351 }
352
353 /**
354 * @brief Checks if the database was opened successfully
355 * @return True if database if opened, false otherwise.
356 */
isValid()357 bool History::isValid()
358 {
359 return db && db->isOpen();
360 }
361
362 /**
363 * @brief Checks if a friend has chat history
364 * @param friendPk
365 * @return True if has, false otherwise.
366 */
historyExists(const ToxPk & friendPk)367 bool History::historyExists(const ToxPk& friendPk)
368 {
369 if (historyAccessBlocked()) {
370 return false;
371 }
372
373 return !getMessagesForFriend(friendPk, 0, 1).empty();
374 }
375
376 /**
377 * @brief Erases all the chat history from the database.
378 */
eraseHistory()379 void History::eraseHistory()
380 {
381 if (!isValid()) {
382 return;
383 }
384
385 db->execNow("DELETE FROM faux_offline_pending;"
386 "DELETE FROM history;"
387 "DELETE FROM aliases;"
388 "DELETE FROM peers;"
389 "DELETE FROM file_transfers;"
390 "DELETE FROM broken_messages;"
391 "VACUUM;");
392 }
393
394 /**
395 * @brief Erases the chat history with one friend.
396 * @param friendPk Friend public key to erase.
397 */
removeFriendHistory(const QString & friendPk)398 void History::removeFriendHistory(const QString& friendPk)
399 {
400 if (!isValid()) {
401 return;
402 }
403
404 if (!peers.contains(friendPk)) {
405 return;
406 }
407
408 int64_t id = peers[friendPk];
409
410 QString queryText = QString("DELETE FROM faux_offline_pending "
411 "WHERE faux_offline_pending.id IN ( "
412 " SELECT faux_offline_pending.id FROM faux_offline_pending "
413 " LEFT JOIN history ON faux_offline_pending.id = history.id "
414 " WHERE chat_id=%1 "
415 "); "
416 "DELETE FROM broken_messages "
417 "WHERE broken_messages.id IN ( "
418 " SELECT broken_messages.id FROM broken_messages "
419 " LEFT JOIN history ON broken_messages.id = history.id "
420 " WHERE chat_id=%1 "
421 "); "
422 "DELETE FROM history WHERE chat_id=%1; "
423 "DELETE FROM aliases WHERE owner=%1; "
424 "DELETE FROM peers WHERE id=%1; "
425 "DELETE FROM file_transfers WHERE chat_id=%1;"
426 "VACUUM;")
427 .arg(id);
428
429 if (db->execNow(queryText)) {
430 peers.remove(friendPk);
431 } else {
432 qWarning() << "Failed to remove friend's history";
433 }
434 }
435
436 /**
437 * @brief Generate query to insert new message in database
438 * @param friendPk Friend publick key to save.
439 * @param message Message to save.
440 * @param sender Sender to save.
441 * @param time Time of message sending.
442 * @param isDelivered True if message was already delivered.
443 * @param dispName Name, which should be displayed.
444 * @param insertIdCallback Function, called after query execution.
445 */
446 QVector<RawDatabase::Query>
generateNewMessageQueries(const QString & friendPk,const QString & message,const QString & sender,const QDateTime & time,bool isDelivered,QString dispName,std::function<void (RowId)> insertIdCallback)447 History::generateNewMessageQueries(const QString& friendPk, const QString& message,
448 const QString& sender, const QDateTime& time, bool isDelivered,
449 QString dispName, std::function<void(RowId)> insertIdCallback)
450 {
451 QVector<RawDatabase::Query> queries;
452
453 // Get the db id of the peer we're chatting with
454 int64_t peerId;
455 if (peers.contains(friendPk)) {
456 peerId = (peers)[friendPk];
457 } else {
458 if (peers.isEmpty()) {
459 peerId = 0;
460 } else {
461 peerId = *std::max_element(peers.begin(), peers.end()) + 1;
462 }
463
464 (peers)[friendPk] = peerId;
465 queries += RawDatabase::Query(("INSERT INTO peers (id, public_key) "
466 "VALUES (%1, '"
467 + friendPk + "');")
468 .arg(peerId));
469 }
470
471 // Get the db id of the sender of the message
472 int64_t senderId;
473 if (peers.contains(sender)) {
474 senderId = (peers)[sender];
475 } else {
476 if (peers.isEmpty()) {
477 senderId = 0;
478 } else {
479 senderId = *std::max_element(peers.begin(), peers.end()) + 1;
480 }
481
482 (peers)[sender] = senderId;
483 queries += RawDatabase::Query{("INSERT INTO peers (id, public_key) "
484 "VALUES (%1, '"
485 + sender + "');")
486 .arg(senderId)};
487 }
488
489 queries += RawDatabase::Query(
490 QString("INSERT OR IGNORE INTO aliases (owner, display_name) VALUES (%1, ?);").arg(senderId),
491 {dispName.toUtf8()});
492
493 // If the alias already existed, the insert will ignore the conflict and last_insert_rowid()
494 // will return garbage,
495 // so we have to check changes() and manually fetch the row ID in this case
496 queries +=
497 RawDatabase::Query(QString(
498 "INSERT INTO history (timestamp, chat_id, message, sender_alias) "
499 "VALUES (%1, %2, ?, ("
500 " CASE WHEN changes() IS 0 THEN ("
501 " SELECT id FROM aliases WHERE owner=%3 AND display_name=?)"
502 " ELSE last_insert_rowid() END"
503 "));")
504 .arg(time.toMSecsSinceEpoch())
505 .arg(peerId)
506 .arg(senderId),
507 {message.toUtf8(), dispName.toUtf8()}, insertIdCallback);
508
509 if (!isDelivered) {
510 queries += RawDatabase::Query{"INSERT INTO faux_offline_pending (id) VALUES ("
511 " last_insert_rowid()"
512 ");"};
513 }
514
515 return queries;
516 }
517
onFileInsertionReady(FileDbInsertionData data)518 void History::onFileInsertionReady(FileDbInsertionData data)
519 {
520
521 QVector<RawDatabase::Query> queries;
522 std::weak_ptr<History> weakThis = shared_from_this();
523
524 // peerId is guaranteed to be inserted since we just used it in addNewMessage
525 auto peerId = peers[data.friendPk];
526 // Copy to pass into labmda for later
527 auto fileId = data.fileId;
528 queries +=
529 RawDatabase::Query(QStringLiteral(
530 "INSERT INTO file_transfers (chat_id, file_restart_id, "
531 "file_path, file_name, file_hash, file_size, direction, file_state) "
532 "VALUES (%1, ?, ?, ?, ?, %2, %3, %4);")
533 .arg(peerId)
534 .arg(data.size)
535 .arg(static_cast<int>(data.direction))
536 .arg(ToxFile::CANCELED),
537 {data.fileId.toUtf8(), data.filePath.toUtf8(), data.fileName.toUtf8(), QByteArray()},
538 [weakThis, fileId](RowId id) {
539 auto pThis = weakThis.lock();
540 if (pThis) {
541 emit pThis->fileInserted(id, fileId);
542 }
543 });
544
545
546 queries += RawDatabase::Query(QStringLiteral("UPDATE history "
547 "SET file_id = (last_insert_rowid()) "
548 "WHERE id = %1")
549 .arg(data.historyId.get()));
550
551 db->execLater(queries);
552 }
553
onFileInserted(RowId dbId,QString fileId)554 void History::onFileInserted(RowId dbId, QString fileId)
555 {
556 auto& fileInfo = fileInfos[fileId];
557 if (fileInfo.finished) {
558 db->execLater(
559 generateFileFinished(dbId, fileInfo.success, fileInfo.filePath, fileInfo.fileHash));
560 fileInfos.remove(fileId);
561 } else {
562 fileInfo.finished = false;
563 fileInfo.fileId = dbId;
564 }
565 }
566
generateFileFinished(RowId id,bool success,const QString & filePath,const QByteArray & fileHash)567 RawDatabase::Query History::generateFileFinished(RowId id, bool success, const QString& filePath,
568 const QByteArray& fileHash)
569 {
570 auto file_state = success ? ToxFile::FINISHED : ToxFile::CANCELED;
571 if (filePath.length()) {
572 return RawDatabase::Query(QStringLiteral("UPDATE file_transfers "
573 "SET file_state = %1, file_path = ?, file_hash = ?"
574 "WHERE id = %2")
575 .arg(file_state)
576 .arg(id.get()),
577 {filePath.toUtf8(), fileHash});
578 } else {
579 return RawDatabase::Query(QStringLiteral("UPDATE file_transfers "
580 "SET finished = %1 "
581 "WHERE id = %2")
582 .arg(file_state)
583 .arg(id.get()));
584 }
585 }
586
addNewFileMessage(const QString & friendPk,const QString & fileId,const QString & fileName,const QString & filePath,int64_t size,const QString & sender,const QDateTime & time,QString const & dispName)587 void History::addNewFileMessage(const QString& friendPk, const QString& fileId,
588 const QString& fileName, const QString& filePath, int64_t size,
589 const QString& sender, const QDateTime& time, QString const& dispName)
590 {
591 if (historyAccessBlocked()) {
592 return;
593 }
594
595 // This is an incredibly far from an optimal way of implementing this,
596 // but given the frequency that people are going to be initiating a file
597 // transfer we can probably live with it.
598
599 // Since both inserting an alias for a user and inserting a file transfer
600 // will generate new ids, there is no good way to inject both new ids into the
601 // history query without refactoring our RawDatabase::Query and processor loops.
602
603 // What we will do instead is chain callbacks to try to get reasonable behavior.
604 // We can call the generateNewMessageQueries() fn to insert a message with an empty
605 // message in it, and get the id with the callbck. Once we have the id we can ammend
606 // the data to have our newly inserted file_id as well
607
608 ToxFile::FileDirection direction;
609 if (sender == friendPk) {
610 direction = ToxFile::RECEIVING;
611 } else {
612 direction = ToxFile::SENDING;
613 }
614
615 std::weak_ptr<History> weakThis = shared_from_this();
616 FileDbInsertionData insertionData;
617 insertionData.friendPk = friendPk;
618 insertionData.fileId = fileId;
619 insertionData.fileName = fileName;
620 insertionData.filePath = filePath;
621 insertionData.size = size;
622 insertionData.direction = direction;
623
624 auto insertFileTransferFn = [weakThis, insertionData](RowId messageId) {
625 auto insertionDataRw = std::move(insertionData);
626
627 insertionDataRw.historyId = messageId;
628
629 auto thisPtr = weakThis.lock();
630 if (thisPtr)
631 emit thisPtr->fileInsertionReady(std::move(insertionDataRw));
632 };
633
634 addNewMessage(friendPk, "", sender, time, true, dispName, insertFileTransferFn);
635 }
636
637 /**
638 * @brief Saves a chat message in the database.
639 * @param friendPk Friend publick key to save.
640 * @param message Message to save.
641 * @param sender Sender to save.
642 * @param time Time of message sending.
643 * @param isDelivered True if message was already delivered.
644 * @param dispName Name, which should be displayed.
645 * @param insertIdCallback Function, called after query execution.
646 */
addNewMessage(const QString & friendPk,const QString & message,const QString & sender,const QDateTime & time,bool isDelivered,QString dispName,const std::function<void (RowId)> & insertIdCallback)647 void History::addNewMessage(const QString& friendPk, const QString& message, const QString& sender,
648 const QDateTime& time, bool isDelivered, QString dispName,
649 const std::function<void(RowId)>& insertIdCallback)
650 {
651 if (historyAccessBlocked()) {
652 return;
653 }
654
655 db->execLater(generateNewMessageQueries(friendPk, message, sender, time, isDelivered, dispName,
656 insertIdCallback));
657 }
658
setFileFinished(const QString & fileId,bool success,const QString & filePath,const QByteArray & fileHash)659 void History::setFileFinished(const QString& fileId, bool success, const QString& filePath,
660 const QByteArray& fileHash)
661 {
662 if (historyAccessBlocked()) {
663 return;
664 }
665
666 auto& fileInfo = fileInfos[fileId];
667 if (fileInfo.fileId.get() == -1) {
668 fileInfo.finished = true;
669 fileInfo.success = success;
670 fileInfo.filePath = filePath;
671 fileInfo.fileHash = fileHash;
672 } else {
673 db->execLater(generateFileFinished(fileInfo.fileId, success, filePath, fileHash));
674 }
675
676 fileInfos.remove(fileId);
677 }
678
getNumMessagesForFriend(const ToxPk & friendPk)679 size_t History::getNumMessagesForFriend(const ToxPk& friendPk)
680 {
681 if (historyAccessBlocked()) {
682 return 0;
683 }
684
685 return getNumMessagesForFriendBeforeDate(friendPk, QDateTime());
686 }
687
getNumMessagesForFriendBeforeDate(const ToxPk & friendPk,const QDateTime & date)688 size_t History::getNumMessagesForFriendBeforeDate(const ToxPk& friendPk, const QDateTime& date)
689 {
690 if (historyAccessBlocked()) {
691 return 0;
692 }
693
694 QString queryText = QString("SELECT COUNT(history.id) "
695 "FROM history "
696 "JOIN peers chat ON chat_id = chat.id "
697 "WHERE chat.public_key='%1'")
698 .arg(friendPk.toString());
699
700 if (date.isNull()) {
701 queryText += ";";
702 } else {
703 queryText += QString(" AND timestamp < %1;").arg(date.toMSecsSinceEpoch());
704 }
705
706 size_t numMessages = 0;
707 auto rowCallback = [&numMessages](const QVector<QVariant>& row) {
708 numMessages = row[0].toLongLong();
709 };
710
711 db->execNow({queryText, rowCallback});
712
713 return numMessages;
714 }
715
getMessagesForFriend(const ToxPk & friendPk,size_t firstIdx,size_t lastIdx)716 QList<History::HistMessage> History::getMessagesForFriend(const ToxPk& friendPk, size_t firstIdx,
717 size_t lastIdx)
718 {
719 if (historyAccessBlocked()) {
720 return {};
721 }
722
723 QList<HistMessage> messages;
724
725 // Don't forget to update the rowCallback if you change the selected columns!
726 QString queryText =
727 QString("SELECT history.id, faux_offline_pending.id, timestamp, "
728 "chat.public_key, aliases.display_name, sender.public_key, "
729 "message, file_transfers.file_restart_id, "
730 "file_transfers.file_path, file_transfers.file_name, "
731 "file_transfers.file_size, file_transfers.direction, "
732 "file_transfers.file_state, broken_messages.id FROM history "
733 "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id "
734 "JOIN peers chat ON history.chat_id = chat.id "
735 "JOIN aliases ON sender_alias = aliases.id "
736 "JOIN peers sender ON aliases.owner = sender.id "
737 "LEFT JOIN file_transfers ON history.file_id = file_transfers.id "
738 "LEFT JOIN broken_messages ON history.id = broken_messages.id "
739 "WHERE chat.public_key='%1' "
740 "LIMIT %2 OFFSET %3;")
741 .arg(friendPk.toString())
742 .arg(lastIdx - firstIdx)
743 .arg(firstIdx);
744
745 auto rowCallback = [&messages](const QVector<QVariant>& row) {
746 // dispName and message could have null bytes, QString::fromUtf8
747 // truncates on null bytes so we strip them
748 auto id = RowId{row[0].toLongLong()};
749 auto isPending = !row[1].isNull();
750 auto timestamp = QDateTime::fromMSecsSinceEpoch(row[2].toLongLong());
751 auto friend_key = row[3].toString();
752 auto display_name = QString::fromUtf8(row[4].toByteArray().replace('\0', ""));
753 auto sender_key = row[5].toString();
754 auto isBroken = !row[13].isNull();
755
756 MessageState messageState = getMessageState(isPending, isBroken);
757
758 if (row[7].isNull()) {
759 messages += {id, messageState, timestamp, friend_key,
760 display_name, sender_key, row[6].toString()};
761 } else {
762 ToxFile file;
763 file.fileKind = TOX_FILE_KIND_DATA;
764 file.resumeFileId = row[7].toString().toUtf8();
765 file.filePath = row[8].toString();
766 file.fileName = row[9].toString();
767 file.filesize = row[10].toLongLong();
768 file.direction = static_cast<ToxFile::FileDirection>(row[11].toLongLong());
769 file.status = static_cast<ToxFile::FileStatus>(row[12].toInt());
770 messages +=
771 {id, messageState, timestamp, friend_key, display_name, sender_key, file};
772 }
773 };
774
775 db->execNow({queryText, rowCallback});
776
777 return messages;
778 }
779
getUndeliveredMessagesForFriend(const ToxPk & friendPk)780 QList<History::HistMessage> History::getUndeliveredMessagesForFriend(const ToxPk& friendPk)
781 {
782 if (historyAccessBlocked()) {
783 return {};
784 }
785
786 auto queryText =
787 QString("SELECT history.id, faux_offline_pending.id, timestamp, chat.public_key, "
788 "aliases.display_name, sender.public_key, message, broken_messages.id "
789 "FROM history "
790 "JOIN faux_offline_pending ON history.id = faux_offline_pending.id "
791 "JOIN peers chat on history.chat_id = chat.id "
792 "JOIN aliases on sender_alias = aliases.id "
793 "JOIN peers sender on aliases.owner = sender.id "
794 "LEFT JOIN broken_messages ON history.id = broken_messages.id "
795 "WHERE chat.public_key='%1';")
796 .arg(friendPk.toString());
797
798 QList<History::HistMessage> ret;
799 auto rowCallback = [&ret](const QVector<QVariant>& row) {
800 // dispName and message could have null bytes, QString::fromUtf8
801 // truncates on null bytes so we strip them
802 auto id = RowId{row[0].toLongLong()};
803 auto isPending = !row[1].isNull();
804 auto timestamp = QDateTime::fromMSecsSinceEpoch(row[2].toLongLong());
805 auto friend_key = row[3].toString();
806 auto display_name = QString::fromUtf8(row[4].toByteArray().replace('\0', ""));
807 auto sender_key = row[5].toString();
808 auto isBroken = !row[7].isNull();
809
810 MessageState messageState = getMessageState(isPending, isBroken);
811
812 ret += {id, messageState, timestamp, friend_key,
813 display_name, sender_key, row[6].toString()};
814 };
815
816 db->execNow({queryText, rowCallback});
817
818 return ret;
819 }
820
821 /**
822 * @brief Search phrase in chat messages
823 * @param friendPk Friend public key
824 * @param from a date message where need to start a search
825 * @param phrase what need to find
826 * @param parameter for search
827 * @return date of the message where the phrase was found
828 */
getDateWhereFindPhrase(const QString & friendPk,const QDateTime & from,QString phrase,const ParameterSearch & parameter)829 QDateTime History::getDateWhereFindPhrase(const QString& friendPk, const QDateTime& from,
830 QString phrase, const ParameterSearch& parameter)
831 {
832 if (historyAccessBlocked()) {
833 return QDateTime();
834 }
835
836 QDateTime result;
837 auto rowCallback = [&result](const QVector<QVariant>& row) {
838 result = QDateTime::fromMSecsSinceEpoch(row[0].toLongLong());
839 };
840
841 phrase.replace("'", "''");
842
843 QString message;
844
845 switch (parameter.filter) {
846 case FilterSearch::Register:
847 message = QStringLiteral("message LIKE '%%1%'").arg(phrase);
848 break;
849 case FilterSearch::WordsOnly:
850 message = QStringLiteral("message REGEXP '%1'")
851 .arg(SearchExtraFunctions::generateFilterWordsOnly(phrase).toLower());
852 break;
853 case FilterSearch::RegisterAndWordsOnly:
854 message = QStringLiteral("REGEXPSENSITIVE(message, '%1')")
855 .arg(SearchExtraFunctions::generateFilterWordsOnly(phrase));
856 break;
857 case FilterSearch::Regular:
858 message = QStringLiteral("message REGEXP '%1'").arg(phrase);
859 break;
860 case FilterSearch::RegisterAndRegular:
861 message = QStringLiteral("REGEXPSENSITIVE(message '%1')").arg(phrase);
862 break;
863 default:
864 message = QStringLiteral("LOWER(message) LIKE '%%1%'").arg(phrase.toLower());
865 break;
866 }
867
868 QDateTime date = from;
869
870 if (!date.isValid()) {
871 date = QDateTime::currentDateTime();
872 }
873
874 if (parameter.period == PeriodSearch::AfterDate || parameter.period == PeriodSearch::BeforeDate) {
875 #if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0))
876 date = parameter.date.startOfDay();
877 #else
878 date = QDateTime(parameter.date);
879 #endif
880 }
881
882 QString period;
883 switch (parameter.period) {
884 case PeriodSearch::WithTheFirst:
885 period = QStringLiteral("ORDER BY timestamp ASC LIMIT 1;");
886 break;
887 case PeriodSearch::AfterDate:
888 period = QStringLiteral("AND timestamp > '%1' ORDER BY timestamp ASC LIMIT 1;")
889 .arg(date.toMSecsSinceEpoch());
890 break;
891 case PeriodSearch::BeforeDate:
892 period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;")
893 .arg(date.toMSecsSinceEpoch());
894 break;
895 default:
896 period = QStringLiteral("AND timestamp < '%1' ORDER BY timestamp DESC LIMIT 1;")
897 .arg(date.toMSecsSinceEpoch());
898 break;
899 }
900
901 QString queryText =
902 QStringLiteral("SELECT timestamp "
903 "FROM history "
904 "LEFT JOIN faux_offline_pending ON history.id = faux_offline_pending.id "
905 "JOIN peers chat ON chat_id = chat.id "
906 "WHERE chat.public_key='%1' "
907 "AND %2 "
908 "%3")
909 .arg(friendPk)
910 .arg(message)
911 .arg(period);
912
913 db->execNow({queryText, rowCallback});
914
915 return result;
916 }
917
918 /**
919 * @brief Gets date boundaries in conversation with friendPk. History doesn't model conversation indexes,
920 * but we can count messages between us and friendPk to effectively give us an index. This function
921 * returns how many messages have happened between us <-> friendPk each time the date changes
922 * @param[in] friendPk ToxPk of conversation to retrieve
923 * @param[in] from Start date to look from
924 * @param[in] maxNum Maximum number of date boundaries to retrieve
925 * @note This API may seem a little strange, why not use QDate from and QDate to? The intent is to
926 * have an API that can be used to get the first item after a date (for search) and to get a list
927 * of date changes (for loadHistory). We could write two separate queries but the query is fairly
928 * intricate compared to our other ones so reducing duplication of it is preferable.
929 */
getNumMessagesForFriendBeforeDateBoundaries(const ToxPk & friendPk,const QDate & from,size_t maxNum)930 QList<History::DateIdx> History::getNumMessagesForFriendBeforeDateBoundaries(const ToxPk& friendPk,
931 const QDate& from,
932 size_t maxNum)
933 {
934 if (historyAccessBlocked()) {
935 return {};
936 }
937
938 auto friendPkString = friendPk.toString();
939
940 // No guarantee that this is the most efficient way to do this...
941 // We want to count messages that happened for a friend before a
942 // certain date. We do this by re-joining our table a second time
943 // but this time with the only filter being that our id is less than
944 // the ID of the corresponding row in the table that is grouped by day
945 auto countMessagesForFriend =
946 QString("SELECT COUNT(*) - 1 " // Count - 1 corresponds to 0 indexed message id for friend
947 "FROM history countHistory " // Import unfiltered table as countHistory
948 "JOIN peers chat ON chat_id = chat.id " // link chat_id to chat.id
949 "WHERE chat.public_key = '%1'" // filter this conversation
950 "AND countHistory.id <= history.id") // and filter that our unfiltered table history id only has elements up to history.id
951 .arg(friendPkString);
952
953 auto limitString = (maxNum) ? QString("LIMIT %1").arg(maxNum) : QString("");
954
955 auto queryString = QString("SELECT (%1), (timestamp / 1000 / 60 / 60 / 24) AS day "
956 "FROM history "
957 "JOIN peers chat ON chat_id = chat.id "
958 "WHERE chat.public_key = '%2' "
959 "AND timestamp >= %3 "
960 "GROUP by day "
961 "%4;")
962 .arg(countMessagesForFriend)
963 .arg(friendPkString)
964 #if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0))
965 .arg(QDateTime(from.startOfDay()).toMSecsSinceEpoch())
966 #else
967 .arg(QDateTime(from).toMSecsSinceEpoch())
968 #endif
969 .arg(limitString);
970
971 QList<DateIdx> dateIdxs;
972 auto rowCallback = [&dateIdxs](const QVector<QVariant>& row) {
973 DateIdx dateIdx;
974 dateIdx.numMessagesIn = row[0].toLongLong();
975 dateIdx.date =
976 QDateTime::fromMSecsSinceEpoch(row[1].toLongLong() * 24 * 60 * 60 * 1000).date();
977 dateIdxs.append(dateIdx);
978 };
979
980 db->execNow({queryString, rowCallback});
981
982 return dateIdxs;
983 }
984
985 /**
986 * @brief Marks a message as delivered.
987 * Removing message from the faux-offline pending messages list.
988 *
989 * @param id Message ID.
990 */
markAsDelivered(RowId messageId)991 void History::markAsDelivered(RowId messageId)
992 {
993 if (historyAccessBlocked()) {
994 return;
995 }
996
997 db->execLater(QString("DELETE FROM faux_offline_pending WHERE id=%1;").arg(messageId.get()));
998 }
999
1000 /**
1001 * @brief Determines if history access should be blocked
1002 * @return True if history should not be accessed
1003 */
historyAccessBlocked()1004 bool History::historyAccessBlocked()
1005 {
1006 if (!Settings::getInstance().getEnableLogging()) {
1007 assert(false);
1008 qCritical() << "Blocked history access while history is disabled";
1009 return true;
1010 }
1011
1012 if (!isValid()) {
1013 return true;
1014 }
1015
1016 return false;
1017
1018 }
1019