/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; /** * Bug 1226498 - Shim Facebook SDK * * This shim provides functionality to enable Facebook's authenticator on third * party sites ("continue/log in with Facebook" buttons). This includes rendering * the button as the SDK would, if sites require it. This way, if users wish to * opt into the Facebook login process regardless of the tracking consequences, * they only need to click the button as usual. * * In addition, the shim also attempts to provide placeholders for Facebook * videos, which users may click to opt into seeing the video (also despite * the increased tracking risks). This is an experimental feature enabled * that is only currently enabled on nightly builds. * * Finally, this shim also stubs out as much of the SDK as possible to prevent * breaking on sites which expect that it will always successfully load. */ if (!window.FB) { const FacebookLogoURL = "https://smartblock.firefox.etp/facebook.svg"; const PlayIconURL = "https://smartblock.firefox.etp/play.svg"; const originalUrl = document.currentScript.src; let haveUnshimmed; let initInfo; let activeOnloginAttribute; const placeholdersToRemoveOnUnshim = new Set(); const loggedGraphApiCalls = []; const eventHandlers = new Map(); function getGUID() { const v = crypto.getRandomValues(new Uint8Array(20)); return Array.from(v, c => c.toString(16)).join(""); } const sendMessageToAddon = (function() { const shimId = "FacebookSDK"; const pendingMessages = new Map(); const channel = new MessageChannel(); channel.port1.onerror = console.error; channel.port1.onmessage = event => { const { messageId, response } = event.data; const resolve = pendingMessages.get(messageId); if (resolve) { pendingMessages.delete(messageId); resolve(response); } }; function reconnect() { const detail = { pendingMessages: [...pendingMessages.values()], port: channel.port2, shimId, }; window.dispatchEvent(new CustomEvent("ShimConnects", { detail })); } window.addEventListener("ShimHelperReady", reconnect); reconnect(); return function(message) { const messageId = getGUID(); return new Promise(resolve => { const payload = { message, messageId, shimId }; pendingMessages.set(messageId, resolve); channel.port1.postMessage(payload); }); }; })(); const isNightly = sendMessageToAddon("getOptions").then(opts => { return opts.releaseBranch === "nightly"; }); function makeLoginPlaceholder(target) { // Sites may provide their own login buttons, or rely on the Facebook SDK // to render one for them. For the latter case, we provide placeholders // which try to match the examples and documentation here: // https://developers.facebook.com/docs/facebook-login/web/login-button/ if (target.textContent || target.hasAttribute("fb-xfbml-state")) { return; } target.setAttribute("fb-xfbml-state", ""); const size = target.getAttribute("data-size") || "large"; let font, margin, minWidth, maxWidth, height, iconHeight; if (size === "small") { font = 11; margin = 8; minWidth = maxWidth = 200; height = 20; iconHeight = 12; } else if (size === "medium") { font = 13; margin = 8; minWidth = 200; maxWidth = 320; height = 28; iconHeight = 16; } else { font = 16; minWidth = 240; maxWidth = 400; margin = 12; height = 40; iconHeight = 24; } const wattr = target.getAttribute("data-width") || ""; const width = wattr === "100%" ? wattr : `${parseFloat(wattr) || minWidth}px`; const round = target.getAttribute("data-layout") === "rounded" ? 20 : 4; const text = target.getAttribute("data-button-type") === "continue_with" ? "Continue with Facebook" : "Log in with Facebook"; const button = document.createElement("div"); button.style = ` display: flex; align-items: center; justify-content: center; padding-left: ${margin + iconHeight}px; ${width}; min-width: ${minWidth}px; max-width: ${maxWidth}px; height: ${height}px; border-radius: ${round}px; -moz-text-size-adjust: none; -moz-user-select: none; color: #fff; font-size: ${font}px; font-weight: bold; font-family: Helvetica, Arial, sans-serif; letter-spacing: .25px; background-color: #1877f2; background-repeat: no-repeat; background-position: ${margin}px 50%; background-size: ${iconHeight}px ${iconHeight}px; background-image: url(${FacebookLogoURL}); `; button.textContent = text; target.appendChild(button); target.addEventListener("click", () => { activeOnloginAttribute = target.getAttribute("onlogin"); }); } async function makeVideoPlaceholder(target) { // For videos, we provide a more generic placeholder of roughly the // expected size with a play button, as well as a Facebook logo. if (!(await isNightly) || target.hasAttribute("fb-xfbml-state")) { return; } target.setAttribute("fb-xfbml-state", ""); let width = parseInt(target.getAttribute("data-width")); let height = parseInt(target.getAttribute("data-height")); if (height) { height = `${width * 0.6}px`; } else { height = `100%; min-height:${width * 0.75}px`; } if (width) { width = `${width}px`; } else { width = `100%; min-width:200px`; } const placeholder = document.createElement("div"); placeholdersToRemoveOnUnshim.add(placeholder); placeholder.style = ` width: ${width}; height: ${height}; top: 0px; left: 0px; background: #000; color: #fff; text-align: center; cursor: pointer; display: flex; align-items: center; justify-content: center; background-image: url(${FacebookLogoURL}), url(${PlayIconURL}); background-position: calc(100% - 24px) 24px, 50% 47.5%; background-repeat: no-repeat, no-repeat; background-size: 43px 42px, 25% 25%; -moz-text-size-adjust: none; -moz-user-select: none; color: #fff; align-items: center; padding-top: 200px; font-size: 14pt; `; placeholder.textContent = "Click to allow blocked Facebook content"; placeholder.addEventListener("click", evt => { if (!evt.isTrusted) { return; } allowFacebookSDK(() => { placeholdersToRemoveOnUnshim.forEach(p => p.remove()); }); }); target.innerHTML = ""; target.appendChild(placeholder); } // We monitor for XFBML objects as Facebook SDK does, so we // can provide placeholders for dynamically-added ones. const xfbmlObserver = new MutationObserver(mutations => { for (let { addedNodes, target, type } of mutations) { const nodes = type === "attributes" ? [target] : addedNodes; for (const node of nodes) { if (node?.classList?.contains("fb-login-button")) { makeLoginPlaceholder(node); } if (node?.classList?.contains("fb-video")) { makeVideoPlaceholder(node); } } } }); xfbmlObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"], }); const needPopup = !/app_runner/.test(window.name) && !/iframe_canvas/.test(window.name); const popupName = getGUID(); let activePopup; if (needPopup) { const oldWindowOpen = window.open; window.open = function(href, name, params) { try { const url = new URL(href, window.location.href); if ( url.protocol === "https:" && (url.hostname === "m.facebook.com" || url.hostname === "www.facebook.com") && url.pathname.endsWith("/oauth") ) { name = popupName; } } catch (e) { console.error(e); } return oldWindowOpen.call(window, href, name, params); }; } let allowingFacebookPromise; async function allowFacebookSDK(postInitCallback) { if (allowingFacebookPromise) { return allowingFacebookPromise; } let resolve, reject; allowingFacebookPromise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); await sendMessageToAddon("optIn"); xfbmlObserver.disconnect(); const shim = window.FB; window.FB = undefined; // We need to pass the site's initialization info to the real // SDK as it loads, so we use the fbAsyncInit mechanism to // do so, also ensuring our own post-init callbacks are called. const oldInit = window.fbAsyncInit; window.fbAsyncInit = () => { try { if (typeof initInfo !== "undefined") { window.FB.init(initInfo); } else if (oldInit) { oldInit(); } } catch (e) { console.error(e); } // Also re-subscribe any SDK event listeners as early as possible. for (const [name, fns] of eventHandlers.entries()) { for (const fn of fns) { window.FB.Event.subscribe(name, fn); } } // Allow the shim to do any post-init work early as well, while the // SDK script finishes loading and we ask it to re-parse XFBML etc. postInitCallback?.(); }; const script = document.createElement("script"); script.src = originalUrl; script.addEventListener("error", () => { allowingFacebookPromise = null; script.remove(); activePopup?.close(); window.FB = shim; reject(); alert("Failed to load Facebook SDK; please try again"); }); script.addEventListener("load", () => { haveUnshimmed = true; // After the real SDK has fully loaded we re-issue any Graph API // calls the page is waiting on, as well as requesting for it to // re-parse any XBFML elements (including ones with placeholders). for (const args of loggedGraphApiCalls) { try { window.FB.api.apply(window.FB, args); } catch (e) { console.error(e); } } window.FB.XFBML.parse(document.body, resolve); }); document.head.appendChild(script); return allowingFacebookPromise; } function buildPopupParams() { // We try to match Facebook's popup size reasonably closely. const { outerWidth, outerHeight, screenX, screenY } = window; const { width, height } = window.screen; const w = Math.min(width, 400); const h = Math.min(height, 400); const ua = navigator.userAgent; const isMobile = ua.includes("Mobile") || ua.includes("Tablet"); const left = screenX + (screenX < 0 ? width : 0) + (outerWidth - w) / 2; const top = screenY + (screenY < 0 ? height : 0) + (outerHeight - h) / 2.5; let params = `left=${left},top=${top},width=${w},height=${h},scrollbars=1,toolbar=0,location=1`; if (!isMobile) { params = `${params},width=${w},height=${h}`; } return params; } // If a page stores the window.FB reference of the shim, then we // want to have it proxy calls to the real SDK once we've unshimmed. function ensureProxiedToUnshimmed(obj) { const shim = {}; for (const key in obj) { const value = obj[key]; if (typeof value === "function") { shim[key] = function() { if (haveUnshimmed) { return window.FB[key].apply(window.FB, arguments); } return value.apply(this, arguments); }; } else if (typeof value !== "object" || value === null) { shim[key] = value; } else { shim[key] = ensureProxiedToUnshimmed(value); } } return new Proxy(shim, { get: (shimmed, key) => (haveUnshimmed ? window.FB : shimmed)[key], }); } window.FB = ensureProxiedToUnshimmed({ api() { loggedGraphApiCalls.push(arguments); }, AppEvents: { activateApp() {}, clearAppVersion() {}, clearUserID() {}, EventNames: { ACHIEVED_LEVEL: "fb_mobile_level_achieved", ADDED_PAYMENT_INFO: "fb_mobile_add_payment_info", ADDED_TO_CART: "fb_mobile_add_to_cart", ADDED_TO_WISHLIST: "fb_mobile_add_to_wishlist", COMPLETED_REGISTRATION: "fb_mobile_complete_registration", COMPLETED_TUTORIAL: "fb_mobile_tutorial_completion", INITIATED_CHECKOUT: "fb_mobile_initiated_checkout", PAGE_VIEW: "fb_page_view", RATED: "fb_mobile_rate", SEARCHED: "fb_mobile_search", SPENT_CREDITS: "fb_mobile_spent_credits", UNLOCKED_ACHIEVEMENT: "fb_mobile_achievement_unlocked", VIEWED_CONTENT: "fb_mobile_content_view", }, getAppVersion: () => "", getUserID: () => "", logEvent() {}, logPageView() {}, logPurchase() {}, ParameterNames: { APP_USER_ID: "_app_user_id", APP_VERSION: "_appVersion", CONTENT_ID: "fb_content_id", CONTENT_TYPE: "fb_content_type", CURRENCY: "fb_currency", DESCRIPTION: "fb_description", LEVEL: "fb_level", MAX_RATING_VALUE: "fb_max_rating_value", NUM_ITEMS: "fb_num_items", PAYMENT_INFO_AVAILABLE: "fb_payment_info_available", REGISTRATION_METHOD: "fb_registration_method", SEARCH_STRING: "fb_search_string", SUCCESS: "fb_success", }, setAppVersion() {}, setUserID() {}, updateUserProperties() {}, }, Canvas: { getHash: () => "", getPageInfo(cb) { cb?.call(this, { clientHeight: 1, clientWidth: 1, offsetLeft: 0, offsetTop: 0, scrollLeft: 0, scrollTop: 0, }); }, Plugin: { hidePluginElement() {}, showPluginElement() {}, }, Prefetcher: { COLLECT_AUTOMATIC: 0, COLLECT_MANUAL: 1, addStaticResource() {}, setCollectionMode() {}, }, scrollTo() {}, setAutoGrow() {}, setDoneLoading() {}, setHash() {}, setSize() {}, setUrlHandler() {}, startTimer() {}, stopTimer() {}, }, Event: { subscribe(e, f) { if (!eventHandlers.has(e)) { eventHandlers.set(e, new Set()); } eventHandlers.get(e).add(f); }, unsubscribe(e, f) { eventHandlers.get(e)?.delete(f); }, }, frictionless: { init() {}, isAllowed: () => false, }, gamingservices: { friendFinder() {}, uploadImageToMediaLibrary() {}, }, getAccessToken: () => null, getAuthResponse() { return { status: "" }; }, getLoginStatus(cb) { cb?.call(this, { status: "unknown" }); }, getUserID() {}, init(_initInfo) { initInfo = _initInfo; // in case the site is not using fbAsyncInit }, login(cb, opts) { // We have to load Facebook's script, and then wait for it to call // window.open. By that time, the popup blocker will likely trigger. // So we open a popup now with about:blank, and then make sure FB // will re-use that same popup later. if (needPopup) { activePopup = window.open("about:blank", popupName, buildPopupParams()); } allowFacebookSDK(() => { activePopup = undefined; function runPostLoginCallbacks() { try { cb?.apply(this, arguments); } catch (e) { console.error(e); } if (activeOnloginAttribute) { setTimeout(activeOnloginAttribute, 1); activeOnloginAttribute = undefined; } } window.FB.login(runPostLoginCallbacks, opts); }).catch(() => { activePopup = undefined; activeOnloginAttribute = undefined; try { cb?.({}); } catch (e) { console.error(e); } }); }, logout(cb) { cb?.call(this); }, ui(params, fn) { if (params.method === "permissions.oauth") { window.FB.login(fn, params); } }, XFBML: { parse(node, cb) { node = node || document; node.querySelectorAll(".fb-login-button").forEach(makeLoginPlaceholder); node.querySelectorAll(".fb-video").forEach(makeVideoPlaceholder); try { cb?.call(this); } catch (e) { console.error(e); } }, }, }); window.FB.XFBML.parse(); window?.fbAsyncInit?.(); }