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