diff options
-rw-r--r-- | lib/quotient_common.h | 8 | ||||
-rw-r--r-- | lib/resourceresolver.cpp | 275 | ||||
-rw-r--r-- | lib/resourceresolver.h | 172 | ||||
-rw-r--r-- | tests/quotest.cpp | 65 |
4 files changed, 372 insertions, 148 deletions
diff --git a/lib/quotient_common.h b/lib/quotient_common.h index 44541b42..446f628b 100644 --- a/lib/quotient_common.h +++ b/lib/quotient_common.h @@ -14,15 +14,15 @@ enum RunningPolicy { ForegroundRequest = 0x0, BackgroundRequest = 0x1 }; Q_ENUM_NS(RunningPolicy) -enum ResourceResolveResult : short { +enum UriResolveResult : short { StillResolving = -1, - Resolved = 0, + UriResolved = 0, UnknownMatrixId, - MalformedMatrixId, + MalformedUri, NoAccount, EmptyMatrixId }; -Q_ENUM_NS(ResourceResolveResult) +Q_ENUM_NS(UriResolveResult) } // namespace Quotient /// \deprecated Use namespace Quotient instead diff --git a/lib/resourceresolver.cpp b/lib/resourceresolver.cpp index 0d5c5a20..e7820061 100644 --- a/lib/resourceresolver.cpp +++ b/lib/resourceresolver.cpp @@ -1,97 +1,236 @@ #include "resourceresolver.h" -#include "settings.h" +#include "connection.h" +#include "logging.h" #include <QtCore/QRegularExpression> using namespace Quotient; -QString ResourceResolver::toMatrixId(const QString& uriOrId, - QStringList uriServers) +struct ReplacePair { QByteArray uriString; char sigil; }; +static const auto replacePairs = { ReplacePair { "user/", '@' }, + { "roomid/", '!' }, + { "room/", '#' } }; + +MatrixUri::MatrixUri(QByteArray primaryId, QByteArray secondaryId, QString query) { - auto id = QUrl::fromPercentEncoding(uriOrId.toUtf8()); - const auto MatrixScheme = "matrix:"_ls; - if (id.startsWith(MatrixScheme)) { - id.remove(0, MatrixScheme.size()); - for (const auto& p: { std::pair { "user/"_ls, '@' }, - { "roomid/"_ls, '!' }, - { "room/"_ls, '#' } }) - if (id.startsWith(p.first)) { - id.replace(0, p.first.size(), p.second); + if (primaryId.isEmpty()) + primaryType_ = Empty; + else { + setScheme("matrix"); + QString pathToBe; + primaryType_ = Invalid; + for (const auto& p: replacePairs) + if (primaryId[0] == p.sigil) { + primaryType_ = Type(p.sigil); + pathToBe = p.uriString + primaryId.mid(1); break; } - // The below assumes that /event/ cannot show up in normal Matrix ids. - id.replace("/event/"_ls, "/$"_ls); - } else { - const auto MatrixTo_ServerName = QStringLiteral("matrix.to"); - if (!uriServers.contains(MatrixTo_ServerName)) - uriServers.push_back(MatrixTo_ServerName); - id.remove( - QRegularExpression("^https://(" + uriServers.join('|') + ")/?#/")); + if (!secondaryId.isEmpty()) + pathToBe += "/event/" + secondaryId.mid(1); + setPath(pathToBe); } - return id; + setQuery(std::move(query)); } -ResourceResolver::Result ResourceResolver::visitResource( - Connection* account, const QString& identifier, - std::function<void(User*)> userHandler, - std::function<void(Room*, QString)> roomEventHandler) +MatrixUri::MatrixUri(QUrl url) : QUrl(std::move(url)) { - const auto& normalizedId = toMatrixId(identifier); - auto&& [sigil, mainId, secondaryId] = parseIdentifier(normalizedId); - Room* room = nullptr; - switch (sigil) { - case char(-1): - return MalformedMatrixId; - case char(0): - return EmptyMatrixId; - case '@': - if (auto* user = account->user(mainId)) { - userHandler(user); - return Success; - } - return MalformedMatrixId; - case '!': - if ((room = account->room(mainId))) - break; - return UnknownMatrixId; - case '#': - if ((room = account->roomByAlias(mainId))) + // NB: url is moved from and empty by now + if (isEmpty()) + return; // primaryType_ == None + + primaryType_ = Invalid; + if (!QUrl::isValid()) // MatrixUri::isValid() checks primaryType_ + return; + + if (scheme() == "matrix") { + // Check sanity as per https://github.com/matrix-org/matrix-doc/pull/2312 + const auto& urlPath = path(); + const auto& splitPath = urlPath.splitRef('/'); + switch (splitPath.size()) { + case 2: + break; + case 4: + if (splitPath[2] == "event") break; [[fallthrough]]; default: - return UnknownMatrixId; + return; // Invalid + } + + for (const auto& p: replacePairs) + if (urlPath.startsWith(p.uriString)) { + primaryType_ = Type(p.sigil); + return; // The only valid return path for matrix: URIs + } + qCWarning(MAIN) << "Invalid matrix: URI passed to MatrixUri"; + } + if (scheme() == "https" && authority() == "matrix.to") { + // See https://matrix.org/docs/spec/appendices#matrix-to-navigation + static const QRegularExpression MatrixToUrlRE { + R"(^/(?<main>[^/?]+)(/(?<sec>[^?]+))?(\?(?<query>.+))?$)" + }; + // matrix.to accepts both literal sigils (as well as & and ? used in + // its "query" substitute) and their %-encoded forms; + // so force QUrl to decode everything. + auto f = fragment(QUrl::FullyDecoded); + if (auto&& m = MatrixToUrlRE.match(f); m.hasMatch()) + *this = MatrixUri { m.captured("main").toUtf8(), + m.captured("sec").toUtf8(), + m.captured("query") }; } - roomEventHandler(room, secondaryId); - return Success; } -ResourceResolver::IdentifierParts -ResourceResolver::parseIdentifier(const QString& identifier) +MatrixUri::MatrixUri(const QString &uriOrId) + : MatrixUri(fromUserInput(uriOrId)) +{ } + +MatrixUri MatrixUri::fromUserInput(const QString& uriOrId) { - if (identifier.isEmpty()) + if (uriOrId.isEmpty()) + return {}; // type() == None + + // A quick check if uriOrId is a plain Matrix id + if (QStringLiteral("!@#+").contains(uriOrId[0])) + return MatrixUri { uriOrId.toUtf8() }; + + // Bare event ids cannot be resolved without a room scope but are treated as + // valid anyway; in the future we might expose them as, say, + // matrix:event/eventid + if (uriOrId[0] == '$') + return MatrixUri { "", uriOrId.toUtf8() }; + + return MatrixUri { QUrl::fromUserInput(uriOrId) }; +} + +MatrixUri::Type MatrixUri::type() const { return primaryType_; } + +MatrixUri::SecondaryType MatrixUri::secondaryType() const +{ + return path().section('/', 2, 2) == "event" ? EventId : NoSecondaryId; +} + +QUrl MatrixUri::toUrl(UriForm form) const +{ + if (!isValid()) return {}; - - // The regex is quick and dirty, only intending to triage the id. - static const QRegularExpression IdRE { - "^(?<main>(?<sigil>.)([^/]+))(/(?<sec>[^?]+))?" - }; - auto dissectedId = IdRE.match(identifier); - if (!dissectedId.hasMatch()) - return { char(-1) }; - - const auto sigil = dissectedId.captured("sigil"); - return { sigil.size() != 1 ? char(-1) : sigil[0].toLatin1(), - dissectedId.captured("main"), dissectedId.captured("sec") }; + + if (form == CanonicalUri) + return *this; + + QUrl url; + url.setScheme("https"); + url.setHost("matrix.to"); + url.setPath("/"); + auto fragment = primaryId(); + if (const auto& secId = secondaryId(); !secId.isEmpty()) + fragment += '/' + secId; + if (const auto& q = query(); !q.isEmpty()) + fragment += '?' + q; + url.setFragment(fragment); + return url; +} + +QString MatrixUri::toDisplayString(MatrixUri::UriForm form) const +{ + return toUrl(form).toDisplayString(); } -ResourceResolver::Result +QString MatrixUri::primaryId() const +{ + if (primaryType_ == Empty || primaryType_ == Invalid) + return {}; + + const auto& idStem = path().section('/', 1, 1); + return idStem.isEmpty() ? idStem : primaryType_ + idStem; +} + +QString MatrixUri::secondaryId() const +{ + const auto& idStem = path().section('/', 3); + return idStem.isEmpty() ? idStem : secondaryType() + idStem; +} + +QString MatrixUri::action() const +{ + return QUrlQuery { query() }.queryItemValue("action"); +} + +QStringList MatrixUri::viaServers() const +{ + return QUrlQuery { query() }.allQueryItemValues(QStringLiteral("via"), + QUrl::EncodeReserved); +} + +bool MatrixUri::isValid() const +{ + return primaryType_ != Empty && primaryType_ != Invalid; +} + +UriResolveResult Quotient::visitResource( + Connection* account, const MatrixUri& uri, + std::function<void(User*)> userHandler, + std::function<void(Room*, QString)> roomEventHandler, + std::function<void(Connection*, QString, QStringList)> joinHandler) +{ + Q_ASSERT_X(account != nullptr, __FUNCTION__, + "The Connection argument passed to visit/openResource must not " + "be nullptr"); + if (uri.action() == "join") { + if (uri.type() != MatrixUri::RoomAlias + && uri.type() != MatrixUri::RoomId) + return MalformedUri; + + joinHandler(account, uri.primaryId(), uri.viaServers()); + return UriResolved; + } + + Room* room = nullptr; + switch (uri.type()) { + case MatrixUri::Invalid: + return MalformedUri; + case MatrixUri::Empty: + return EmptyMatrixId; + case MatrixUri::UserId: + if (auto* user = account->user(uri.primaryId())) { + userHandler(user); + return UriResolved; + } + return MalformedUri; + case MatrixUri::RoomId: + if ((room = account->room(uri.primaryId()))) + break; + return UnknownMatrixId; + case MatrixUri::RoomAlias: + if ((room = account->roomByAlias(uri.primaryId()))) + break; + [[fallthrough]]; + default: + return UnknownMatrixId; + } + roomEventHandler(room, uri.secondaryId()); + return UriResolved; +} + +UriResolveResult ResourceResolver::openResource(Connection* account, const QString& identifier, const QString& action) { - return visitResource(account, identifier, - [this, &action](User* u) { emit userAction(u, action); }, - [this, &action](Room* room, const QString& eventId) { - emit roomAction(room, eventId, action); + return openResource(account, MatrixUri(identifier), action); +} + +UriResolveResult ResourceResolver::openResource(Connection* account, + const MatrixUri& uri, + const QString& overrideAction) +{ + return visitResource( + account, uri, + [this, &overrideAction](User* u) { emit userAction(u, overrideAction); }, + [this, &overrideAction](Room* room, const QString& eventId) { + emit roomAction(room, eventId, overrideAction); + }, + [this](Connection* account, const QString& roomAliasOrId, + const QStringList& viaServers) { + emit joinAction(account, roomAliasOrId, viaServers); }); } diff --git a/lib/resourceresolver.h b/lib/resourceresolver.h index 794b7796..fea07e97 100644 --- a/lib/resourceresolver.h +++ b/lib/resourceresolver.h @@ -1,10 +1,118 @@ #pragma once -#include "connection.h" +#include "quotient_common.h" + +#include <QtCore/QUrl> +#include <QtCore/QUrlQuery> +#include <QtCore/QObject> #include <functional> namespace Quotient { +class Connection; +class Room; +class User; + +/*! \brief A wrapper around a Matrix URI or identifier + * + * This class encapsulates a Matrix resource identifier, passed in either of + * 3 forms: a plain Matrix identifier (sigil, localpart, serverpart or, for + * modern event ids, sigil and base64 hash); an MSC2312 URI (aka matrix: URI); + * or a matrix.to URL. The input can be either encoded (serverparts with + * punycode, the rest with percent-encoding) or unencoded (in this case it is + * the caller's responsibility to resolve all possible ambiguities). + * + * The class provides functions to check the validity of the identifier, + * its type, and obtain components, also in either unencoded (for displaying) + * or encoded (for APIs) form. + */ +class MatrixUri : private QUrl { + Q_GADGET + Q_PROPERTY(QString primaryId READ primaryId CONSTANT) + Q_PROPERTY(QString secondaryId READ secondaryId CONSTANT) +// Q_PROPERTY(QUrlQuery query READ query CONSTANT) + Q_PROPERTY(QString action READ action CONSTANT) +// Q_PROPERTY(QStringList viaServers READ viaServers CONSTANT) +public: + enum Type : char { + Invalid = char(-1), + Empty = 0x0, + UserId = '@', + RoomId = '!', + RoomAlias = '#', + Group = '+' + }; + Q_ENUM(Type) + enum SecondaryType : char { + NoSecondaryId = 0x0, + EventId = '$' + }; + + enum UriForm : short { CanonicalUri, MatrixToUri }; + Q_ENUM(UriForm) + + /// Construct an empty Matrix URI + MatrixUri() = default; + /*! \brief Decode a user input string to a Matrix identifier + * + * Accepts plain Matrix ids, MSC2312 URIs (aka matrix: URIs) and + * matrix.to URLs. In case of URIs/URLs, it uses QUrl's TolerantMode + * parser to decode common mistakes/irregularities (see QUrl documentation + * for more details). + */ + MatrixUri(const QString& uriOrId); + + /// Construct a Matrix URI from components + explicit MatrixUri(QByteArray primaryId, QByteArray secondaryId = {}, + QString query = {}); + /// Construct a Matrix URI from matrix.to or MSC2312 (matrix:) URI + explicit MatrixUri(QUrl url); + + static MatrixUri fromUserInput(const QString& uriOrId); + static MatrixUri fromUrl(QUrl url); + + /// Get the primary type of the Matrix URI (user id, room id or alias) + /*! Note that this does not include an event as a separate type, since + * events can only be addressed inside of rooms, which, in turn, are + * addressed either by id or alias. If you need to check whether the URI + * is specifically an event URI, use secondaryType() instead. + */ + Type type() const; + SecondaryType secondaryType() const; + QUrl toUrl(UriForm form = CanonicalUri) const; + QString toDisplayString(UriForm form = CanonicalUri) const; + QString primaryId() const; + QString secondaryId() const; + QString action() const; + QStringList viaServers() const; + bool isValid() const; + using QUrl::isEmpty, QUrl::path, QUrl::query, QUrl::fragment; + +private: + + Type primaryType_ = Empty; +}; + +/*! \brief Resolve the resource and invoke an action on it, visitor style + * + * This template function encapsulates the logic of resolving a Matrix + * identifier or URI into a Quotient object (or objects) and applying an + * appropriate action handler from the set provided by the caller to it. + * A typical use case for that is opening a room or mentioning a user in + * response to clicking on a Matrix URI or identifier. + * + * \param account The connection used as a context to resolve the identifier + * + * \param uri The Matrix identifier or URI; MSC2312 URIs and classic Matrix IDs + * are supported + * + * \sa ResourceResolver + */ +UriResolveResult +visitResource(Connection* account, const MatrixUri& uri, + std::function<void(User*)> userHandler, + std::function<void(Room*, QString)> roomEventHandler, + std::function<void(Connection*, QString, QStringList)> joinHandler); /*! \brief Matrix resource resolver * TODO: rewrite @@ -22,51 +130,9 @@ namespace Quotient { class ResourceResolver : public QObject { Q_OBJECT public: - enum Result : short { - StillResolving = -1, - Success = 0, - UnknownMatrixId, - MalformedMatrixId, - NoAccount, - EmptyMatrixId - }; - Q_ENUM(Result) - explicit ResourceResolver(QObject* parent = nullptr) : QObject(parent) { } - /*! \brief Decode a URI to a Matrix identifier (or a room/event pair) - * - * This accepts plain Matrix ids, MSC2312 URIs (aka matrix: URIs) and - * matrix.to URIs. - * - * \return a Matrix identifier as defined by the common identifier grammars - * or a slash separated pair of Matrix identifiers if the original - * uri/id pointed to an event in a room - */ - static QString toMatrixId(const QString& uriOrId, - QStringList uriServers = {}); - - /*! \brief Resolve the resource and invoke an action on it, visitor style - * - * This template function encapsulates the logic of resolving a Matrix - * identifier or URI into a Quotient object (or objects) and applying an - * appropriate action handler from the set provided by the caller to it. - * A typical use case for that is opening a room or mentioning a user in - * response to clicking on a Matrix URI or identifier. - * - * \param account The connection used as a context to resolve the identifier - * - * \param identifier The Matrix identifier or URI. MSC2312 URIs and classic - * Matrix ID scheme are supported. - * - * \sa ResourceResolver - */ - static Result - visitResource(Connection* account, const QString& identifier, - std::function<void(User*)> userHandler, - std::function<void(Room*, QString)> roomEventHandler); - /*! \brief Resolve the resource and request an action on it, signal style * * This method: @@ -91,9 +157,12 @@ public: * and also connect to ResourceFuture::ready() signal in order to process * the result of resolving and action. */ - Q_INVOKABLE Result openResource(Connection* account, - const QString& identifier, - const QString& action = {}); + Q_INVOKABLE UriResolveResult openResource(Connection* account, + const QString& identifier, + const QString& action = {}); + Q_INVOKABLE UriResolveResult + openResource(Connection* account, const MatrixUri& uri, + const QString& overrideAction = {}); signals: /// An action on a user has been requested @@ -102,14 +171,9 @@ signals: /// An action on a room has been requested, with optional event id void roomAction(Quotient::Room* room, QString eventId, QString action); -private: - struct IdentifierParts { - char sigil; - QString mainId {}; - QString secondaryId = {}; - }; - - static IdentifierParts parseIdentifier(const QString& identifier); + /// A join action has been requested, with optional 'via' servers + void joinAction(Quotient::Connection* account, QString roomAliasOrId, + QStringList viaServers); }; } // namespace Quotient diff --git a/tests/quotest.cpp b/tests/quotest.cpp index 19aa5b85..f9e25284 100644 --- a/tests/quotest.cpp +++ b/tests/quotest.cpp @@ -627,13 +627,15 @@ TEST_IMPL(visitResources) // This lambda returns true in case of error, false if it's fine so far auto testResourceResolver = [this, thisTest](const QStringList& uris, auto signal, auto* target, - const QString& eventId = {}) { + QVariantList otherArgs = {}) { int r = qRegisterMetaType<decltype(target)>(); Q_ASSERT(r != 0); QSignalSpy spy(&rr, signal); - for (const auto& uri: uris) { - clog << "Resolving uri " << uri.toStdString() << endl; - rr.openResource(connection(), uri, "action"); + for (const auto& uriString: uris) { + MatrixUri uri { uriString }; + clog << "Checking " << uriString.toStdString() + << " -> " << uri.toDisplayString().toStdString() << endl; + rr.openResource(connection(), uri); if (spy.count() != 1) { clog << "Wrong number of signal emissions (" << spy.count() << ')' << endl; @@ -642,21 +644,19 @@ TEST_IMPL(visitResources) const auto& emission = spy.front(); Q_ASSERT(emission.count() >= 2); if (emission.front().value<decltype(target)>() != target) { - clog << "Action on an incorrect target called" << endl; + clog << "Signal emitted with an incorrect target" << endl; FAIL_TEST(); } - if (emission.back() != "action") { - clog << "Action wasn't passed" << endl; - FAIL_TEST(); - } - if (!eventId.isEmpty()) { - const auto passedEvtId = (emission.cend() - 2)->toString(); - if (passedEvtId != eventId) { - clog << "Event passed incorrectly (received " - << passedEvtId.toStdString() << " instead of " - << eventId.toStdString() << ')' << endl; + if (!otherArgs.empty()) { + if (emission.size() < otherArgs.size() + 1) { + clog << "Emission doesn't include all arguments" << endl; FAIL_TEST(); } + for (auto i = 0; i < otherArgs.size(); ++i) + if (otherArgs[i] != emission[i + 1]) { + clog << "Mismatch in argument #" << i + 1 << endl; + FAIL_TEST(); + } } spy.clear(); } @@ -674,13 +674,10 @@ TEST_IMPL(visitResources) Q_ASSERT(!eventId.isEmpty()); const QStringList roomUris { - roomId, - "matrix:roomid/" + roomId.mid(1), - "https://matrix.to/#/" + roomId, - roomAlias, - "matrix:room/" + roomAlias.mid(1), + roomId, "matrix:roomid/" + roomId.mid(1), + "https://matrix.to/#/%21" + roomId.mid(1), + roomAlias, "matrix:room/" + roomAlias.mid(1), "https://matrix.to/#/" + roomAlias, - "https://matrix.to#/" + roomAlias, // Just in case }; const QStringList userUris { userId, "matrix:user/" + userId.mid(1), "https://matrix.to/#/" + userId }; @@ -688,13 +685,37 @@ TEST_IMPL(visitResources) "matrix:room/" + roomAlias.mid(1) + "/event/" + eventId.mid(1), "https://matrix.to/#/" + roomId + '/' + eventId }; + // The following URIs are not supposed to be actually joined (and even + // exist, as yet) - only to be syntactically correct + static const auto& joinRoomAlias = + QStringLiteral("unjoined:example.org"); // # will be added below + QString joinQuery { "?action=join" }; + static const QStringList joinByAliasUris { + "matrix:room/" + joinRoomAlias + joinQuery, + "https://matrix.to/#/%23"/*`#`*/ + joinRoomAlias + joinQuery + }; + static const auto& joinRoomId = QStringLiteral("!anyid:example.org"); + static const QStringList viaServers { "matrix.org", "example.org" }; + static const auto viaQuery = + std::accumulate(viaServers.cbegin(), viaServers.cend(), joinQuery, + [](QString q, const QString& s) { + return q + "&via=" + s; + }); + static const QStringList joinByIdUris { + "matrix:roomid/" + joinRoomId.mid(1) + viaQuery, + "https://matrix.to/#/" + joinRoomId + viaQuery + }; // If any test breaks, the breaking call will return true, and further // execution will be cut by ||'s short-circuiting if (testResourceResolver(roomUris, &ResourceResolver::roomAction, room()) || testResourceResolver(userUris, &ResourceResolver::userAction, connection()->user()) || testResourceResolver(eventUris, &ResourceResolver::roomAction, - room(), eventId)) + room(), { eventId }) + || testResourceResolver(joinByAliasUris, &ResourceResolver::joinAction, + connection(), { '#' + joinRoomAlias }) + || testResourceResolver(joinByIdUris, &ResourceResolver::joinAction, + connection(), { joinRoomId, viaServers })) return true; // TODO: negative cases FINISH_TEST(true); |