diff options
Diffstat (limited to 'src/js/contentscripts/socialwidgets.js')
-rw-r--r-- | src/js/contentscripts/socialwidgets.js | 641 |
1 files changed, 641 insertions, 0 deletions
diff --git a/src/js/contentscripts/socialwidgets.js b/src/js/contentscripts/socialwidgets.js new file mode 100644 index 0000000..14ae2b3 --- /dev/null +++ b/src/js/contentscripts/socialwidgets.js @@ -0,0 +1,641 @@ +/* + * This file is part of Privacy Badger <https://www.eff.org/privacybadger> + * Copyright (C) 2014 Electronic Frontier Foundation + * Derived from ShareMeNot + * Copyright (C) 2011-2014 University of Washington + * + * Privacy Badger is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * Privacy Badger is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Privacy Badger. If not, see <http://www.gnu.org/licenses/>. + */ + +/* + * ShareMeNot is licensed under the MIT license: + * http://www.opensource.org/licenses/mit-license.php + * + * Copyright (c) 2011-2014 University of Washington + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// widget data +let widgetList; + +// cached chrome.i18n.getMessage() results +const TRANSLATIONS = {}; + +// references to widget page elements +const WIDGET_ELS = {}; + + +/** + * @param {Object} response response to checkWidgetReplacementEnabled + */ +function initialize(response) { + for (const key in response.translations) { + TRANSLATIONS[key] = response.translations[key]; + } + + widgetList = response.widgetList; + + // check for widgets blocked before we got here + replaceInitialTrackerButtonsHelper(response.widgetsToReplace); + + // set up listener for dynamically created widgets + chrome.runtime.onMessage.addListener(function (request) { + if (request.replaceWidget) { + replaceSubsequentTrackerButtonsHelper(request.trackerDomain); + } + }); +} + +/** + * Creates a replacement placeholder element for the given widget. + * + * @param {Object} widget the SocialWidget object + * @param {Element} trackerElem the button/widget element we are replacing + * @param {Function} callback called with the replacement element + */ +function createReplacementElement(widget, trackerElem, callback) { + let buttonData = widget.replacementButton; + + // no image data to fetch + if (!buttonData.hasOwnProperty('imagePath')) { + return setTimeout(function () { + _createReplacementElementCallback(widget, trackerElem, callback); + }, 0); + } + + // already have replacement button image URI cached + if (buttonData.buttonUrl) { + return setTimeout(function () { + _createReplacementElementCallback(widget, trackerElem, callback); + }, 0); + } + + // already messaged for but haven't yet received the image data + if (buttonData.loading) { + // check back in 10 ms + return setTimeout(function () { + createReplacementElement(widget, trackerElem, callback); + }, 10); + } + + // don't have image data cached yet, get it from the background page + buttonData.loading = true; + chrome.runtime.sendMessage({ + type: "getReplacementButton", + widgetName: widget.name + }, function (response) { + if (response) { + buttonData.buttonUrl = response; // cache image data + _createReplacementElementCallback(widget, trackerElem, callback); + } + }); +} + +function _createReplacementElementCallback(widget, trackerElem, callback) { + if (widget.replacementButton.buttonUrl) { + _createButtonReplacement(widget, callback); + } else { + _createWidgetReplacement(widget, trackerElem, callback); + } +} + +function _createButtonReplacement(widget, callback) { + let buttonData = widget.replacementButton, + button_type = buttonData.type; + + let button = document.createElement("img"); + button.setAttribute("src", buttonData.buttonUrl); + + // TODO use custom tooltip to support RTL locales? + button.setAttribute( + "title", + TRANSLATIONS.social_tooltip_pb_has_replaced.replace("XXX", widget.name) + ); + + let styleAttrs = [ + "border: none", + "cursor: pointer", + "height: auto", + "width: auto", + ]; + button.setAttribute("style", styleAttrs.join(" !important;") + " !important"); + + // normal button type; just open a new window when clicked + if (button_type === 0) { + let popup_url = buttonData.details + encodeURIComponent(window.location.href); + + button.addEventListener("click", function () { + window.open(popup_url); + }); + + // in place button type; replace the existing button + // with an iframe when clicked + } else if (button_type == 1) { + let iframe_url = buttonData.details + encodeURIComponent(window.location.href); + + button.addEventListener("click", function () { + replaceButtonWithIframeAndUnblockTracker(button, widget.name, iframe_url); + }, { once: true }); + + // in place button type; replace the existing button with code + // specified in the widgets JSON + } else if (button_type == 2) { + button.addEventListener("click", function () { + replaceButtonWithHtmlCodeAndUnblockTracker(button, widget.name, buttonData.details); + }, { once: true }); + } + + callback(button); +} + +function _createWidgetReplacement(widget, trackerElem, callback) { + let replacementEl; + + // in-place widget type: + // reinitialize the widget by reinserting its element's HTML + if (widget.replacementButton.type == 3) { + replacementEl = createReplacementWidget( + widget, trackerElem, reinitializeWidgetAndUnblockTracker); + + // in-place widget type: + // reinitialize the widget by reinserting its element's HTML + // and activating associated scripts + } else if (widget.replacementButton.type == 4) { + let activationFn = replaceWidgetAndReloadScripts; + + // if there are no matching script elements + if (!document.querySelectorAll(widget.scriptSelectors.join(',')).length) { + // and we don't have a fallback script URL + if (!widget.fallbackScriptUrl) { + // we can't do "in-place" activation; reload the page instead + activationFn = function () { + unblockTracker(widget.name, function () { + location.reload(); + }); + }; + } + } + + replacementEl = createReplacementWidget(widget, trackerElem, activationFn); + } + + callback(replacementEl); +} + +/** + * Unblocks the given widget and replaces the given button with an iframe + * pointing to the given URL. + * + * @param {Element} button the DOM element of the button to replace + * @param {String} widget_name the name of the replacement widget + * @param {String} iframeUrl the URL of the iframe to replace the button + */ +function replaceButtonWithIframeAndUnblockTracker(button, widget_name, iframeUrl) { + unblockTracker(widget_name, function () { + // check is needed as for an unknown reason this callback function is + // executed for buttons that have already been removed; we are trying + // to prevent replacing an already removed button + if (button.parentNode !== null) { + let iframe = document.createElement("iframe"); + + iframe.setAttribute("src", iframeUrl); + iframe.setAttribute("style", "border: none !important; height: 1.5em !important;"); + + button.parentNode.replaceChild(iframe, button); + } + }); +} + +/** + * Unblocks the given widget and replaces the given button with the + * HTML code defined in the provided SocialWidget object. + * + * @param {Element} button the DOM element of the button to replace + * @param {String} widget_name the name of the replacement widget + * @param {String} html the HTML string that should replace the button + */ +function replaceButtonWithHtmlCodeAndUnblockTracker(button, widget_name, html) { + unblockTracker(widget_name, function () { + // check is needed as for an unknown reason this callback function is + // executed for buttons that have already been removed; we are trying + // to prevent replacing an already removed button + if (button.parentNode !== null) { + let codeContainer = document.createElement("div"); + codeContainer.innerHTML = html; + + button.parentNode.replaceChild(codeContainer, button); + + replaceScriptsRecurse(codeContainer); + } + }); +} + +/** + * Unblocks the given widget and replaces our replacement placeholder + * with the original third-party widget element. + * + * The teardown to the initialization defined in createReplacementWidget(). + * + * @param {String} name the name/type of this widget (SoundCloud, Vimeo etc.) + */ +function reinitializeWidgetAndUnblockTracker(name) { + unblockTracker(name, function () { + // restore all widgets of this type + WIDGET_ELS[name].forEach(data => { + data.parent.replaceChild(data.widget, data.replacement); + }); + WIDGET_ELS[name] = []; + }); +} + +/** + * Similar to reinitializeWidgetAndUnblockTracker() above, + * but also reruns scripts defined in scriptSelectors. + * + * @param {String} name the name/type of this widget (Disqus, Google reCAPTCHA) + */ +function replaceWidgetAndReloadScripts(name) { + unblockTracker(name, function () { + // restore all widgets of this type + WIDGET_ELS[name].forEach(data => { + data.parent.replaceChild(data.widget, data.replacement); + reloadScripts(data.scriptSelectors, data.fallbackScriptUrl); + }); + WIDGET_ELS[name] = []; + }); +} + +/** + * Find and replace script elements with their copies to trigger re-running. + */ +function reloadScripts(selectors, fallback_script_url) { + let scripts = document.querySelectorAll(selectors.join(',')); + + // if there are no matches, try a known script URL + if (!scripts.length && fallback_script_url) { + let parent = document.documentElement, + replacement = document.createElement("script"); + replacement.src = fallback_script_url; + parent.insertBefore(replacement, parent.firstChild); + return; + } + + for (let scriptEl of scripts) { + // reinsert script elements only + if (!scriptEl.nodeName || scriptEl.nodeName.toLowerCase() != 'script') { + continue; + } + + let replacement = document.createElement("script"); + for (let attr of scriptEl.attributes) { + replacement.setAttribute(attr.nodeName, attr.value); + } + scriptEl.parentNode.replaceChild(replacement, scriptEl); + // reinsert one script and quit + break; + } +} + +/** + * Dumping scripts into innerHTML won't execute them, so replace them + * with executable scripts. + */ +function replaceScriptsRecurse(node) { + if (node.nodeName && node.nodeName.toLowerCase() == 'script' && + node.getAttribute && node.getAttribute("type") == "text/javascript") { + let script = document.createElement("script"); + script.text = node.innerHTML; + script.src = node.src; + node.parentNode.replaceChild(script, node); + } else { + let i = 0, + children = node.childNodes; + while (i < children.length) { + replaceScriptsRecurse(children[i]); + i++; + } + } + return node; +} + + +/** + * Replaces all tracker buttons on the current web page with the internal + * replacement buttons, respecting the user's blocking settings. + * + * @param {Array} widgetsToReplace a list of widget names to replace + */ +function replaceInitialTrackerButtonsHelper(widgetsToReplace) { + widgetList.forEach(function (widget) { + if (widgetsToReplace.hasOwnProperty(widget.name)) { + replaceIndividualButton(widget); + } + }); +} + +/** + * Individually replaces tracker buttons blocked after initial check. + */ +function replaceSubsequentTrackerButtonsHelper(tracker_domain) { + if (!widgetList) { + return; + } + widgetList.forEach(function (widget) { + let replace = widget.domains.some(domain => { + if (domain == tracker_domain) { + return true; + // leading wildcard + } else if (domain[0] == "*") { + if (tracker_domain.endsWith(domain.slice(1))) { + return true; + } + } + return false; + }); + if (replace) { + replaceIndividualButton(widget); + } + }); +} + +function _make_id(prefix) { + return prefix + "-" + Math.random().toString().replace(".", ""); +} + +function createReplacementWidget(widget, elToReplace, activationFn) { + let name = widget.name; + + let widgetFrame = document.createElement('iframe'); + + // widget replacement frame styles + let border_width = 1; + let styleAttrs = [ + "background-color: #fff", + "border: " + border_width + "px solid #ec9329", + "min-width: 220px", + "min-height: 210px", + "max-height: 600px", + "z-index: 2147483647", + ]; + if (elToReplace.offsetWidth > 0) { + styleAttrs.push(`width: ${elToReplace.offsetWidth - 2*border_width}px`); + } + if (elToReplace.offsetHeight > 0) { + styleAttrs.push(`height: ${elToReplace.offsetHeight - 2*border_width}px`); + } + widgetFrame.style = styleAttrs.join(" !important;") + " !important"; + + let widgetDiv = document.createElement('div'); + + // parent div styles + styleAttrs = [ + "display: flex", + "flex-direction: column", + "align-items: center", + "justify-content: center", + "width: 100%", + "height: 100%", + ]; + if (TRANSLATIONS.rtl) { + styleAttrs.push("direction: rtl"); + } + widgetDiv.style = styleAttrs.join(" !important;") + " !important"; + + // child div styles + styleAttrs = [ + "color: #303030", + "font-family: helvetica, arial, sans-serif", + "font-size: 16px", + "display: flex", + "flex-wrap: wrap", + "justify-content: center", + "text-align: center", + "margin: 10px", + ]; + + let textDiv = document.createElement('div'); + textDiv.style = styleAttrs.join(" !important;") + " !important"; + textDiv.appendChild(document.createTextNode( + TRANSLATIONS.widget_placeholder_pb_has_replaced.replace("XXX", name))); + let infoIcon = document.createElement('a'), + info_icon_id = _make_id("ico-help"); + infoIcon.id = info_icon_id; + infoIcon.href = "https://privacybadger.org/#How-does-Privacy-Badger-handle-social-media-widgets"; + infoIcon.rel = "noreferrer"; + infoIcon.target = "_blank"; + textDiv.appendChild(infoIcon); + widgetDiv.appendChild(textDiv); + + let buttonDiv = document.createElement('div'); + styleAttrs.push("width: 100%"); + buttonDiv.style = styleAttrs.join(" !important;") + " !important"; + + // allow once button + let button = document.createElement('button'), + button_id = _make_id("btn-once"); + button.id = button_id; + styleAttrs = [ + "transition: background-color 0.25s ease-out, border-color 0.25s ease-out, color 0.25s ease-out", + "border-radius: 3px", + "cursor: pointer", + "font-family: 'Lucida Grande', 'Segoe UI', Tahoma, 'DejaVu Sans', Arial, sans-serif", + "font-size: 12px", + "font-weight: bold", + "line-height: 16px", + "padding: 10px", + "margin: 4px", + "text-align: center", + "width: 70%", + "max-width: 280px", + ]; + button.style = styleAttrs.join(" !important;") + " !important"; + + // allow on this site button + let site_button = document.createElement('button'), + site_button_id = _make_id("btn-site"); + site_button.id = site_button_id; + site_button.style = styleAttrs.join(" !important;") + " !important"; + + button.appendChild(document.createTextNode(TRANSLATIONS.allow_once)); + site_button.appendChild(document.createTextNode(TRANSLATIONS.allow_on_site)); + + buttonDiv.appendChild(button); + buttonDiv.appendChild(site_button); + + widgetDiv.appendChild(buttonDiv); + + // save refs. to elements for use in teardown + if (!WIDGET_ELS.hasOwnProperty(name)) { + WIDGET_ELS[name] = []; + } + let data = { + parent: elToReplace.parentNode, + widget: elToReplace, + replacement: widgetFrame + }; + if (widget.scriptSelectors) { + data.scriptSelectors = widget.scriptSelectors; + if (widget.fallbackScriptUrl) { + data.fallbackScriptUrl = widget.fallbackScriptUrl; + } + } + WIDGET_ELS[name].push(data); + + // set up click handler + widgetFrame.addEventListener('load', function () { + let onceButton = widgetFrame.contentDocument.getElementById(button_id), + siteButton = widgetFrame.contentDocument.getElementById(site_button_id); + + onceButton.addEventListener("click", function (e) { + e.preventDefault(); + activationFn(name); + }, { once: true }); + + siteButton.addEventListener("click", function (e) { + e.preventDefault(); + + // first message the background page to record that + // this widget should always be allowed on this site + chrome.runtime.sendMessage({ + type: "allowWidgetOnSite", + widgetName: name + }, function () { + activationFn(name); + }); + }, { once: true }); + + }, false); // end of click handler + + let head_styles = ` +html, body { + height: 100% !important; + overflow: hidden !important; +} +#${button_id} { + border: 2px solid #f06a0a !important; + background-color: #f06a0a !important; + color: #fefefe !important; +} +#${site_button_id} { + border: 2px solid #333 !important; + background-color: #fefefe !important; + color: #333 !important; +} +#${button_id}:hover { + background-color: #fefefe !important; + color: #333 !important; +} +#${site_button_id}:hover { + background-color: #fefefe !important; + border: 2px solid #f06a0a !important; +} +#${info_icon_id} { + position: absolute; + ${TRANSLATIONS.rtl ? "left" : "right"}: 4px; + top: 4px; + line-height: 12px; + text-decoration: none; +} +#${info_icon_id}:before { + border: 2px solid; + border-radius: 50%; + display: inline-block; + color: #555; + content: '?'; + font-size: 12px; + font-weight: bold; + padding: 1px; + height: 1em; + width: 1em; +} +#${info_icon_id}:hover:before { + color: #ec9329; +} + `.trim(); + + widgetFrame.srcdoc = '<html><head><style>' + head_styles + '</style></head><body style="margin:0">' + widgetDiv.outerHTML + '</body></html>'; + + return widgetFrame; +} + +/** + * Replaces buttons/widgets in the DOM. + */ +function replaceIndividualButton(widget) { + let selector = widget.buttonSelectors.join(','), + elsToReplace = document.querySelectorAll(selector); + + elsToReplace.forEach(function (el) { + createReplacementElement(widget, el, function (replacementEl) { + el.parentNode.replaceChild(replacementEl, el); + }); + }); +} + +/** + * Messages the background page to temporarily allow domains associated with a + * given replacement widget. + * Calls the provided callback function upon response. + * + * @param {String} name the name of the replacement widget + * @param {Function} callback the callback function + */ +function unblockTracker(name, callback) { + let request = { + type: "unblockWidget", + widgetName: name + }; + chrome.runtime.sendMessage(request, callback); +} + +// END FUNCTION DEFINITIONS /////////////////////////////////////////////////// + +(function () { + +// don't inject into non-HTML documents (such as XML documents) +// but do inject into XHTML documents +if (document instanceof HTMLDocument === false && ( + document instanceof XMLDocument === false || + document.createElement('div') instanceof HTMLDivElement === false +)) { + return; +} + +chrome.runtime.sendMessage({ + type: "checkWidgetReplacementEnabled" +}, function (response) { + if (!response) { + return; + } + initialize(response); +}); + +}()); |