TwitchAuth.cpp 13 KB

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