From 978174af774f99bb70df2ad5307ae161be6190ff Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Sat, 24 May 2025 12:10:29 +0200 Subject: [PATCH] Invite permission management using the /allowinvites and /blockinvites commands Rejecting such invites should be handled serverside in the future and there should be a GUI for this, but for now this should work. --- CMakeLists.txt | 2 +- im.nheko.Nheko.yaml | 2 +- man/nheko.1.adoc | 8 +++ src/ChatPage.cpp | 133 ++++++++++---------------------------- src/CommandCompleter.cpp | 14 ++++ src/CommandCompleter.h | 2 + src/Utils.cpp | 116 +++++++++++++++++++++++++++++++++ src/Utils.h | 9 +++ src/timeline/InputBar.cpp | 80 ++++++++++++++++++++++- src/timeline/InputBar.h | 1 + 10 files changed, 266 insertions(+), 101 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c0e3fea4..31ac125d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -615,7 +615,7 @@ if(USE_BUNDLED_MTXCLIENT) FetchContent_Declare( MatrixClient GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git - GIT_TAG a0b203980491ddf2e2fe4f1cd6af8c2562b3ee35 + GIT_TAG d6a0a4ebee83275dbbeb999679a22a7d238326ff ) set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "") set(BUILD_LIB_TESTS OFF CACHE INTERNAL "") diff --git a/im.nheko.Nheko.yaml b/im.nheko.Nheko.yaml index 55cfbd46..c9f90de7 100644 --- a/im.nheko.Nheko.yaml +++ b/im.nheko.Nheko.yaml @@ -211,7 +211,7 @@ modules: - -DBUILD_SHARED_LIBS=OFF buildsystem: cmake-ninja sources: - - commit: a0b203980491ddf2e2fe4f1cd6af8c2562b3ee35 + - commit: d6a0a4ebee83275dbbeb999679a22a7d238326ff #tag: v0.10.0 type: git url: https://github.com/Nheko-Reborn/mtxclient.git diff --git a/man/nheko.1.adoc b/man/nheko.1.adoc index 586295bb..ac7dadff 100644 --- a/man/nheko.1.adoc +++ b/man/nheko.1.adoc @@ -243,6 +243,14 @@ Ignore a user, invites from them are also rejected. */unignore* __:: Stops ignoring a user. +=== Invite permission management + +*/blockinvites* __|__|__|all:: +Block all invites either by default ("all") or from a specific user or server or to a specific room. + +*/allowinvites* __|__|__|all:: +Allow all invites either by default ("all") or from a specific user or server or to a specific room. + === Advanced */clear-timeline*:: diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index d8f2f0c9..bc83973a 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -807,6 +807,36 @@ ChatPage::handleSyncResponse(const mtx::responses::Sync &res, const std::string cache::client()->saveState(res); olm::handle_to_device_messages(res.to_device.events); + // reject forbidden invites + if (!res.rooms.invite.empty()) { + if (auto ev = + cache::client()->getAccountData(mtx::events::EventType::NhekoInvitePermissions)) { + const auto &invitePerms = std::get>(*ev) + .content; + + for (const auto &[roomid, invite] : res.rooms.invite) { + std::string_view inviter = ""; + for (const auto &memberEv : invite.invite_state) { + if (auto member = + std::get_if>( + &memberEv)) { + if (member->content.membership == + mtx::events::state::Membership::Invite && + member->state_key == http::client()->user_id().to_string()) { + inviter = member->sender; + break; + } + } + } + + if (!invitePerms.invite_allowed(roomid, inviter)) { + leaveRoom(QString::fromStdString(roomid), ""); + } + } + } + } + emit syncUI(std::move(res)); // if the ignored users changed, clear timeline of all affected rooms. @@ -1583,112 +1613,19 @@ ChatPage::startChat(QString userid, std::optional encryptionEnabled) emit ChatPage::instance()->createRoom(req); } -static QString -mxidFromSegments(QStringView sigil, QStringView mxid) -{ - if (mxid.isEmpty()) - return QString(); - - auto mxid_ = QUrl::fromPercentEncoding(mxid.toUtf8()); - - if (sigil == u"u") { - return "@" + mxid_; - } else if (sigil == u"roomid") { - return "!" + mxid_; - } else if (sigil == u"r") { - return "#" + mxid_; - //} else if (sigil == "group") { - // return "+" + mxid_; - } else { - return QString(); - } -} - bool ChatPage::handleMatrixUri(QString uri) { nhlog::ui()->info("Received uri! {}", uri.toStdString()); - QUrl uri_{uri}; - // Convert matrix.to URIs to proper format - if (uri_.scheme() == QLatin1String("https") && uri_.host() == QLatin1String("matrix.to")) { - QString p = uri_.fragment(QUrl::FullyEncoded); - if (p.startsWith(QLatin1String("/"))) - p.remove(0, 1); + auto m = utils::parseMatrixUri(uri); - auto temp = p.split(QStringLiteral("?")); - QString query; - if (temp.size() >= 2) - query = QUrl::fromPercentEncoding(temp.takeAt(1).toUtf8()); - - temp = temp.first().split(QStringLiteral("/")); - auto identifier = QUrl::fromPercentEncoding(temp.takeFirst().toUtf8()); - QString eventId = QUrl::fromPercentEncoding(temp.join('/').toUtf8()); - if (!identifier.isEmpty()) { - if (identifier.startsWith(QLatin1String("@"))) { - QByteArray newUri = "matrix:u/" + QUrl::toPercentEncoding(identifier.remove(0, 1)); - if (!query.isEmpty()) - newUri.append("?" + query.toUtf8()); - return handleMatrixUri(QUrl::fromEncoded(newUri)); - } else if (identifier.startsWith(QLatin1String("#"))) { - QByteArray newUri = "matrix:r/" + QUrl::toPercentEncoding(identifier.remove(0, 1)); - if (!eventId.isEmpty()) - newUri.append("/e/" + QUrl::toPercentEncoding(eventId.remove(0, 1))); - if (!query.isEmpty()) - newUri.append("?" + query.toUtf8()); - return handleMatrixUri(QUrl::fromEncoded(newUri)); - } else if (identifier.startsWith(QLatin1String("!"))) { - QByteArray newUri = - "matrix:roomid/" + QUrl::toPercentEncoding(identifier.remove(0, 1)); - if (!eventId.isEmpty()) - newUri.append("/e/" + QUrl::toPercentEncoding(eventId.remove(0, 1))); - if (!query.isEmpty()) - newUri.append("?" + query.toUtf8()); - return handleMatrixUri(QUrl::fromEncoded(newUri)); - } - } + if (!m) { + nhlog::ui()->info("failed to parse uri! {}", uri.toStdString()); + return false; } - // non-matrix URIs are not handled by us, return false - if (uri_.scheme() != QLatin1String("matrix")) - return false; - - auto tempPath = uri_.path(QUrl::ComponentFormattingOption::FullyEncoded); - if (tempPath.startsWith('/')) - tempPath.remove(0, 1); - auto segments = QStringView(tempPath).split('/'); - - if (segments.size() != 2 && segments.size() != 4) - return false; - - auto sigil1 = segments[0]; - auto mxid1 = mxidFromSegments(sigil1, segments[1]); - if (mxid1.isEmpty()) - return false; - - QString mxid2; - if (segments.size() == 4 && segments[2] == QStringView(u"e")) { - if (segments[3].isEmpty()) - return false; - else - mxid2 = "$" + QUrl::fromPercentEncoding(segments[3].toUtf8()); - } - - std::vector vias; - QString action; - - auto items = - uri_.query(QUrl::ComponentFormattingOption::FullyEncoded).split('&', Qt::SkipEmptyParts); - for (QString item : std::as_const(items)) { - nhlog::ui()->info("item: {}", item.toStdString()); - - if (item.startsWith(QLatin1String("action="))) { - action = item.remove(QStringLiteral("action=")); - } else if (item.startsWith(QLatin1String("via="))) { - vias.push_back(QUrl::fromPercentEncoding(item.remove(QStringLiteral("via=")).toUtf8()) - .toStdString()); - } - } + const auto &[sigil1, mxid1, sigil2, mxid2, action, vias] = *m; if (sigil1 == u"u") { if (action.isEmpty()) { diff --git a/src/CommandCompleter.cpp b/src/CommandCompleter.cpp index 9ee12e5b..921ec860 100644 --- a/src/CommandCompleter.cpp +++ b/src/CommandCompleter.cpp @@ -105,6 +105,10 @@ CommandCompleter::data(const QModelIndex &index, int role) const return QStringLiteral("/ignore"); case Unignore: return QStringLiteral("/unignore"); + case BlockInvites: + return QStringLiteral("/blockinvites"); + case AllowInvites: + return QStringLiteral("/allowinvites"); default: return {}; } @@ -186,6 +190,10 @@ CommandCompleter::data(const QModelIndex &index, int role) const return QStringLiteral("/ignore <@userid>"); case Unignore: return QStringLiteral("/unignore <@userid>"); + case BlockInvites: + return QStringLiteral("/blockinvites <@userid>|||all"); + case AllowInvites: + return QStringLiteral("/allowinvites <@userid>|||all"); default: return {}; } @@ -271,6 +279,12 @@ CommandCompleter::data(const QModelIndex &index, int role) const return tr("Ignore a user."); case Unignore: return tr("Stop ignoring a user."); + case BlockInvites: + return tr("Block all invites from a user, a server, to a specific room or set the " + "default."); + case AllowInvites: + return tr("Allow all invites from a user, a server, to a specific room or set the " + "default."); default: return {}; } diff --git a/src/CommandCompleter.h b/src/CommandCompleter.h index 9dc18119..785480e6 100644 --- a/src/CommandCompleter.h +++ b/src/CommandCompleter.h @@ -55,6 +55,8 @@ public: ConvertToRoom, Ignore, Unignore, + BlockInvites, + AllowInvites, COUNT, }; diff --git a/src/Utils.cpp b/src/Utils.cpp index 381cc971..1e34d7a4 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -2114,3 +2114,119 @@ utils::graduallyGlitchText(const QString &text) return result; } + +static QString +mxidFromSegments(QStringView sigil, QStringView mxid) +{ + if (mxid.isEmpty()) + return QString(); + + auto mxid_ = QUrl::fromPercentEncoding(mxid.toUtf8()); + + if (sigil == u"u") { + return "@" + mxid_; + } else if (sigil == u"roomid") { + return "!" + mxid_; + } else if (sigil == u"r") { + return "#" + mxid_; + //} else if (sigil == "group") { + // return "+" + mxid_; + } else { + return QString(); + } +} + +std::optional +utils::parseMatrixUri(QString uri) +{ + QUrl uri_{uri}; + + // Convert matrix.to URIs to proper format + if (uri_.scheme() == QLatin1String("https") && uri_.host() == QLatin1String("matrix.to")) { + QString p = uri_.fragment(QUrl::FullyEncoded); + if (p.startsWith(QLatin1String("/"))) + p.remove(0, 1); + + auto temp = p.split(QStringLiteral("?")); + QString query; + if (temp.size() >= 2) + query = QUrl::fromPercentEncoding(temp.takeAt(1).toUtf8()); + + temp = temp.first().split(QStringLiteral("/")); + auto identifier = QUrl::fromPercentEncoding(temp.takeFirst().toUtf8()); + QString eventId = QUrl::fromPercentEncoding(temp.join('/').toUtf8()); + if (!identifier.isEmpty()) { + if (identifier.startsWith(QLatin1String("@"))) { + QByteArray newUri = "matrix:u/" + QUrl::toPercentEncoding(identifier.remove(0, 1)); + if (!query.isEmpty()) + newUri.append("?" + query.toUtf8()); + uri_ = QUrl::fromEncoded(newUri); + } else if (identifier.startsWith(QLatin1String("#"))) { + QByteArray newUri = "matrix:r/" + QUrl::toPercentEncoding(identifier.remove(0, 1)); + if (!eventId.isEmpty()) + newUri.append("/e/" + QUrl::toPercentEncoding(eventId.remove(0, 1))); + if (!query.isEmpty()) + newUri.append("?" + query.toUtf8()); + uri_ = QUrl::fromEncoded(newUri); + } else if (identifier.startsWith(QLatin1String("!"))) { + QByteArray newUri = + "matrix:roomid/" + QUrl::toPercentEncoding(identifier.remove(0, 1)); + if (!eventId.isEmpty()) + newUri.append("/e/" + QUrl::toPercentEncoding(eventId.remove(0, 1))); + if (!query.isEmpty()) + newUri.append("?" + query.toUtf8()); + uri_ = QUrl::fromEncoded(newUri); + } + } + } + + // non-matrix URIs are not handled by us, return false + if (uri_.scheme() != QLatin1String("matrix")) + return {}; + + auto tempPath = uri_.path(QUrl::ComponentFormattingOption::FullyEncoded); + if (tempPath.startsWith('/')) + tempPath.remove(0, 1); + auto segments = QStringView(tempPath).split('/'); + + if (segments.size() != 2 && segments.size() != 4) + return {}; + + auto sigil1 = segments[0]; + auto mxid1 = mxidFromSegments(sigil1, segments[1]); + if (mxid1.isEmpty()) + return {}; + + QString mxid2; + QString sigil2; + if (segments.size() == 4 && segments[2] == QStringView(u"e")) { + if (segments[3].isEmpty()) + return {}; + else + mxid2 = "$" + QUrl::fromPercentEncoding(segments[3].toUtf8()); + sigil2 = "$"; + } + + std::vector vias; + QString action; + + auto items = + uri_.query(QUrl::ComponentFormattingOption::FullyEncoded).split('&', Qt::SkipEmptyParts); + for (QString item : std::as_const(items)) { + if (item.startsWith(QLatin1String("action="))) { + action = item.remove(QStringLiteral("action=")); + } else if (item.startsWith(QLatin1String("via="))) { + vias.push_back(QUrl::fromPercentEncoding(item.remove(QStringLiteral("via=")).toUtf8()) + .toStdString()); + } + } + + return MatrixUriParseResult{ + .sigil1 = sigil1.toString(), + .mxid1 = std::move(mxid1), + .sigil2 = std::move(sigil2), + .mxid2 = std::move(mxid2), + .action = std::move(action), + .vias = std::move(vias), + }; +} diff --git a/src/Utils.h b/src/Utils.h index cf7e5a80..a3b39978 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -206,4 +206,13 @@ glitchText(const QString &text); QString graduallyGlitchText(const QString &text); + +struct MatrixUriParseResult +{ + QString sigil1, mxid1, sigil2, mxid2, action; + std::vector vias; +}; + +std::optional +parseMatrixUri(QString uri); } diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp index c8ea0b5d..93bb5583 100644 --- a/src/timeline/InputBar.cpp +++ b/src/timeline/InputBar.cpp @@ -285,7 +285,9 @@ InputBar::updateTextContentProperties(const QString &t, bool charDeleted) QStringLiteral("converttodm"), QStringLiteral("converttoroom"), QStringLiteral("ignore"), - QStringLiteral("unignore")}; + QStringLiteral("unignore"), + QStringLiteral("blockinvites"), + QStringLiteral("allowinvites")}; bool hasInvalidCommand = !commandName.isNull() && !validCommands.contains(commandName); bool hasIncompleteCommand = hasInvalidCommand && '/' + commandName == t; @@ -1025,6 +1027,10 @@ InputBar::command(const QString &command, QString args) this->toggleIgnore(args.trimmed(), true); } else if (command == QLatin1String("unignore")) { this->toggleIgnore(args.trimmed(), false); + } else if (command == QLatin1String("blockinvites")) { + this->toggleInvitePermission(args.trimmed(), true); + } else if (command == QLatin1String("allowinvites")) { + this->toggleInvitePermission(args.trimmed(), false); } else { return false; } @@ -1056,6 +1062,78 @@ InputBar::toggleIgnore(const QString &user, const bool ignored) }); } +void +InputBar::toggleInvitePermission(const QString &id, bool block) +{ + mtx::events::account_data::nheko_extensions::InvitePermissions permissions; + if (auto ev = cache::client()->getAccountData(mtx::events::EventType::NhekoInvitePermissions)) { + permissions = std::get>(*ev) + .content; + } + + auto idstr = id.toStdString(); + + if (id.startsWith("matrix:") || id.startsWith("https://matrix.to")) { + auto m = utils::parseMatrixUri(id); + if (m) { + idstr = m->mxid1.toStdString(); + } else { + return; + } + } + + if (idstr.starts_with("@")) { + if (block) { + permissions.user_allow.erase(idstr); + permissions.user_deny.emplace(idstr, "{}"); + } else { + permissions.user_deny.erase(idstr); + permissions.user_allow.emplace(idstr, "{}"); + } + } else if (idstr.starts_with("!")) { + if (block) { + permissions.room_allow.erase(idstr); + permissions.room_deny.emplace(idstr, "{}"); + } else { + permissions.room_deny.erase(idstr); + permissions.room_allow.emplace(idstr, "{}"); + } + } else if (idstr == "all" || idstr == "default") { + if (block) + permissions.default_ = "deny"; + else + permissions.default_ = "allow"; + } else if (!idstr.starts_with("#")) { + if (block) { + permissions.server_allow.erase(idstr); + permissions.server_deny.emplace(idstr, "{}"); + } else { + permissions.server_deny.erase(idstr); + permissions.server_allow.emplace(idstr, "{}"); + } + } + + http::client()->put_account_data(permissions, [](mtx::http::RequestErr err) { + if (err) { + nhlog::ui()->error("Failed to update invite permissions: {}", *err); + } + }); + + auto invites = cache::client()->invites(); + + for (const auto &[roomid, info] : invites.asKeyValueRange()) { + auto roomid_ = roomid.toStdString(); + auto self = + cache::client()->getInviteMember(roomid_, http::client()->user_id().to_string()); + if (!self->inviter.empty()) { + if (!permissions.invite_allowed(roomid_, self->inviter)) { + ChatPage::instance()->leaveRoom(roomid, ""); + } + } + } +} + MediaUpload::MediaUpload(std::unique_ptr source_, const QString &mimetype, const QString &originalFilename, diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h index 9ee2accf..6f32d811 100644 --- a/src/timeline/InputBar.h +++ b/src/timeline/InputBar.h @@ -320,6 +320,7 @@ private: void updateTextContentProperties(const QString &t, bool textDeleted = false); void toggleIgnore(const QString &user, const bool ignored); + void toggleInvitePermission(const QString &id, bool block); QTimer typingRefresh_; QTimer typingTimeout_;