PageSourceSearch

https://songtr.ee/app/libs/angular-google-gis.js

js songtr.ee collected 2026-10-01 12:22:28 UTC 23,763 bytes, 510 lines download raw bytes

1/*! angular-google-gis - Google Identity Services (GIS) replacement for angular-google-plus
2 *
3 *  Drop-in for the deprecated angular-google-plus v0.1.3 (gapi.auth) library.
4 *  Preserves the same module name ('googleplus') and the same GooglePlus provider
5 *  API ( .init() / .login() / .getUser() ) so existing callers in app.js and
6 *  login.js work unchanged.
7 *
8 *  Two sign-in flows are supported, both validated by api/song_login.php:
9 *
10 *  1. Rendered-button id-token flow (renderButton with a credential handler) -
11 *     Google's own button presents the compact FedCM account chooser and returns
12 *     an id_token, sent with authmode=googlesignin so the backend verifies it via
13 *     verifyIdToken. This is what the Songtree buttons and n-Track use.
14 *
15 *  2. OAuth token-client flow (login()/getUser(), google.accounts.oauth2) -
16 *     returns an access token, matching the backend's access-token validation
17 *     path (no authmode sent). Opens a full-size OAuth window rather than the
18 *     compact chooser. Retained for API compatibility and for callers that need
19 *     an access token.
20 *
21 *  Requires the GIS loader on the page:
22 *    <script src="https://accounts.google.com/gsi/client" async defer></script>
23 */
24angular.module('googleplus', [])
25  .provider('GooglePlus', [function () {
26
27    var config = {
28      clientId: null,
29      apiKey: null,                       // accepted for compatibility; unused by GIS
30      scopes: 'openid email profile'
31    };
32
33    this.setClientId = function (v) { config.clientId = v; return this; };
34    this.getClientId = function () { return config.clientId; };
35    this.setApiKey = function (v) { config.apiKey = v; return this; };
36    this.getApiKey = function () { return config.apiKey; };
37    this.setScopes = function (v) { config.scopes = v; return this; };
38    this.getScopes = function () { return config.scopes; };
39    this.init = function (obj) { angular.extend(config, obj); };
40
41    // Kept for API compatibility with the old provider (no-ops under GIS).
42    this.enableServerSide = function () {};
43    this.disableServerSide = function () {};
44
45    // FedCM fallback. GIS decides to use FedCM from the user agent, so Chromium
46    // browsers that report a Chrome UA but don't implement FedCM (Arc, for one)
47    // have navigator.credentials.get() rejected with NotSupportedError. GIS only
48    // logs that and drops the sign-in, so we watch for the rejection ourselves,
49    // switch GIS to its non-FedCM popup flow (the one Firefox and Safari use), and
50    // remember the result so later page loads start on that flow.
51    // Only NotSupportedError counts: Chrome's own FedCM failures (chooser
52    // dismissed, third-party sign-in blocked) reject with NetworkError/
53    // NotAllowedError, and dropping FedCM there would put Chrome on the popup flow
54    // that third-party cookie blocking breaks.
55    var FEDCM_OFF_KEY = 'gis_fedcm_unsupported';
56    var FEDCM_OFF_TTL = 30 * 24 * 60 * 60 * 1000;   // try FedCM again after 30 days
57    var fedcmSupported = !fedcmMarkedUnsupported();
58    var onFedcmUnsupported = null;                   // installed by $get
59
60    function fedcmMarkedUnsupported() {
61      try {
62        var markedAt = parseInt(window.localStorage.getItem(FEDCM_OFF_KEY), 10);
63        return !isNaN(markedAt) && (Date.now() - markedAt) < FEDCM_OFF_TTL;
64      } catch (e) {
65        return false;
66      }
67    }
68
69    // Wrap navigator.credentials.get so a FedCM request GIS makes from this page
70    // can be observed. Installed when the provider is created, before any sign-in
71    // can start. The original promise is returned untouched, so GIS's own handling
72    // (and password/passkey requests, which carry no `identity`) are unaffected.
73    (function watchForFedcmUnsupported() {
74      var creds = window.navigator && navigator.credentials;
75      if (!creds || typeof creds.get !== 'function' || creds.get.gisFedcmWatched) { return; }
76      var originalGet = creds.get;
77      var watchedGet = function (options) {
78        var result = originalGet.apply(creds, arguments);
79        if (options && options.identity && result && typeof result.then === 'function') {
80          result.then(null, function (err) {
81            if (err && err.name === 'NotSupportedError' && onFedcmUnsupported) { onFedcmUnsupported(); }
82          });
83        }
84        return result;
85      };
86      watchedGet.gisFedcmWatched = true;
87      try { creds.get = watchedGet; } catch (e) {}
88    })();
89
90    this.$get = ['$q', '$http', '$timeout', function ($q, $http, $timeout) {
91
92      var tokenClient = null;   // lazily created GIS token client
93      var currentToken = null;  // most recent access token
94      var pending = null;       // deferred for the in-flight login()
95
96      var idInitialized = false;        // google.accounts.id.initialize() done once
97
98      // Google exposes a single global credential callback and doesn't tell us
99      // which rendered button produced it, but a page can hold several buttons
100      // with different completions (login vs. register-with-captcha vs.
101      // onboarding). These let us route a credential to the right one.
102      var credentialHandlers = [];      // [{ element, handler }] in render order
103      var lastClickedElement = null;    // set by each button's capture listener
104      var oneTapHandler = null;         // set by promptOneTap()
105
106      var renderedButtons = [];         // [{ element, opts, onClick, onCredential }] drawn so far
107      var buttonClickedAt = 0;          // set by each button's click_listener
108      var fedcmFallbackDone = false;
109
110      onFedcmUnsupported = function () {
111        if (fedcmFallbackDone) { return; }
112        fedcmFallbackDone = true;
113        fedcmSupported = false;
114        try { window.localStorage.setItem(FEDCM_OFF_KEY, String(Date.now())); } catch (e) {}
115        if (window.console) { console.log('GIS: FedCM is not supported by this browser; using the sign-in popup instead.'); }
116
117        if (!idInitialized) { return; }
118
119        // Re-initialize with FedCM off; GIS applies the most recent initialize()
120        // config (it logs a "called multiple times" warning, expected here).
121        idInitialized = false;
122        ensureIdInitialized();
123
124        // Was this a button click? GIS reports clicks through click_listener; as a
125        // backup, a click into Google's button iframe leaves that iframe focused.
126        var active = document.activeElement;
127        var fromClick = (Date.now() - buttonClickedAt) < 5000;
128        var i, entry;
129        for (i = 0; i < renderedButtons.length && !fromClick; i++) {
130          if (active && active.tagName === 'IFRAME' && renderedButtons[i].element.contains(active)) { fromClick = true; }
131        }
132
133        // Buttons already on the page were rendered for FedCM, so draw them again.
134        // Visibility is read before clearing, since an emptied container can collapse.
135        for (i = 0; i < renderedButtons.length; i++) {
136          entry = renderedButtons[i];
137          if (!document.documentElement.contains(entry.element)) { continue; }  // removed by ng-if etc.
138          var visible = entry.element.offsetWidth > 0;
139          entry.element.innerHTML = '';
140          if (visible) {
141            drawButton(entry);
142            // The click that hit the error is lost: ask for one more. Later page
143            // loads start on the popup flow, so this only happens once.
144            if (fromClick) { showRetryNote(entry.element); }
145          } else {
146            drawWhenVisible(entry);
147          }
148        }
149
150        // The failure came from the One Tap card: try it once more without FedCM.
151        if (!fromClick && oneTapHandler) { google.accounts.id.prompt(); }
152      };
153
154      function showRetryNote(element) {
155        var next = element.nextElementSibling;
156        if (next && next.className === 'google-retry-note') { return; }
157        var note = document.createElement('div');
158        note.className = 'google-retry-note';
159        note.style.cssText = 'font-size:13px;margin:6px 0;text-align:center;';
160        note.textContent = 'Please click Continue with Google again.';
161        element.parentNode.insertBefore(note, element.nextSibling);
162      }
163
164      function isVisible(el) {
165        return !!(el && el.offsetParent !== null && el.offsetWidth > 0);
166      }
167
168      // Pick the handler for the button the user actually used. The click listener
169      // is the reliable signal; the visibility check is a fallback for the case
170      // where Google renders the button inside an iframe and the click never
171      // reaches our container.
172      function resolveCredentialHandler() {
173        var i;
174        if (lastClickedElement) {
175          for (i = 0; i < credentialHandlers.length; i++) {
176            if (credentialHandlers[i].element === lastClickedElement) {
177              return credentialHandlers[i].handler;
178            }
179          }
180        }
181        for (i = credentialHandlers.length - 1; i >= 0; i--) {
182          if (isVisible(credentialHandlers[i].element)) {
183            return credentialHandlers[i].handler;
184          }
185        }
186        return credentialHandlers.length ? credentialHandlers[credentialHandlers.length - 1].handler : null;
187      }
188
189      // google.accounts.id.initialize() may only be called once per page, and both
190      // the rendered buttons and One Tap share it (and its single credential
191      // callback), so it lives here rather than inside renderButton().
192      function ensureIdInitialized() {
193        if (idInitialized) { return; }
194        google.accounts.id.initialize({
195          client_id: config.clientId,
196          auto_select: false,          // always show the card; never sign in silently
197          ux_mode: 'popup',
198          // FedCM is what lets the credential come back without relying on
199          // third-party cookies, and is what renders the compact chooser.
200          // use_fedcm_for_prompt is required for One Tap: Google has deprecated
201          // the non-FedCM prompt, and without it the card stops appearing.
202          // Both are turned off only for browsers that reject FedCM outright
203          // (see onFedcmUnsupported).
204          use_fedcm_for_button: fedcmSupported,
205          use_fedcm_for_prompt: fedcmSupported,
206          itp_support: true,
207          callback: function (resp) {
208            if (!resp || !resp.credential) {
209              if (window.console) { console.warn('GIS: sign-in returned no credential', resp); }
210              return;
211            }
212            // select_by tells us which surface produced the credential: anything
213            // starting with 'btn' came from a rendered button, everything else
214            // ('user', 'user_1tap', 'auto', ...) came from the One Tap card.
215            var fromButton = resp.select_by && resp.select_by.indexOf('btn') === 0;
216            var handler = fromButton ? resolveCredentialHandler() : (oneTapHandler || resolveCredentialHandler());
217            if (!handler) {
218              if (window.console) { console.warn('GIS: credential arrived with no handler registered'); }
219              return;
220            }
221            $timeout(function () { handler(resp.credential); });
222          }
223        });
224        idInitialized = true;
225      }
226
227      function gisReady() {
228        return window.google && google.accounts && google.accounts.oauth2;
229      }
230
231      // Load the GIS library if the page doesn't already include the tag, so One Tap
232      // and the buttons work anywhere the app runs rather than only on pages that
233      // hardcode the script. No-op when it's already present or loaded.
234      function ensureGisScript() {
235        if (gisReady()) { return; }
236        if (document.querySelector("script[src^='https://accounts.google.com/gsi/client']")) { return; }
237        var s = document.createElement('script');
238        s.src = 'https://accounts.google.com/gsi/client';
239        s.async = true;
240        s.defer = true;
241        document.head.appendChild(s);
242      }
243
244      // Resolve once the gsi/client script has finished loading.
245      function whenGisReady() {
246        var d = $q.defer();
247        ensureGisScript();
248        (function check(attempt) {
249          if (gisReady()) {
250            d.resolve();
251          } else if (attempt > 100) {           // ~10s ceiling
252            d.reject('Google Identity Services (gsi/client) failed to load');
253          } else {
254            $timeout(function () { check(attempt + 1); }, 100);
255          }
256        })(0);
257        return d.promise;
258      }
259
260      function ensureTokenClient() {
261        if (tokenClient) { return; }
262        tokenClient = google.accounts.oauth2.initTokenClient({
263          client_id: config.clientId,
264          scope: config.scopes,
265          // The popup callback fires outside Angular's digest, so settle the
266          // pending promise inside $timeout to trigger a safe digest.
267          callback: function (resp) {
268            $timeout(function () {
269              if (resp && resp.access_token) {
270                currentToken = resp.access_token;
271                if (pending) { pending.resolve({ access_token: resp.access_token }); }
272              } else if (pending) {
273                pending.reject(resp && resp.error ? resp.error : 'token_error');
274              }
275              pending = null;
276            });
277          },
278          error_callback: function (err) {
279            if (window.console) {
280              console.warn('GIS token flow error:', err && err.type, err && err.message);
281            }
282            $timeout(function () {
283              if (pending) { pending.reject(err); }
284              pending = null;
285            });
286          }
287        });
288      }
289
290      // Draws Google's button into entry.element and, in compat mode, lays the
291      // click-catcher over it. Runs again after a FedCM fallback clears the element.
292      function drawButton(entry) {
293        var element = entry.element;
294        google.accounts.id.renderButton(element, entry.opts);
295        if (entry.onCredential) { return; }
296
297        // Compat mode only: transparent click-catcher over the whole
298        // container (Chrome can render the personalized "Continue as ..."
299        // button taller than a fixed box, and any uncovered strip would
300        // click through to the button underneath).
301        if (window.getComputedStyle(element).position === 'static') {
302          element.style.position = 'relative';
303        }
304        var overlay = document.createElement('div');
305        overlay.style.cssText = 'position:absolute;top:0;left:0;' +
306          'width:100%;height:100%;cursor:pointer;z-index:5;background:transparent';
307        overlay.addEventListener('click', function () { entry.onClick(); });
308        element.appendChild(overlay);
309      }
310
311      // GIS renders a collapsed/tiny button when the target is hidden (e.g. a
312      // container inside an ng-show/ng-if block that is display:none at link
313      // time). Wait until it actually has width, then render at full size.
314      function drawWhenVisible(entry, done) {
315        (function whenVisible(attempt) {
316          if (entry.element.offsetWidth > 0 || attempt > 300) {
317            drawButton(entry);
318            if (done) { done(); }
319          } else {
320            $timeout(function () { whenVisible(attempt + 1); }, 100);
321          }
322        })(0);
323      }
324
325      function GIS() {}
326
327      // Opens the Google account chooser/popup and resolves with { access_token }.
328      // When GIS is already loaded we call requestAccessToken() synchronously so
329      // the popup stays tied to the user's click gesture (avoids popup blocking).
330      GIS.prototype.login = function () {
331        var d = $q.defer();
332        pending = d;
333        if (gisReady()) {
334          ensureTokenClient();
335          tokenClient.requestAccessToken();
336        } else {
337          whenGisReady().then(function () {
338            ensureTokenClient();
339            tokenClient.requestAccessToken();
340          }, function (err) {
341            pending = null;
342            d.reject(err);
343          });
344        }
345        return d.promise;
346      };
347
348      // Fetches the signed-in user's profile using the access token.
349      // Resolves with { name, email, picture, ... }.
350      GIS.prototype.getUser = function () {
351        var d = $q.defer();
352        if (!currentToken) { d.reject('no_access_token'); return d.promise; }
353        $http.get('https://www.googleapis.com/oauth2/v3/userinfo', {
354          headers: { Authorization: 'Bearer ' + currentToken }
355        }).then(function (res) {
356          d.resolve(res.data);
357        }, function (err) {
358          d.reject(err);
359        });
360        return d.promise;
361      };
362
363      // Songtree shows the One Tap card everywhere except the home, welcome and
364      // login pages. Matched against the FIRST path segment only, so both the
365      // pretty URLs (/home, /welcome, /login) and the underlying files
366      // (home.php, welcome.php, login.php, home2(welcome).php) are covered
367      // without catching user content further down the path -- a plain substring
368      // test would wrongly exclude an artist named "Homer" at
369      // /artist/123/homer. The site root ("/") is not excluded.
370      // Edit this list to change placement.
371      var ONE_TAP_EXCLUDED_PATHS = ['home', 'welcome', 'login'];
372
373      function oneTapAllowedHere() {
374        var path = (window.location.pathname || '').toLowerCase();
375        var segment = path.replace(/^\/+/, '').split('/')[0];
376        for (var i = 0; i < ONE_TAP_EXCLUDED_PATHS.length; i++) {
377          if (segment.indexOf(ONE_TAP_EXCLUDED_PATHS[i]) === 0) { return false; }
378        }
379        return true;
380      }
381
382      // Decodes the payload of an id_token credential (base64url -> JSON) to read
383      // email/name/picture. The backend independently verifies the token, so this
384      // is not a trust boundary — it only fills in the fields the login endpoint
385      // expects alongside the token.
386      GIS.prototype.decodeCredential = function (jwt) {
387        try {
388          var part = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
389          var pad = part.length % 4;
390          if (pad) { part += new Array(5 - pad).join('='); }
391          return JSON.parse(decodeURIComponent(escape(window.atob(part))));
392        } catch (e) {
393          if (window.console) { console.log('Failed to decode Google credential', e); }
394          return null;
395        }
396      };
397
398      // Shows Google's One Tap card (the automatic sign-in prompt shown to
399      // logged-out visitors on arrival, before any button is pressed). `handler`
400      // receives the raw id_token credential.
401      //
402      // Callers are responsible for only calling this when the visitor is logged
403      // out. Google applies its own exponential cooldown after a dismissal, so the
404      // card legitimately won't appear on every load.
405      GIS.prototype.promptOneTap = function (handler) {
406        if (!oneTapAllowedHere()) { return; }
407        oneTapHandler = handler;
408        whenGisReady().then(function () {
409          ensureIdInitialized();
410          google.accounts.id.prompt();
411        }, function (err) {
412          if (window.console) { console.log('GIS One Tap unavailable:', err); }
413        });
414      };
415
416      GIS.prototype.getToken = function () { return currentToken; };
417      GIS.prototype.setToken = function (t) { currentToken = t; };
418      GIS.prototype.getClientId = function () { return config.clientId; };
419      GIS.prototype.logout = function () {
420        if (currentToken && gisReady()) {
421          google.accounts.oauth2.revoke(currentToken, function () {});
422        }
423        currentToken = null;
424      };
425
426      // Renders Google's official "Sign in with Google" button into `element`.
427      //
428      // Preferred mode (onCredential supplied): Google's button runs its own
429      // id-token flow, which presents the compact FedCM account chooser that
430      // n-Track uses, rather than the full-size OAuth consent window the
431      // access-token flow opens. The resulting credential is routed back through
432      // onCredential. This is the flow n-Track has used successfully.
433      //
434      // Compat mode (no onCredential): a transparent click-catcher is overlaid so
435      // the click runs the access-token flow via onClick() instead. This was the
436      // original workaround for the id-token popup failing in Chrome, where
437      // COOP/third-party-cookie restrictions severed the popup's postMessage back
438      // to the opener. FedCM (use_fedcm_for_button) removes that dependency, so
439      // the preferred mode above is no longer subject to it.
440      GIS.prototype.renderButton = function (element, onClick, options, onCredential) {
441        if (onCredential) { credentialHandlers.push({ element: element, handler: onCredential }); }
442        whenGisReady().then(function () {
443          ensureIdInitialized();
444
445          var opts = angular.extend({
446            theme: 'outline',
447            size: 'large',
448            type: 'standard',
449            text: 'continue_with',
450            width: 300
451          }, options || {});
452          opts.click_listener = function () {
453            buttonClickedAt = Date.now();
454            var notes = document.querySelectorAll('.google-retry-note');
455            for (var n = 0; n < notes.length; n++) { notes[n].parentNode.removeChild(notes[n]); }
456          };
457
458          var entry = { element: element, opts: opts, onClick: onClick, onCredential: onCredential };
459          drawWhenVisible(entry, function () {
460            renderedButtons.push(entry);
461            if (onCredential) {
462              // Note which button was used, so the single global credential
463              // callback can be routed to the right handler. Capture phase and
464              // non-intercepting: Google's button still handles the click.
465              element.addEventListener('click', function () {
466                lastClickedElement = element;
467              }, true);
468            }
469          });
470        }, function (err) {
471          if (window.console) { console.log('GIS renderButton error:', err); }
472        });
473      };
474
475      return new GIS();
476    }];
477  }]);
478
479// Attribute directive that drops Google's official rendered button into an element.
480//
481// Preferred usage — let Google's button run its own sign-in (compact FedCM
482// chooser) and hand the credential to an expression, with `credential` (the raw
483// id_token JWT) in scope:
484//   <div google-signin-button
485//        google-signin-credential="LoginWithGoogleCredential(credential)"></div>
486// (google-signin-credential-fallback is the original name for this attribute and
487// still works; it predates this being the primary path.)
488//
489// Compat usage — if no credential expression is given, a transparent overlay is
490// placed over the button and this expression runs on click instead, which should
491// invoke the access-token flow (GooglePlus.login()):
492//   <div google-signin-button="LoginWithGoothenOnboard()"></div>
493//
494// Registered on the shared 'googleplus' module so it's available anywhere the GIS
495// wrapper is loaded (both the Songtree app and the login page modules depend on it).
496angular.module('googleplus').directive('googleSigninButton', ['GooglePlus', function (GooglePlus) {
497  return {
498    restrict: 'A',
499    link: function (scope, element, attrs) {
500      var credentialExpr = attrs.googleSigninCredential || attrs.googleSigninCredentialFallback;
501      // Both callbacks fire outside Angular (Google's callback / a DOM listener),
502      // so settle them with $apply.
503      GooglePlus.renderButton(element[0], function () {
504        scope.$apply(function () { scope.$eval(attrs.googleSigninButton); });
505      }, scope.$eval(attrs.googleSigninOptions), credentialExpr ? function (credential) {
506        scope.$apply(function () { scope.$eval(credentialExpr, { credential: credential }); });
507      } : null);
508    }
509  };
510}]);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.