зеркало из https://github.com/mozilla/gecko-dev.git
362 строки
10 KiB
JavaScript
362 строки
10 KiB
JavaScript
/* 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 EXPORTED_SYMBOLS = ["LoginRecipesContent", "LoginRecipesParent"];
|
|
|
|
const REQUIRED_KEYS = ["hosts"];
|
|
const OPTIONAL_KEYS = [
|
|
"description",
|
|
"notPasswordSelector",
|
|
"notUsernameSelector",
|
|
"passwordSelector",
|
|
"pathRegex",
|
|
"usernameSelector",
|
|
];
|
|
const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS);
|
|
|
|
const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
|
|
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
|
|
const { XPCOMUtils } = ChromeUtils.import(
|
|
"resource://gre/modules/XPCOMUtils.jsm"
|
|
);
|
|
|
|
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
|
|
|
|
ChromeUtils.defineModuleGetter(
|
|
this,
|
|
"LoginHelper",
|
|
"resource://gre/modules/LoginHelper.jsm"
|
|
);
|
|
|
|
XPCOMUtils.defineLazyGetter(this, "log", () =>
|
|
LoginHelper.createLogger("LoginRecipes")
|
|
);
|
|
|
|
/**
|
|
* Create an instance of the object to manage recipes in the parent process.
|
|
* Consumers should wait until {@link initializationPromise} resolves before
|
|
* calling methods on the object.
|
|
*
|
|
* @constructor
|
|
* @param {String} [aOptions.defaults=null] the URI to load the recipes from.
|
|
* If it's null, nothing is loaded.
|
|
*
|
|
*/
|
|
function LoginRecipesParent(aOptions = { defaults: null }) {
|
|
if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
|
|
throw new Error(
|
|
"LoginRecipesParent should only be used from the main process"
|
|
);
|
|
}
|
|
this._defaults = aOptions.defaults;
|
|
this.reset();
|
|
}
|
|
|
|
LoginRecipesParent.prototype = {
|
|
/**
|
|
* Promise resolved with an instance of itself when the module is ready.
|
|
*
|
|
* @type {Promise}
|
|
*/
|
|
initializationPromise: null,
|
|
|
|
/**
|
|
* @type {bool} Whether default recipes were loaded at construction time.
|
|
*/
|
|
_defaults: null,
|
|
|
|
/**
|
|
* @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes.
|
|
* e.g. "example.com:8080" => Set({...})
|
|
*/
|
|
_recipesByHost: null,
|
|
|
|
/**
|
|
* @param {Object} aRecipes an object containing recipes to load for use. The object
|
|
* should be compatible with JSON (e.g. no RegExp).
|
|
* @return {Promise} resolving when the recipes are loaded
|
|
*/
|
|
load(aRecipes) {
|
|
let recipeErrors = 0;
|
|
for (let rawRecipe of aRecipes.siteRecipes) {
|
|
try {
|
|
rawRecipe.pathRegex = rawRecipe.pathRegex
|
|
? new RegExp(rawRecipe.pathRegex)
|
|
: undefined;
|
|
this.add(rawRecipe);
|
|
} catch (ex) {
|
|
recipeErrors++;
|
|
log.error("Error loading recipe", rawRecipe, ex);
|
|
}
|
|
}
|
|
|
|
if (recipeErrors) {
|
|
return Promise.reject(`There were ${recipeErrors} recipe error(s)`);
|
|
}
|
|
|
|
return Promise.resolve();
|
|
},
|
|
|
|
/**
|
|
* Reset the set of recipes to the ones from the time of construction.
|
|
*/
|
|
reset() {
|
|
log.debug("Resetting recipes with defaults:", this._defaults);
|
|
this._recipesByHost = new Map();
|
|
|
|
if (this._defaults) {
|
|
let channel = NetUtil.newChannel({
|
|
uri: NetUtil.newURI(this._defaults, "UTF-8"),
|
|
loadUsingSystemPrincipal: true,
|
|
});
|
|
channel.contentType = "application/json";
|
|
|
|
try {
|
|
this.initializationPromise = new Promise(function(resolve) {
|
|
NetUtil.asyncFetch(channel, function(stream, result) {
|
|
if (!Components.isSuccessCode(result)) {
|
|
throw new Error("Error fetching recipe file:" + result);
|
|
}
|
|
let count = stream.available();
|
|
let data = NetUtil.readInputStreamToString(stream, count, {
|
|
charset: "UTF-8",
|
|
});
|
|
resolve(JSON.parse(data));
|
|
});
|
|
})
|
|
.then(recipes => {
|
|
Services.ppmm.broadcastAsyncMessage("clearRecipeCache");
|
|
return this.load(recipes);
|
|
})
|
|
.then(resolve => {
|
|
return this;
|
|
});
|
|
} catch (e) {
|
|
throw new Error("Error reading recipe file:" + e);
|
|
}
|
|
} else {
|
|
this.initializationPromise = Promise.resolve(this);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Validate the recipe is sane and then add it to the set of recipes.
|
|
*
|
|
* @param {Object} recipe
|
|
*/
|
|
add(recipe) {
|
|
log.debug("Adding recipe:", recipe);
|
|
let recipeKeys = Object.keys(recipe);
|
|
let unknownKeys = recipeKeys.filter(key => !SUPPORTED_KEYS.includes(key));
|
|
if (unknownKeys.length) {
|
|
throw new Error(
|
|
"The following recipe keys aren't supported: " + unknownKeys.join(", ")
|
|
);
|
|
}
|
|
|
|
let missingRequiredKeys = REQUIRED_KEYS.filter(
|
|
key => !recipeKeys.includes(key)
|
|
);
|
|
if (missingRequiredKeys.length) {
|
|
throw new Error(
|
|
"The following required recipe keys are missing: " +
|
|
missingRequiredKeys.join(", ")
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(recipe.hosts)) {
|
|
throw new Error("'hosts' must be a array");
|
|
}
|
|
|
|
if (!recipe.hosts.length) {
|
|
throw new Error("'hosts' must be a non-empty array");
|
|
}
|
|
|
|
if (recipe.pathRegex && recipe.pathRegex.constructor.name != "RegExp") {
|
|
throw new Error("'pathRegex' must be a regular expression");
|
|
}
|
|
|
|
const OPTIONAL_STRING_PROPS = [
|
|
"description",
|
|
"passwordSelector",
|
|
"usernameSelector",
|
|
];
|
|
for (let prop of OPTIONAL_STRING_PROPS) {
|
|
if (recipe[prop] && typeof recipe[prop] != "string") {
|
|
throw new Error(`'${prop}' must be a string`);
|
|
}
|
|
}
|
|
|
|
// Add the recipe to the map for each host
|
|
for (let host of recipe.hosts) {
|
|
if (!this._recipesByHost.has(host)) {
|
|
this._recipesByHost.set(host, new Set());
|
|
}
|
|
this._recipesByHost.get(host).add(recipe);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Currently only exact host matches are returned but this will eventually handle parent domains.
|
|
*
|
|
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
|
|
* @return {Set} of recipes that apply to the host ordered by host priority
|
|
*/
|
|
getRecipesForHost(aHost) {
|
|
let hostRecipes = this._recipesByHost.get(aHost);
|
|
if (!hostRecipes) {
|
|
return new Set();
|
|
}
|
|
|
|
return hostRecipes;
|
|
},
|
|
};
|
|
|
|
this.LoginRecipesContent = {
|
|
_recipeCache: new WeakMap(),
|
|
|
|
_clearRecipeCache() {
|
|
log.debug("_clearRecipeCache");
|
|
this._recipeCache = new WeakMap();
|
|
},
|
|
|
|
/**
|
|
* Locally caches recipes for a given host.
|
|
*
|
|
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
|
|
* @param {Object} win - the window of the host
|
|
* @param {Set} recipes - recipes that apply to the host
|
|
*/
|
|
cacheRecipes(aHost, win, recipes) {
|
|
log.debug("cacheRecipes: for:", aHost);
|
|
let recipeMap = this._recipeCache.get(win);
|
|
|
|
if (!recipeMap) {
|
|
recipeMap = new Map();
|
|
this._recipeCache.set(win, recipeMap);
|
|
}
|
|
|
|
recipeMap.set(aHost, recipes);
|
|
},
|
|
|
|
/**
|
|
* Tries to fetch recipes for a given host, using a local cache if possible.
|
|
* Otherwise, the recipes are cached for later use.
|
|
*
|
|
* @param {JSWindowActor} aActor - actor making request
|
|
* @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
|
|
* @param {Object} win - the window of the host
|
|
* @return {Set} of recipes that apply to the host
|
|
*/
|
|
getRecipes(aActor, aHost, win) {
|
|
let recipes;
|
|
let recipeMap = this._recipeCache.get(win);
|
|
|
|
if (recipeMap) {
|
|
recipes = recipeMap.get(aHost);
|
|
|
|
if (recipes) {
|
|
return recipes;
|
|
}
|
|
}
|
|
|
|
log.warn("getRecipes: falling back to a synchronous message for:", aHost);
|
|
recipes = Services.cpmm.sendSyncMessage("PasswordManager:findRecipes", {
|
|
formOrigin: aHost,
|
|
})[0];
|
|
this.cacheRecipes(aHost, win, recipes);
|
|
|
|
return recipes;
|
|
},
|
|
|
|
/**
|
|
* @param {Set} aRecipes - Possible recipes that could apply to the form
|
|
* @param {FormLike} aForm - We use a form instead of just a URL so we can later apply
|
|
* tests to the page contents.
|
|
* @return {Set} a subset of recipes that apply to the form with the order preserved
|
|
*/
|
|
_filterRecipesForForm(aRecipes, aForm) {
|
|
let formDocURL = aForm.ownerDocument.location;
|
|
let hostRecipes = aRecipes;
|
|
let recipes = new Set();
|
|
log.debug("_filterRecipesForForm", aRecipes);
|
|
if (!hostRecipes) {
|
|
return recipes;
|
|
}
|
|
|
|
for (let hostRecipe of hostRecipes) {
|
|
if (
|
|
hostRecipe.pathRegex &&
|
|
!hostRecipe.pathRegex.test(formDocURL.pathname)
|
|
) {
|
|
continue;
|
|
}
|
|
recipes.add(hostRecipe);
|
|
}
|
|
|
|
return recipes;
|
|
},
|
|
|
|
/**
|
|
* Given a set of recipes that apply to the host, choose the one most applicable for
|
|
* overriding login fields in the form.
|
|
*
|
|
* @param {Set} aRecipes The set of recipes to consider for the form
|
|
* @param {FormLike} aForm The form where login fields exist.
|
|
* @return {Object} The recipe that is most applicable for the form.
|
|
*/
|
|
getFieldOverrides(aRecipes, aForm) {
|
|
let recipes = this._filterRecipesForForm(aRecipes, aForm);
|
|
log.debug("getFieldOverrides: filtered recipes:", recipes.size, recipes);
|
|
if (!recipes.size) {
|
|
return null;
|
|
}
|
|
|
|
let chosenRecipe = null;
|
|
// Find the first (most-specific recipe that involves field overrides).
|
|
for (let recipe of recipes) {
|
|
if (
|
|
!recipe.usernameSelector &&
|
|
!recipe.passwordSelector &&
|
|
!recipe.notUsernameSelector &&
|
|
!recipe.notPasswordSelector
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
chosenRecipe = recipe;
|
|
break;
|
|
}
|
|
|
|
return chosenRecipe;
|
|
},
|
|
|
|
/**
|
|
* @param {HTMLElement} aParent the element to query for the selector from.
|
|
* @param {CSSSelector} aSelector the CSS selector to query for the login field.
|
|
* @return {HTMLElement|null}
|
|
*/
|
|
queryLoginField(aParent, aSelector) {
|
|
if (!aSelector) {
|
|
return null;
|
|
}
|
|
let field = aParent.ownerDocument.querySelector(aSelector);
|
|
if (!field) {
|
|
log.debug("Login field selector wasn't matched:", aSelector);
|
|
return null;
|
|
}
|
|
// ownerGlobal doesn't exist in content privileged windows.
|
|
if (
|
|
// eslint-disable-next-line mozilla/use-ownerGlobal
|
|
!(field instanceof aParent.ownerDocument.defaultView.HTMLInputElement)
|
|
) {
|
|
log.warn("Login field isn't an <input> so ignoring it:", aSelector);
|
|
return null;
|
|
}
|
|
return field;
|
|
},
|
|
};
|