auth-twitch.cpp 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. #include "auth-twitch.hpp"
  2. #include <QRegularExpression>
  3. #include <QPushButton>
  4. #include <QHBoxLayout>
  5. #include <QVBoxLayout>
  6. #include <QUuid>
  7. #include <qt-wrappers.hpp>
  8. #include <obs-app.hpp>
  9. #include "window-dock-browser.hpp"
  10. #include "window-basic-main.hpp"
  11. #include "remote-text.hpp"
  12. #include <json11.hpp>
  13. #include "ui-config.h"
  14. #include "obf.h"
  15. using namespace json11;
  16. /* ------------------------------------------------------------------------- */
  17. #define TWITCH_AUTH_URL OAUTH_BASE_URL "v1/twitch/redirect"
  18. #define TWITCH_TOKEN_URL OAUTH_BASE_URL "v1/twitch/token"
  19. #define TWITCH_SCOPE_VERSION 1
  20. static Auth::Def twitchDef = {"Twitch", Auth::Type::OAuth_StreamKey};
  21. /* ------------------------------------------------------------------------- */
  22. TwitchAuth::TwitchAuth(const Def &d) : OAuthStreamKey(d)
  23. {
  24. if (!cef)
  25. return;
  26. cef->add_popup_whitelist_url(
  27. "https://twitch.tv/popout/frankerfacez/chat?ffz-settings",
  28. this);
  29. /* enables javascript-based popups. basically bttv popups */
  30. cef->add_popup_whitelist_url("about:blank#blocked", this);
  31. uiLoadTimer.setSingleShot(true);
  32. uiLoadTimer.setInterval(500);
  33. connect(&uiLoadTimer, &QTimer::timeout, this,
  34. &TwitchAuth::TryLoadSecondaryUIPanes);
  35. }
  36. bool TwitchAuth::MakeApiRequest(const char *path, Json &json_out)
  37. {
  38. std::string client_id = TWITCH_CLIENTID;
  39. deobfuscate_str(&client_id[0], TWITCH_HASH);
  40. std::string url = "https://api.twitch.tv/helix/";
  41. url += std::string(path);
  42. std::vector<std::string> headers;
  43. headers.push_back(std::string("Client-ID: ") + client_id);
  44. headers.push_back(std::string("Authorization: Bearer ") + token);
  45. std::string output;
  46. std::string error;
  47. long error_code = 0;
  48. bool success = false;
  49. auto func = [&]() {
  50. success = GetRemoteFile(url.c_str(), output, error, &error_code,
  51. "application/json", "", nullptr,
  52. headers, nullptr, 5);
  53. };
  54. ExecThreadedWithoutBlocking(
  55. func, QTStr("Auth.LoadingChannel.Title"),
  56. QTStr("Auth.LoadingChannel.Text").arg(service()));
  57. if (error_code == 403) {
  58. OBSMessageBox::warning(OBSBasic::Get(),
  59. Str("TwitchAuth.TwoFactorFail.Title"),
  60. Str("TwitchAuth.TwoFactorFail.Text"),
  61. true);
  62. blog(LOG_WARNING, "%s: %s", __FUNCTION__,
  63. "Got 403 from Twitch, user probably does not "
  64. "have two-factor authentication enabled on "
  65. "their account");
  66. return false;
  67. }
  68. if (!success || output.empty())
  69. throw ErrorInfo("Failed to get text from remote", error);
  70. json_out = Json::parse(output, error);
  71. if (!error.empty())
  72. throw ErrorInfo("Failed to parse json", error);
  73. error = json_out["error"].string_value();
  74. if (!error.empty())
  75. throw ErrorInfo(error, json_out["message"].string_value());
  76. return true;
  77. }
  78. bool TwitchAuth::GetChannelInfo()
  79. try {
  80. std::string client_id = TWITCH_CLIENTID;
  81. deobfuscate_str(&client_id[0], TWITCH_HASH);
  82. if (!GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION))
  83. return false;
  84. if (token.empty())
  85. return false;
  86. if (!key_.empty())
  87. return true;
  88. Json json;
  89. bool success = MakeApiRequest("users", json);
  90. if (!success)
  91. return false;
  92. name = json["data"][0]["login"].string_value();
  93. std::string path = "streams/key?broadcaster_id=" +
  94. json["data"][0]["id"].string_value();
  95. success = MakeApiRequest(path.c_str(), json);
  96. if (!success)
  97. return false;
  98. key_ = json["data"][0]["stream_key"].string_value();
  99. return true;
  100. } catch (ErrorInfo info) {
  101. QString title = QTStr("Auth.ChannelFailure.Title");
  102. QString text = QTStr("Auth.ChannelFailure.Text")
  103. .arg(service(), info.message.c_str(),
  104. info.error.c_str());
  105. QMessageBox::warning(OBSBasic::Get(), title, text);
  106. blog(LOG_WARNING, "%s: %s: %s", __FUNCTION__, info.message.c_str(),
  107. info.error.c_str());
  108. return false;
  109. }
  110. void TwitchAuth::SaveInternal()
  111. {
  112. OBSBasic *main = OBSBasic::Get();
  113. config_set_string(main->Config(), service(), "Name", name.c_str());
  114. config_set_string(main->Config(), service(), "UUID", uuid.c_str());
  115. if (uiLoaded) {
  116. config_set_string(main->Config(), service(), "DockState",
  117. main->saveState().toBase64().constData());
  118. }
  119. OAuthStreamKey::SaveInternal();
  120. }
  121. static inline std::string get_config_str(OBSBasic *main, const char *section,
  122. const char *name)
  123. {
  124. const char *val = config_get_string(main->Config(), section, name);
  125. return val ? val : "";
  126. }
  127. bool TwitchAuth::LoadInternal()
  128. {
  129. if (!cef)
  130. return false;
  131. OBSBasic *main = OBSBasic::Get();
  132. name = get_config_str(main, service(), "Name");
  133. uuid = get_config_str(main, service(), "UUID");
  134. firstLoad = false;
  135. return OAuthStreamKey::LoadInternal();
  136. }
  137. static const char *ffz_script = "\
  138. var ffz = document.createElement('script');\
  139. ffz.setAttribute('src','https://cdn.frankerfacez.com/script/script.min.js');\
  140. document.head.appendChild(ffz);";
  141. static const char *bttv_script = "\
  142. localStorage.setItem('bttv_clickTwitchEmotes', true);\
  143. localStorage.setItem('bttv_darkenedMode', true);\
  144. localStorage.setItem('bttv_bttvGIFEmotes', true);\
  145. var bttv = document.createElement('script');\
  146. bttv.setAttribute('src','https://cdn.betterttv.net/betterttv.js');\
  147. document.head.appendChild(bttv);";
  148. static const char *referrer_script1 = "\
  149. Object.defineProperty(document, 'referrer', {get : function() { return '";
  150. static const char *referrer_script2 = "'; }});";
  151. void TwitchAuth::LoadUI()
  152. {
  153. if (!cef)
  154. return;
  155. if (uiLoaded)
  156. return;
  157. if (!GetChannelInfo())
  158. return;
  159. OBSBasic::InitBrowserPanelSafeBlock();
  160. OBSBasic *main = OBSBasic::Get();
  161. QCefWidget *browser;
  162. std::string url;
  163. std::string script;
  164. /* Twitch panels require a UUID, it does not actually need to be unique,
  165. * and is generated client-side.
  166. * It is only for preferences stored in the browser's local store. */
  167. if (uuid.empty()) {
  168. QString qtUuid = QUuid::createUuid().toString();
  169. qtUuid.replace(QRegularExpression("[{}-]"), "");
  170. uuid = qtUuid.toStdString();
  171. }
  172. std::string moderation_tools_url;
  173. moderation_tools_url = "https://www.twitch.tv/";
  174. moderation_tools_url += name;
  175. moderation_tools_url += "/dashboard/settings/moderation?no-reload=true";
  176. /* ----------------------------------- */
  177. url = "https://www.twitch.tv/popout/";
  178. url += name;
  179. url += "/chat";
  180. QSize size = main->frameSize();
  181. QPoint pos = main->pos();
  182. chat.reset(new BrowserDock());
  183. chat->setObjectName("twitchChat");
  184. chat->resize(300, 600);
  185. chat->setMinimumSize(200, 300);
  186. chat->setWindowTitle(QTStr("Auth.Chat"));
  187. chat->setAllowedAreas(Qt::AllDockWidgetAreas);
  188. browser = cef->create_widget(chat.data(), url, panel_cookies);
  189. chat->SetWidget(browser);
  190. cef->add_force_popup_url(moderation_tools_url, chat.data());
  191. if (App()->IsThemeDark()) {
  192. script = "localStorage.setItem('twilight.theme', 1);";
  193. } else {
  194. script = "localStorage.setItem('twilight.theme', 0);";
  195. }
  196. const int twAddonChoice =
  197. config_get_int(main->Config(), service(), "AddonChoice");
  198. if (twAddonChoice) {
  199. if (twAddonChoice & 0x1)
  200. script += bttv_script;
  201. if (twAddonChoice & 0x2)
  202. script += ffz_script;
  203. }
  204. browser->setStartupScript(script);
  205. main->addDockWidget(Qt::RightDockWidgetArea, chat.data());
  206. chatMenu.reset(main->AddDockWidget(chat.data()));
  207. /* ----------------------------------- */
  208. chat->setFloating(true);
  209. chat->move(pos.x() + size.width() - chat->width() - 50, pos.y() + 50);
  210. if (firstLoad) {
  211. chat->setVisible(true);
  212. } else {
  213. const char *dockStateStr = config_get_string(
  214. main->Config(), service(), "DockState");
  215. QByteArray dockState =
  216. QByteArray::fromBase64(QByteArray(dockStateStr));
  217. if (main->isVisible() || !main->isMaximized())
  218. main->restoreState(dockState);
  219. }
  220. TryLoadSecondaryUIPanes();
  221. uiLoaded = true;
  222. }
  223. void TwitchAuth::LoadSecondaryUIPanes()
  224. {
  225. OBSBasic *main = OBSBasic::Get();
  226. QCefWidget *browser;
  227. std::string url;
  228. std::string script;
  229. QSize size = main->frameSize();
  230. QPoint pos = main->pos();
  231. if (App()->IsThemeDark()) {
  232. script = "localStorage.setItem('twilight.theme', 1);";
  233. } else {
  234. script = "localStorage.setItem('twilight.theme', 0);";
  235. }
  236. script += referrer_script1;
  237. script += "https://www.twitch.tv/";
  238. script += name;
  239. script += "/dashboard/live";
  240. script += referrer_script2;
  241. const int twAddonChoice =
  242. config_get_int(main->Config(), service(), "AddonChoice");
  243. if (twAddonChoice) {
  244. if (twAddonChoice & 0x1)
  245. script += bttv_script;
  246. if (twAddonChoice & 0x2)
  247. script += ffz_script;
  248. }
  249. /* ----------------------------------- */
  250. url = "https://dashboard.twitch.tv/popout/u/";
  251. url += name;
  252. url += "/stream-manager/edit-stream-info";
  253. info.reset(new BrowserDock());
  254. info->setObjectName("twitchInfo");
  255. info->resize(300, 650);
  256. info->setMinimumSize(200, 300);
  257. info->setWindowTitle(QTStr("Auth.StreamInfo"));
  258. info->setAllowedAreas(Qt::AllDockWidgetAreas);
  259. browser = cef->create_widget(info.data(), url, panel_cookies);
  260. info->SetWidget(browser);
  261. browser->setStartupScript(script);
  262. main->addDockWidget(Qt::RightDockWidgetArea, info.data());
  263. infoMenu.reset(main->AddDockWidget(info.data()));
  264. /* ----------------------------------- */
  265. url = "https://www.twitch.tv/popout/";
  266. url += name;
  267. url += "/dashboard/live/stats";
  268. stat.reset(new BrowserDock());
  269. stat->setObjectName("twitchStats");
  270. stat->resize(200, 250);
  271. stat->setMinimumSize(200, 150);
  272. stat->setWindowTitle(QTStr("TwitchAuth.Stats"));
  273. stat->setAllowedAreas(Qt::AllDockWidgetAreas);
  274. browser = cef->create_widget(stat.data(), url, panel_cookies);
  275. stat->SetWidget(browser);
  276. browser->setStartupScript(script);
  277. main->addDockWidget(Qt::RightDockWidgetArea, stat.data());
  278. statMenu.reset(main->AddDockWidget(stat.data()));
  279. /* ----------------------------------- */
  280. url = "https://dashboard.twitch.tv/popout/u/";
  281. url += name;
  282. url += "/stream-manager/activity-feed";
  283. url += "?uuid=" + uuid;
  284. feed.reset(new BrowserDock());
  285. feed->setObjectName("twitchFeed");
  286. feed->resize(300, 650);
  287. feed->setMinimumSize(200, 300);
  288. feed->setWindowTitle(QTStr("TwitchAuth.Feed"));
  289. feed->setAllowedAreas(Qt::AllDockWidgetAreas);
  290. browser = cef->create_widget(feed.data(), url, panel_cookies);
  291. feed->SetWidget(browser);
  292. browser->setStartupScript(script);
  293. main->addDockWidget(Qt::RightDockWidgetArea, feed.data());
  294. feedMenu.reset(main->AddDockWidget(feed.data()));
  295. /* ----------------------------------- */
  296. info->setFloating(true);
  297. stat->setFloating(true);
  298. feed->setFloating(true);
  299. QSize statSize = stat->frameSize();
  300. info->move(pos.x() + 50, pos.y() + 50);
  301. stat->move(pos.x() + size.width() / 2 - statSize.width() / 2,
  302. pos.y() + size.height() / 2 - statSize.height() / 2);
  303. feed->move(pos.x() + 100, pos.y() + 100);
  304. if (firstLoad) {
  305. info->setVisible(true);
  306. stat->setVisible(false);
  307. feed->setVisible(false);
  308. } else {
  309. uint32_t lastVersion = config_get_int(App()->GlobalConfig(),
  310. "General", "LastVersion");
  311. if (lastVersion <= MAKE_SEMANTIC_VERSION(23, 0, 2)) {
  312. feed->setVisible(false);
  313. }
  314. const char *dockStateStr = config_get_string(
  315. main->Config(), service(), "DockState");
  316. QByteArray dockState =
  317. QByteArray::fromBase64(QByteArray(dockStateStr));
  318. if (main->isVisible() || !main->isMaximized())
  319. main->restoreState(dockState);
  320. }
  321. }
  322. /* Twitch.tv has an OAuth for itself. If we try to load multiple panel pages
  323. * at once before it's OAuth'ed itself, they will all try to perform the auth
  324. * process at the same time, get their own request codes, and only the last
  325. * code will be valid -- so one or more panels are guaranteed to fail.
  326. *
  327. * To solve this, we want to load just one panel first (the chat), and then all
  328. * subsequent panels should only be loaded once we know that Twitch has auth'ed
  329. * itself (if the cookie "auth-token" exists for twitch.tv).
  330. *
  331. * This is annoying to deal with. */
  332. void TwitchAuth::TryLoadSecondaryUIPanes()
  333. {
  334. QPointer<TwitchAuth> this_ = this;
  335. auto cb = [this_](bool found) {
  336. if (!this_) {
  337. return;
  338. }
  339. if (!found) {
  340. QMetaObject::invokeMethod(&this_->uiLoadTimer, "start");
  341. } else {
  342. QMetaObject::invokeMethod(this_,
  343. "LoadSecondaryUIPanes");
  344. }
  345. };
  346. panel_cookies->CheckForCookie("https://www.twitch.tv", "auth-token",
  347. cb);
  348. }
  349. bool TwitchAuth::RetryLogin()
  350. {
  351. OAuthLogin login(OBSBasic::Get(), TWITCH_AUTH_URL, false);
  352. if (login.exec() == QDialog::Rejected) {
  353. return false;
  354. }
  355. std::shared_ptr<TwitchAuth> auth =
  356. std::make_shared<TwitchAuth>(twitchDef);
  357. std::string client_id = TWITCH_CLIENTID;
  358. deobfuscate_str(&client_id[0], TWITCH_HASH);
  359. return GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION,
  360. QT_TO_UTF8(login.GetCode()), true);
  361. }
  362. std::shared_ptr<Auth> TwitchAuth::Login(QWidget *parent, const std::string &)
  363. {
  364. OAuthLogin login(parent, TWITCH_AUTH_URL, false);
  365. if (login.exec() == QDialog::Rejected) {
  366. return nullptr;
  367. }
  368. std::shared_ptr<TwitchAuth> auth =
  369. std::make_shared<TwitchAuth>(twitchDef);
  370. std::string client_id = TWITCH_CLIENTID;
  371. deobfuscate_str(&client_id[0], TWITCH_HASH);
  372. if (!auth->GetToken(TWITCH_TOKEN_URL, client_id, TWITCH_SCOPE_VERSION,
  373. QT_TO_UTF8(login.GetCode()))) {
  374. return nullptr;
  375. }
  376. if (auth->GetChannelInfo()) {
  377. return auth;
  378. }
  379. return nullptr;
  380. }
  381. static std::shared_ptr<Auth> CreateTwitchAuth()
  382. {
  383. return std::make_shared<TwitchAuth>(twitchDef);
  384. }
  385. static void DeleteCookies()
  386. {
  387. if (panel_cookies)
  388. panel_cookies->DeleteCookies("twitch.tv", std::string());
  389. }
  390. void RegisterTwitchAuth()
  391. {
  392. OAuth::RegisterOAuth(twitchDef, CreateTwitchAuth, TwitchAuth::Login,
  393. DeleteCookies);
  394. }