auth-twitch.cpp 14 KB

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