1 /++
2     Bits and bobs to get Google API credentials for YouTube playlist management.
3 
4     See_Also:
5         [kameloso.plugins.twitch.base],
6         [kameloso.plugins.twitch.keygen],
7         [kameloso.plugins.twitch.api]
8 
9     Copyright: [JR](https://github.com/zorael)
10     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
11 
12     Authors:
13         [JR](https://github.com/zorael)
14  +/
15 module kameloso.plugins.twitch.google;
16 
17 version(TwitchSupport):
18 version(WithTwitchPlugin):
19 
20 private:
21 
22 import kameloso.plugins.twitch.base;
23 import kameloso.plugins.twitch.common;
24 
25 import kameloso.common : logger;
26 import arsd.http2 : HttpClient;
27 import std.json : JSONValue;
28 import std.typecons : Flag, No, Yes;
29 import core.thread : Fiber;
30 
31 
32 // requestGoogleKeys
33 /++
34     Requests a Google API authorisation code from Google servers, then uses it
35     to obtain an access key and a refresh OAuth key.
36 
37     Params:
38         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
39 
40     Throws:
41         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
42         if the returned JSON has an `"error"` field.
43  +/
44 package void requestGoogleKeys(TwitchPlugin plugin)
45 {
46     import kameloso.logger : LogLevel;
47     import kameloso.terminal.colours.tags : expandTags;
48     import kameloso.time : timeSince;
49     import lu.string : contains, nom, stripped;
50     import std.conv : to;
51     import std.format : format;
52     import std.process : Pid, ProcessException, wait;
53     import std.stdio : File, readln, stdin, stdout, write, writeln;
54     import core.time : seconds;
55 
56     scope(exit) if (plugin.state.settings.flush) stdout.flush();
57 
58     logger.trace();
59     logger.info("== Google authorisation key generation mode ==");
60     enum message = `
61 To access the Google API you need a <i>client ID</> and a <i>client secret</>.
62 
63 <l>Go here to create a project:</>
64 
65     <i>https://console.cloud.google.com/projectcreate</>
66 
67 <l>OAuth consent screen</> tab (choose <i>External</>), follow instructions.
68 <i>*</> <l>Scopes:</> <i>https://www.googleapis.com/auth/youtube</>
69 <i>*</> <l>Test users:</> (your Google account)
70 
71 Then pick <i>+ Create Credentials</> -> <i>OAuth client ID</>:
72 <i>*</> <l>Application type:</> <i>Desktop app</>
73 
74 Now you should have a newly-generated client ID and client secret.
75 Copy these somewhere; you'll need them soon.
76 
77 <l>Enabled APIs and Services</> tab -> <i>+ Enable APIs and Services</>
78 <i>--></> enter "<i>YouTube Data API v3</>", hit <i>Enable</>
79 
80 You also need to supply a channel for which it all relates.
81 (Channels are Twitch lowercase account names, prepended with a '<i>#</>' sign.)
82 
83 Lastly you need a <i>YouTube playlist ID</> for song requests to work.
84 A normal URL to any playlist you can modify will work fine.
85 `;
86     writeln(message.expandTags(LogLevel.off));
87 
88     Credentials creds;
89 
90     string channel;
91     while (!channel.length)
92     {
93         immutable rawChannel = readNamedString("<l>Enter your <i>#channel<l>:</> ",
94             0L, *plugin.state.abort);
95         if (*plugin.state.abort) return;
96 
97         channel = rawChannel.stripped;
98 
99         if (!channel.length || channel[0] != '#')
100         {
101             enum channelMessage = "Channels are Twitch lowercase account names, prepended with a '<i>#</>' sign.";
102             logger.warning(channelMessage);
103             channel = string.init;
104         }
105     }
106 
107     creds.googleClientID = readNamedString("<l>Copy and paste your <i>OAuth client ID<l>:</> ",
108         72L, *plugin.state.abort);
109     if (*plugin.state.abort) return;
110 
111     creds.googleClientSecret = readNamedString("<l>Copy and paste your <i>OAuth client secret<l>:</> ",
112         35L, *plugin.state.abort);
113     if (*plugin.state.abort) return;
114 
115     while (!creds.youtubePlaylistID.length)
116     {
117         enum playlistIDLength = 34;
118 
119         immutable playlistURL = readNamedString("<l>Copy and paste your <i>YouTube playlist URL<l>:</> ",
120             0L, *plugin.state.abort);
121         if (*plugin.state.abort) return;
122 
123         if (playlistURL.length == playlistIDLength)
124         {
125             // Likely a playlist ID
126             creds.youtubePlaylistID = playlistURL;
127         }
128         else if (playlistURL.contains("/playlist?list="))
129         {
130             string slice = playlistURL;  // mutable
131             slice.nom("/playlist?list=");
132             creds.youtubePlaylistID = slice.nom!(Yes.inherit)('&');
133         }
134         else
135         {
136             writeln();
137             enum invalidMessage = "Cannot recognise link as a YouTube playlist URL. " ~
138                 "Try copying again or file a bug.";
139             logger.error(invalidMessage);
140             writeln();
141             continue;
142         }
143     }
144 
145     enum attemptToOpenPattern = `
146 --------------------------------------------------------------------------------
147 
148 <l>Attempting to open a Google login page in your default web browser.</>
149 
150 Follow the instructions and log in to authorise the use of this program with your account.
151 Be sure to <l>select a YouTube account</> if presented with several alternatives.
152 (One that says <i>YouTube</> underneath it.)
153 
154 <l>Then paste the address of the empty page you are redirected to afterwards here.</>
155 
156 * The redirected address should start with <i>http://localhost</>.
157 * It will probably say "<l>this site can't be reached</>" or "<l>unable to connect</>".
158 * If you are running local web server on port <i>80</>, you may have to temporarily
159   disable it for this to work.
160 `;
161     writeln(attemptToOpenPattern.expandTags(LogLevel.off));
162     if (plugin.state.settings.flush) stdout.flush();
163 
164     enum authNode = "https://accounts.google.com/o/oauth2/v2/auth";
165     enum urlPattern = authNode ~
166         "?client_id=%s" ~
167         "&redirect_uri=http://localhost" ~
168         "&response_type=code" ~
169         "&scope=https://www.googleapis.com/auth/youtube";
170     immutable url = urlPattern.format(creds.googleClientID);
171 
172     Pid browser;
173     scope(exit) if (browser !is null) wait(browser);
174 
175     if (plugin.state.settings.force)
176     {
177         logger.warning("Forcing; not automatically opening browser.");
178         printManualURL(url);
179         if (plugin.state.settings.flush) stdout.flush();
180     }
181     else
182     {
183         try
184         {
185             import kameloso.platform : openInBrowser;
186             browser = openInBrowser(url);
187         }
188         catch (ProcessException _)
189         {
190             // Probably we got some platform wrong and command was not found
191             logger.warning("Error: could not automatically open browser.");
192             printManualURL(url);
193             if (plugin.state.settings.flush) stdout.flush();
194         }
195         catch (Exception _)
196         {
197             logger.warning("Error: no graphical environment detected");
198             printManualURL(url);
199             if (plugin.state.settings.flush) stdout.flush();
200         }
201     }
202 
203     string code;
204 
205     while (!code.length)
206     {
207         scope(exit) if (plugin.state.settings.flush) stdout.flush();
208 
209         enum pasteMessage = "<l>Paste the address of the page you were redirected to here (empty line exits):</>
210 
211 > ";
212         write(pasteMessage.expandTags(LogLevel.off));
213         stdout.flush();
214 
215         stdin.flush();
216         immutable readCode = readln().stripped;
217 
218         if (*plugin.state.abort || !readCode.length)
219         {
220             writeln();
221             logger.warning("Aborting.");
222             logger.trace();
223             *plugin.state.abort = true;
224             return;
225         }
226 
227         if (!readCode.contains("code="))
228         {
229             import lu.string : beginsWith;
230 
231             writeln();
232 
233             if (readCode.beginsWith(authNode))
234             {
235                 enum wrongPageMessage = "Not that page; the empty page you're " ~
236                     "lead to after clicking <l>Allow</>.";
237                 logger.error(wrongPageMessage);
238             }
239             else
240             {
241                 logger.error("Could not make sense of URL. Try again or file a bug.");
242             }
243 
244             writeln();
245             continue;
246         }
247 
248         string slice = readCode;  // mutable
249         slice.nom("?code=");
250         code = slice.nom!(Yes.inherit)('&');
251 
252         if (code.length != 73L)
253         {
254             writeln();
255             logger.error("Invalid code length. Try copying again or file a bug.");
256             writeln();
257             code = string.init;  // reset it so the while loop repeats
258         }
259     }
260 
261     // All done, fetch
262     auto client = getHTTPClient();
263     getGoogleTokens(client, creds, code);
264 
265     writeln();
266     logger.info("Validating...");
267 
268     immutable validationJSON = validateGoogleToken(client, creds);
269     if (*plugin.state.abort) return;
270 
271     scope(failure)
272     {
273         import std.stdio : writeln;
274         writeln(validationJSON.toPrettyString);
275     }
276 
277     if (const errorJSON = "error" in validationJSON)
278     {
279         throw new ErrorJSONException(validationJSON["error_description"].str, validationJSON);
280     }
281 
282     // "expires_in" is a string
283     immutable expiresIn = validationJSON["expires_in"].str.to!uint;
284 
285     enum isValidPattern = "Your key is valid for another <l>%s</> but will be automatically refreshed.";
286     logger.infof(isValidPattern, expiresIn.seconds.timeSince!(3, 1));
287     logger.trace();
288 
289     if (auto storedCreds = channel in plugin.secretsByChannel)
290     {
291         import lu.meld : MeldingStrategy, meldInto;
292         creds.meldInto!(MeldingStrategy.aggressive)(*storedCreds);
293     }
294     else
295     {
296         plugin.secretsByChannel[channel] = creds;
297     }
298 
299     saveSecretsToDisk(plugin.secretsByChannel, plugin.secretsFile);
300 }
301 
302 
303 // addVideoToYouTubePlaylist
304 /++
305     Adds a video to the YouTube playlist whose ID is stored in the passed [Credentials].
306 
307     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
308 
309     Params:
310         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
311         creds = [Credentials] aggregate.
312         videoID = YouTube video ID of the video to add.
313         recursing = Whether or not the function is recursing into itself.
314 
315     Returns:
316         A [std.json.JSONValue|JSONValue] of the response.
317 
318     Throws:
319         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
320         on unexpected JSON.
321 
322         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
323         if the returned JSON has an `"error"` field.
324  +/
325 package JSONValue addVideoToYouTubePlaylist(
326     TwitchPlugin plugin,
327     ref Credentials creds,
328     const string videoID,
329     const Flag!"recursing" recursing = No.recursing)
330 in (Fiber.getThis, "Tried to call `addVideoToYouTubePlaylist` from outside a Fiber")
331 {
332     import kameloso.plugins.twitch.api : getUniqueNumericalID, waitForQueryResponse;
333     import kameloso.plugins.common.delayawait : delay;
334     import kameloso.thread : ThreadMessage;
335     import arsd.http2 : HttpVerb;
336     import std.algorithm.searching : endsWith;
337     import std.concurrency : prioritySend, send;
338     import std.format : format;
339     import std.json : JSONType, parseJSON;
340     import std.string : representation;
341     import core.time : msecs;
342 
343     enum url = "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet";
344 
345     if (plugin.state.settings.trace)
346     {
347         import kameloso.common : logger;
348         enum pattern = "GET: <i>%s";
349         logger.tracef(pattern, url);
350     }
351 
352     static string authorizationBearer;
353 
354     if (!authorizationBearer.length || !authorizationBearer.endsWith(creds.googleAccessToken))
355     {
356         authorizationBearer = "Bearer " ~ creds.googleAccessToken;
357     }
358 
359     enum pattern =
360 `{
361   "snippet": {
362     "playlistId": "%s",
363     "resourceId": {
364       "kind": "youtube#video",
365       "videoId": "%s"
366     }
367   }
368 }`;
369 
370     immutable data = pattern.format(creds.youtubePlaylistID, videoID).representation;
371     /*immutable*/ int id = getUniqueNumericalID(plugin.bucket);  // Making immutable bumps compilation memory +44mb
372 
373     foreach (immutable i; 0..TwitchPlugin.delegateRetries)
374     {
375         try
376         {
377             plugin.state.mainThread.prioritySend(ThreadMessage.shortenReceiveTimeout());
378 
379             plugin.persistentWorkerTid.send(
380                 id,
381                 url,
382                 authorizationBearer,
383                 HttpVerb.POST,
384                 data,
385                 "application/json");
386 
387             static immutable guesstimatePeriodToWaitForCompletion = 600.msecs;
388             delay(plugin, guesstimatePeriodToWaitForCompletion, Yes.yield);
389             immutable response = waitForQueryResponse(plugin, id);
390 
391             /*
392             {
393                 "kind": "youtube#playlistItem",
394                 "etag": "QG1leAsBIlxoG2Y4MxMsV_zIaD8",
395                 "id": "UExNNnd5dmt2ME9GTVVfc0IwRUZyWDdUd0pZUHdkMUYwRi4xMkVGQjNCMUM1N0RFNEUx",
396                 "snippet": {
397                     "publishedAt": "2022-05-24T22:03:44Z",
398                     "channelId": "UC_iiOE42xes48ZXeQ4FkKAw",
399                     "title": "How Do Sinkholes Form?",
400                     "description": "CAN CONTAIN NEWLINES",
401                     "thumbnails": {
402                         "default": {
403                             "url": "https://i.ytimg.com/vi/e-DVIQPqS8E/default.jpg",
404                             "width": 120,
405                             "height": 90
406                         },
407                     },
408                     "channelTitle": "zorael",
409                     "playlistId": "PLM6wyvkv0OFMU_sB0EFrX7TwJYPwd1F0F",
410                     "position": 5,
411                     "resourceId": {
412                         "kind": "youtube#video",
413                         "videoId": "e-DVIQPqS8E"
414                     },
415                     "videoOwnerChannelTitle": "Practical Engineering",
416                     "videoOwnerChannelId": "UCMOqf8ab-42UUQIdVoKwjlQ"
417                 }
418             }
419             */
420 
421             /*
422             {
423                 "error": {
424                     "code": 401,
425                     "message": "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.",
426                     "errors": [
427                         {
428                             "message": "Invalid Credentials",
429                             "domain": "global",
430                             "reason": "authError",
431                             "location": "Authorization",
432                             "locationType": "header"
433                         }
434                     ],
435                     "status": "UNAUTHENTICATED"
436                 }
437             }
438             */
439 
440             const json = parseJSON(response.str);
441 
442             if (json.type != JSONType.object)
443             {
444                 enum message = "Wrong JSON type in playlist append response";
445                 throw new UnexpectedJSONException(message, json);
446             }
447 
448             const errorJSON = "error" in json;
449             if (!errorJSON) return json;  // Success
450 
451             if (const statusJSON = "status" in errorJSON.object)
452             {
453                 if (statusJSON.str == "UNAUTHENTICATED")
454                 {
455                     if (recursing)
456                     {
457                         const errorAAJSON = "errors" in *errorJSON;
458 
459                         if (errorAAJSON &&
460                             (errorAAJSON.type == JSONType.array) &&
461                             (errorAAJSON.array.length > 0))
462                         {
463                             immutable message = errorAAJSON.array[0].object["message"].str;
464                             throw new InvalidCredentialsException(message, *errorJSON);
465                         }
466                         else
467                         {
468                             enum message = "A non-specific error occurred.";
469                             throw new ErrorJSONException(message, *errorJSON);
470                         }
471                     }
472                     else
473                     {
474                         refreshGoogleToken(getHTTPClient(), creds);
475                         saveSecretsToDisk(plugin.secretsByChannel, plugin.secretsFile);
476                         return addVideoToYouTubePlaylist(plugin, creds, videoID, Yes.recursing);
477                     }
478                 }
479             }
480 
481             // If we're here, the above didn't match
482             throw new ErrorJSONException(errorJSON.object["message"].str, *errorJSON);
483         }
484         catch (InvalidCredentialsException e)
485         {
486             // Immediately rethrow
487             throw e;
488         }
489         catch (Exception e)
490         {
491             // Retry until we reach the retry limit, then rethrow
492             if (i < TwitchPlugin.delegateRetries-1) continue;
493             throw e;
494         }
495     }
496 
497     assert(0, "Unreachable");
498 }
499 
500 
501 // getGoogleTokens
502 /++
503     Request OAuth API tokens from Google.
504 
505     Params:
506         client = [arsd.http2.HttpClient|HttpClient] to use.
507         creds = [Credentials] aggregate.
508         code = Google authorization code.
509 
510     Throws:
511         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
512         on unexpected JSON.
513 
514         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
515         if the returned JSON has an `"error"` field.
516  +/
517 void getGoogleTokens(HttpClient client, ref Credentials creds, const string code)
518 {
519     import arsd.http2 : HttpVerb, Uri;
520     import std.format : format;
521     import std.json : JSONType, parseJSON;
522     import std.string : indexOf;
523 
524     enum pattern = "https://oauth2.googleapis.com/token" ~
525         "?client_id=%s" ~
526         "&client_secret=%s" ~
527         "&code=%s" ~
528         "&grant_type=authorization_code" ~
529         "&redirect_uri=http://localhost";
530 
531     immutable url = pattern.format(creds.googleClientID, creds.googleClientSecret, code);
532     enum data = cast(ubyte[])"{}";
533     auto req = client.request(Uri(url), HttpVerb.POST, data);
534     auto res = req.waitForCompletion();
535 
536     /*
537     {
538         "access_token": "[redacted]"
539         "expires_in": 3599,
540         "refresh_token": "[redacted]",
541         "scope": "https://www.googleapis.com/auth/youtube",
542         "token_type": "Bearer"
543     }
544     */
545 
546     const json = parseJSON(res.contentText);
547 
548     if (json.type != JSONType.object)
549     {
550         throw new UnexpectedJSONException("Wrong JSON type in token request response", json);
551     }
552 
553     if (auto errorJSON = "error" in json)
554     {
555         throw new ErrorJSONException(errorJSON.str, *errorJSON);
556     }
557 
558     creds.googleAccessToken = json["access_token"].str;
559     creds.googleRefreshToken = json["refresh_token"].str;
560 }
561 
562 
563 // refreshGoogleToken
564 /++
565     Refreshes the OAuth API token in the passed Google credentials.
566 
567     Params:
568         client = [arsd.http2.HttpClient|HttpClient] to use.
569         creds = [Credentials] aggregate.
570 
571     Throws:
572         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
573         on unexpected JSON.
574 
575         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
576         if the returned JSON has an `"error"` field.
577  +/
578 void refreshGoogleToken(HttpClient client, ref Credentials creds)
579 {
580     import arsd.http2 : HttpVerb, Uri;
581     import std.format : format;
582     import std.json : JSONType, parseJSON;
583 
584     enum pattern = "https://oauth2.googleapis.com/token" ~
585         "?client_id=%s" ~
586         "&client_secret=%s" ~
587         "&refresh_token=%s" ~
588         "&grant_type=refresh_token";
589 
590     immutable url = pattern.format(creds.googleClientID, creds.googleClientSecret, creds.googleRefreshToken);
591     enum data = cast(ubyte[])"{}";
592     auto req = client.request(Uri(url), HttpVerb.POST, data);
593     auto res = req.waitForCompletion();
594     const json = parseJSON(res.contentText);
595 
596     if (json.type != JSONType.object)
597     {
598         throw new UnexpectedJSONException("Wrong JSON type in token refresh response", json);
599     }
600 
601     if (auto errorJSON = "error" in json)
602     {
603         if (errorJSON.str == "invalid_grant")
604         {
605             enum message = "Invalid grant";
606             throw new InvalidCredentialsException(message, *errorJSON);
607         }
608         else
609         {
610             throw new ErrorJSONException(errorJSON.str, *errorJSON);
611         }
612     }
613 
614     creds.googleAccessToken = json["access_token"].str;
615     // refreshToken is not present and stays the same as before
616 }
617 
618 
619 // validateGoogleToken
620 /++
621     Validates a Google OAuth token, returning the JSON received from the server.
622 
623     Params:
624         client = [arsd.http2.HttpClient|HttpClient] to use.
625         creds = [Credentials] aggregate.
626 
627     Returns:
628         The server [std.json.JSONValue|JSONValue] response.
629 
630     Throws:
631         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
632         on unexpected JSON.
633 
634         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
635         if the returned JSON has an `"error"` field.
636  +/
637 auto validateGoogleToken(HttpClient client, ref Credentials creds)
638 {
639     import arsd.http2 : Uri;
640     import std.json : JSONType, parseJSON;
641 
642     enum urlHead = "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=";
643     immutable url = urlHead ~ creds.googleAccessToken;
644     auto req = client.request(Uri(url));
645     auto res = req.waitForCompletion();
646     const json = parseJSON(res.contentText);
647 
648     /*
649     {
650         "error": "invalid_token",
651         "error_description": "Invalid Value"
652     }
653     */
654     /*
655     {
656         "access_type": "offline",
657         "aud": "[redacted]",
658         "azp": "[redacted]",
659         "exp": "[redacted]",
660         "expires_in": "3599",
661         "scope": "https:\/\/www.googleapis.com\/auth\/youtube"
662     }
663     */
664 
665     if (json.type != JSONType.object)
666     {
667         throw new UnexpectedJSONException("Wrong JSON type in token validation response", json);
668     }
669 
670     if (auto errorJSON = "error" in json)
671     {
672         throw new ErrorJSONException(errorJSON.str, *errorJSON);
673     }
674 
675     return json;
676 }