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.
This commit is contained in:
Nicolas Werner 2025-05-24 12:10:29 +02:00
parent 5ef67d2a91
commit 978174af77
No known key found for this signature in database
GPG key ID: C8D75E610773F2D9
10 changed files with 266 additions and 101 deletions

View file

@ -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 "")

View file

@ -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

View file

@ -243,6 +243,14 @@ Ignore a user, invites from them are also rejected.
*/unignore* _<username>_::
Stops ignoring a user.
=== Invite permission management
*/blockinvites* _<username>_|_<roomid>_|_<servername>_|all::
Block all invites either by default ("all") or from a specific user or server or to a specific room.
*/allowinvites* _<username>_|_<roomid>_|_<servername>_|all::
Allow all invites either by default ("all") or from a specific user or server or to a specific room.
=== Advanced
*/clear-timeline*::

View file

@ -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<mtx::events::AccountDataEvent<
mtx::events::account_data::nheko_extensions::InvitePermissions>>(*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<mtx::events::StrippedEvent<mtx::events::state::Member>>(
&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<bool> 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<std::string> 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()) {

View file

@ -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>|<!roomid>|<servername>|all");
case AllowInvites:
return QStringLiteral("/allowinvites <@userid>|<!roomid>|<servername>|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 {};
}

View file

@ -55,6 +55,8 @@ public:
ConvertToRoom,
Ignore,
Unignore,
BlockInvites,
AllowInvites,
COUNT,
};

View file

@ -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::MatrixUriParseResult>
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<std::string> 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),
};
}

View file

@ -206,4 +206,13 @@ glitchText(const QString &text);
QString
graduallyGlitchText(const QString &text);
struct MatrixUriParseResult
{
QString sigil1, mxid1, sigil2, mxid2, action;
std::vector<std::string> vias;
};
std::optional<MatrixUriParseResult>
parseMatrixUri(QString uri);
}

View file

@ -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<mtx::events::AccountDataEvent<
mtx::events::account_data::nheko_extensions::InvitePermissions>>(*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<QIODevice> source_,
const QString &mimetype,
const QString &originalFilename,

View file

@ -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_;