diff options
-rw-r--r-- | .appveyor.yml | 9 | ||||
-rw-r--r-- | CMakeLists.txt | 2 | ||||
-rw-r--r-- | examples/qmc-example.cpp | 255 | ||||
-rw-r--r-- | lib/connection.cpp | 75 | ||||
-rw-r--r-- | lib/connection.h | 6 | ||||
-rw-r--r-- | lib/converters.h | 85 | ||||
-rw-r--r-- | lib/e2ee.h | 30 | ||||
-rw-r--r-- | lib/encryptionmanager.cpp | 34 | ||||
-rw-r--r-- | lib/encryptionmanager.h | 7 | ||||
-rw-r--r-- | lib/events/encryptedevent.cpp | 32 | ||||
-rw-r--r-- | lib/events/encryptedevent.h | 68 | ||||
-rw-r--r-- | lib/events/encryptionevent.cpp | 18 | ||||
-rw-r--r-- | lib/events/event.h | 8 | ||||
-rw-r--r-- | lib/events/reactionevent.cpp | 44 | ||||
-rw-r--r-- | lib/events/reactionevent.h | 79 | ||||
-rw-r--r-- | lib/events/roomevent.cpp | 14 | ||||
-rw-r--r-- | lib/events/roomevent.h | 2 | ||||
-rw-r--r-- | lib/events/roommessageevent.cpp | 107 | ||||
-rw-r--r-- | lib/events/roommessageevent.h | 3 | ||||
-rw-r--r-- | lib/jobs/basejob.cpp | 2 | ||||
-rw-r--r-- | lib/jobs/basejob.h | 3 | ||||
-rw-r--r-- | lib/room.cpp | 273 | ||||
-rw-r--r-- | lib/room.h | 19 | ||||
-rw-r--r-- | libqmatrixclient.pri | 4 |
24 files changed, 925 insertions, 254 deletions
diff --git a/.appveyor.yml b/.appveyor.yml index 4e2d4b5d..f596b856 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -1,14 +1,13 @@ -image: Visual Studio 2015 +image: Visual Studio 2017 environment: #DEPLOY_DIR: libqmatrixclient-%APPVEYOR_BUILD_VERSION% matrix: - - APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 - QTDIR: C:\Qt\5.9\msvc2017_64 + - QTDIR: C:\Qt\5.12\msvc2017_64 VCVARS: "C:\\Program Files (x86)\\Microsoft Visual Studio\\2017\\Community\\VC\\Auxiliary\\Build\\vcvars64.bat" PLATFORM: - - QTDIR: C:\Qt\5.9\msvc2015 - VCVARS: "C:\\Program Files (x86)\\Microsoft Visual Studio 14.0\\VC\\vcvarsall.bat" + - QTDIR: C:\Qt\5.12\msvc2017 + VCVARS: "C:\\Program Files (x86)\\Microsoft Visual Studio\\2017\\Community\\VC\\Auxiliary\\Build\\vcvars32.bat" PLATFORM: x86 init: diff --git a/CMakeLists.txt b/CMakeLists.txt index a40d4385..996e76d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -143,12 +143,14 @@ set(libqmatrixclient_SRCS lib/events/roommemberevent.cpp lib/events/typingevent.cpp lib/events/receiptevent.cpp + lib/events/reactionevent.cpp lib/events/callanswerevent.cpp lib/events/callcandidatesevent.cpp lib/events/callhangupevent.cpp lib/events/callinviteevent.cpp lib/events/directchatevent.cpp lib/events/encryptionevent.cpp + lib/events/encryptedevent.cpp lib/jobs/requestdata.cpp lib/jobs/basejob.cpp lib/jobs/syncjob.cpp diff --git a/examples/qmc-example.cpp b/examples/qmc-example.cpp index 36fc61c4..d6cba76a 100644 --- a/examples/qmc-example.cpp +++ b/examples/qmc-example.cpp @@ -1,17 +1,21 @@ #include "connection.h" +#include "room.h" +#include "user.h" + #include "csapi/joining.h" #include "csapi/leaving.h" #include "csapi/room_send.h" + +#include "events/reactionevent.h" #include "events/simplestateevents.h" -#include "room.h" -#include "user.h" #include <QtCore/QCoreApplication> #include <QtCore/QFileInfo> #include <QtCore/QStringBuilder> #include <QtCore/QTemporaryFile> #include <QtCore/QTimer> + #include <functional> #include <iostream> @@ -22,28 +26,33 @@ using namespace std::placeholders; class QMCTest : public QObject { - public: +public: QMCTest(Connection* conn, QString testRoomName, QString source); - private slots: +private slots: + // clang-format off void setupAndRun(); void onNewRoom(Room* r); void run(); void doTests(); - void loadMembers(); - void sendMessage(); - void sendFile(); - void checkFileSendingOutcome(const QString& txnId, const QString& fileName); - void setTopic(); - void addAndRemoveTag(); - void sendAndRedact(); - bool checkRedactionOutcome(const QString& evtIdToRedact); - void markDirectChat(); - void checkDirectChatOutcome(const Connection::DirectChatsMap& added); + void loadMembers(); + void sendMessage(); + void sendReaction(const QString& targetEvtId); + void sendFile(); + void checkFileSendingOutcome(const QString& txnId, + const QString& fileName); + void setTopic(); + void addAndRemoveTag(); + void sendAndRedact(); + bool checkRedactionOutcome(const QString& evtIdToRedact); + void markDirectChat(); + void checkDirectChatOutcome( + const Connection::DirectChatsMap& added); void conclude(); void finalize(); + // clang-format on - private: +private: QScopedPointer<Connection, QScopedPointerDeleteLater> c; QStringList running; QStringList succeeded; @@ -55,37 +64,37 @@ class QMCTest : public QObject bool validatePendingEvent(const QString& txnId); }; -#define QMC_CHECK(description, condition) \ - { \ - Q_ASSERT(running.removeOne(description)); \ - if (!!(condition)) { \ - succeeded.push_back(description); \ - cout << (description) << " successful" << endl; \ - if (targetRoom) \ - targetRoom->postMessage(origin % ": " % (description) \ - % " successful", \ - MessageEventType::Notice); \ - } else { \ - failed.push_back(description); \ - cout << (description) << " FAILED" << endl; \ - if (targetRoom) \ - targetRoom->postPlainText(origin % ": " % (description) \ - % " FAILED"); \ - } \ +#define QMC_CHECK(description, condition) \ + { \ + Q_ASSERT(running.removeOne(description)); \ + if (!!(condition)) { \ + succeeded.push_back(description); \ + cout << (description) << " successful" << endl; \ + if (targetRoom) \ + targetRoom->postMessage(origin % ": " % (description) \ + % " successful", \ + MessageEventType::Notice); \ + } else { \ + failed.push_back(description); \ + cout << (description) << " FAILED" << endl; \ + if (targetRoom) \ + targetRoom->postPlainText(origin % ": " % (description) \ + % " FAILED"); \ + } \ } bool QMCTest::validatePendingEvent(const QString& txnId) { auto it = targetRoom->findPendingEvent(txnId); return it != targetRoom->pendingEvents().end() - && it->deliveryStatus() == EventStatus::Submitted - && (*it)->transactionId() == txnId; + && it->deliveryStatus() == EventStatus::Submitted + && (*it)->transactionId() == txnId; } QMCTest::QMCTest(Connection* conn, QString testRoomName, QString source) - : c(conn), - origin(std::move(source)), - targetRoomName(std::move(testRoomName)) + : c(conn) + , origin(std::move(source)) + , targetRoomName(std::move(testRoomName)) { if (!origin.isEmpty()) cout << "Origin for the test message: " << origin.toStdString() << endl; @@ -114,9 +123,8 @@ void QMCTest::setupAndRun() QMC_CHECK("Join room", false); conclude(); }); - // Connection::joinRoom() creates a Room object upon - // JoinRoomJob::success but this object is empty until the first sync is - // done. + // Connection::joinRoom() creates a Room object upon JoinRoomJob::success + // but this object is empty until the first sync is done. connect(joinJob, &BaseJob::success, this, [this, joinJob] { targetRoom = c->room(joinJob->roomId(), JoinState::Join); QMC_CHECK("Join room", targetRoom != nullptr); @@ -183,13 +191,10 @@ void QMCTest::doTests() void QMCTest::loadMembers() { running.push_back("Loading members"); - // The dedicated qmc-test room is too small to test - // lazy-loading-then-full-loading; use #qmatrixclient:matrix.org instead. - // TODO: #264 - auto* r = c->room(QStringLiteral("!PCzUtxtOjUySxSelof:matrix.org")); + auto* r = c->roomByAlias(QStringLiteral("#quotient:matrix.org"), + JoinState::Join); if (!r) { - cout << "#test:matrix.org is not found in the test user's rooms" - << endl; + cout << "#test:matrix.org is not found in the test user's rooms" << endl; QMC_CHECK("Loading members", false); return; } @@ -219,23 +224,60 @@ void QMCTest::sendMessage() return; } - connectUntil(targetRoom, &Room::pendingEventAboutToMerge, this, - [this, txnId](const RoomEvent* evt, int pendingIdx) { - const auto& pendingEvents = targetRoom->pendingEvents(); - Q_ASSERT(pendingIdx >= 0 - && pendingIdx < int(pendingEvents.size())); - - if (evt->transactionId() != txnId) - return false; - - QMC_CHECK("Message sending", - is<RoomMessageEvent>(*evt) - && !evt->id().isEmpty() - && pendingEvents[size_t(pendingIdx)] - ->transactionId() - == evt->transactionId()); - return true; - }); + connectUntil( + targetRoom, &Room::pendingEventAboutToMerge, this, + [this, txnId](const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return false; + + QMC_CHECK("Message sending", + is<RoomMessageEvent>(*evt) && !evt->id().isEmpty() + && pendingEvents[size_t(pendingIdx)]->transactionId() + == evt->transactionId()); + sendReaction(evt->id()); + return true; + }); +} + +void QMCTest::sendReaction(const QString& targetEvtId) +{ + running.push_back("Reaction sending"); + cout << "Reacting to the newest message in the room" << endl; + Q_ASSERT(targetRoom->timelineSize() > 0); + const auto key = QStringLiteral("+1"); + auto txnId = targetRoom->postReaction(targetEvtId, key); + if (!validatePendingEvent(txnId)) { + cout << "Invalid pending event right after submitting" << endl; + QMC_CHECK("Reaction sending", false); + return; + } + + // TODO: Check that it came back as a reaction event and that it attached to + // the right event + connectUntil( + targetRoom, &Room::updatedEvent, this, + [this, txnId, key, targetEvtId](const QString& actualTargetEvtId) { + if (actualTargetEvtId != targetEvtId) + return false; + const auto reactions = targetRoom->relatedEvents( + targetEvtId, EventRelation::Annotation()); + // It's a test room, assuming no interference there should + // be exactly one reaction + if (reactions.size() != 1) { + QMC_CHECK("Reaction sending", false); + } else { + const auto* evt = + eventCast<const ReactionEvent>(reactions.back()); + QMC_CHECK("Reaction sending", + is<ReactionEvent>(*evt) && !evt->id().isEmpty() + && evt->relation().key == key + && evt->transactionId() == txnId); + } + return true; + }); } void QMCTest::sendFile() @@ -254,8 +296,8 @@ void QMCTest::sendFile() // the full path const auto tfName = QFileInfo(*tf).fileName(); cout << "Sending file" << tfName.toStdString() << endl; - const auto txnId = targetRoom->postFile( - "Test file", QUrl::fromLocalFile(tf->fileName())); + const auto txnId = + targetRoom->postFile("Test file", QUrl::fromLocalFile(tf->fileName())); if (!validatePendingEvent(txnId)) { cout << "Invalid pending event right after submitting" << endl; QMC_CHECK("File sending", false); @@ -307,41 +349,37 @@ void QMCTest::checkFileSendingOutcome(const QString& txnId, } connectUntil( - targetRoom, &Room::pendingEventAboutToMerge, this, - [this, txnId, fileName](const RoomEvent* evt, int pendingIdx) { - const auto& pendingEvents = targetRoom->pendingEvents(); - Q_ASSERT(pendingIdx >= 0 - && pendingIdx < int(pendingEvents.size())); - - if (evt->transactionId() != txnId) - return false; - - cout << "File event " << txnId.toStdString() - << " arrived in the timeline" << endl; - visit(*evt, - [&](const RoomMessageEvent& e) { - QMC_CHECK("File sending", - !e.id().isEmpty() - && pendingEvents[size_t(pendingIdx)] - ->transactionId() - == txnId - && e.hasFileContent() - && e.content()->fileInfo() - ->originalName - == fileName); - }, - [this](const RoomEvent&) { - QMC_CHECK("File sending", false); - }); - return true; - }); + targetRoom, &Room::pendingEventAboutToMerge, this, + [this, txnId, fileName](const RoomEvent* evt, int pendingIdx) { + const auto& pendingEvents = targetRoom->pendingEvents(); + Q_ASSERT(pendingIdx >= 0 && pendingIdx < int(pendingEvents.size())); + + if (evt->transactionId() != txnId) + return false; + + cout << "File event " << txnId.toStdString() + << " arrived in the timeline" << endl; + visit( + *evt, + [&](const RoomMessageEvent& e) { + QMC_CHECK( + "File sending", + !e.id().isEmpty() + && pendingEvents[size_t(pendingIdx)]->transactionId() + == txnId + && e.hasFileContent() + && e.content()->fileInfo()->originalName == fileName); + }, + [this](const RoomEvent&) { QMC_CHECK("File sending", false); }); + return true; + }); } void QMCTest::setTopic() { static const char* const stateTestName = "State setting test"; static const char* const fakeStateTestName = - "Fake state event immunity test"; + "Fake state event immunity test"; running.push_back(stateTestName); running.push_back(fakeStateTestName); auto initialTopic = targetRoom->topic(); @@ -368,8 +406,7 @@ void QMCTest::setTopic() connectUntil(targetRoom, &Room::pendingEventAboutToMerge, this, [this, fakeTopic, initialTopic](const RoomEvent* e, int) { - if (e->contentJson().value("topic").toString() - != fakeTopic) + if (e->contentJson().value("topic").toString() != fakeTopic) return false; // Wait on for the right event QMC_CHECK(fakeStateTestName, !e->isStateEvent()); @@ -420,9 +457,10 @@ void QMCTest::sendAndRedact() cout << "Redacting the message" << endl; targetRoom->redactEvent(evtId, origin); - connectUntil( - targetRoom, &Room::addedMessages, this, - [this, evtId] { return checkRedactionOutcome(evtId); }); + connectUntil(targetRoom, &Room::addedMessages, this, + [this, evtId] { + return checkRedactionOutcome(evtId); + }); }); } @@ -449,8 +487,7 @@ bool QMCTest::checkRedactionOutcome(const QString& evtIdToRedact) QMC_CHECK("Redaction", newEvent->isRedacted() - && newEvent->redactionReason() - == origin); + && newEvent->redactionReason() == origin); return true; }); } @@ -498,23 +535,23 @@ void QMCTest::conclude() c->stopSync(); auto succeededRec = QString::number(succeeded.size()) + " tests succeeded"; if (!failed.isEmpty() || !running.isEmpty()) - succeededRec += " of " - % QString::number(succeeded.size() + failed.size() - + running.size()) - % " total"; + succeededRec += + " of " + % QString::number(succeeded.size() + failed.size() + running.size()) + % " total"; QString plainReport = origin % ": Testing complete, " % succeededRec; QString color = failed.isEmpty() && running.isEmpty() ? "00AA00" : "AA0000"; QString htmlReport = origin % ": <strong><font data-mx-color='#" % color - % "' color='#" % color % "'>Testing complete</font></strong>, " - % succeededRec; + % "' color='#" % color + % "'>Testing complete</font></strong>, " % succeededRec; if (!failed.isEmpty()) { plainReport += "\nFAILED: " % failed.join(", "); htmlReport += "<br><strong>Failed:</strong> " % failed.join(", "); } if (!running.isEmpty()) { plainReport += "\nDID NOT FINISH: " % running.join(", "); - htmlReport += - "<br><strong>Did not finish:</strong> " % running.join(", "); + htmlReport += "<br><strong>Did not finish:</strong> " + % running.join(", "); } cout << plainReport.toStdString() << endl; diff --git a/lib/connection.cpp b/lib/connection.cpp index 52a693f7..6ebe05dc 100644 --- a/lib/connection.cpp +++ b/lib/connection.cpp @@ -33,7 +33,9 @@ #include "csapi/receipts.h" #include "csapi/room_send.h" #include "csapi/to_device.h" +#include "csapi/versions.h" #include "csapi/voip.h" +#include "csapi/wellknown.h" #include "events/directchatevent.h" #include "events/eventloader.h" @@ -176,36 +178,50 @@ void Connection::resolveServer(const QString& mxid) } setHomeserver(maybeBaseUrl); - emit resolved(); - return; - // FIXME, #178: The below code is incorrect and is no more executed. The - // correct server resolution should be done from .well-known/matrix/client auto domain = maybeBaseUrl.host(); qCDebug(MAIN) << "Finding the server" << domain; - // Check if the Matrix server has a dedicated service record. - auto* dns = new QDnsLookup(); - dns->setType(QDnsLookup::SRV); - dns->setName("_matrix._tcp." + domain); - - connect(dns, &QDnsLookup::finished, [this, dns, maybeBaseUrl]() { - QUrl baseUrl { maybeBaseUrl }; - if (dns->error() == QDnsLookup::NoError - && dns->serviceRecords().isEmpty()) { - auto record = dns->serviceRecords().front(); - baseUrl.setHost(record.target()); - baseUrl.setPort(record.port()); - qCDebug(MAIN) << "SRV record for" << maybeBaseUrl.host() << "is" - << baseUrl.authority(); - } else { - qCDebug(MAIN) << baseUrl.host() << "doesn't have SRV record" - << dns->name() << "- using the hostname as is"; - } - setHomeserver(baseUrl); - emit resolved(); - dns->deleteLater(); - }); - dns->lookup(); + + auto getWellKnownJob = callApi<GetWellknownJob>(); + connect(getWellKnownJob, &BaseJob::finished, + [this, getWellKnownJob, maybeBaseUrl] { + if (getWellKnownJob->status() == BaseJob::NotFoundError) { + qCDebug(MAIN) << "No .well-known file, IGNORE"; + } else if (getWellKnownJob->status() != BaseJob::Success) { + qCDebug(MAIN) + << "Fetching .well-known file failed, FAIL_PROMPT"; + emit resolveError(tr("Fetching .well-known file failed")); + return; + } else if (getWellKnownJob->data().homeserver.baseUrl.isEmpty()) { + qCDebug(MAIN) << "base_url not provided, FAIL_PROMPT"; + emit resolveError(tr("base_url not provided")); + return; + } else if (!QUrl(getWellKnownJob->data().homeserver.baseUrl) + .isValid()) { + qCDebug(MAIN) << "base_url invalid, FAIL_ERROR"; + emit resolveError(tr("base_url invalid")); + return; + } else { + QUrl baseUrl(getWellKnownJob->data().homeserver.baseUrl); + + qCDebug(MAIN) << ".well-known for" << maybeBaseUrl.host() + << "is" << baseUrl.toString(); + setHomeserver(baseUrl); + } + + auto getVersionsJob = callApi<GetVersionsJob>(); + + connect(getVersionsJob, &BaseJob::finished, + [this, getVersionsJob] { + if (getVersionsJob->status() == BaseJob::Success) { + qCDebug(MAIN) << "homeserver url is valid"; + emit resolved(); + } else { + qCDebug(MAIN) << "homeserver url invalid"; + emit resolveError(tr("homeserver url invalid")); + } + }); + }); } void Connection::connectToServer(const QString& user, const QString& password, @@ -921,6 +937,11 @@ QString Connection::deviceId() const { return d->data->deviceId(); } QByteArray Connection::accessToken() const { return d->data->accessToken(); } +QtOlm::Account* Connection::olmAccount() const +{ + return d->encryptionManager->account(); +} + SyncJob* Connection::syncJob() const { return d->syncJob; } int Connection::millisToReconnect() const diff --git a/lib/connection.h b/lib/connection.h index ef6cc156..8d65f0e7 100644 --- a/lib/connection.h +++ b/lib/connection.h @@ -32,6 +32,11 @@ #include <functional> +namespace QtOlm +{ +class Account; +} + namespace QMatrixClient { class Room; @@ -274,6 +279,7 @@ public: QString userId() const; QString deviceId() const; QByteArray accessToken() const; + QtOlm::Account* olmAccount() const; Q_INVOKABLE SyncJob* syncJob() const; Q_INVOKABLE int millisToReconnect() const; diff --git a/lib/converters.h b/lib/converters.h index 3ba65c22..aa07261d 100644 --- a/lib/converters.h +++ b/lib/converters.h @@ -62,8 +62,8 @@ namespace QMatrixClient template <typename T> struct JsonObjectConverter { - static void dumpTo(QJsonObject& jo, const T& pod) { jo = pod; } - static void fillFrom(const QJsonObject& jo, T& pod) { pod = jo; } + static void dumpTo(QJsonObject& jo, const T& pod) { jo = pod.toJson(); } + static void fillFrom(const QJsonObject& jo, T& pod) { pod = T(jo); } }; template <typename T> @@ -91,14 +91,16 @@ inline auto toJson(const T& pod) return JsonConverter<T>::dump(pod); } +inline auto toJson(const QJsonObject& jo) { return jo; } + template <typename T> -inline auto fillJson(QJsonObject& json, const T& data) +inline void fillJson(QJsonObject& json, const T& data) { JsonObjectConverter<T>::dumpTo(json, data); } template <typename T> -inline auto fromJson(const QJsonValue& jv) +inline T fromJson(const QJsonValue& jv) { return JsonConverter<T>::load(jv); } @@ -109,11 +111,14 @@ inline T fromJson(const QJsonDocument& jd) return JsonConverter<T>::load(jd); } +// Convenience fromJson() overloads that deduce T instead of requiring +// the coder to explicitly type it. They still enforce the +// overwrite-everything semantics of fromJson(), unlike fillFromJson() + template <typename T> inline void fromJson(const QJsonValue& jv, T& pod) { - if (!jv.isUndefined()) - pod = fromJson<T>(jv); + pod = jv.isUndefined() ? T() : fromJson<T>(jv); } template <typename T> @@ -122,21 +127,13 @@ inline void fromJson(const QJsonDocument& jd, T& pod) pod = fromJson<T>(jd); } -// Unfolds Omittable<> -template <typename T> -inline void fromJson(const QJsonValue& jv, Omittable<T>& pod) -{ - if (jv.isUndefined()) - pod = none; - else - pod = fromJson<T>(jv); -} - template <typename T> inline void fillFromJson(const QJsonValue& jv, T& pod) { if (jv.isObject()) JsonObjectConverter<T>::fillFrom(jv.toObject(), pod); + else if (!jv.isUndefined()) + pod = fromJson<T>(jv); } // JsonConverter<> specialisations @@ -228,6 +225,21 @@ struct JsonConverter<QVariant> static QVariant load(const QJsonValue& jv); }; +template <typename T> +struct JsonConverter<Omittable<T>> +{ + static QJsonValue dump(const Omittable<T>& from) + { + return from.omitted() ? QJsonValue() : toJson(from.value()); + } + static Omittable<T> load(const QJsonValue& jv) + { + if (jv.isUndefined()) + return none; + return fromJson<T>(jv); + } +}; + template <typename VectorT, typename T = typename VectorT::value_type> struct JsonArrayConverter { @@ -369,7 +381,8 @@ namespace _impl q.addQueryItem(it.key(), it.value().toString()); } - // This one is for types that don't have isEmpty() + // This one is for types that don't have isEmpty() and for all types + // when Force is true template <typename ValT, bool Force = true, typename = bool> struct AddNode { @@ -381,7 +394,7 @@ namespace _impl } }; - // This one is for types that have isEmpty() + // This one is for types that have isEmpty() when Force is false template <typename ValT> struct AddNode<ValT, false, decltype(std::declval<ValT>().isEmpty())> { @@ -390,23 +403,20 @@ namespace _impl ForwardedT&& value) { if (!value.isEmpty()) - AddNode<ValT>::impl(container, key, - std::forward<ForwardedT>(value)); + addTo(container, key, std::forward<ForwardedT>(value)); } }; - // This is a special one that unfolds Omittable<> - template <typename ValT, bool Force> - struct AddNode<Omittable<ValT>, Force> + // This one unfolds Omittable<> (also only when Force is false) + template <typename ValT> + struct AddNode<Omittable<ValT>, false> { template <typename ContT, typename OmittableT> static void impl(ContT& container, const QString& key, const OmittableT& value) { if (!value.omitted()) - AddNode<ValT>::impl(container, key, value.value()); - else if (Force) // Edge case, no value but must put something - AddNode<ValT>::impl(container, key, QString {}); + addTo(container, key, value.value()); } }; @@ -431,6 +441,29 @@ namespace _impl static constexpr bool IfNotEmpty = false; +/*! Add a key-value pair to QJsonObject or QUrlQuery + * + * Adds a key-value pair(s) specified by \p key and \p value to + * \p container, optionally (in case IfNotEmpty is passed for the first + * template parameter) taking into account the value "emptiness". + * With IfNotEmpty, \p value is NOT added to the container if and only if: + * - it has a method `isEmpty()` and `value.isEmpty() == true`, or + * - it's an `Omittable<>` and `value.omitted() == true`. + * + * If \p container is a QUrlQuery, an attempt to fit \p value into it is + * made as follows: + * - if \p value is a QJsonObject, \p key is ignored and pairs from \p value + * are copied to \p container, assuming that the value in each pair + * is a string; + * - if \p value is a QStringList, it is "exploded" into a list of key-value + * pairs with key equal to \p key and value taken from each list item; + * - if \p value is a bool, its OpenAPI (i.e. JSON) representation is added + * to the query (`true` or `false`, respectively). + * + * \tparam Force add the pair even if the value is empty. This is true + * by default; passing IfNotEmpty or false for this parameter + * enables emptiness checks as described above + */ template <bool Force = true, typename ContT, typename ValT> inline void addParam(ContT& container, const QString& key, ValT&& value) { diff --git a/lib/e2ee.h b/lib/e2ee.h new file mode 100644 index 00000000..11f411f1 --- /dev/null +++ b/lib/e2ee.h @@ -0,0 +1,30 @@ +#pragma once + +#include <QtCore/QStringList> + +namespace QMatrixClient +{ +static const auto CiphertextKeyL = "ciphertext"_ls; +static const auto SenderKeyKeyL = "sender_key"_ls; +static const auto DeviceIdKeyL = "device_id"_ls; +static const auto SessionIdKeyL = "session_id"_ls; + +static const auto AlgorithmKeyL = "algorithm"_ls; +static const auto RotationPeriodMsKeyL = "rotation_period_ms"_ls; +static const auto RotationPeriodMsgsKeyL = "rotation_period_msgs"_ls; + +static const auto AlgorithmKey = QStringLiteral("algorithm"); +static const auto RotationPeriodMsKey = QStringLiteral("rotation_period_ms"); +static const auto RotationPeriodMsgsKey = + QStringLiteral("rotation_period_msgs"); + +static const auto Ed25519Key = QStringLiteral("ed25519"); +static const auto Curve25519Key = QStringLiteral("curve25519"); +static const auto SignedCurve25519Key = QStringLiteral("signed_curve25519"); +static const auto OlmV1Curve25519AesSha2AlgoKey = + QStringLiteral("m.olm.v1.curve25519-aes-sha2"); +static const auto MegolmV1AesSha2AlgoKey = + QStringLiteral("m.megolm.v1.aes-sha2"); +static const QStringList SupportedAlgorithms = { OlmV1Curve25519AesSha2AlgoKey, + MegolmV1AesSha2AlgoKey }; +} // namespace QMatrixClient diff --git a/lib/encryptionmanager.cpp b/lib/encryptionmanager.cpp index 61fcf9b4..46d937b8 100644 --- a/lib/encryptionmanager.cpp +++ b/lib/encryptionmanager.cpp @@ -1,6 +1,7 @@ #include "encryptionmanager.h" #include "connection.h" +#include "e2ee.h" #include "csapi/keys.h" @@ -15,16 +16,6 @@ using namespace QMatrixClient; using namespace QtOlm; using std::move; -static const auto ed25519Name = QStringLiteral("ed25519"); -static const auto Curve25519Name = QStringLiteral("curve25519"); -static const auto SignedCurve25519Name = QStringLiteral("signed_curve25519"); -static const auto OlmV1Curve25519AesSha2AlgoName = - QStringLiteral("m.olm.v1.curve25519-aes-sha2"); -static const auto MegolmV1AesSha2AlgoName = - QStringLiteral("m.megolm.v1.aes-sha2"); -static const QStringList SupportedAlgorithms = { OlmV1Curve25519AesSha2AlgoName, - MegolmV1AesSha2AlgoName }; - class EncryptionManager::Private { public: @@ -56,9 +47,9 @@ public: */ targetKeysNumber = olmAccount->maxOneTimeKeys(); // 2 // see note below targetOneTimeKeyCounts = { - { SignedCurve25519Name, + { SignedCurve25519Key, qRound(signedKeysProportion * targetKeysNumber) }, - { Curve25519Name, + { Curve25519Key, qRound((1 - signedKeysProportion) * targetKeysNumber) } }; } @@ -115,9 +106,9 @@ void EncryptionManager::uploadIdentityKeys(Connection* connection) * format <algorithm>:<device_id>. The keys themselves should be encoded * as specified by the key algorithm. */ - { { Curve25519Name + QStringLiteral(":") + connection->deviceId(), + { { Curve25519Key + QStringLiteral(":") + connection->deviceId(), d->olmAccount->curve25519IdentityKey() }, - { ed25519Name + QStringLiteral(":") + connection->deviceId(), + { Ed25519Key + QStringLiteral(":") + connection->deviceId(), d->olmAccount->ed25519IdentityKey() } }, /* signatures should be provided after the unsigned deviceKeys generation */ @@ -138,7 +129,7 @@ void EncryptionManager::uploadIdentityKeys(Connection* connection) */ deviceKeys.signatures = { { connection->userId(), - { { ed25519Name + QStringLiteral(":") + connection->deviceId(), + { { Ed25519Key + QStringLiteral(":") + connection->deviceId(), d->olmAccount->sign(deviceKeysJsonObject) } } } }; @@ -160,9 +151,9 @@ void EncryptionManager::uploadOneTimeKeys(Connection* connection, } int signedKeysToUploadCount = - d->oneTimeKeysToUploadCounts.value(SignedCurve25519Name, 0); + d->oneTimeKeysToUploadCounts.value(SignedCurve25519Key, 0); int unsignedKeysToUploadCount = - d->oneTimeKeysToUploadCounts.value(Curve25519Name, 0); + d->oneTimeKeysToUploadCounts.value(Curve25519Key, 0); d->olmAccount->generateOneTimeKeys(signedKeysToUploadCount + unsignedKeysToUploadCount); @@ -181,11 +172,11 @@ void EncryptionManager::uploadOneTimeKeys(Connection* connection, QJsonObject message { { QStringLiteral("key"), it.value().toString() } }; key = d->olmAccount->sign(message); - keyType = SignedCurve25519Name; + keyType = SignedCurve25519Key; } else { key = it.value(); - keyType = Curve25519Name; + keyType = Curve25519Key; } ++oneTimeKeysCounter; oneTimeKeys.insert(QString("%1:%2").arg(keyType).arg(keyId), key); @@ -204,6 +195,11 @@ QByteArray EncryptionManager::olmAccountPickle() return d->olmAccount->pickle(); // TODO: passphrase even with qtkeychain? } +QtOlm::Account* EncryptionManager::account() const +{ + return d->olmAccount.data(); +} + void EncryptionManager::Private::updateKeysToUpload() { for (auto it = targetOneTimeKeyCounts.cbegin(); diff --git a/lib/encryptionmanager.h b/lib/encryptionmanager.h index a41d88e1..02bb882f 100644 --- a/lib/encryptionmanager.h +++ b/lib/encryptionmanager.h @@ -5,6 +5,11 @@ #include <functional> #include <memory> +namespace QtOlm +{ +class Account; +} + namespace QMatrixClient { class Connection; @@ -26,6 +31,8 @@ public: void uploadOneTimeKeys(Connection* connection, bool forceUpdate = false); QByteArray olmAccountPickle(); + QtOlm::Account* account() const; + private: class Private; std::unique_ptr<Private> d; diff --git a/lib/events/encryptedevent.cpp b/lib/events/encryptedevent.cpp new file mode 100644 index 00000000..dac245fa --- /dev/null +++ b/lib/events/encryptedevent.cpp @@ -0,0 +1,32 @@ +#include "encryptedevent.h" + +#include "room.h" + +using namespace QMatrixClient; +using namespace QtOlm; + +EncryptedEvent::EncryptedEvent(const QJsonObject& ciphertext, + const QString& senderKey) + : RoomEvent(typeId(), matrixTypeId(), + { { AlgorithmKeyL, OlmV1Curve25519AesSha2AlgoKey }, + { CiphertextKeyL, ciphertext }, + { SenderKeyKeyL, senderKey } }) +{} + +EncryptedEvent::EncryptedEvent(QByteArray ciphertext, const QString& senderKey, + const QString& deviceId, const QString& sessionId) + : RoomEvent(typeId(), matrixTypeId(), + { + { AlgorithmKeyL, MegolmV1AesSha2AlgoKey }, + { CiphertextKeyL, QString(ciphertext) }, + { DeviceIdKeyL, deviceId }, + { SenderKeyKeyL, senderKey }, + { SessionIdKeyL, sessionId }, + }) +{} + +EncryptedEvent::EncryptedEvent(const QJsonObject& obj) + : RoomEvent(typeId(), obj) +{ + qCDebug(EVENTS) << "Encrypted event" << id(); +} diff --git a/lib/events/encryptedevent.h b/lib/events/encryptedevent.h new file mode 100644 index 00000000..0dbce25c --- /dev/null +++ b/lib/events/encryptedevent.h @@ -0,0 +1,68 @@ +#pragma once + +#include "e2ee.h" +#include "roomevent.h" + +namespace QMatrixClient +{ +class Room; +/* + * While the specification states: + * + * "This event type is used when sending encrypted events. + * It can be used either within a room + * (in which case it will have all of the Room Event fields), + * or as a to-device event." + * "The encrypted payload can contain any message event." + * https://matrix.org/docs/spec/client_server/latest#id493 + * + * -- for most of the cases the message event is the room message event. + * And even for the to-device events the context is for the room. + * + * So, to simplify integration to the timeline, EncryptedEvent is a RoomEvent + * inheritor. Strictly speaking though, it's not always a RoomEvent, but an Event + * in general. It's possible, because RoomEvent interface is similar to Event's + * one and doesn't add new restrictions, just provides additional features. + */ +class EncryptedEvent : public RoomEvent +{ + Q_GADGET +public: + DEFINE_EVENT_TYPEID("m.room.encrypted", EncryptedEvent) + + /* In case with Olm, the encrypted content of the event is + * a map from the recipient Curve25519 identity key to ciphertext + * information */ + explicit EncryptedEvent(const QJsonObject& ciphertext, + const QString& senderKey); + /* In case with Megolm, device_id and session_id are required */ + explicit EncryptedEvent(QByteArray ciphertext, const QString& senderKey, + const QString& deviceId, const QString& sessionId); + explicit EncryptedEvent(const QJsonObject& obj); + + QString algorithm() const + { + QString algo = content<QString>(AlgorithmKeyL); + if (!SupportedAlgorithms.contains(algo)) { + qWarning(MAIN) << "The EncryptedEvent's algorithm" << algo + << "is not supported"; + } + return algo; + } + QByteArray ciphertext() const + { + return content<QString>(CiphertextKeyL).toLatin1(); + } + QJsonObject ciphertext(const QString& identityKey) const + { + return content<QJsonObject>(CiphertextKeyL).value(identityKey).toObject(); + } + QString senderKey() const { return content<QString>(SenderKeyKeyL); } + + /* device_id and session_id are required with Megolm */ + QString deviceId() const { return content<QString>(DeviceIdKeyL); } + QString sessionId() const { return content<QString>(SessionIdKeyL); } +}; +REGISTER_EVENT_TYPE(EncryptedEvent) + +} // namespace QMatrixClient diff --git a/lib/events/encryptionevent.cpp b/lib/events/encryptionevent.cpp index 6aa7063b..995c8dad 100644 --- a/lib/events/encryptionevent.cpp +++ b/lib/events/encryptionevent.cpp @@ -6,12 +6,14 @@ #include "encryptionevent.h" #include "converters.h" +#include "e2ee.h" #include "logging.h" #include <array> -static const std::array<QString, 1> encryptionStrings = { { QStringLiteral( - "m.megolm.v1.aes-sha2") } }; +static const std::array<QString, 1> encryptionStrings = { + { QMatrixClient::MegolmV1AesSha2AlgoKey } +}; namespace QMatrixClient { @@ -36,9 +38,9 @@ using namespace QMatrixClient; EncryptionEventContent::EncryptionEventContent(const QJsonObject& json) : encryption(fromJson<EncryptionType>(json["algorithm"_ls])) - , algorithm(sanitized(json["algorithm"_ls].toString())) - , rotationPeriodMs(json["rotation_period_ms"_ls].toInt(604800000)) - , rotationPeriodMsgs(json["rotation_period_msgs"_ls].toInt(100)) + , algorithm(sanitized(json[AlgorithmKeyL].toString())) + , rotationPeriodMs(json[RotationPeriodMsKeyL].toInt(604800000)) + , rotationPeriodMsgs(json[RotationPeriodMsgsKeyL].toInt(100)) {} void EncryptionEventContent::fillJson(QJsonObject* o) const @@ -48,7 +50,7 @@ void EncryptionEventContent::fillJson(QJsonObject* o) const encryption != EncryptionType::Undefined, __FUNCTION__, "The key 'algorithm' must be explicit in EncryptionEventContent"); if (encryption != EncryptionType::Undefined) - o->insert(QStringLiteral("algorithm"), algorithm); - o->insert(QStringLiteral("rotation_period_ms"), rotationPeriodMs); - o->insert(QStringLiteral("rotation_period_msgs"), rotationPeriodMsgs); + o->insert(AlgorithmKey, algorithm); + o->insert(RotationPeriodMsKey, rotationPeriodMs); + o->insert(RotationPeriodMsgsKey, rotationPeriodMsgs); } diff --git a/lib/events/event.h b/lib/events/event.h index 8056ccbe..d6525281 100644 --- a/lib/events/event.h +++ b/lib/events/event.h @@ -58,11 +58,13 @@ ptrCast(event_ptr_tt<SourceT>&& ptr) // === Standard Matrix key names and basicEventJson() === static const auto TypeKey = QStringLiteral("type"); +static const auto BodyKey = QStringLiteral("body"); static const auto ContentKey = QStringLiteral("content"); static const auto EventIdKey = QStringLiteral("event_id"); static const auto UnsignedKey = QStringLiteral("unsigned"); static const auto StateKeyKey = QStringLiteral("state_key"); static const auto TypeKeyL = "type"_ls; +static const auto BodyKeyL = "body"_ls; static const auto ContentKeyL = "content"_ls; static const auto EventIdKeyL = "event_id"_ls; static const auto UnsignedKeyL = "unsigned"_ls; @@ -391,10 +393,8 @@ template <typename BaseEventT, typename FnT> inline std::enable_if_t<is_event<BaseEventT>() && needs_cast<BaseEventT, FnT>(), fn_return_t<FnT>> // non-voidness is guarded by // defaultValue type - visit(const BaseEventT& event, - FnT&& visitor, - fn_return_t<FnT>&& - defaultValue = {}) +visit(const BaseEventT& event, FnT&& visitor, + fn_return_t<FnT>&& defaultValue = {}) { using event_type = fn_arg_t<FnT>; if (is<std::decay_t<event_type>>(event)) diff --git a/lib/events/reactionevent.cpp b/lib/events/reactionevent.cpp new file mode 100644 index 00000000..0a080607 --- /dev/null +++ b/lib/events/reactionevent.cpp @@ -0,0 +1,44 @@ +/****************************************************************************** + * Copyright (C) 2019 Kitsune Ral <kitsune-ral@users.sf.net> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "reactionevent.h" + +using namespace QMatrixClient; + +void QMatrixClient::JsonObjectConverter<EventRelation>::dumpTo( + QJsonObject& jo, const EventRelation& pod) +{ + if (pod.type.isEmpty()) { + qCWarning(MAIN) << "Empty relation type; won't dump to JSON"; + return; + } + jo.insert(QStringLiteral("rel_type"), pod.type); + jo.insert(EventIdKey, pod.eventId); + if (pod.type == EventRelation::Annotation()) + jo.insert(QStringLiteral("key"), pod.key); +} + +void QMatrixClient::JsonObjectConverter<EventRelation>::fillFrom( + const QJsonObject& jo, EventRelation& pod) +{ + // The experimental logic for generic relationships (MSC1849) + fromJson(jo["rel_type"_ls], pod.type); + fromJson(jo[EventIdKeyL], pod.eventId); + if (pod.type == EventRelation::Annotation()) + fromJson(jo["key"_ls], pod.key); +} diff --git a/lib/events/reactionevent.h b/lib/events/reactionevent.h new file mode 100644 index 00000000..d524b549 --- /dev/null +++ b/lib/events/reactionevent.h @@ -0,0 +1,79 @@ +/****************************************************************************** + * Copyright (C) 2019 Kitsune Ral <kitsune-ral@users.sf.net> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#pragma once + +#include "roomevent.h" + +namespace QMatrixClient +{ + +struct EventRelation +{ + using reltypeid_t = const char*; + static constexpr reltypeid_t Reply() { return "m.in_reply_to"; } + static constexpr reltypeid_t Annotation() { return "m.annotation"; } + static constexpr reltypeid_t Replacement() { return "m.replace"; } + + QString type; + QString eventId; + QString key = {}; // Only used for m.annotation for now + + static EventRelation replyTo(QString eventId) + { + return { Reply(), std::move(eventId) }; + } + static EventRelation annotate(QString eventId, QString key) + { + return { Annotation(), std::move(eventId), std::move(key) }; + } + static EventRelation replace(QString eventId) + { + return { Replacement(), std::move(eventId) }; + } +}; +template <> +struct JsonObjectConverter<EventRelation> +{ + static void dumpTo(QJsonObject& jo, const EventRelation& pod); + static void fillFrom(const QJsonObject& jo, EventRelation& pod); +}; + +class ReactionEvent : public RoomEvent +{ +public: + DEFINE_EVENT_TYPEID("m.reaction", ReactionEvent) + + explicit ReactionEvent(const EventRelation& value) + : RoomEvent(typeId(), matrixTypeId(), + { { QStringLiteral("m.relates_to"), toJson(value) } }) + {} + explicit ReactionEvent(const QJsonObject& obj) + : RoomEvent(typeId(), obj) + {} + EventRelation relation() const + { + return content<EventRelation>(QStringLiteral("m.relates_to")); + } + +private: + EventRelation _relation; +}; +REGISTER_EVENT_TYPE(ReactionEvent) + +} // namespace QMatrixClient diff --git a/lib/events/roomevent.cpp b/lib/events/roomevent.cpp index 513a99d0..fb715473 100644 --- a/lib/events/roomevent.cpp +++ b/lib/events/roomevent.cpp @@ -62,6 +62,20 @@ QString RoomEvent::senderId() const return fullJson()["sender"_ls].toString(); } +bool RoomEvent::isReplaced() const +{ + return unsignedJson()["m.relations"_ls].toObject().contains("m.replace"); +} + +QString RoomEvent::replacedBy() const +{ + // clang-format off + return unsignedJson()["m.relations"_ls].toObject() + .value("m.replace").toObject() + .value(EventIdKeyL).toString(); + // clang-format on +} + QString RoomEvent::redactionReason() const { return isRedacted() ? _redactedBecause->reason() : QString {}; diff --git a/lib/events/roomevent.h b/lib/events/roomevent.h index dd0d25eb..8edb397c 100644 --- a/lib/events/roomevent.h +++ b/lib/events/roomevent.h @@ -51,6 +51,8 @@ public: QDateTime timestamp() const; QString roomId() const; QString senderId() const; + bool isReplaced() const; + QString replacedBy() const; bool isRedacted() const { return bool(_redactedBecause); } const event_ptr_tt<RedactionEvent>& redactedBecause() const { diff --git a/lib/events/roommessageevent.cpp b/lib/events/roommessageevent.cpp index c7f17303..da8d59ca 100644 --- a/lib/events/roommessageevent.cpp +++ b/lib/events/roommessageevent.cpp @@ -30,12 +30,12 @@ using namespace EventContent; using MsgType = RoomMessageEvent::MsgType; -static const auto RelatesToKey = "m.relates_to"_ls; -static const auto MsgTypeKey = "msgtype"_ls; -static const auto BodyKey = "body"_ls; -static const auto FormattedBodyKey = "formatted_body"_ls; +static const auto RelatesToKeyL = "m.relates_to"_ls; +static const auto MsgTypeKeyL = "msgtype"_ls; +static const auto FormattedBodyKeyL = "formatted_body"_ls; static const auto TextTypeKey = "m.text"; +static const auto EmoteTypeKey = "m.emote"; static const auto NoticeTypeKey = "m.notice"; static const auto HtmlContentTypeId = QStringLiteral("org.matrix.custom.html"); @@ -49,7 +49,7 @@ TypedBase* make(const QJsonObject& json) template <> TypedBase* make<TextContent>(const QJsonObject& json) { - return json.contains(FormattedBodyKey) || json.contains(RelatesToKey) + return json.contains(FormattedBodyKeyL) || json.contains(RelatesToKeyL) ? new TextContent(json) : nullptr; } @@ -63,7 +63,7 @@ struct MsgTypeDesc const std::vector<MsgTypeDesc> msgTypes = { { TextTypeKey, MsgType::Text, make<TextContent> }, - { QStringLiteral("m.emote"), MsgType::Emote, make<TextContent> }, + { EmoteTypeKey, MsgType::Emote, make<TextContent> }, { NoticeTypeKey, MsgType::Notice, make<TextContent> }, { QStringLiteral("m.image"), MsgType::Image, make<ImageContent> }, { QStringLiteral("m.file"), MsgType::File, make<FileContent> }, @@ -101,11 +101,25 @@ QJsonObject RoomMessageEvent::assembleContentJson(const QString& plainBody, TypedBase* content) { auto json = content ? content->toJson() : QJsonObject(); - if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey - && json.contains(RelatesToKey)) { - json.remove(RelatesToKey); - qCWarning(EVENTS) << RelatesToKey << "cannot be used in" << jsonMsgType - << "messages; the relation has been stripped off"; + if (json.contains(RelatesToKeyL)) { + if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey + && jsonMsgType != EmoteTypeKey) { + json.remove(RelatesToKeyL); + qCWarning(EVENTS) + << RelatesToKeyL << "cannot be used in" << jsonMsgType + << "messages; the relation has been stripped off"; + } else { + // After the above, we know for sure that the content is TextContent + // and that its RelatesTo structure is not omitted + auto* textContent = static_cast<const TextContent*>(content); + if (textContent->relatesTo->type == RelatesTo::ReplacementTypeId()) { + auto newContentJson = json.take("m.new_content"_ls).toObject(); + newContentJson.insert(BodyKey, plainBody); + newContentJson.insert(TypeKey, jsonMsgType); + json.insert(QStringLiteral("m.new_content"), newContentJson); + json[BodyKeyL] = "* " + plainBody; + } + } } json.insert(QStringLiteral("msgtype"), jsonMsgType); json.insert(QStringLiteral("body"), plainBody); @@ -166,8 +180,8 @@ RoomMessageEvent::RoomMessageEvent(const QJsonObject& obj) if (isRedacted()) return; const QJsonObject content = contentJson(); - if (content.contains(MsgTypeKey) && content.contains(BodyKey)) { - auto msgtype = content[MsgTypeKey].toString(); + if (content.contains(MsgTypeKeyL) && content.contains(BodyKeyL)) { + auto msgtype = content[MsgTypeKeyL].toString(); bool msgTypeFound = false; for (const auto& mt : msgTypes) if (mt.matrixType == msgtype) { @@ -193,12 +207,12 @@ RoomMessageEvent::MsgType RoomMessageEvent::msgtype() const QString RoomMessageEvent::rawMsgtype() const { - return contentJson()[MsgTypeKey].toString(); + return contentJson()[MsgTypeKeyL].toString(); } QString RoomMessageEvent::plainBody() const { - return contentJson()[BodyKey].toString(); + return contentJson()[BodyKeyL].toString(); } QMimeType RoomMessageEvent::mimeType() const @@ -225,6 +239,17 @@ bool RoomMessageEvent::hasThumbnail() const return content() && content()->thumbnailInfo(); } +QString RoomMessageEvent::replacedEvent() const +{ + if (!content() || !hasTextContent()) + return {}; + + const auto& rel = static_cast<const TextContent*>(content())->relatesTo; + return !rel.omitted() && rel->type == RelatesTo::ReplacementTypeId() + ? rel->eventId + : QString(); +} + QString rawMsgTypeForMimeType(const QMimeType& mimeType) { auto name = mimeType.name(); @@ -256,39 +281,71 @@ TextContent::TextContent(const QString& text, const QString& contentType, mimeType = QMimeDatabase().mimeTypeForName("text/html"); } +namespace QMatrixClient +{ +// Overload the default fromJson<> logic that defined in converters.h +// as we want +template <> +Omittable<RelatesTo> fromJson(const QJsonValue& jv) +{ + const auto jo = jv.toObject(); + if (jo.isEmpty()) + return none; + const auto replyJson = jo.value(RelatesTo::ReplyTypeId()).toObject(); + if (!replyJson.isEmpty()) + return replyTo(fromJson<QString>(replyJson[EventIdKeyL])); + + return RelatesTo { jo.value("rel_type"_ls).toString(), + jo.value(EventIdKeyL).toString() }; +} +} // namespace QMatrixClient + TextContent::TextContent(const QJsonObject& json) + : relatesTo(fromJson<Omittable<RelatesTo>>(json[RelatesToKeyL])) { QMimeDatabase db; static const auto PlainTextMimeType = db.mimeTypeForName("text/plain"); static const auto HtmlMimeType = db.mimeTypeForName("text/html"); + const auto actualJson = + relatesTo.omitted() || relatesTo->type != RelatesTo::ReplacementTypeId() + ? json + : json.value("m.new_content"_ls).toObject(); // Special-casing the custom matrix.org's (actually, Riot's) way // of sending HTML messages. - if (json["format"_ls].toString() == HtmlContentTypeId) { + if (actualJson["format"_ls].toString() == HtmlContentTypeId) { mimeType = HtmlMimeType; - body = json[FormattedBodyKey].toString(); + body = actualJson[FormattedBodyKeyL].toString(); } else { // Falling back to plain text, as there's no standard way to describe // rich text in messages. mimeType = PlainTextMimeType; - body = json[BodyKey].toString(); + body = actualJson[BodyKeyL].toString(); } - const auto replyJson = - json[RelatesToKey].toObject().value(RelatesTo::ReplyTypeId()).toObject(); - if (!replyJson.isEmpty()) - relatesTo = replyTo(fromJson<QString>(replyJson[EventIdKeyL])); } void TextContent::fillJson(QJsonObject* json) const { + static const auto FormatKey = QStringLiteral("format"); + static const auto FormattedBodyKey = QStringLiteral("formatted_body"); + Q_ASSERT(json); if (mimeType.inherits("text/html")) { - json->insert(QStringLiteral("format"), HtmlContentTypeId); - json->insert(QStringLiteral("formatted_body"), body); + json->insert(FormatKey, HtmlContentTypeId); + json->insert(FormattedBodyKey, body); } - if (!relatesTo.omitted()) + if (!relatesTo.omitted()) { json->insert(QStringLiteral("m.relates_to"), QJsonObject { { relatesTo->type, relatesTo->eventId } }); + if (relatesTo->type == RelatesTo::ReplacementTypeId()) { + QJsonObject newContentJson; + if (mimeType.inherits("text/html")) { + json->insert(FormatKey, HtmlContentTypeId); + json->insert(FormattedBodyKey, body); + } + json->insert(QStringLiteral("m.new_content"), newContentJson); + } + } } LocationContent::LocationContent(const QString& geoUri, diff --git a/lib/events/roommessageevent.h b/lib/events/roommessageevent.h index eabb21e3..1f1fde41 100644 --- a/lib/events/roommessageevent.h +++ b/lib/events/roommessageevent.h @@ -77,6 +77,7 @@ public: bool hasTextContent() const; bool hasFileContent() const; bool hasThumbnail() const; + QString replacedEvent() const; static QString rawMsgTypeForUrl(const QUrl& url); static QString rawMsgTypeForFile(const QFileInfo& fi); @@ -84,6 +85,7 @@ public: private: QScopedPointer<EventContent::TypedBase> _content; + // FIXME: should it really be static? static QJsonObject assembleContentJson(const QString& plainBody, const QString& jsonMsgType, EventContent::TypedBase* content); @@ -101,6 +103,7 @@ namespace EventContent struct RelatesTo { static constexpr const char* ReplyTypeId() { return "m.in_reply_to"; } + static constexpr const char* ReplacementTypeId() { return "m.replace"; } QString type; // The only supported relation so far QString eventId; }; diff --git a/lib/jobs/basejob.cpp b/lib/jobs/basejob.cpp index f46d2d61..e4a74954 100644 --- a/lib/jobs/basejob.cpp +++ b/lib/jobs/basejob.cpp @@ -286,7 +286,7 @@ void BaseJob::gotReply() QJsonDocument::fromJson(d->rawResponse).object())); } - if (error() != TooManyRequestsError) + if (status().code != TooManyRequestsError) finishJob(); else { stop(); diff --git a/lib/jobs/basejob.h b/lib/jobs/basejob.h index d94ab31d..f445d087 100644 --- a/lib/jobs/basejob.h +++ b/lib/jobs/basejob.h @@ -61,8 +61,7 @@ public: WarningLevel = 20, UnexpectedResponseType = 21, UnexpectedResponseTypeWarning = UnexpectedResponseType, - Abandoned = 50 //< A very brief period between abandoning and object - //deletion + Abandoned = 50 //< A tiny period between abandoning and object deletion , ErrorLevel = 100 //< Errors have codes starting from this , diff --git a/lib/room.cpp b/lib/room.cpp index 045cc7bc..cf58f3c0 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -21,6 +21,7 @@ #include "avatar.h" #include "connection.h" #include "converters.h" +#include "e2ee.h" #include "syncdata.h" #include "user.h" @@ -42,6 +43,7 @@ #include "events/callhangupevent.h" #include "events/callinviteevent.h" #include "events/encryptionevent.h" +#include "events/reactionevent.h" #include "events/receiptevent.h" #include "events/redactionevent.h" #include "events/roomavatarevent.h" @@ -65,6 +67,9 @@ #include <array> #include <cmath> #include <functional> +#include <groupsession.h> // QtOlm +#include <message.h> // QtOlm +#include <session.h> // QtOlm using namespace QMatrixClient; using namespace std::placeholders; @@ -114,7 +119,10 @@ public: Timeline timeline; PendingEvents unsyncedEvents; QHash<QString, TimelineItem::index_t> eventsIndex; - + // A map from evtId to a map of relation type to a vector of event + // pointers. Not using QMultiHash, because we want to quickly return + // a number of relations for a given event without enumerating them. + QHash<QPair<QString, QString>, RelatedEvents> relations; QString displayname; Avatar avatar; int highlightCount = 0; @@ -314,14 +322,22 @@ public: return requestSetState(EvT(std::forward<ArgTs>(args)...)); } - /** - * @brief Apply redaction to the timeline + /*! Apply redaction to the timeline * * Tries to find an event in the timeline and redact it; deletes the * redaction event whether the redacted event was found or not. + * \return true if the event has been found and redacted; false otherwise */ bool processRedaction(const RedactionEvent& redaction); + /*! Apply a new revision of the event to the timeline + * + * Tries to find an event in the timeline and replace it with the new + * content passed in \p newMessage. + * \return true if the event has been found and replaced; false otherwise + */ + bool processReplacement(const RoomMessageEvent& newMessage); + void setTags(TagsMap newTags); QJsonObject toJson() const; @@ -679,10 +695,10 @@ Room::rev_iter_t Room::findInTimeline(const QString& evtId) const { if (!d->timeline.empty() && d->eventsIndex.contains(evtId)) { auto it = findInTimeline(d->eventsIndex.value(evtId)); - Q_ASSERT((*it)->id() == evtId); + Q_ASSERT(it != historyEdge() && (*it)->id() == evtId); return it; } - return timelineEdge(); + return historyEdge(); } Room::PendingEvents::iterator Room::findPendingEvent(const QString& txnId) @@ -702,6 +718,18 @@ Room::findPendingEvent(const QString& txnId) const }); } +const Room::RelatedEvents Room::relatedEvents(const QString& evtId, + const char* relType) const +{ + return d->relations.value({ evtId, relType }); +} + +const Room::RelatedEvents Room::relatedEvents(const RoomEvent& evt, + const char* relType) const +{ + return relatedEvents(evt.id(), relType); +} + void Room::Private::getAllMembers() { // If already loaded or already loading, there's nothing to do here. @@ -1094,6 +1122,92 @@ bool Room::usesEncryption() const return !d->getCurrentState<EncryptionEvent>()->algorithm().isEmpty(); } +const RoomEvent* Room::decryptMessage(EncryptedEvent* encryptedEvent) const +{ + if (encryptedEvent->algorithm() == OlmV1Curve25519AesSha2AlgoKey) { + QString identityKey = connection()->olmAccount()->curve25519IdentityKey(); + QJsonObject personalCipherObject = + encryptedEvent->ciphertext(identityKey); + if (personalCipherObject.isEmpty()) { + qCDebug(EVENTS) << "Encrypted event is not for the current device"; + return nullptr; + } + return makeEvent<RoomMessageEvent>( + decryptMessage(personalCipherObject, + encryptedEvent->senderKey().toLatin1())) + .get(); + } + if (encryptedEvent->algorithm() == MegolmV1AesSha2AlgoKey) { + return makeEvent<RoomMessageEvent>( + decryptMessage(encryptedEvent->ciphertext(), + encryptedEvent->senderKey(), + encryptedEvent->deviceId(), + encryptedEvent->sessionId())) + .get(); + } + return nullptr; +} + +const QString Room::decryptMessage(QJsonObject personalCipherObject, + QByteArray senderKey) const +{ + QString decrypted; + + using namespace QtOlm; + // TODO: new objects to private fields: + InboundSession* session; + + int type = personalCipherObject.value(TypeKeyL).toInt(-1); + QByteArray body = personalCipherObject.value(BodyKeyL).toString().toLatin1(); + + PreKeyMessage* preKeyMessage = new PreKeyMessage(body); + session = new InboundSession(connection()->olmAccount(), preKeyMessage, + senderKey); + if (type == 0) { + if (!session->matches(preKeyMessage, senderKey)) { + connection()->olmAccount()->removeOneTimeKeys(session); + } + try { + decrypted = session->decrypt(preKeyMessage); + } catch (std::runtime_error& e) { + qWarning(EVENTS) << "Decrypt failed:" << e.what(); + } + } else if (type == 1) { + Message* message = new Message(body); + if (!session->matches(preKeyMessage, senderKey)) { + qWarning(EVENTS) << "Invalid encrypted message"; + } + try { + decrypted = session->decrypt(message); + } catch (std::runtime_error& e) { + qWarning(EVENTS) << "Decrypt failed:" << e.what(); + } + } + + return decrypted; +} + +const QString Room::sessionKey(const QString& senderKey, const QString& deviceId, + const QString& sessionId) const +{ + // TODO: handling an m.room_key event + return ""; +} + +const QString Room::decryptMessage(QByteArray cipher, const QString& senderKey, + const QString& deviceId, + const QString& sessionId) const +{ + QString decrypted; + using namespace QtOlm; + InboundGroupSession* groupSession; + groupSession = new InboundGroupSession( + sessionKey(senderKey, deviceId, sessionId).toLatin1()); + groupSession->decrypt(cipher); + // TODO: avoid replay attacks + return decrypted; +} + int Room::joinedCount() const { return d->summary.joinedMemberCount.omitted() @@ -1468,6 +1582,11 @@ QString Room::postHtmlText(const QString& plainText, const QString& html) return postHtmlMessage(plainText, html); } +QString Room::postReaction(const QString& eventId, const QString& key) +{ + return d->sendEvent<ReactionEvent>(EventRelation::annotate(eventId, key)); +} + QString Room::postFile(const QString& plainText, const QUrl& localPath, bool asGenericFile) { @@ -1915,11 +2034,64 @@ bool Room::Private::processRedaction(const RedactionEvent& redaction) updateDisplayname(); } } + if (const auto* reaction = eventCast<ReactionEvent>(oldEvent)) { + const auto& targetEvtId = reaction->relation().eventId; + const auto lookupKey = qMakePair(targetEvtId, + EventRelation::Annotation()); + if (relations.contains(lookupKey)) { + relations[lookupKey].removeOne(reaction); + } + } q->onRedaction(*oldEvent, *ti); emit q->replacedEvent(ti.event(), rawPtr(oldEvent)); return true; } +/** Make a replaced event + * + * Takes \p target and returns a copy of it with content taken from + * \p replacement. Disposal of the original event after that is on the caller. + */ +RoomEventPtr makeReplaced(const RoomEvent& target, + const RoomMessageEvent& replacement) +{ + auto originalJson = target.originalJsonObject(); + originalJson[ContentKeyL] = replacement.contentJson(); + + auto unsignedData = originalJson.take(UnsignedKeyL).toObject(); + auto relations = unsignedData.take("m.relations"_ls).toObject(); + relations["m.replace"_ls] = replacement.id(); + unsignedData.insert(QStringLiteral("m.relations"), relations); + originalJson.insert(UnsignedKey, unsignedData); + + return loadEvent<RoomEvent>(originalJson); +} + +bool Room::Private::processReplacement(const RoomMessageEvent& newEvent) +{ + // Can't use findInTimeline because it returns a const iterator, and + // we need to change the underlying TimelineItem. + const auto pIdx = eventsIndex.find(newEvent.replacedEvent()); + if (pIdx == eventsIndex.end()) + return false; + + Q_ASSERT(q->isValidIndex(*pIdx)); + + auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())]; + if (ti->replacedBy() == newEvent.id()) { + qCDebug(MAIN) << "Event" << ti->id() << "is already replaced with" + << newEvent.id(); + return true; + } + + // Make a new event from the redacted JSON and put it in the timeline + // instead of the redacted one. oldEvent will be deleted on return. + auto oldEvent = ti.replaceEvent(makeReplaced(*ti, newEvent)); + qCDebug(MAIN) << "Replaced" << oldEvent->id() << "with" << newEvent.id(); + emit q->replacedEvent(ti.event(), rawPtr(oldEvent)); + return true; +} + Connection* Room::connection() const { Q_ASSERT(d->connection); @@ -1928,10 +2100,16 @@ Connection* Room::connection() const User* Room::localUser() const { return connection()->user(); } -inline bool isRedaction(const RoomEventPtr& ep) +/// Whether the event is a redaction or a replacement +inline bool isEditing(const RoomEventPtr& ep) { Q_ASSERT(ep); - return is<RedactionEvent>(*ep); + if (is<RedactionEvent>(*ep)) + return true; + if (auto* msgEvent = eventCast<RoomMessageEvent>(ep)) + return msgEvent->replacedEvent().isEmpty(); + + return false; } Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) @@ -1940,28 +2118,52 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) if (events.empty()) return Change::NoChange; - // Pre-process redactions so that events that get redacted in the same - // batch landed in the timeline already redacted. - // NB: We have to store redaction events to the timeline too - see #220. - auto redactionIt = std::find_if(events.begin(), events.end(), isRedaction); - for (const auto& eptr : RoomEventsRange(redactionIt, events.end())) - if (auto* r = eventCast<RedactionEvent>(eptr)) { - // Try to find the target in the timeline, then in the batch. - if (processRedaction(*r)) - continue; - auto targetIt = - std::find_if(events.begin(), redactionIt, - [id = r->redactedEvent()](const RoomEventPtr& ep) { - return ep->id() == id; - }); - if (targetIt != redactionIt) - *targetIt = makeRedacted(**targetIt, *r); - else - qCDebug(MAIN) - << "Redaction" << r->id() << "ignored: target event" - << r->redactedEvent() << "is not found"; - // If the target event comes later, it comes already redacted. + { + // Pre-process redactions and edits so that events that get + // redacted/replaced in the same batch landed in the timeline already + // treated. + // NB: We have to store redacting/replacing events to the timeline too - + // see #220. + auto it = std::find_if(events.begin(), events.end(), isEditing); + for (const auto& eptr : RoomEventsRange(it, events.end())) { + if (auto* r = eventCast<RedactionEvent>(eptr)) { + // Try to find the target in the timeline, then in the batch. + if (processRedaction(*r)) + continue; + auto targetIt = std::find_if(events.begin(), it, + [id = r->redactedEvent()]( + const RoomEventPtr& ep) { + return ep->id() == id; + }); + if (targetIt != it) + *targetIt = makeRedacted(**targetIt, *r); + else + qCDebug(MAIN) + << "Redaction" << r->id() << "ignored: target event" + << r->redactedEvent() << "is not found"; + // If the target event comes later, it comes already redacted. + } + if (auto* msg = eventCast<RoomMessageEvent>(eptr)) { + if (!msg->replacedEvent().isEmpty()) { + if (processReplacement(*msg)) + continue; + auto targetIt = std::find_if(events.begin(), it, + [id = msg->replacedEvent()]( + const RoomEventPtr& ep) { + return ep->id() == id; + }); + if (targetIt != it) + *targetIt = makeReplaced(**targetIt, *msg); + else // FIXME: don't ignore, just show it wherever it arrived + qCDebug(MAIN) << "Replacing event" << msg->id() + << "ignored: replaced event" + << msg->replacedEvent() << "is not found"; + // Same as with redactions above, the replaced event coming + // later will come already with the new content. + } + } } + } // State changes arrive as a part of timeline; the current room state gets // updated before merging events to the timeline because that's what @@ -2026,6 +2228,14 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) emit q->callEvent(q, evt); if (totalInserted > 0) { + for (auto it = from; it != timeline.cend(); ++it) { + if (const auto* reaction = it->viewAs<ReactionEvent>()) { + const auto& relation = reaction->relation(); + relations[{ relation.eventId, relation.type }] << reaction; + emit q->updatedEvent(relation.eventId); + } + } + qCDebug(MAIN) << "Room" << q->objectName() << "received" << totalInserted << "new events; the last event is now" << timeline.back(); @@ -2081,6 +2291,13 @@ void Room::Private::addHistoricalMessageEvents(RoomEvents&& events) q->onAddHistoricalTimelineEvents(from); emit q->addedMessages(timeline.front().index(), from->index()); + for (auto it = from; it != timeline.crend(); ++it) { + if (const auto* reaction = it->viewAs<ReactionEvent>()) { + const auto& relation = reaction->relation(); + relations[{ relation.eventId, relation.type }] << reaction; + emit q->updatedEvent(relation.eventId); + } + } if (from <= q->readMarker()) updateUnreadCount(from, timeline.crend()); @@ -25,8 +25,10 @@ #include "csapi/message_pagination.h" #include "events/accountdataevents.h" +#include "events/encryptedevent.h" #include "events/roommessageevent.h" +#include <QtCore/QJsonObject> #include <QtGui/QImage> #include <deque> @@ -140,6 +142,7 @@ class Room : public QObject public: using Timeline = std::deque<TimelineItem>; using PendingEvents = std::vector<PendingEventItem>; + using RelatedEvents = QVector<const RoomEvent*>; using rev_iter_t = Timeline::const_reverse_iterator; using timeline_iter_t = Timeline::const_iterator; @@ -199,6 +202,14 @@ public: memberCount() const; int timelineSize() const; bool usesEncryption() const; + const RoomEvent* decryptMessage(EncryptedEvent* encryptedEvent) const; + const QString decryptMessage(QJsonObject personalCipherObject, + QByteArray senderKey) const; + const QString sessionKey(const QString& senderKey, const QString& deviceId, + const QString& sessionId) const; + const QString decryptMessage(QByteArray cipher, const QString& senderKey, + const QString& deviceId, + const QString& sessionId) const; int joinedCount() const; int invitedCount() const; int totalMemberCount() const; @@ -284,6 +295,11 @@ public: PendingEvents::iterator findPendingEvent(const QString& txnId); PendingEvents::const_iterator findPendingEvent(const QString& txnId) const; + const RelatedEvents relatedEvents(const QString& evtId, + const char* relType) const; + const RelatedEvents relatedEvents(const RoomEvent& evt, + const char* relType) const; + bool displayed() const; /// Mark the room as currently displayed to the user /** @@ -443,6 +459,8 @@ public slots: QString postHtmlMessage(const QString& plainText, const QString& html, MessageEventType type = MessageEventType::Text); QString postHtmlText(const QString& plainText, const QString& html); + /// Send a reaction on a given event with a given key + QString postReaction(const QString& eventId, const QString& key); QString postFile(const QString& plainText, const QUrl& localPath, bool asGenericFile = false); /** Post a pre-created room message event @@ -599,6 +617,7 @@ signals: void tagsAboutToChange(); void tagsChanged(); + void updatedEvent(QString eventId); void replacedEvent(const RoomEvent* newEvent, const RoomEvent* oldEvent); void newFileTransfer(QString id, QUrl localFile); diff --git a/libqmatrixclient.pri b/libqmatrixclient.pri index c561a415..2ff14ce2 100644 --- a/libqmatrixclient.pri +++ b/libqmatrixclient.pri @@ -35,6 +35,7 @@ HEADERS += \ $$SRCPATH/events/roomavatarevent.h \ $$SRCPATH/events/typingevent.h \ $$SRCPATH/events/receiptevent.h \ + $$SRCPATH/events/reactionevent.h \ $$SRCPATH/events/callanswerevent.h \ $$SRCPATH/events/callcandidatesevent.h \ $$SRCPATH/events/callhangupevent.h \ @@ -42,6 +43,7 @@ HEADERS += \ $$SRCPATH/events/accountdataevents.h \ $$SRCPATH/events/directchatevent.h \ $$SRCPATH/events/encryptionevent.h \ + $$SRCPATH/events/encryptedevent.h \ $$SRCPATH/events/redactionevent.h \ $$SRCPATH/events/eventloader.h \ $$SRCPATH/jobs/requestdata.h \ @@ -80,6 +82,7 @@ SOURCES += \ $$SRCPATH/events/roommessageevent.cpp \ $$SRCPATH/events/roommemberevent.cpp \ $$SRCPATH/events/typingevent.cpp \ + $$SRCPATH/events/reactionevent.cpp \ $$SRCPATH/events/callanswerevent.cpp \ $$SRCPATH/events/callcandidatesevent.cpp \ $$SRCPATH/events/callhangupevent.cpp \ @@ -87,6 +90,7 @@ SOURCES += \ $$SRCPATH/events/receiptevent.cpp \ $$SRCPATH/events/directchatevent.cpp \ $$SRCPATH/events/encryptionevent.cpp \ + $$SRCPATH/events/encryptedevent.cpp \ $$SRCPATH/jobs/requestdata.cpp \ $$SRCPATH/jobs/basejob.cpp \ $$SRCPATH/jobs/syncjob.cpp \ |