/****************************************************************************** * Copyright (C) 2015 Felix Rohrbach * * 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 "connection.h" #include "connectiondata.h" #include "user.h" #include "events/event.h" #include "events/directchatevent.h" #include "room.h" #include "settings.h" #include "jobs/generated/login.h" #include "jobs/generated/logout.h" #include "jobs/generated/receipts.h" #include "jobs/generated/leaving.h" #include "jobs/generated/account-data.h" #include "jobs/sendeventjob.h" #include "jobs/joinroomjob.h" #include "jobs/roommessagesjob.h" #include "jobs/syncjob.h" #include "jobs/mediathumbnailjob.h" #include "jobs/downloadfilejob.h" #include #include #include #include #include #include #include #include #include using namespace QMatrixClient; using DirectChatsMap = QMultiHash; class Connection::Private { public: explicit Private(std::unique_ptr&& connection) : data(move(connection)) { } Q_DISABLE_COPY(Private) Private(Private&&) = delete; Private operator=(Private&&) = delete; Connection* q = nullptr; std::unique_ptr data; // A complex key below is a pair of room name and whether its // state is Invited. The spec mandates to keep Invited room state // separately so we should, e.g., keep objects for Invite and // Leave state of the same room. QHash, Room*> roomMap; QVector roomIdsToForget; QMap userMap; DirectChatsMap directChats; QHash accountData; QString userId; SyncJob* syncJob = nullptr; bool cacheState = true; bool cacheToBinary = SettingsGroup("libqmatrixclient") .value("cache_type").toString() != "json"; void connectWithToken(const QString& user, const QString& accessToken, const QString& deviceId); void applyDirectChatUpdates(const DirectChatsMap& newMap); }; Connection::Connection(const QUrl& server, QObject* parent) : QObject(parent) , d(std::make_unique(std::make_unique(server))) { d->q = this; // All d initialization should occur before this line } Connection::Connection(QObject* parent) : Connection({}, parent) { } Connection::~Connection() { qCDebug(MAIN) << "deconstructing connection object for" << d->userId; stopSync(); } void Connection::resolveServer(const QString& mxidOrDomain) { // At this point we may have something as complex as // @username:[IPv6:address]:port, or as simple as a plain domain name. // Try to parse as an FQID; if there's no @ part, assume it's a domain name. QRegularExpression parser( "^(@.+?:)?" // Optional username (allow everything for compatibility) "(\\[[^]]+\\]|[^:@]+)" // Either IPv6 address or hostname/IPv4 address "(:\\d{1,5})?$", // Optional port QRegularExpression::UseUnicodePropertiesOption); // Because asian digits auto match = parser.match(mxidOrDomain); QUrl maybeBaseUrl = QUrl::fromUserInput(match.captured(2)); maybeBaseUrl.setScheme("https"); // Instead of the Qt-default "http" if (!match.hasMatch() || !maybeBaseUrl.isValid()) { emit resolveError( tr("%1 is not a valid homeserver address") .arg(maybeBaseUrl.toString())); return; } 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. QDnsLookup* 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(); } void Connection::connectToServer(const QString& user, const QString& password, const QString& initialDeviceName, const QString& deviceId) { checkAndConnect(user, [=] { doConnectToServer(user, password, initialDeviceName, deviceId); }); } void Connection::doConnectToServer(const QString& user, const QString& password, const QString& initialDeviceName, const QString& deviceId) { auto loginJob = callApi(QStringLiteral("m.login.password"), user, /*medium*/ "", /*address*/ "", password, /*token*/ "", deviceId, initialDeviceName); connect(loginJob, &BaseJob::success, this, [this, loginJob] { d->connectWithToken(loginJob->userId(), loginJob->accessToken(), loginJob->deviceId()); }); connect(loginJob, &BaseJob::failure, this, [this, loginJob] { emit loginError(loginJob->errorString()); }); } void Connection::connectWithToken(const QString& userId, const QString& accessToken, const QString& deviceId) { checkAndConnect(userId, [=] { d->connectWithToken(userId, accessToken, deviceId); }); } void Connection::Private::connectWithToken(const QString& user, const QString& accessToken, const QString& deviceId) { userId = user; data->setToken(accessToken.toLatin1()); data->setDeviceId(deviceId); qCDebug(MAIN) << "Using server" << data->baseUrl().toDisplayString() << "by user" << userId << "from device" << deviceId; emit q->connected(); } void Connection::checkAndConnect(const QString& userId, std::function connectFn) { if (d->data->baseUrl().isValid()) { connectFn(); return; } // Not good to go, try to fix the homeserver URL. if (userId.startsWith('@') && userId.indexOf(':') != -1) { // The below construct makes a single-shot connection that triggers // on the signal and then self-disconnects. // NB: doResolveServer can emit resolveError, so this is a part of // checkAndConnect function contract. QMetaObject::Connection connection; connection = connect(this, &Connection::homeserverChanged, this, [=] { connectFn(); disconnect(connection); }); resolveServer(userId); } else emit resolveError( tr("%1 is an invalid homeserver URL") .arg(d->data->baseUrl().toString())); } void Connection::logout() { auto job = callApi(); connect( job, &LogoutJob::success, this, [this] { stopSync(); emit loggedOut(); }); } void Connection::sync(int timeout) { if (d->syncJob) return; // Raw string: http://en.cppreference.com/w/cpp/language/string_literal const QString filter { R"({"room": { "timeline": { "limit": 100 } } })" }; auto job = d->syncJob = callApi(d->data->lastEvent(), filter, timeout); connect( job, &SyncJob::success, [this, job] { onSyncSuccess(job->takeData()); d->syncJob = nullptr; emit syncDone(); }); connect( job, &SyncJob::retryScheduled, this, &Connection::networkError); connect( job, &SyncJob::failure, [this, job] { d->syncJob = nullptr; if (job->error() == BaseJob::ContentAccessError) emit loginError(job->errorString()); else emit syncError(job->errorString()); }); } void Connection::onSyncSuccess(SyncData &&data) { d->data->setLastEvent(data.nextBatch()); for (auto&& roomData: data.takeRoomData()) { const auto forgetIdx = d->roomIdsToForget.indexOf(roomData.roomId); if (forgetIdx != -1) { d->roomIdsToForget.removeAt(forgetIdx); if (roomData.joinState == JoinState::Leave) { qDebug(MAIN) << "Room" << roomData.roomId << "has been forgotten, ignoring /sync response for it"; continue; } qWarning(MAIN) << "Room" << roomData.roomId << "has just been forgotten but /sync returned it in" << toCString(roomData.joinState) << "state - suspiciously fast turnaround"; } if ( auto* r = provideRoom(roomData.roomId, roomData.joinState) ) r->updateData(std::move(roomData)); QCoreApplication::processEvents(); } for (auto&& accountEvent: data.takeAccountData()) { if (accountEvent->type() == EventType::DirectChat) { DirectChatsMap newDirectChats; const auto* event = static_cast(accountEvent.get()); auto usersToDCs = event->usersToDirectChats(); for (auto it = usersToDCs.begin(); it != usersToDCs.end(); ++it) { newDirectChats.insert(user(it.key()), it.value()); qCDebug(MAIN) << "Marked room" << it.value() << "as a direct chat with" << it.key(); } if (newDirectChats != d->directChats) { d->directChats = newDirectChats; emit directChatsListChanged(); } continue; } d->accountData[accountEvent->jsonType()] = accountEvent->contentJson().toVariantHash(); } } void Connection::stopSync() { if (d->syncJob) { d->syncJob->abandon(); d->syncJob = nullptr; } } void Connection::postMessage(Room* room, const QString& type, const QString& message) const { callApi(room->id(), type, message); } PostReceiptJob* Connection::postReceipt(Room* room, RoomEvent* event) const { return callApi(room->id(), "m.read", event->id()); } JoinRoomJob* Connection::joinRoom(const QString& roomAlias) { auto job = callApi(roomAlias); connect(job, &JoinRoomJob::success, this, [this, job] { provideRoom(job->roomId(), JoinState::Join); }); return job; } void Connection::leaveRoom(Room* room) { callApi(room->id()); } RoomMessagesJob* Connection::getMessages(Room* room, const QString& from) const { return callApi(room->id(), from); } inline auto splitMediaId(const QString& mediaId) { auto idParts = mediaId.split('/'); Q_ASSERT_X(idParts.size() == 2, __FUNCTION__, ("'" + mediaId + "' doesn't look like 'serverName/localMediaId'").toLatin1()); return idParts; } MediaThumbnailJob* Connection::getThumbnail(const QString& mediaId, QSize requestedSize) const { auto idParts = splitMediaId(mediaId); return callApi(idParts.front(), idParts.back(), requestedSize); } MediaThumbnailJob* Connection::getThumbnail(const QUrl& url, QSize requestedSize) const { return getThumbnail(url.authority() + url.path(), requestedSize); } MediaThumbnailJob* Connection::getThumbnail(const QUrl& url, int requestedWidth, int requestedHeight) const { return getThumbnail(url, QSize(requestedWidth, requestedHeight)); } UploadContentJob* Connection::uploadContent(QIODevice* contentSource, const QString& filename, const QString& contentType) const { return callApi(contentSource, filename, contentType); } UploadContentJob* Connection::uploadFile(const QString& fileName, const QString& contentType) { auto sourceFile = new QFile(fileName); if (sourceFile->open(QIODevice::ReadOnly)) { qCWarning(MAIN) << "Couldn't open" << sourceFile->fileName() << "for reading"; return nullptr; } return uploadContent(sourceFile, QFileInfo(*sourceFile).fileName(), contentType); } GetContentJob* Connection::getContent(const QString& mediaId) const { auto idParts = splitMediaId(mediaId); return callApi(idParts.front(), idParts.back()); } GetContentJob* Connection::getContent(const QUrl& url) const { return getContent(url.authority() + url.path()); } DownloadFileJob* Connection::downloadFile(const QUrl& url, const QString& localFilename) const { auto mediaId = url.authority() + url.path(); auto idParts = splitMediaId(mediaId); auto* job = callApi(idParts.front(), idParts.back(), localFilename); return job; } CreateRoomJob* Connection::createRoom(RoomVisibility visibility, const QString& alias, const QString& name, const QString& topic, const QVector& invites, const QString& presetName, bool isDirect, bool guestsCanJoin, const QVector& initialState, const QVector& invite3pids, const QJsonObject creationContent) { auto job = callApi( visibility == PublishRoom ? "public" : "private", alias, name, topic, invites, invite3pids, creationContent, initialState, presetName, isDirect, guestsCanJoin); connect(job, &BaseJob::success, this, [this,job] { emit createdRoom(provideRoom(job->roomId(), JoinState::Join)); }); return job; } void Connection::requestDirectChat(const QString& userId) { auto roomId = d->directChats.value(user(userId)); if (roomId.isEmpty()) { auto j = createDirectChat(userId); connect(j, &BaseJob::success, this, [this,j,userId,roomId] { qCDebug(MAIN) << "Direct chat with" << userId << "has been created as" << roomId; emit directChatAvailable(roomMap().value({j->roomId(), false})); }); return; } auto room = roomMap().value({roomId, false}, nullptr); if (room) { Q_ASSERT(room->id() == roomId); qCDebug(MAIN) << "Requested direct chat with" << userId << "is already available as" << room->id(); emit directChatAvailable(room); return; } room = roomMap().value({roomId, true}, nullptr); if (room) { Q_ASSERT(room->id() == roomId); auto j = joinRoom(room->id()); connect(j, &BaseJob::success, this, [this,j,roomId,userId] { qCDebug(MAIN) << "Joined the already invited direct chat with" << userId << "as" << roomId; emit directChatAvailable(roomMap().value({roomId, false})); }); } } CreateRoomJob* Connection::createDirectChat(const QString& userId, const QString& topic, const QString& name) { return createRoom(UnpublishRoom, "", name, topic, {userId}, "trusted_private_chat", true); } ForgetRoomJob* Connection::forgetRoom(const QString& id) { // To forget is hard :) First we should ensure the local user is not // in the room (by leaving it, if necessary); once it's done, the /forget // endpoint can be called; and once this is through, the local Room object // (if any existed) is deleted. At the same time, we still have to // (basically immediately) return a pointer to ForgetRoomJob. Therefore // a ForgetRoomJob is created in advance and can be returned in a probably // not-yet-started state (it will start once /leave completes). auto forgetJob = new ForgetRoomJob(id); auto room = d->roomMap.value({id, false}); if (!room) room = d->roomMap.value({id, true}); if (room && room->joinState() != JoinState::Leave) { auto leaveJob = room->leaveRoom(); connect(leaveJob, &BaseJob::success, this, [this, forgetJob, room] { forgetJob->start(connectionData()); // If the matching /sync response hasn't arrived yet, mark the room // for explicit deletion if (room->joinState() != JoinState::Leave) d->roomIdsToForget.push_back(room->id()); }); connect(leaveJob, &BaseJob::failure, forgetJob, &BaseJob::abandon); } else forgetJob->start(connectionData()); connect(forgetJob, &BaseJob::success, this, [this, id] { // If the room is in the map (possibly in both forms), delete all forms. for (auto f: {false, true}) if (auto r = d->roomMap.take({ id, f })) { emit aboutToDeleteRoom(r); qCDebug(MAIN) << "Room" << id << "in join state" << toCString(r->joinState()) << "will be deleted"; r->deleteLater(); } }); return forgetJob; } QUrl Connection::homeserver() const { return d->data->baseUrl(); } User* Connection::user(const QString& userId) { if( d->userMap.contains(userId) ) return d->userMap.value(userId); auto* user = userFactory(this, userId); d->userMap.insert(userId, user); emit newUser(user); return user; } const User* Connection::user() const { return d->userId.isEmpty() ? nullptr : d->userMap.value(d->userId, nullptr); } User* Connection::user() { return d->userId.isEmpty() ? nullptr : user(d->userId); } QString Connection::userId() const { return d->userId; } QString Connection::deviceId() const { return d->data->deviceId(); } QString Connection::token() const { return accessToken(); } QByteArray Connection::accessToken() const { return d->data->accessToken(); } SyncJob* Connection::syncJob() const { return d->syncJob; } int Connection::millisToReconnect() const { return d->syncJob ? d->syncJob->millisToRetry() : 0; } QHash< QPair, Room* > Connection::roomMap() const { // Copy-on-write-and-remove-elements is faster than copying elements one by one. QHash< QPair, Room* > roomMap = d->roomMap; for (auto it = roomMap.begin(); it != roomMap.end(); ) { if (it.value()->joinState() == JoinState::Leave) it = roomMap.erase(it); else ++it; } return roomMap; } QHash> Connection::tagsToRooms() const { QHash> result; for (auto* r: d->roomMap) { for (const auto& tagName: r->tagNames()) result[tagName].push_back(r); } for (auto it = result.begin(); it != result.end(); ++it) std::sort(it->begin(), it->end(), [t=it.key()] (Room* r1, Room* r2) { return r1->tags().value(t).order < r2->tags().value(t).order; }); return result; } QStringList Connection::tagNames() const { QStringList tags ({FavouriteTag}); for (auto* r: d->roomMap) for (const auto& tag: r->tagNames()) if (tag != LowPriorityTag && !tags.contains(tag)) tags.push_back(tag); tags.push_back(LowPriorityTag); return tags; } QVector Connection::roomsWithTag(const QString& tagName) const { QVector rooms; std::copy_if(d->roomMap.begin(), d->roomMap.end(), std::back_inserter(rooms), [&tagName] (Room* r) { return r->tags().contains(tagName); }); return rooms; } QJsonObject toJson(const DirectChatsMap& directChats) { QJsonObject json; for (auto it = directChats.keyBegin(); it != directChats.keyEnd(); ++it) json.insert((*it)->id(), toJson(directChats.values(*it))); return json; } void Connection::Private::applyDirectChatUpdates(const DirectChatsMap& newMap) { auto j = q->callApi(userId, "m.direct", toJson(newMap)); connect(j, &BaseJob::success, q, [this, newMap] { if (directChats != newMap) { directChats = newMap; emit q->directChatsListChanged(); } }); } void Connection::addToDirectChats(const Room* room, const User* user) { Q_ASSERT(room != nullptr && user != nullptr); if (d->directChats.contains(user, room->id())) return; auto newMap = d->directChats; newMap.insert(user, room->id()); d->applyDirectChatUpdates(newMap); } void Connection::removeFromDirectChats(const Room* room, const User* user) { Q_ASSERT(room != nullptr); if ((user != nullptr && !d->directChats.contains(user, room->id())) || d->directChats.key(room->id()) == nullptr) return; DirectChatsMap newMap; for (auto it = d->directChats.begin(); it != d->directChats.end(); ++it) { if (it.value() != room->id() || (user != nullptr && it.key() != user)) newMap.insert(it.key(), it.value()); } d->applyDirectChatUpdates(newMap); } bool Connection::isDirectChat(const Room* room) const { return d->directChats.key(room->id()) != nullptr; } QMap Connection::users() const { return d->userMap; } const ConnectionData* Connection::connectionData() const { return d->data.get(); } Room* Connection::provideRoom(const QString& id, JoinState joinState) { // TODO: This whole function is a strong case for a RoomManager class. Q_ASSERT_X(!id.isEmpty(), __FUNCTION__, "Empty room id"); const auto roomKey = qMakePair(id, joinState == JoinState::Invite); auto* room = d->roomMap.value(roomKey, nullptr); if (room) { // Leave is a special case because in transition (5a) (see the .h file) // joinState == room->joinState but we still have to preempt the Invite // and emit a signal. For Invite and Join, there's no such problem. if (room->joinState() == joinState && joinState != JoinState::Leave) return room; } else { room = roomFactory(this, id, joinState); if (!room) { qCCritical(MAIN) << "Failed to create a room" << id; return nullptr; } d->roomMap.insert(roomKey, room); emit newRoom(room); } if (joinState == JoinState::Invite) { // prev is either Leave or nullptr auto* prev = d->roomMap.value({id, false}, nullptr); emit invitedRoom(room, prev); } else { room->setJoinState(joinState); // Preempt the Invite room (if any) with a room in Join/Leave state. auto* prevInvite = d->roomMap.take({id, true}); if (joinState == JoinState::Join) emit joinedRoom(room, prevInvite); else if (joinState == JoinState::Leave) emit leftRoom(room, prevInvite); if (prevInvite) { qCDebug(MAIN) << "Deleting Invite state for room" << prevInvite->id(); emit aboutToDeleteRoom(prevInvite); prevInvite->deleteLater(); } } return room; } Connection::room_factory_t Connection::roomFactory = [](Connection* c, const QString& id, JoinState joinState) { return new Room(c, id, joinState); }; Connection::user_factory_t Connection::userFactory = [](Connection* c, const QString& id) { return new User(id, c); }; QByteArray Connection::generateTxnId() { return d->data->generateTxnId(); } void Connection::setHomeserver(const QUrl& url) { if (homeserver() == url) return; d->data->setBaseUrl(url); emit homeserverChanged(homeserver()); } static constexpr int CACHE_VERSION_MAJOR = 6; static constexpr int CACHE_VERSION_MINOR = 0; void Connection::saveState(const QUrl &toFile) const { if (!d->cacheState) return; QElapsedTimer et; et.start(); QFileInfo stateFile { toFile.isEmpty() ? stateCachePath() : toFile.toLocalFile() }; if (!stateFile.dir().exists()) stateFile.dir().mkpath("."); QFile outfile { stateFile.absoluteFilePath() }; if (!outfile.open(QFile::WriteOnly)) { qCWarning(MAIN) << "Error opening" << stateFile.absoluteFilePath() << ":" << outfile.errorString(); qCWarning(MAIN) << "Caching the rooms state disabled"; d->cacheState = false; return; } QJsonObject rootObj; { QJsonObject rooms; QJsonObject inviteRooms; for (const auto* i : roomMap()) // Pass on rooms in Leave state { if (i->joinState() == JoinState::Invite) inviteRooms.insert(i->id(), i->toJson()); else rooms.insert(i->id(), i->toJson()); QElapsedTimer et1; et1.start(); QCoreApplication::processEvents(); if (et1.elapsed() > 1) qCDebug(PROFILER) << "processEvents() borrowed" << et1; } QJsonObject roomObj; if (!rooms.isEmpty()) roomObj.insert("join", rooms); if (!inviteRooms.isEmpty()) roomObj.insert("invite", inviteRooms); rootObj.insert("next_batch", d->data->lastEvent()); rootObj.insert("rooms", roomObj); } { QJsonArray accountDataEvents { QJsonObject { { QStringLiteral("type"), QStringLiteral("m.direct") }, { QStringLiteral("content"), toJson(d->directChats) } } }; for (auto it = d->accountData.begin(); it != d->accountData.end(); ++it) accountDataEvents.append(QJsonObject { {"type", it.key()}, {"content", QJsonObject::fromVariantHash(it.value())} }); rootObj.insert("account_data", QJsonObject {{ QStringLiteral("events"), accountDataEvents }}); } QJsonObject versionObj; versionObj.insert("major", CACHE_VERSION_MAJOR); versionObj.insert("minor", CACHE_VERSION_MINOR); rootObj.insert("cache_version", versionObj); QJsonDocument json { rootObj }; auto data = d->cacheToBinary ? json.toBinaryData() : json.toJson(QJsonDocument::Compact); qCDebug(PROFILER) << "Cache for" << userId() << "generated in" << et; outfile.write(data.data(), data.size()); qCDebug(MAIN) << "State cache saved to" << outfile.fileName(); } void Connection::loadState(const QUrl &fromFile) { if (!d->cacheState) return; QElapsedTimer et; et.start(); QFile file { fromFile.isEmpty() ? stateCachePath() : fromFile.toLocalFile() }; if (!file.exists()) { qCDebug(MAIN) << "No state cache file found"; return; } if(!file.open(QFile::ReadOnly)) { qCWarning(MAIN) << "file " << file.fileName() << "failed to open for read"; return; } QByteArray data = file.readAll(); auto jsonDoc = d->cacheToBinary ? QJsonDocument::fromBinaryData(data) : QJsonDocument::fromJson(data); if (jsonDoc.isNull()) { qCWarning(MAIN) << "Cache file broken, discarding"; return; } auto actualCacheVersionMajor = jsonDoc.object() .value("cache_version").toObject() .value("major").toInt(); if (actualCacheVersionMajor < CACHE_VERSION_MAJOR) { qCWarning(MAIN) << "Major version of the cache file is" << actualCacheVersionMajor << "but" << CACHE_VERSION_MAJOR << "required; discarding the cache"; return; } SyncData sync; sync.parseJson(jsonDoc); onSyncSuccess(std::move(sync)); qCDebug(PROFILER) << "*** Cached state for" << userId() << "loaded in" << et; } QString Connection::stateCachePath() const { auto safeUserId = userId(); safeUserId.replace(':', '_'); return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) % '/' % safeUserId % "_state.json"; } bool Connection::cacheState() const { return d->cacheState; } void Connection::setCacheState(bool newValue) { if (d->cacheState != newValue) { d->cacheState = newValue; emit cacheStateChanged(); } }