diff --git a/.eslintrc.json b/.eslintrc.json index 985ec1c..b9832ef 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,6 +23,7 @@ "valid-jsdoc": "error", "prefer-const": "error", "no-template-curly-in-string": "error", - "no-unused-expressions": "error" + "no-unused-expressions": "error", + "no-trailing-spaces": "error" } } diff --git a/add-on/background_scripts/background.js b/add-on/background_scripts/background.js index c9900ed..854b890 100644 --- a/add-on/background_scripts/background.js +++ b/add-on/background_scripts/background.js @@ -54,14 +54,14 @@ rootObject.tabs.onUpdated.addListener(updateBrowserActionBadge); rootObject.tabs.onActivated.addListener(updateBrowserActionBadge); - window.setInterval(function () { - rootObject.tabs.getCurrent(function (currentTab) { - if (currentTab === undefined) { - return; - } - updateBrowserActionBadge({tabId: currentTab.id}); - }); - }, 1000); + // window.setInterval(function () { + // rootObject.tabs.getCurrent(function (currentTab) { + // if (currentTab === undefined) { + // return; + // } + // updateBrowserActionBadge({tabId: currentTab.id}); + // }); + // }, 1000); // Listen for updates to the domain rules from the config page. // The two types of messages that are sent to the background page are @@ -73,26 +73,56 @@ rootObject.runtime.onMessage.addListener(function (request, ignore, sendResponse) { const [label, data] = request; + + // Sent from the config page, when the "which APIs should be + // blocked for which domains" has been changed on the config page. if (label === "stateUpdate") { domainRules = data.domainRules; shouldLog = data.shouldLog; return; } + // Sent from the popup / browser action, asking for infromation about + // which blocking rules are being applied to which domains in the + // tab. if (label === "rulesForDomains") { const matchHostName = domainMatcherLib.matchHostName; const matchHostNameBound = matchHostName.bind(undefined, Object.keys(domainRules)); - const rulesForDomains = data.map(matchHostNameBound); const domainToRuleMapping = {}; - data.forEach(function (aHostName, index) { - domainToRuleMapping[aHostName] = rulesForDomains[index] || defaultKey; + data.forEach(function (aHostName) { + const ruleNameForHost = matchHostNameBound(aHostName) || defaultKey; + domainToRuleMapping[aHostName] = { + "ruleName": ruleNameForHost, + "numRules": domainRules[ruleNameForHost].length + }; }); sendResponse(domainToRuleMapping); return; } + + // Sent from the popup / browser action, saying that a given + // host name should have the default blocking rule applied + // (action === "block", or all APIs allowed + // (action === "allow"). + if (label === "toggleBlocking") { + + const {action, hostName} = data; + if (action === "block") { + delete domainRules[hostName]; + sendResponse(["toggleBlockingResponse", domainRules[defaultKey].length]); + } else if (action === "allow") { + domainRules[hostName] = []; + sendResponse(["toggleBlockingResponse", 0]); + } + + storageLib.set({ + domainRules, + shouldLog + }); + } }); const requestFilter = { diff --git a/add-on/config/js/state.js b/add-on/config/js/state.js index 0b61301..6d07955 100644 --- a/add-on/config/js/state.js +++ b/add-on/config/js/state.js @@ -3,6 +3,75 @@ const defaultDomain = "(default)"; + /** + * Checks if two arrays contain the same values, regardless of order. + * + * @param {array} arrayOne + * One array to test. + * @param {array} arrayTwo + * The second array to test against. + * + * @return {bool} + * Returns true if the two arrays contain all of the same values, + * an otherwise false. + */ + const areArrayValuesIdentical = function (arrayOne, arrayTwo) { + if (arrayOne.length !== arrayTwo.length) { + return false; + } + + const arrayOneSorted = arrayOne.sort(); + const arrayTwoSorted = arrayTwo.sort(); + + const areAllValuesEqual = arrayOneSorted.every(function (value, index) { + return value === arrayTwoSorted[index]; + }); + + return areAllValuesEqual; + }; + + /** + * Checks if two domain rule sets describe the standard blocking same policy. + * + * This check is independent of the ordering of the domain matching rules + * (the keys of the rule sets), or the standards that should be blocked + * (the values in the rule sets). + * + * @param {object} firstRuleSet + * The first rule set to compare. + * @param {object} secondRuleSet + * The second rule set to compare against. + * + * @return {bool} + * Returns true if the two objects describe identical policies + * (ie would block the same standards on the same domains), and + * otherwise false. + */ + const areRuleSetsIdentical = function (firstRuleSet, secondRuleSet) { + + const firstRuleSetDomains = Object.keys(firstRuleSet).sort(); + const secondRuleSetDomains = Object.keys(secondRuleSet).sort(); + + // First check if both rule sets have the same matching patterns + // defined. If not, then no need to consider further. + const haveSameMatchPatterns = areArrayValuesIdentical(firstRuleSet, secondRuleSetDomains); + + if (haveSameMatchPatterns === false) { + return false; + } + + // Next, now that we know both rule sets have rules describing the + // same domains, check that standards blocked for all domains are + // the same. + return firstRuleSetDomains.every(function (value) { + if (secondRuleSet[value] === undefined) { + return false; + } + + return areArrayValuesIdentical(firstRuleSet[value], secondRuleSet[value]); + }); + }; + const generateStateObject = function (initialDomain, standards) { const state = { @@ -20,15 +89,45 @@ }; }, + domainsBlockingNoStandards: function () { + return this.domainNames + .filter(domain => this.domainRules[domain].length === 0) + .sort(); + }, + + domainsBlockingStandards: function () { + return this.domainNames + .filter(domain => this.domainRules[domain].length > 0) + .sort(); + }, + populateFromStorage: function (storedValues) { this.setDomainRules(storedValues.domainRules); this.setShouldLog(storedValues.shouldLog); }, setDomainRules: function (newDomainRules) { + + const isRuleSetMatchingCurrentRules = areRuleSetsIdentical( + newDomainRules, + this.domainRules + ); + + // If the "new" domain rule set is identical to the existing + // one, then don't set any propreties (to avoid unnecessarily + // triggering storage and Vue.js callbacks). + if (isRuleSetMatchingCurrentRules === true) { + return; + } + this.domainRules = newDomainRules; this.domainNames = Object.keys(newDomainRules); - this.selectedStandards = this.domainRules[this.selectedDomain]; + + if (this.domainRules[this.selectedDomain] === undefined) { + this.selectedStandards = this.domainRules[this.defaultDomain]; + } else { + this.selectedStandards = this.domainRules[this.selectedDomain]; + } }, setSelectedDomain: function (newDomain) { @@ -77,6 +176,8 @@ }; window.WEB_API_MANAGER.stateLib = { - generateStateObject + generateStateObject, + areRuleSetsIdentical, + areArrayValuesIdentical }; }()); diff --git a/add-on/config/js/vue_components/config-root.vue.js b/add-on/config/js/vue_components/config-root.vue.js index ef9b798..bb6d9a6 100644 --- a/add-on/config/js/vue_components/config-root.vue.js +++ b/add-on/config/js/vue_components/config-root.vue.js @@ -1,7 +1,7 @@ (function () { "use strict"; - const rootObject = (window.browser || window.chrome); + const rootObject = window.browser || window.chrome; const doc = window.document; const standards = window.WEB_API_MANAGER.standards; const {storageLib, stateLib} = window.WEB_API_MANAGER; @@ -10,12 +10,14 @@ const state = stateLib.generateStateObject(defaultDomain, standards); + let globalVmInstance; + const onSettingsLoaded = function (storedSettings) { state.populateFromStorage(storedSettings); state.activeTab = "domain-rules"; - const vm = new Vue({ + globalVmInstance = new Vue({ el: doc.querySelector("#config-root"), render: window.WEB_API_MANAGER.vueComponents["config-root"].render, staticRenderFns: window.WEB_API_MANAGER.vueComponents["config-root"].staticRenderFns, @@ -36,12 +38,15 @@ }); }; - vm.$watch("selectedStandards", updateStoredSettings); - vm.$watch("domainNames", updateStoredSettings); - vm.$watch("shouldLog", updateStoredSettings); + globalVmInstance.$watch("selectedStandards", updateStoredSettings); + globalVmInstance.$watch("domainNames", updateStoredSettings); + globalVmInstance.$watch("shouldLog", updateStoredSettings); }; window.onload = function () { storageLib.get(onSettingsLoaded); + storageLib.onChange(function (newStoredValues) { + globalVmInstance.$data.setDomainRules(newStoredValues.domainRules); + }); }; }()); diff --git a/add-on/config/js/vue_components/domain-rules.vue.js b/add-on/config/js/vue_components/domain-rules.vue.js index 36f371e..d5354f7 100644 --- a/add-on/config/js/vue_components/domain-rules.vue.js +++ b/add-on/config/js/vue_components/domain-rules.vue.js @@ -14,6 +14,12 @@ }; }, methods: { + blockingRules: function () { + return this.$root.$data.domainsBlockingStandards(); + }, + allowingRules: function () { + return this.$root.$data.domainsBlockingNoStandards(); + }, newDomainSubmitted: function () { const state = this.$root.$data; diff --git a/add-on/config/js/vue_components/import-export.vue.js b/add-on/config/js/vue_components/import-export.vue.js index ce1a2ed..6efc6f1 100644 --- a/add-on/config/js/vue_components/import-export.vue.js +++ b/add-on/config/js/vue_components/import-export.vue.js @@ -51,7 +51,7 @@ const logMessages = newDomainRules.map(function (newDomainRule) { const {pattern, standards} = newDomainRule; if (currentDomainRules[pattern] !== undefined && shouldOverwrite === false) { - return ` ! ${pattern}: Skipped. Set to not override.\n`; + return ` ! ${pattern}: Skipped. Set to not override.\n`; } stateObject.setStandardsForDomain(pattern, standards); diff --git a/add-on/lib/httpheaders.js b/add-on/lib/httpheaders.js index f94890e..3a621a0 100644 --- a/add-on/lib/httpheaders.js +++ b/add-on/lib/httpheaders.js @@ -55,7 +55,7 @@ /** * Returns a new CSP instruction, with source with the given hash * whitelisted. - * + * * If the CSP instruction has a "script-src" rule, then the hash-value * will be inserted there. Otherwise, it will be inserted in the * default-src section. diff --git a/add-on/lib/pack.js b/add-on/lib/pack.js index 2881a6e..301960e 100644 --- a/add-on/lib/pack.js +++ b/add-on/lib/pack.js @@ -20,7 +20,7 @@ /** * Encodes a buffer (such as a Uint8Array) to a base64 encoded string. - * + * * @param {ArrayBuffer} buf * A buffer of binary data. * diff --git a/add-on/lib/storage.js b/add-on/lib/storage.js index afc95fb..be3b355 100644 --- a/add-on/lib/storage.js +++ b/add-on/lib/storage.js @@ -34,8 +34,39 @@ storageObject.sync.set(valueToStore, callback); }; + const onChange = (function () { + + const queue = []; + + storageObject.onChanged.addListener(function (changes) { + + if (changes[webApiManagerKeySettingsKey] === undefined) { + return; + } + + const {newValue, oldValue} = changes[webApiManagerKeySettingsKey]; + + if (JSON.stringify(newValue) === JSON.stringify(oldValue)) { + return; + } + + queue.forEach(function (callback) { + try { + callback(newValue); + } catch (e) { + // Intentionally left blank... + } + }); + }); + + return function (callback) { + queue.push(callback); + }; + }()); + window.WEB_API_MANAGER.storageLib = { get, - set + set, + onChange }; }()); diff --git a/add-on/popup/css/popup.css b/add-on/popup/css/popup.css index f063a36..1194094 100644 --- a/add-on/popup/css/popup.css +++ b/add-on/popup/css/popup.css @@ -1,5 +1,6 @@ body, html { min-width: 320px; + width: 640px; } section .row { @@ -12,4 +13,4 @@ section .row { .loaded .loading-section { display: none; -} \ No newline at end of file +} diff --git a/add-on/popup/js/popup.js b/add-on/popup/js/popup.js index 4034e83..d6203cb 100644 --- a/add-on/popup/js/popup.js +++ b/add-on/popup/js/popup.js @@ -5,31 +5,131 @@ const rootObject = window.browser || window.chrome; const doc = window.document; - const configureButton = doc.getElementById("config-page-link"); - const listGroupElm = doc.querySelector("ul.list-group"); + const domainRuleTableBody = doc.querySelector("#domain-rule-table tbody"); + const defaultDomainRule = "(default)"; - const addRuleToList = function (hostToRuleMapping, listElm, aHostName) { + /** + * Returns a function for use as the "onclick" handler for a toggle button. + * + * @param {string} hostName + * The name of the host to change the blocking settings for. + * @param {string} action + * Either "allow" (indicating that all APIs should be allowed for this + * host) or "block" (indicating that all APIs should be blocked for + * this host). + * + * @return {function} + * A function that takes a single event object as an argument. For + * use as an event handler callback. + */ + const createOnToggleHandler = function (hostName, action) { + const onClickHandler = function (event) { + const message = ["toggleBlocking", { + "action": action, + "hostName": hostName + }]; + const button = event.target; + const containingRowElm = button.parentNode.parentNode; - const domainRule = hostToRuleMapping[aHostName]; + const appliedRuleTd = containingRowElm.querySelector("td:nth-child(2)"); + const numApisBlockedTd = containingRowElm.querySelector("td:nth-child(3)"); - const liElm = doc.createElement("li"); - liElm.className = "list-group-item"; + button.className += " disabled"; + button.innerHtml = "setting…"; - if (domainRule !== "(default)") { - liElm.className += " list-group-item-success"; + rootObject.runtime.sendMessage(message, function (responseMessage) { + + const [messageType, numAPIsBlocked] = responseMessage; + + if (messageType === "toggleBlockingResponse") { + + numApisBlockedTd.innerText = numAPIsBlocked; + + if (action === "block") { + appliedRuleTd.innerText = defaultDomainRule; + } else if (action === "allow") { + appliedRuleTd.innerText = hostName; + } + + button.innerText = "👍"; + } + }); + + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + return onClickHandler; + }; + + /** + * Generates a TR element based on a domain's blocking status + * + * @param {string} domainName + * The name of a domain of a frame on the current tab + * @param {string} appliedRuleName + * The pattern matching rule for the rule set applied (or, + * if no matching rule "(default)"). + * @param {number} numAPIsBlocked + * The number of APIs blocked for this domain. + * + * @return {Node} + * a HTMLTRElement object. + */ + const ruleToTr = function (domainName, appliedRuleName, numAPIsBlocked) { + + const trElm = doc.createElement("tr"); + + const domainTd = doc.createElement("td"); + const domainTdText = doc.createTextNode(domainName); + domainTd.appendChild(domainTdText); + trElm.appendChild(domainTd); + + const ruleTd = doc.createElement("td"); + ruleTd.appendChild(doc.createTextNode(appliedRuleName)); + trElm.appendChild(ruleTd); + + const numBlockedTd = doc.createElement("td"); + numBlockedTd.appendChild(doc.createTextNode(numAPIsBlocked)); + trElm.appendChild(numBlockedTd); + + const actionsTd = doc.createElement("td"); + const toggleButton = doc.createElement("button"); + toggleButton.className = "btn btn-default btn-xs block-toggle"; + + const isAllowingAll = numAPIsBlocked === 0; + + let toggleButtonText; + let toggleAction; + + // If the domain is using the default rule, and the default rule is + // allowing all API's then do nothing, since there is no sensible + // option to "toggle" to. + if (isAllowingAll === false) { + toggleButtonText = "allow all"; + toggleButton.className += " success"; + toggleAction = "allow"; + } else { + toggleButtonText = "remove grant"; + toggleButton.className += " warn"; + toggleAction = "block"; } - const spanElm = doc.createElement("span"); - spanElm.className = "badge"; + if (toggleButtonText !== undefined) { + const toggleButtonTextElm = doc.createTextNode(toggleButtonText); + toggleButton.appendChild(toggleButtonTextElm); - const badgeText = doc.createTextNode(domainRule); - spanElm.appendChild(badgeText); - liElm.appendChild(spanElm); + if (toggleAction !== undefined) { + const onClickToggleButton = createOnToggleHandler(domainName, toggleAction); + toggleButton.addEventListener("click", onClickToggleButton, false); + } + } - const textElm = doc.createTextNode(aHostName); - liElm.appendChild(textElm); - listElm.appendChild(liElm); + actionsTd.appendChild(toggleButton); + trElm.appendChild(actionsTd); + + return trElm; }; configureButton.addEventListener("click", function (event) { @@ -52,10 +152,12 @@ doc.body.className = "loaded"; - const domainNames = Object.keys(response); - const addRuleToListBound = addRuleToList.bind(undefined, response, listGroupElm); - - domainNames.forEach(addRuleToListBound); + const currentDomains = Object.keys(response); + currentDomains.forEach(function (aDomain) { + const {ruleName, numRules} = response[aDomain]; + const rowElm = ruleToTr(aDomain, ruleName, numRules); + domainRuleTableBody.appendChild(rowElm); + }); }); } ); diff --git a/add-on/popup/popup.html b/add-on/popup/popup.html index c213ee7..65b0b17 100644 --- a/add-on/popup/popup.html +++ b/add-on/popup/popup.html @@ -27,8 +27,19 @@
The following origins are executing code on this page.
-Domain | +Rule | +# APIs Blocked | +Action | +
---|