From 6cdd28c7d46ff70a16347f3172b0a5bde611d033 Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 15 Mar 2025 13:14:03 +0100 Subject: [PATCH] feat: warn for missing scopes when logging in (#6072) --- CHANGELOG.md | 1 + src/common/LinkParser.cpp | 2 +- .../notifications/NotificationController.hpp | 2 +- src/messages/MessageBuilder.cpp | 26 +++ src/messages/MessageBuilder.hpp | 2 + src/providers/twitch/TwitchAccountManager.cpp | 187 ++++++++++++++++++ src/providers/twitch/TwitchAccountManager.hpp | 2 + src/util/QCompareCaseInsensitive.hpp | 144 -------------- src/util/QCompareTransparent.hpp | 166 ++++++++++++++++ tests/src/EventSubMessages.cpp | 2 +- 10 files changed, 387 insertions(+), 147 deletions(-) delete mode 100644 src/util/QCompareCaseInsensitive.hpp create mode 100644 src/util/QCompareTransparent.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1979b7..fde3674d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Minor: The font weight of chat messages can now be changed. (#6037) - Minor: Messages from restricted users can now be hidden when in streamer mode. This is enabled by default. (#6042, #6049) - Minor: Added a tab style option allowing you to make your tabs more compact. (#5858) +- Minor: Chatterino now warns about missing scopes when logging in. (#6072) - Bugfix: Fixed a potential way to escape the Lua Plugin sandbox. (#5846) - Bugfix: Fixed a crash relating to Lua HTTP. (#5800) - Bugfix: Fixed a crash that could occur on Linux and macOS when clicking "Install" from the update prompt. (#5818) diff --git a/src/common/LinkParser.cpp b/src/common/LinkParser.cpp index c6ec1ab2..64262d3a 100644 --- a/src/common/LinkParser.cpp +++ b/src/common/LinkParser.cpp @@ -1,7 +1,7 @@ #define QT_NO_CAST_FROM_ASCII // avoids unexpected implicit casts #include "common/LinkParser.hpp" -#include "util/QCompareCaseInsensitive.hpp" +#include "util/QCompareTransparent.hpp" #include #include diff --git a/src/controllers/notifications/NotificationController.hpp b/src/controllers/notifications/NotificationController.hpp index e7ad1731..98498e60 100644 --- a/src/controllers/notifications/NotificationController.hpp +++ b/src/controllers/notifications/NotificationController.hpp @@ -2,7 +2,7 @@ #include "common/ChatterinoSetting.hpp" #include "common/SignalVector.hpp" -#include "util/QCompareCaseInsensitive.hpp" +#include "util/QCompareTransparent.hpp" #include diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 139401d2..5fbd2074 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -2057,6 +2057,32 @@ MessagePtr MessageBuilder::makeLowTrustUpdateMessage( return builder.release(); } +MessagePtrMut MessageBuilder::makeMissingScopesMessage( + const QString &missingScopes) +{ + auto warnText = u"Your account is missing the following permission(s): " % + missingScopes % + u". Some features might not work correctly."; + auto linkText = u"Consider re-adding your account."_s; + + MessageBuilder builder; + auto text = warnText % ' ' % linkText; + builder->messageText = text; + builder->searchText = text; + builder->flags.set(MessageFlag::System, + MessageFlag::DoNotTriggerNotification); + + builder.emplace(); + builder.emplace(warnText, MessageElementFlag::Text, + MessageColor::System); + builder + .emplace(linkText, MessageElementFlag::Text, + MessageColor::Link) + ->setLink({Link::OpenAccountsPage, {}}); + + return builder.release(); +} + MessagePtrMut MessageBuilder::makeClearChatMessage(const QDateTime &now, const QString &actor, uint32_t count) diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 69ecfd01..db848821 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -270,6 +270,8 @@ public: const QVariantMap &tags, const QTime &time); + static MessagePtrMut makeMissingScopesMessage(const QString &missingScopes); + /// "Chat has been cleared by a moderator." or "{actor} cleared the chat." /// @param actor The user who cleared the chat (empty if unknown) /// @param count How many times this message has been received already diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index 7f4ed78a..f15d20e1 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -3,15 +3,198 @@ #include "Application.hpp" #include "common/Args.hpp" #include "common/Common.hpp" +#include "common/Literals.hpp" +#include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchCommon.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchUser.hpp" +#include "util/QCompareTransparent.hpp" #include "util/SharedPtrElementLess.hpp" +#include + +namespace { + +using namespace chatterino; +using namespace literals; + +QString missingScopes(const QJsonArray &scopesArray) +{ + std::set scopes; + for (auto s : scopesArray) + { + scopes.emplace(s.toString()); + } + + QString missingList; + for (auto scope : AUTH_SCOPES) + { + if (!scopes.contains(scope)) + { + if (!missingList.isEmpty()) + { + missingList.append(u", "); + } + missingList.append(scope); + } + } + + return missingList; +} + +void checkMissingScopes(const QString &token) +{ + NetworkRequest(u"https://id.twitch.tv/oauth2/validate"_s, + NetworkRequestType::Get) + .header("Authorization", u"OAuth " % token) + .timeout(20000) + .onSuccess([](const auto &res) { + auto *app = tryGetApp(); + if (!app) + { + return; + } + + const auto json = res.parseJson(); + auto missing = missingScopes(json["scopes"_L1].toArray()); + if (missing.isEmpty()) + { + return; + } + + auto msg = MessageBuilder::makeMissingScopesMessage(missing); + app->getTwitch()->forEachChannel([msg](const auto &chan) { + chan->addMessage(msg, MessageContext::Original); + }); + }) + .onError([](const auto &res) { + qCWarning(chatterinoTwitch) + << "Failed to check for missing scopes:" << res.formatError(); + }) + .execute(); +} + +} // namespace + namespace chatterino { +const std::vector AUTH_SCOPES{ + u"channel:moderate", // for seeing automod & which moderator banned/unbanned a user (felanbird unbanned weeb123) + u"channel:read:redemptions", // for getting the list of channel point redemptions (not currently used) + u"chat:edit", // for sending messages in chat + u"chat:read", // for viewing messages in chat + u"whispers:read", // for viewing recieved whispers + + // https://dev.twitch.tv/docs/api/reference#start-commercial + u"channel:edit:commercial", // for /commercial api + + // https://dev.twitch.tv/docs/api/reference#create-clip + u"clips:edit", // for /clip creation + + // https://dev.twitch.tv/docs/api/reference#create-stream-marker + // https://dev.twitch.tv/docs/api/reference#modify-channel-information + u"channel:manage:broadcast", // for creating stream markers with /marker command, and for the /settitle and /setgame commands + + // https://dev.twitch.tv/docs/api/reference#get-user-block-list + u"user:read:blocked_users", // for getting list of blocked users + + // https://dev.twitch.tv/docs/api/reference#block-user + // https://dev.twitch.tv/docs/api/reference#unblock-user + u"user:manage:blocked_users", // for blocking/unblocking other users + + // https://dev.twitch.tv/docs/api/reference#manage-held-automod-messages + u"moderator:manage:automod", // for approving/denying automod messages + + // https://dev.twitch.tv/docs/api/reference#start-a-raid + // https://dev.twitch.tv/docs/api/reference#cancel-a-raid + u"channel:manage:raids", // for starting/canceling raids + + // https://dev.twitch.tv/docs/api/reference#create-poll + // https://dev.twitch.tv/docs/api/reference#end-poll + u"channel:manage:polls", // for creating & ending polls (not currently used) + + // https://dev.twitch.tv/docs/api/reference#get-polls + u"channel:read:polls", // for reading broadcaster poll status (not currently used) + + // https://dev.twitch.tv/docs/api/reference#create-prediction + // https://dev.twitch.tv/docs/api/reference#end-prediction + u"channel:manage:predictions", // for creating & ending predictions (not currently used) + + // https://dev.twitch.tv/docs/api/reference#get-predictions + u"channel:read:predictions", // for reading broadcaster prediction status (not currently used) + + // https://dev.twitch.tv/docs/api/reference#send-chat-announcement + u"moderator:manage:announcements", // for /announce api + + // https://dev.twitch.tv/docs/api/reference#send-whisper + u"user:manage:whispers", // for whispers api + + // https://dev.twitch.tv/docs/api/reference#ban-user + // https://dev.twitch.tv/docs/api/reference#unban-user + u"moderator:manage:banned_users", // for ban/unban/timeout/untimeout api & channel.moderate eventsub topic + + // https://dev.twitch.tv/docs/api/reference#delete-chat-messages + u"moderator:manage:chat_messages", // for delete message api (/delete, /clear) & channel.moderate eventsub topic + + // https://dev.twitch.tv/docs/api/reference#update-user-chat-color + u"user:manage:chat_color", // for update user color api (/color coral) + + // https://dev.twitch.tv/docs/api/reference#get-chat-settings + u"moderator:manage:chat_settings", // for roomstate api (/followersonly, /uniquechat, /slow) & channel.moderate eventsub topic + + // https://dev.twitch.tv/docs/api/reference#get-moderators + // https://dev.twitch.tv/docs/api/reference#add-channel-moderator + // https://dev.twitch.tv/docs/api/reference#remove-channel-vip + u"channel:manage:moderators", // for add/remove/view mod api + + // https://dev.twitch.tv/docs/api/reference#add-channel-vip + // https://dev.twitch.tv/docs/api/reference#remove-channel-vip + // https://dev.twitch.tv/docs/api/reference#get-vips + u"channel:manage:vips", // for add/remove/view vip api + + // https://dev.twitch.tv/docs/api/reference#get-chatters + u"moderator:read:chatters", // for get chatters api + + // https://dev.twitch.tv/docs/api/reference#get-shield-mode-status + // https://dev.twitch.tv/docs/api/reference#update-shield-mode-status + u"moderator:manage:shield_mode", // for reading/managing the channel's shield-mode status + + // https://dev.twitch.tv/docs/api/reference/#send-a-shoutout + u"moderator:manage:shoutouts", // for reading/managing the channel's shoutouts (not currently used) + + // https://dev.twitch.tv/docs/api/reference/#get-moderated-channels + u"user:read:moderated_channels", // for reading where the user is modded (not currently used) + + // https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/#channelchatmessage + u"user:read:chat", // for reading chat via eventsub (in progress) + + // https://dev.twitch.tv/docs/api/reference/#send-chat-message + u"user:write:chat", // for sending chat messages via helix (in testing) + + // https://dev.twitch.tv/docs/api/reference/#get-user-emotes + u"user:read:emotes", // for fetching emotes that a user can use via helix + + // https://dev.twitch.tv/docs/api/reference/#warn-chat-user + u"moderator:manage:warnings", // for /warn api & channel.moderate eventsub topic + + // https://dev.twitch.tv/docs/api/reference/#get-followed-channels + u"user:read:follows", // for determining if the current user follows a streamer + + u"moderator:manage:blocked_terms", // for channel.moderate eventsub topic + + u"moderator:manage:unban_requests", // for channel.moderate eventsub topic + + u"moderator:read:moderators", // for channel.moderate eventsub topic + + u"moderator:read:vips", // for channel.moderate eventsub topic + + u"moderator:read:suspicious_users", // for channel.suspicious_user.message and channel.suspicious_user.update +}; + TwitchAccountManager::TwitchAccountManager() : accounts(SharedPtrElementLess{}) , anonymousUser_(new TwitchAccount(ANONYMOUS_USERNAME, "", "", "")) @@ -20,6 +203,10 @@ TwitchAccountManager::TwitchAccountManager() auto currentUser = this->getCurrent(); currentUser->loadBlocks(); currentUser->loadSeventvUserID(); + if (!currentUser->isAnon()) + { + checkMissingScopes(currentUser->getOAuthToken()); + } }); // We can safely ignore this signal connection since accounts will always be removed diff --git a/src/providers/twitch/TwitchAccountManager.hpp b/src/providers/twitch/TwitchAccountManager.hpp index d0e21111..09fc8830 100644 --- a/src/providers/twitch/TwitchAccountManager.hpp +++ b/src/providers/twitch/TwitchAccountManager.hpp @@ -24,6 +24,8 @@ namespace chatterino { class TwitchAccount; class AccountController; +extern const std::vector AUTH_SCOPES; + class TwitchAccountManager { TwitchAccountManager(); diff --git a/src/util/QCompareCaseInsensitive.hpp b/src/util/QCompareCaseInsensitive.hpp deleted file mode 100644 index 70e8a1a0..00000000 --- a/src/util/QCompareCaseInsensitive.hpp +++ /dev/null @@ -1,144 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) -# include -#endif - -namespace chatterino { - -/// Case insensitive transparent comparator for Qt's string types -struct QCompareCaseInsensitive { - using is_transparent = void; - - // clang-format off - bool operator()(const QString & a, const QString & b) const noexcept; - bool operator()(QStringView a, QStringView b) const noexcept; - bool operator()(QLatin1String a, QLatin1String b) const noexcept; - - bool operator()(const QString & a, QStringView b) const noexcept; - bool operator()(const QString & a, QLatin1String b) const noexcept; - - bool operator()(QStringView a, const QString & b) const noexcept; - bool operator()(QLatin1String a, const QString & b) const noexcept; - - bool operator()(QStringView a, QLatin1String b) const noexcept; - bool operator()(QLatin1String a, QStringView b) const noexcept; - -#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) - bool operator()(QUtf8StringView a, QUtf8StringView b) const noexcept; - - bool operator()(const QString & a, QUtf8StringView b) const noexcept; - bool operator()(QStringView a, QUtf8StringView b) const noexcept; - bool operator()(QLatin1String a, QUtf8StringView b) const noexcept; - - bool operator()(QUtf8StringView a, const QString & b) const noexcept; - bool operator()(QUtf8StringView a, QStringView b) const noexcept; - bool operator()(QUtf8StringView a, QLatin1String b) const noexcept; -#endif - // clang-format on -}; - -inline bool QCompareCaseInsensitive::operator()(const QString &a, - const QString &b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QStringView a, - QStringView b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QLatin1String a, - QLatin1String b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(const QString &a, - QStringView b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(const QString &a, - QLatin1String b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QStringView a, - const QString &b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QLatin1String a, - const QString &b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QStringView a, - QLatin1String b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QLatin1String a, - QStringView b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) -inline bool QCompareCaseInsensitive::operator()( - QUtf8StringView a, QUtf8StringView b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()( - const QString &a, QUtf8StringView b) const noexcept -{ - return QStringView{a}.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()( - QStringView a, QUtf8StringView b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()( - QLatin1String a, QUtf8StringView b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QUtf8StringView a, - const QString &b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QUtf8StringView a, - QStringView b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} - -inline bool QCompareCaseInsensitive::operator()(QUtf8StringView a, - QLatin1String b) const noexcept -{ - return a.compare(b, Qt::CaseInsensitive) < 0; -} -#endif - -} // namespace chatterino diff --git a/src/util/QCompareTransparent.hpp b/src/util/QCompareTransparent.hpp new file mode 100644 index 00000000..a513a70c --- /dev/null +++ b/src/util/QCompareTransparent.hpp @@ -0,0 +1,166 @@ +#pragma once + +#include +#include +#include +#include + +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) +# include +#endif + +namespace chatterino { + +template +struct QCompareTransparentBase { + using is_transparent = void; + + // clang-format off + bool operator()(const QString & a, const QString & b) const noexcept; + bool operator()(QStringView a, QStringView b) const noexcept; + bool operator()(QLatin1String a, QLatin1String b) const noexcept; + + bool operator()(const QString & a, QStringView b) const noexcept; + bool operator()(const QString & a, QLatin1String b) const noexcept; + + bool operator()(QStringView a, const QString & b) const noexcept; + bool operator()(QLatin1String a, const QString & b) const noexcept; + + bool operator()(QStringView a, QLatin1String b) const noexcept; + bool operator()(QLatin1String a, QStringView b) const noexcept; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + bool operator()(QUtf8StringView a, QUtf8StringView b) const noexcept; + + bool operator()(const QString & a, QUtf8StringView b) const noexcept; + bool operator()(QStringView a, QUtf8StringView b) const noexcept; + bool operator()(QLatin1String a, QUtf8StringView b) const noexcept; + + bool operator()(QUtf8StringView a, const QString & b) const noexcept; + bool operator()(QUtf8StringView a, QStringView b) const noexcept; + bool operator()(QUtf8StringView a, QLatin1String b) const noexcept; +#endif + // clang-format on +}; + +/// Case insensitive transparent comparator for Qt's string types +using QCompareCaseInsensitive = QCompareTransparentBase; + +/// Case sensitive transparent comparator for Qt's string types +using QCompareTransparent = QCompareTransparentBase; + +template +inline bool QCompareTransparentBase::operator()( + const QString &a, const QString &b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QStringView a, QStringView b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QLatin1String a, QLatin1String b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + const QString &a, QStringView b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + const QString &a, QLatin1String b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QStringView a, const QString &b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QLatin1String a, const QString &b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QStringView a, QLatin1String b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QLatin1String a, QStringView b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) +template +inline bool QCompareTransparentBase::operator()( + QUtf8StringView a, QUtf8StringView b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + const QString &a, QUtf8StringView b) const noexcept +{ + return QStringView{a}.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QStringView a, QUtf8StringView b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QLatin1String a, QUtf8StringView b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QUtf8StringView a, const QString &b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QUtf8StringView a, QStringView b) const noexcept +{ + return a.compare(b, CS) < 0; +} + +template +inline bool QCompareTransparentBase::operator()( + QUtf8StringView a, QLatin1String b) const noexcept +{ + return a.compare(b, CS) < 0; +} +#endif + +} // namespace chatterino diff --git a/tests/src/EventSubMessages.cpp b/tests/src/EventSubMessages.cpp index 3bceeb7c..5064cbe4 100644 --- a/tests/src/EventSubMessages.cpp +++ b/tests/src/EventSubMessages.cpp @@ -8,7 +8,7 @@ #include "mocks/TwitchIrcServer.hpp" #include "providers/twitch/eventsub/Connection.hpp" #include "Test.hpp" -#include "util/QCompareCaseInsensitive.hpp" +#include "util/QCompareTransparent.hpp" #include