Bug 1242522 - [webext] Implement optional UrlFilter on WebNavigation event listeners. r=kmag
MozReview-Commit-ID: 7tVgBhgDwfM
--- a/toolkit/components/extensions/ext-webNavigation.js
+++ b/toolkit/components/extensions/ext-webNavigation.js
@@ -1,17 +1,17 @@
"use strict";
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
"resource://gre/modules/ExtensionManagement.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
+XPCOMUtils.defineLazyModuleGetter(this, "MatchURLFilters",
"resource://gre/modules/MatchPattern.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "WebNavigation",
"resource://gre/modules/WebNavigation.jsm");
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
var {
SingletonEventManager,
ignoreEvent,
@@ -94,17 +94,21 @@ function fillTransitionProperties(eventN
dst.transitionType = transitionType;
dst.transitionQualifiers = transitionQualifiers;
}
}
// Similar to WebRequestEventManager but for WebNavigation.
function WebNavigationEventManager(context, eventName) {
let name = `webNavigation.${eventName}`;
- let register = callback => {
+ let register = (callback, urlFilters) => {
+ // Don't create a MatchURLFilters instance if the listener does not include any filter.
+ let filters = urlFilters ?
+ new MatchURLFilters(urlFilters.url) : null;
+
let listener = data => {
if (!data.browser) {
return;
}
let tabId = TabManager.getBrowserId(data.browser);
if (tabId == -1) {
return;
@@ -124,17 +128,17 @@ function WebNavigationEventManager(conte
return;
}
fillTransitionProperties(eventName, data, data2);
runSafe(context, callback, data2);
};
- WebNavigation[eventName].addListener(listener);
+ WebNavigation[eventName].addListener(listener, filters);
return () => {
WebNavigation[eventName].removeListener(listener);
};
};
return SingletonEventManager.call(this, context, name, register);
}
--- a/toolkit/modules/addons/MatchPattern.jsm
+++ b/toolkit/modules/addons/MatchPattern.jsm
@@ -1,22 +1,25 @@
/* 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";
const Cu = Components.utils;
+const Ci = Components.interfaces;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
"resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
-this.EXPORTED_SYMBOLS = ["MatchPattern", "MatchGlobs"];
+this.EXPORTED_SYMBOLS = ["MatchPattern", "MatchGlobs", "MatchURLFilters"];
/* globals MatchPattern, MatchGlobs */
const PERMITTED_SCHEMES = ["http", "https", "file", "ftp", "app", "data"];
const PERMITTED_SCHEMES_REGEXP = PERMITTED_SCHEMES.join("|");
// This function converts a glob pattern (containing * and possibly ?
// as wildcards) to a regular expression.
@@ -186,8 +189,171 @@ this.MatchGlobs = function(globs) {
MatchGlobs.prototype = {
matches(str) {
return this.regexps.some(regexp => regexp.test(str));
},
serialize() {
return this.original;
},
};
+
+// Match WebNavigation URL Filters.
+this.MatchURLFilters = function(filters) {
+ if (!Array.isArray(filters)) {
+ throw new TypeError("filters should be an array");
+ }
+
+ if (filters.length == 0) {
+ throw new Error("filters array should not be empty");
+ }
+
+ this.filters = filters;
+};
+
+MatchURLFilters.prototype = {
+ matches(url) {
+ let uri = NetUtil.newURI(url);
+ // Set uriURL to an empty object (needed because some schemes, e.g. about doesn't support nsIURL).
+ let uriURL = {};
+ if (uri instanceof Ci.nsIURL) {
+ uriURL = uri;
+ }
+
+ // Set host to a empty string by default (needed so that schemes without an host,
+ // e.g. about, can pass an empty string for host based event filtering as expected).
+ let host = "";
+ try {
+ host = uri.host;
+ } catch (e) {
+ // 'uri.host' throws an exception with some uri schemes (e.g. about).
+ }
+
+ let port;
+ try {
+ port = uri.port;
+ } catch (e) {
+ // 'uri.port' throws an exception with some uri schemes (e.g. about),
+ // in which case it will be |undefined|.
+ }
+
+ let data = {
+ // NOTE: This properties are named after the name of their related
+ // filters (e.g. `pathContains/pathEquals/...` will be tested against the
+ // `data.path` property, and the same is done for the `host`, `query` and `url`
+ // components as well).
+ path: uriURL.filePath,
+ query: uriURL.query,
+ host,
+ port,
+ url,
+ };
+
+ // If any of the filters matches, matches returns true.
+ return this.filters.some(filter => this.matchURLFilter({filter, data, uri, uriURL}));
+ },
+
+ matchURLFilter({filter, data, uri, uriURL}) {
+ // Test for scheme based filtering.
+ if (filter.schemes) {
+ // Return false if none of the schemes matches.
+ if (!filter.schemes.some((scheme) => uri.schemeIs(scheme))) {
+ return false;
+ }
+ }
+
+ // Test for exact port matching or included in a range of ports.
+ if (filter.ports) {
+ let port = data.port;
+ if (port === -1) {
+ // NOTE: currently defaultPort for "resource" and "chrome" schemes defaults to -1,
+ // for "about", "data" and "javascript" schemes defaults to undefined.
+ if (["resource", "chrome"].includes(uri.scheme)) {
+ port = undefined;
+ } else {
+ port = Services.io.getProtocolHandler(uri.scheme).defaultPort;
+ }
+ }
+
+ // Return false if none of the ports (or port ranges) is verified
+ return filter.ports.some((filterPort) => {
+ if (Array.isArray(filterPort)) {
+ let [lower, upper] = filterPort;
+ return port >= lower && port <= upper;
+ }
+
+ return port === filterPort;
+ });
+ }
+
+ // Filters on host, url, path, query:
+ // hostContains, hostEquals, hostSuffix, hostPrefix,
+ // urlContains, urlEquals, ...
+ for (let urlComponent of ["host", "path", "query", "url"]) {
+ if (!this.testMatchOnURLComponent({urlComponent, data, filter})) {
+ return false;
+ }
+ }
+
+ // urlMatches is a regular expression string and it is tested for matches
+ // on the "url without the ref".
+ if (filter.urlMatches) {
+ let urlWithoutRef = uri.specIgnoringRef;
+ if (!urlWithoutRef.match(filter.urlMatches)) {
+ return false;
+ }
+ }
+
+ // originAndPathMatches is a regular expression string and it is tested for matches
+ // on the "url without the query and the ref".
+ if (filter.originAndPathMatches) {
+ let urlWithoutQueryAndRef = uri.resolve(uriURL.filePath);
+ // The above 'uri.resolve(...)' will be null for some URI schemes
+ // (e.g. about).
+ // TODO: handle schemes which will not be able to resolve the filePath
+ // (e.g. for "about:blank", 'urlWithoutQueryAndRef' should be "about:blank" instead
+ // of null)
+ if (!urlWithoutQueryAndRef ||
+ !urlWithoutQueryAndRef.match(filter.originAndPathMatches)) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ testMatchOnURLComponent({urlComponent: key, data, filter}) {
+ // Test for equals.
+ // NOTE: an empty string should not be considered a filter to skip.
+ if (filter[`${key}Equals`] != null) {
+ if (data[key] !== filter[`${key}Equals`]) {
+ return false;
+ }
+ }
+
+ // Test for contains.
+ if (filter[`${key}Contains`]) {
+ let value = (key == "host" ? "." : "") + data[key];
+ if (!data[key] || !value.includes(filter[`${key}Contains`])) {
+ return false;
+ }
+ }
+
+ // Test for prefix.
+ if (filter[`${key}Prefix`]) {
+ if (!data[key] || !data[key].startsWith(filter[`${key}Prefix`])) {
+ return false;
+ }
+ }
+
+ // Test for suffix.
+ if (filter[`${key}Suffix`]) {
+ if (!data[key] || !data[key].endsWith(filter[`${key}Suffix`])) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ serialize() {
+ return this.filters;
+ },
+};
--- a/toolkit/modules/addons/WebNavigation.jsm
+++ b/toolkit/modules/addons/WebNavigation.jsm
@@ -20,16 +20,17 @@ XPCOMUtils.defineLazyModuleGetter(this,
// the data recent (similar to how is done in nsNavHistory,
// e.g. nsNavHistory::CheckIsRecentEvent, but with a lower threshold value).
const RECENT_DATA_THRESHOLD = 5 * 1000000;
// TODO:
// onCreatedNavigationTarget
var Manager = {
+ // Map[string -> Map[listener -> URLFilter]]
listeners: new Map(),
init() {
// Collect recent tab transition data in a WeakMap:
// browser -> tabTransitionData
this.recentTabTransitionData = new WeakMap();
Services.obs.addObserver(this, "autocomplete-did-enter-text", true);
@@ -52,26 +53,26 @@ var Manager = {
Services.mm.removeMessageListener("Extension:DocumentChange", this);
Services.mm.removeMessageListener("Extension:HistoryChange", this);
Services.mm.removeMessageListener("Extension:DOMContentLoaded", this);
Services.mm.removeDelayedFrameScript("resource://gre/modules/WebNavigationContent.js");
Services.mm.broadcastAsyncMessage("Extension:DisableWebNavigation");
},
- addListener(type, listener) {
+ addListener(type, listener, filters) {
if (this.listeners.size == 0) {
this.init();
}
if (!this.listeners.has(type)) {
- this.listeners.set(type, new Set());
+ this.listeners.set(type, new Map());
}
let listeners = this.listeners.get(type);
- listeners.add(listener);
+ listeners.set(listener, filters);
},
removeListener(type, listener) {
let listeners = this.listeners.get(type);
if (!listeners) {
return;
}
listeners.delete(listener);
@@ -334,18 +335,21 @@ var Manager = {
if (data.parentWindowId) {
details.parentWindowId = data.parentWindowId;
}
for (let prop in extra) {
details[prop] = extra[prop];
}
- for (let listener of listeners) {
- listener(details);
+ for (let [listener, filters] of listeners) {
+ // Call the listener if the listener has no filter or if its filter matches.
+ if (!filters || filters.matches(extra.url)) {
+ listener(details);
+ }
}
},
};
const EVENTS = [
"onBeforeNavigate",
"onCommitted",
"onDOMContentLoaded",