SceneSwitcher/plugins/twitch/token.cpp
2026-03-21 22:52:27 +01:00

973 lines
27 KiB
C++

#include "token.hpp"
#include "twitch-helpers.hpp"
#include <help-icon.hpp>
#include <layout-helpers.hpp>
#include <log-helper.hpp>
#include <obs-module-helper.hpp>
#include <plugin-state-helpers.hpp>
#include <QDesktopServices>
#include <QGroupBox>
#include <QScrollArea>
#include <QUrl>
#include <ui-helpers.hpp>
namespace advss {
static const int tokenGrabberPort = 42171;
static std::deque<std::shared_ptr<Item>> twitchTokens;
const std::unordered_map<std::string, std::string> TokenOption::_apiIdToLocale{
{"channel:manage:broadcast",
"AdvSceneSwitcher.twitchToken.channel.broadcast.manage"},
{"moderator:read:followers",
"AdvSceneSwitcher.twitchToken.moderator.followers.read"},
{"channel:read:subscriptions",
"AdvSceneSwitcher.twitchToken.channel.subscriptions.read"},
{"bits:read", "AdvSceneSwitcher.twitchToken.bits.read"},
{"moderator:read:shoutouts",
"AdvSceneSwitcher.twitchToken.moderator.shoutouts.read"},
{"channel:manage:raids",
"AdvSceneSwitcher.twitchToken.channel.raids.manage"},
{"moderator:manage:shoutouts",
"AdvSceneSwitcher.twitchToken.moderator.shoutouts.manage"},
{"channel:read:ads", "AdvSceneSwitcher.twitchToken.channel.ads.read"},
{"channel:read:polls",
"AdvSceneSwitcher.twitchToken.channel.polls.read"},
{"channel:manage:polls",
"AdvSceneSwitcher.twitchToken.channel.polls.manage"},
{"channel:read:predictions",
"AdvSceneSwitcher.twitchToken.channel.predictions.read"},
{"channel:manage:predictions",
"AdvSceneSwitcher.twitchToken.channel.predictions.manage"},
{"channel:read:goals",
"AdvSceneSwitcher.twitchToken.channel.goals.read"},
{"channel:read:hype_train",
"AdvSceneSwitcher.twitchToken.channel.hypeTrain.read"},
{"channel:read:charity",
"AdvSceneSwitcher.twitchToken.channel.charity.read"},
{"moderator:read:shield_mode",
"AdvSceneSwitcher.twitchToken.moderator.shieldMode.read"},
{"moderator:manage:shield_mode",
"AdvSceneSwitcher.twitchToken.moderator.shieldMode.manage"},
{"channel:read:redemptions",
"AdvSceneSwitcher.twitchToken.channel.redemptions.read"},
{"channel:manage:redemptions",
"AdvSceneSwitcher.twitchToken.channel.redemptions.manage"},
{"channel:moderate", "AdvSceneSwitcher.twitchToken.channel.moderate"},
{"moderator:manage:banned_users",
"AdvSceneSwitcher.twitchToken.moderator.bannedUsers.manage"},
{"user:manage:blocked_users",
"AdvSceneSwitcher.twitchToken.user.blockedUsers.manage"},
{"moderation:read", "AdvSceneSwitcher.twitchToken.moderation.read"},
{"channel:manage:moderators",
"AdvSceneSwitcher.twitchToken.channel.moderators.manage"},
{"channel:manage:vips",
"AdvSceneSwitcher.twitchToken.channel.vips.manage"},
{"channel:edit:commercial",
"AdvSceneSwitcher.twitchToken.channel.commercial.edit"},
{"channel:manage:ads",
"AdvSceneSwitcher.twitchToken.channel.ads.manage"},
{"clips:edit", "AdvSceneSwitcher.twitchToken.clips.edit"},
{"moderator:manage:announcements",
"AdvSceneSwitcher.twitchToken.moderator.announcements.manage"},
{"moderator:manage:chat_settings",
"AdvSceneSwitcher.twitchToken.moderator.chat.settings.manage"},
{"user:manage:whispers",
"AdvSceneSwitcher.twitchToken.user.whispers.manage"},
{"chat:read", "AdvSceneSwitcher.twitchToken.chat.read"},
{"chat:edit", "AdvSceneSwitcher.twitchToken.chat.edit"},
};
static void saveConnections(obs_data_t *obj);
static void loadConnections(obs_data_t *obj);
static bool setupTwitchTokenSupport()
{
AddSaveStep(saveConnections);
AddLoadStep(loadConnections);
return true;
}
bool TwitchToken::_setup = setupTwitchTokenSupport();
static void saveConnections(obs_data_t *obj)
{
OBSDataArrayAutoRelease connectionArray = obs_data_array_create();
for (const auto &c : twitchTokens) {
OBSDataAutoRelease arrayObj = obs_data_create();
c->Save(arrayObj);
obs_data_array_push_back(connectionArray, arrayObj);
}
obs_data_set_array(obj, "twitchConnections", connectionArray);
}
static void loadConnections(obs_data_t *obj)
{
twitchTokens.clear();
OBSDataArrayAutoRelease connectionArray =
obs_data_get_array(obj, "twitchConnections");
size_t count = obs_data_array_count(connectionArray);
for (size_t i = 0; i < count; i++) {
OBSDataAutoRelease arrayObj =
obs_data_array_item(connectionArray, i);
auto con = TwitchToken::Create();
twitchTokens.emplace_back(con);
twitchTokens.back()->Load(arrayObj);
}
}
void TokenOption::Load(obs_data_t *obj)
{
apiId = obs_data_get_string(obj, "apiID");
}
void TokenOption::Save(obs_data_t *obj) const
{
obs_data_set_string(obj, "apiID", apiId.c_str());
}
std::string TokenOption::GetLocale() const
{
return _apiIdToLocale.at(apiId);
}
const std::unordered_map<std::string, std::string> &
TokenOption::GetTokenOptionMap()
{
return _apiIdToLocale;
}
std::set<TokenOption> TokenOption::GetAllTokenOptions()
{
std::set<TokenOption> result;
for (const auto &[optionStr, _] : _apiIdToLocale) {
TokenOption option = {optionStr};
result.emplace(option);
}
return result;
}
bool TokenOption::operator<(const TokenOption &other) const
{
return apiId < other.apiId;
}
TwitchToken::TwitchToken(const TwitchToken &other)
: Item(other._name),
_token(other._token),
_lastValidityCheckTime(),
_lastValidityCheckValue(),
_lastValidityCheckResult(false),
_userID(other._userID),
_tokenOptions(other._tokenOptions),
_eventSub(),
_validateEventSubTimestamps(other._validateEventSubTimestamps),
_warnIfInvalid(other._warnIfInvalid)
{
}
TwitchToken &TwitchToken::operator=(const TwitchToken &other)
{
_name = other._name;
_token = other._token;
_userID = other._userID;
_tokenOptions = other._tokenOptions;
_validateEventSubTimestamps = other._validateEventSubTimestamps;
_warnIfInvalid = other._warnIfInvalid;
return *this;
}
void TwitchToken::Load(obs_data_t *obj)
{
Item::Load(obj);
_token = obs_data_get_string(obj, "token");
_userID = obs_data_get_string(obj, "userID");
obs_data_set_default_bool(obj, "validateEventSubTimestamps", true);
_validateEventSubTimestamps =
obs_data_get_bool(obj, "validateEventSubTimestamps");
_warnIfInvalid = obs_data_has_user_value(obj, "warnIfInvalid")
? obs_data_get_bool(obj, "warnIfInvalid")
: true;
_tokenOptions.clear();
OBSDataArrayAutoRelease options = obs_data_get_array(obj, "options");
size_t count = obs_data_array_count(options);
for (size_t i = 0; i < count; i++) {
OBSDataAutoRelease arrayObj = obs_data_array_item(options, i);
TokenOption tokenOption;
tokenOption.Load(arrayObj);
_tokenOptions.insert(tokenOption);
}
}
void TwitchToken::Save(obs_data_t *obj) const
{
Item::Save(obj);
obs_data_set_string(obj, "token", _token.c_str());
if (_userID) {
obs_data_set_string(obj, "userID", _userID->c_str());
}
obs_data_set_bool(obj, "validateEventSubTimestamps",
_validateEventSubTimestamps);
obs_data_set_bool(obj, "warnIfInvalid", _warnIfInvalid);
OBSDataArrayAutoRelease options = obs_data_array_create();
for (auto &option : _tokenOptions) {
OBSDataAutoRelease arrayObj = obs_data_create();
option.Save(arrayObj);
obs_data_array_push_back(options, arrayObj);
}
obs_data_set_array(obj, "options", options);
}
bool TwitchToken::OptionIsActive(const TokenOption &option) const
{
for (const auto &activeOption : _tokenOptions) {
if (activeOption.apiId == option.apiId) {
return true;
}
}
return false;
}
bool TwitchToken::OptionIsEnabled(const TokenOption &option) const
{
if (!IsValid()) {
return false;
}
return OptionIsActive(option);
}
bool TwitchToken::AnyOptionIsEnabled(
const std::vector<TokenOption> &options) const
{
if (!IsValid()) {
return false;
}
if (options.empty()) {
return true;
}
for (const auto &tokenOption : options) {
if (OptionIsActive(tokenOption)) {
return true;
}
}
return false;
}
void TwitchToken::SetToken(const std::string &value)
{
_token = value;
auto res =
SendGetRequest(*this, "https://api.twitch.tv", "/helix/users");
if (res.status != 200) {
blog(LOG_WARNING, "failed to get Twitch user id from token!");
_userID = {};
return;
}
OBSDataArrayAutoRelease array = obs_data_get_array(res.data, "data");
size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; i++) {
OBSDataAutoRelease arrayObj = obs_data_array_item(array, i);
_userID = obs_data_get_string(arrayObj, "id");
_name = obs_data_get_string(arrayObj, "display_name");
}
// Trigger resubscribes with new token
if (_eventSub) {
_eventSub->ClearActiveSubscriptions();
}
}
std::optional<std::string> TwitchToken::GetToken() const
{
if (!IsValid()) {
return {};
}
return _token;
}
std::shared_ptr<EventSub> TwitchToken::GetEventSub()
{
if (!_eventSub) {
_eventSub = std::make_shared<EventSub>();
_eventSub->EnableTimestampValidation(
_validateEventSubTimestamps);
}
return _eventSub;
}
bool TwitchToken::IsValid(bool forceUpdate) const
{
static httplib::Client cli("https://id.twitch.tv");
httplib::Headers headers{{"Authorization", "OAuth " + _token}};
std::scoped_lock<std::mutex> lock(_cacheMutex);
auto currentTime = std::chrono::system_clock::now();
auto diff = currentTime - _lastValidityCheckTime;
const bool cacheIsTooOld = diff >= std::chrono::hours(1);
const auto checkToken = [&]() -> bool {
const auto response =
cli.Get("/oauth2/validate", httplib::Params{}, headers);
_lastValidityCheckTime = std::chrono::system_clock::now();
_lastValidityCheckValue = _token;
if (!response || response->status != 200) {
blog(LOG_INFO, "Twitch token %s is not valid!",
_name.c_str());
_lastValidityCheckResult = false;
return false;
}
OBSDataAutoRelease replyData =
obs_data_create_from_json(response->body.c_str());
const char *id = obs_data_get_string(replyData, "user_id");
if (!id) {
blog(LOG_INFO,
"Twitch token %s does validity check did not report user_id! Assume invalid!",
_name.c_str());
_lastValidityCheckResult = false;
return false;
}
if (_userID && _userID != id) {
blog(LOG_INFO,
"Twitch token %s does not match expected user (got %s, expected %s)!",
_name.c_str(), id, _userID->c_str());
_lastValidityCheckResult = false;
return false;
}
_lastValidityCheckResult = true;
return true;
};
const bool tokenChanged = _lastValidityCheckValue != _token;
if (tokenChanged) {
return checkToken();
}
if (!forceUpdate && !cacheIsTooOld) {
return _lastValidityCheckResult;
}
return checkToken();
}
TwitchToken *GetTwitchTokenByName(const QString &name)
{
return GetTwitchTokenByName(name.toStdString());
}
TwitchToken *GetTwitchTokenByName(const std::string &name)
{
for (auto &t : twitchTokens) {
if (t->Name() == name) {
return dynamic_cast<TwitchToken *>(t.get());
}
}
return nullptr;
}
std::weak_ptr<TwitchToken> GetWeakTwitchTokenByName(const std::string &name)
{
for (const auto &t : twitchTokens) {
if (t->Name() == name) {
std::weak_ptr<TwitchToken> wp =
std::dynamic_pointer_cast<TwitchToken>(t);
return wp;
}
}
return std::weak_ptr<TwitchToken>();
}
std::weak_ptr<TwitchToken> GetWeakTwitchTokenByQString(const QString &name)
{
return GetWeakTwitchTokenByName(name.toStdString());
}
std::string GetWeakTwitchTokenName(std::weak_ptr<TwitchToken> token)
{
auto con = token.lock();
if (!con) {
return obs_module_text("AdvSceneSwitcher.twitchToken.invalid");
}
return con->Name();
}
bool TokenIsValid(const std::weak_ptr<TwitchToken> &token_)
{
auto token = token_.lock();
if (!token) {
return false;
}
return token->IsValid();
}
std::deque<std::shared_ptr<Item>> &GetTwitchTokens()
{
return twitchTokens;
}
static bool ConnectionNameAvailable(const QString &name)
{
return !GetTwitchTokenByName(name);
}
static bool ConnectionNameAvailable(const std::string &name)
{
return ConnectionNameAvailable(QString::fromStdString(name));
}
static bool AskForSettingsWrapper(QWidget *parent, Item &settings)
{
TwitchToken &ConnectionSettings = dynamic_cast<TwitchToken &>(settings);
return TwitchTokenSettingsDialog::AskForSettings(parent,
ConnectionSettings);
}
TwitchConnectionSelection::TwitchConnectionSelection(QWidget *parent)
: ItemSelection(twitchTokens, TwitchToken::Create,
AskForSettingsWrapper,
"AdvSceneSwitcher.twitchToken.select",
"AdvSceneSwitcher.twitchToken.add",
"AdvSceneSwitcher.twitchToken.nameNotAvailable",
"AdvSceneSwitcher.twitchToken.configure", parent)
{
ShowRenameContextMenu(false);
// Connect to slots
QWidget::connect(TwitchConnectionSignalManager::Instance(),
SIGNAL(Add(const QString &)), this,
SLOT(AddItem(const QString &)));
QWidget::connect(TwitchConnectionSignalManager::Instance(),
SIGNAL(Remove(const QString &)), this,
SLOT(RemoveItem(const QString &)));
// Forward signals
QWidget::connect(this, SIGNAL(ItemAdded(const QString &)),
TwitchConnectionSignalManager::Instance(),
SIGNAL(Add(const QString &)));
QWidget::connect(this, SIGNAL(ItemRemoved(const QString &)),
TwitchConnectionSignalManager::Instance(),
SIGNAL(Remove(const QString &)));
}
void TwitchConnectionSelection::SetToken(const std::string &token)
{
if (!!GetTwitchTokenByName(token)) {
SetItem(token);
} else {
SetItem("");
}
}
void TwitchConnectionSelection::SetToken(
const std::weak_ptr<TwitchToken> &token_)
{
auto token = token_.lock();
if (token) {
SetItem(token->Name());
} else {
SetItem("");
}
}
static QCheckBox *addOption(const TokenOption &option, const TwitchToken &token,
QGridLayout *layout, int &row)
{
auto label = new QLabel(obs_module_text(option.GetLocale().c_str()));
label->setWordWrap(true);
layout->addWidget(label, row, 1);
auto checkBox = new QCheckBox();
checkBox->setChecked(token.OptionIsActive(option));
layout->addWidget(checkBox, row, 0);
row++;
return checkBox;
}
TwitchTokenSettingsDialog::TwitchTokenSettingsDialog(
QWidget *parent, const TwitchToken &settings)
: ItemSettingsDialog(settings, twitchTokens,
"AdvSceneSwitcher.twitchToken.select",
"AdvSceneSwitcher.twitchToken.add",
"AdvSceneSwitcher.twitchToken.nameNotAvailable",
false, parent),
_requestToken(new QPushButton(
obs_module_text("AdvSceneSwitcher.twitchToken.request"))),
_showToken(new QPushButton()),
_currentTokenValue(new QLineEdit()),
_tokenStatus(new QLabel()),
_generalSettingsGrid(new QGridLayout()),
_validateTimestamps(new QCheckBox(obs_module_text(
"AdvSceneSwitcher.twitchToken.validateTimestamps"))),
_warnIfInvalid(new QCheckBox(obs_module_text(
"AdvSceneSwitcher.twitchToken.warnIfInvalid")))
{
_showToken->setMaximumWidth(22);
_showToken->setFlat(true);
_showToken->setStyleSheet(
"QPushButton { background-color: transparent; border: 0px }");
_currentTokenValue->setReadOnly(true);
_currentTokenValue->setText(QString::fromStdString(settings._token));
_name->setReadOnly(true);
QWidget::connect(_requestToken, SIGNAL(clicked()), this,
SLOT(RequestToken()));
QWidget::connect(_showToken, SIGNAL(pressed()), this,
SLOT(ShowToken()));
QWidget::connect(_showToken, SIGNAL(released()), this,
SLOT(HideToken()));
QWidget::connect(&_tokenGrabber, &TokenGrabberThread::GotToken, this,
&TwitchTokenSettingsDialog::GotToken);
int row = 0;
_generalSettingsGrid->addWidget(
new QLabel(
obs_module_text("AdvSceneSwitcher.twitchToken.name")),
row, 0);
auto nameLayout = new QHBoxLayout();
nameLayout->addWidget(_name);
nameLayout->addWidget(_nameHint);
_generalSettingsGrid->addLayout(nameLayout, row, 1);
_nameRow = row;
++row;
_generalSettingsGrid->addWidget(
new QLabel(
obs_module_text("AdvSceneSwitcher.twitchToken.value")),
row, 0);
auto tokenValueLayout = new QHBoxLayout();
tokenValueLayout->addWidget(_currentTokenValue);
tokenValueLayout->addWidget(_showToken);
_generalSettingsGrid->addLayout(tokenValueLayout, row, 1);
_tokenValueRow = row;
++row;
_generalSettingsGrid->addWidget(_requestToken, row, 0);
_generalSettingsGrid->addWidget(_tokenStatus, row, 1);
auto optionsGrid = new QGridLayout();
row = 0;
auto optionsBox = new QGroupBox(
obs_module_text("AdvSceneSwitcher.twitchToken.permissions"));
for (const auto &[id, _] : TokenOption::GetTokenOptionMap()) {
auto checkBox = addOption({id}, settings, optionsGrid, row);
QWidget::connect(checkBox, SIGNAL(stateChanged(int)), this,
SLOT(TokenOptionChanged(int)));
_optionWidgets[id] = checkBox;
}
MinimizeSizeOfColumn(optionsGrid, 0);
optionsBox->setLayout(optionsGrid);
auto scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
auto timestampsLayout = new QHBoxLayout();
timestampsLayout->addWidget(_validateTimestamps);
auto timestampHelp = new HelpIcon(obs_module_text(
"AdvSceneSwitcher.twitchToken.validateTimestamps.tooltip"));
timestampsLayout->addWidget(timestampHelp);
auto contentWidget = new QWidget(scrollArea);
auto layout = new QVBoxLayout(contentWidget);
layout->addLayout(_generalSettingsGrid);
layout->addWidget(optionsBox);
layout->addLayout(timestampsLayout);
layout->addWidget(_warnIfInvalid);
layout->setContentsMargins(0, 0, 0, 0);
scrollArea->setWidget(contentWidget);
auto dialogLayout = new QVBoxLayout();
dialogLayout->addWidget(scrollArea);
dialogLayout->addWidget(_buttonbox);
setLayout(dialogLayout);
_currentTokenValue->setText(QString::fromStdString(settings._token));
if (settings._token.empty()) {
_tokenStatus->setText(obs_module_text(
"AdvSceneSwitcher.twitchToken.request.notSet"));
SetTokenInfoVisible(false);
}
HideToken();
if (_name->text().isEmpty()) {
HighlightWidget(_requestToken, Qt::green, QColor(0, 0, 0, 0),
true);
}
_validateTimestamps->setChecked(settings._validateEventSubTimestamps);
#ifndef VERIFY_TIMESTAMPS
_validateTimestamps->hide();
#endif
_warnIfInvalid->setChecked(settings._warnIfInvalid);
_currentToken = settings;
QWidget::connect(&_validationTimer, &QTimer::timeout, this,
&TwitchTokenSettingsDialog::CheckIfTokenValid);
_validationTimer.start(10000);
CheckIfTokenValid();
}
void TwitchTokenSettingsDialog::SetTokenInfoVisible(bool visible)
{
SetGridLayoutRowVisible(_generalSettingsGrid, _nameRow, visible);
SetGridLayoutRowVisible(_generalSettingsGrid, _tokenValueRow, visible);
}
void TwitchTokenSettingsDialog::CheckIfTokenValid()
{
if (_currentToken._token.empty()) {
return;
}
if (_currentToken.IsValid(true)) {
return;
}
_tokenStatus->setText(
obs_module_text("AdvSceneSwitcher.twitchToken.request.notSet"));
}
void TwitchTokenSettingsDialog::ShowToken()
{
SetButtonIcon(_showToken, GetThemeTypeName() == "Light"
? ":res/images/visible.svg"
: "theme:Dark/visible.svg");
_currentTokenValue->setEchoMode(QLineEdit::Normal);
}
void TwitchTokenSettingsDialog::HideToken()
{
SetButtonIcon(_showToken, ":res/images/invisible.svg");
_currentTokenValue->setEchoMode(QLineEdit::PasswordEchoOnEdit);
}
void TwitchTokenSettingsDialog::TokenOptionChanged(int)
{
if (!_name->text().isEmpty()) {
HighlightWidget(_requestToken, Qt::green, QColor(0, 0, 0, 0),
true);
}
_name->setText("");
SetTokenInfoVisible(false);
QMetaObject::invokeMethod(this, "NameChanged",
Q_ARG(const QString &, ""));
_tokenStatus->setText(
obs_module_text("AdvSceneSwitcher.twitchToken.request.notSet"));
_currentTokenValue->setText("");
}
static void revokeToken(const std::string &token)
{
httplib::Client cli("https://id.twitch.tv");
auto response = cli.Post("/oauth2/revoke",
std::string("client_id=") + GetClientID() +
"&token=" + token,
"application/x-www-form-urlencoded");
if (!response) {
auto err = response.error();
blog(LOG_INFO, "Failed to revoke token: %s",
httplib::to_string(err).c_str());
return;
}
if (response->status != 200) {
blog(LOG_INFO, "Failed to revoke token: %d", response->status);
}
}
static std::string generateStateString()
{
const char *chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
const size_t stateStringLen = 32;
static std::random_device rd;
static std::mt19937 gen(rd());
static std::uniform_int_distribution<size_t> dis(0, sizeof(chars) - 2);
std::string state;
state.reserve(stateStringLen);
for (size_t i = 0; i < stateStringLen; ++i) {
state += chars[dis(gen)];
}
return state;
}
static std::string generateScopeString(const std::set<TokenOption> &options)
{
if (options.empty()) {
return "";
}
std::string scope;
for (const auto &option : options) {
scope += option.apiId + "+";
}
scope.pop_back(); // Remove trailing +
return scope;
}
static std::string getHtml(const QString &redirect)
{
QString html = R"(
<html>
<head>
<title>Advanced scene switcher</title>
</head>
<body>
<div id="output">Please click this link to continue if not automatically redirected</div>
<p><a href="%1">Login with Twitch</a></a></p>
<script type="text/javascript">
if (document.location.hash && document.location.hash != '') {
var parsedHash = new URLSearchParams(window.location.hash.slice(1));
if (parsedHash.get('access_token')) {
window.location.replace(`http://localhost:%1/token?access_token=${parsedHash.get('access_token')}&state=${parsedHash.get('state')}`);
output.textContent = 'It is safe to close this window';
}
} else {
window.location.replace('%2');
}
</script>
</body>
</html>)";
return html.arg(QString::number(tokenGrabberPort), redirect)
.toStdString();
}
void TwitchTokenSettingsDialog::RequestToken()
{
// Don't allow parallel RequestToken() calls
_requestToken->setDisabled(true);
auto scope = QString::fromStdString(
generateScopeString(GetEnabledOptions()));
_validationTimer.stop();
_tokenGrabber.SetTokenScope(scope);
_tokenGrabber.start();
_tokenStatus->setText(obs_module_text(
"AdvSceneSwitcher.twitchToken.request.waiting"));
}
void TwitchTokenSettingsDialog::GotToken(const std::optional<QString> &value)
{
_currentTokenValue->setText(value.value_or(""));
if (!value) {
_tokenStatus->setText(obs_module_text(
"AdvSceneSwitcher.twitchToken.request.fail"));
_name->setText("");
SetTokenInfoVisible(false);
_requestToken->setEnabled(true);
return;
}
_currentToken.SetToken(value.value().toStdString());
auto name = QString::fromStdString(_currentToken._name);
if (name.isEmpty()) {
_tokenStatus->setText(obs_module_text(
"AdvSceneSwitcher.twitchToken.request.fail"));
_name->setText("");
SetTokenInfoVisible(false);
_requestToken->setEnabled(true);
return;
}
_tokenStatus->setText(obs_module_text(
"AdvSceneSwitcher.twitchToken.request.success"));
_name->setText(name);
_name->textEdited(name);
QMetaObject::invokeMethod(this, "NameChanged",
Q_ARG(const QString &, name));
SetTokenInfoVisible(true);
_requestToken->setEnabled(true);
_validationTimer.start();
}
std::set<TokenOption> TwitchTokenSettingsDialog::GetEnabledOptions()
{
std::set<TokenOption> result;
for (const auto &[id, checkBox] : _optionWidgets) {
if (checkBox->isChecked()) {
TokenOption option;
option.apiId = id;
result.emplace(option);
}
}
return result;
}
bool TwitchTokenSettingsDialog::AskForSettings(QWidget *parent,
TwitchToken &settings)
{
TwitchTokenSettingsDialog dialog(parent, settings);
dialog.setWindowTitle(obs_module_text("AdvSceneSwitcher.windowTitle"));
if (dialog.exec() != DialogCode::Accepted) {
return false;
}
settings = dialog._currentToken;
settings._tokenOptions = dialog.GetEnabledOptions();
settings._validateEventSubTimestamps =
dialog._validateTimestamps->isChecked();
settings._warnIfInvalid = dialog._warnIfInvalid->isChecked();
if (settings._eventSub) {
settings._eventSub->EnableTimestampValidation(
settings._validateEventSubTimestamps);
}
return true;
}
int TokenGrabberThread::_timeout = 15;
TokenGrabberThread::~TokenGrabberThread()
{
_stopWaiting = true;
_cv.notify_all();
Stop();
_server.stop();
}
static std::string getAuthErrorString(const char *errDetail)
{
QString err = obs_module_text(
"AdvSceneSwitcher.twitchToken.request.fail.browser");
return err.arg(obs_module_text(errDetail)).toStdString();
}
void TokenGrabberThread::run()
{
// Reset
_server.stop();
_server.~Server();
new (&_server) httplib::Server();
if (_serverThread.joinable()) {
_serverThread.join();
}
_stopWaiting = false;
// Generate URI to request token
auto state = generateStateString();
QString getTokenURI =
("https://id.twitch.tv/oauth2/authorize"
"?response_type=token"
"&client_id=" +
QString(GetClientID()) +
"&redirect_uri=http://localhost:%1"
"/auth"
"&scope=" +
_scope + "&state=" + QString::fromStdString(state))
.arg(QString::number(tokenGrabberPort));
// Setup server receiving token string
auto html = getHtml(getTokenURI);
_server.Get("/auth", [html, state](const httplib::Request &req,
httplib::Response &res) {
// Check for errors
if (req.has_param("error")) {
auto recvState = req.get_param_value("state");
if (recvState != state) {
blog(LOG_WARNING,
"state string does not match in error handling?! "
"Got \"%s\" - expected \"%s\"\n"
"ignoring error ...",
recvState.c_str(), state.c_str());
return;
}
auto errorStr =
req.get_param_value("error_description");
res.set_content(getAuthErrorString(errorStr.c_str()),
"text/plain");
return;
}
// Parse fragments and redirect to /token with
// corresponding parameters.
res.set_content(html, "text/html");
});
_server.Get("/token", [&](const httplib::Request &req,
httplib::Response &res) {
// Check if valid request and grab the token string
std::lock_guard<std::mutex> lk(_mutex);
auto recvState = req.get_param_value("state");
if (recvState != state) {
blog(LOG_WARNING,
"state string does not match! "
"Got \"%s\" - expected \"%s\"",
recvState.c_str(), state.c_str());
res.set_content(
getAuthErrorString(
"AdvSceneSwitcher.twitchToken.request.fail.stateMismatch"),
"text/plain");
} else {
_tokenString = QString::fromStdString(
req.get_param_value("access_token"));
res.set_content(
obs_module_text(
"AdvSceneSwitcher.twitchToken.request.success.browser"),
"text/plain");
}
_stopWaiting = true;
_cv.notify_all();
});
// Request user to grant token
QDesktopServices::openUrl(getTokenURI);
// Start the server and wait
std::unique_lock<std::mutex> lock(_mutex);
_serverThread = std::thread([this]() {
if (!_server.bind_to_port("localhost", tokenGrabberPort, 0)) {
blog(LOG_WARNING,
"Failed to bind token server to localhost %d!",
tokenGrabberPort);
return;
}
if (!_server.listen_after_bind()) {
blog(LOG_WARNING, "Token server failed to listen()!");
return;
}
vblog(LOG_INFO, "Token server stopped!");
});
auto time = std::chrono::high_resolution_clock::now() +
std::chrono::seconds(_timeout);
while (!_stopWaiting) {
if (_cv.wait_until(lock, time) == std::cv_status::timeout) {
break;
}
}
_server.stop();
emit GotToken(_tokenString);
}
void TokenGrabberThread::Stop()
{
if (_server.is_running()) {
_server.stop();
}
if (_serverThread.joinable()) {
_serverThread.join();
}
wait();
}
TwitchConnectionSignalManager *TwitchConnectionSignalManager::Instance()
{
static TwitchConnectionSignalManager manager;
return &manager;
}
} // namespace advss