Source code

Revision control

Copy as Markdown

Other Tools

/* 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 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:
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?.();
}