fune/browser/components/payments/content/paymentDialogWrapper.js
Timothy Guan-tin Chien a3d7d3c3d1 Bug 1399367 - Remove MasterPassword.{encrypt|decrypt}Sync() methods r=MattN
This also makes various AutofillRecords methods async, with the exception of
remove() and removeAll().

Noted that I didn't implement any kind of "lock" for FormAutofillStorage --
please do not call these methods concurrently -- if you must please |await|
for the last call to resolve. This most likely would happen in tests, and
shouldn't happen in the real world, given that all user actions happen on
macrotasks, and probably not at the next tick, unless Quicksilver is a
Firefox user.

FormAutofillStorage can be improved if there are complex use cases for it.

Differential Revision: https://phabricator.services.mozilla.com/D4420

--HG--
extra : moz-landing-system : lando
2018-09-05 00:32:57 +00:00

683 lines
21 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/. */
/**
* Runs in the privileged outer dialog. Each dialog loads this script in its
* own scope.
*/
"use strict";
const paymentSrv = Cc["@mozilla.org/dom/payments/payment-request-service;1"]
.getService(Ci.nsIPaymentRequestService);
ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.defineModuleGetter(this, "MasterPassword",
"resource://formautofill/MasterPassword.jsm");
ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
XPCOMUtils.defineLazyGetter(this, "formAutofillStorage", () => {
let storage;
try {
storage = ChromeUtils.import("resource://formautofill/FormAutofillStorage.jsm", {})
.formAutofillStorage;
storage.initialize();
} catch (ex) {
storage = null;
Cu.reportError(ex);
}
return storage;
});
/**
* Temporary/transient storage for address and credit card records
*
* Implements a subset of the FormAutofillStorage collection class interface, and delegates to
* those classes for some utility methods
*/
class TempCollection {
constructor(type, data = {}) {
/**
* The name of the collection. e.g. 'addresses' or 'creditCards'
* Used to access methods from the FormAutofillStorage collections
*/
this._type = type;
this._data = data;
}
get _formAutofillCollection() {
// lazy getter for the formAutofill collection - to resolve on first access
Object.defineProperty(this, "_formAutofillCollection", {
value: formAutofillStorage[this._type], writable: false, configurable: true,
});
return this._formAutofillCollection;
}
get(guid) {
return this._data[guid];
}
async update(guid, record, preserveOldProperties) {
let recordToSave = Object.assign(preserveOldProperties ? this._data[guid] : {}, record);
await this._formAutofillCollection.computeFields(recordToSave);
return (this._data[guid] = recordToSave);
}
async add(record) {
let guid = "temp-" + Math.abs(Math.random() * 0xffffffff|0);
let recordToSave = Object.assign({guid}, record);
await this._formAutofillCollection.computeFields(recordToSave);
this._data[guid] = recordToSave;
return guid;
}
getAll() {
return this._data;
}
}
var paymentDialogWrapper = {
componentsLoaded: new Map(),
frame: null,
mm: null,
request: null,
temporaryStore: null,
QueryInterface: ChromeUtils.generateQI([
Ci.nsIObserver,
Ci.nsISupportsWeakReference,
]),
/**
* @param {string} guid
* @returns {object} containing only the requested payer values.
*/
async _convertProfileAddressToPayerData(guid) {
let addressData = this.temporaryStore.addresses.get(guid) ||
await formAutofillStorage.addresses.get(guid);
if (!addressData) {
throw new Error(`Payer address not found: ${guid}`);
}
let {
requestPayerName,
requestPayerEmail,
requestPayerPhone,
} = this.request.paymentOptions;
let payerData = {
payerName: requestPayerName ? addressData.name : "",
payerEmail: requestPayerEmail ? addressData.email : "",
payerPhone: requestPayerPhone ? addressData.tel : "",
};
return payerData;
},
/**
* @param {string} guid
* @returns {nsIPaymentAddress}
*/
async _convertProfileAddressToPaymentAddress(guid) {
let addressData = this.temporaryStore.addresses.get(guid) ||
await formAutofillStorage.addresses.get(guid);
if (!addressData) {
throw new Error(`Shipping address not found: ${guid}`);
}
let address = this.createPaymentAddress({
country: addressData.country,
addressLines: addressData["street-address"].split("\n"),
region: addressData["address-level1"],
city: addressData["address-level2"],
postalCode: addressData["postal-code"],
organization: addressData.organization,
recipient: addressData.name,
phone: addressData.tel,
});
return address;
},
/**
* @param {string} guid The GUID of the basic card record from storage.
* @param {string} cardSecurityCode The associated card security code (CVV/CCV/etc.)
* @throws if the user cancels entering their master password or an error decrypting
* @returns {nsIBasicCardResponseData?} returns response data or null (if the
* master password dialog was cancelled);
*/
async _convertProfileBasicCardToPaymentMethodData(guid, cardSecurityCode) {
let cardData = this.temporaryStore.creditCards.get(guid) ||
await formAutofillStorage.creditCards.get(guid);
if (!cardData) {
throw new Error(`Basic card not found in storage: ${guid}`);
}
let cardNumber;
try {
cardNumber = await MasterPassword.decrypt(cardData["cc-number-encrypted"], true);
} catch (ex) {
if (ex.result != Cr.NS_ERROR_ABORT) {
throw ex;
}
// User canceled master password entry
return null;
}
let billingAddressGUID = cardData.billingAddressGUID;
let billingAddress;
try {
billingAddress = await this._convertProfileAddressToPaymentAddress(billingAddressGUID);
} catch (ex) {
// The referenced address may not exist if it was deleted or hasn't yet synced to this profile
Cu.reportError(ex);
}
let methodData = this.createBasicCardResponseData({
cardholderName: cardData["cc-name"],
cardNumber,
expiryMonth: cardData["cc-exp-month"].toString().padStart(2, "0"),
expiryYear: cardData["cc-exp-year"].toString(),
cardSecurityCode,
billingAddress,
});
return methodData;
},
init(requestId, frame) {
if (!requestId || typeof(requestId) != "string") {
throw new Error("Invalid PaymentRequest ID");
}
window.addEventListener("unload", this);
// The Request object returned by the Payment Service is live and
// will automatically get updated if event.updateWith is used.
this.request = paymentSrv.getPaymentRequestById(requestId);
if (!this.request) {
throw new Error(`PaymentRequest not found: ${requestId}`);
}
this.frame = frame;
this.mm = frame.frameLoader.messageManager;
this.mm.addMessageListener("paymentContentToChrome", this);
this.mm.loadFrameScript("chrome://payments/content/paymentDialogFrameScript.js", true);
// Until we have bug 1446164 and bug 1407418 we use form autofill's temporary
// shim for data-localization* attributes.
this.mm.loadFrameScript("chrome://formautofill/content/l10n.js", true);
if (AppConstants.platform == "win") {
this.frame.setAttribute("selectmenulist", "ContentSelectDropdown-windows");
}
this.frame.loadURI("resource://payments/paymentRequest.xhtml");
this.temporaryStore = {
addresses: new TempCollection("addresses"),
creditCards: new TempCollection("creditCards"),
};
},
createShowResponse({
acceptStatus,
methodName = "",
methodData = null,
payerName = "",
payerEmail = "",
payerPhone = "",
}) {
let showResponse = this.createComponentInstance(Ci.nsIPaymentShowActionResponse);
showResponse.init(this.request.requestId,
acceptStatus,
methodName,
methodData,
payerName,
payerEmail,
payerPhone);
return showResponse;
},
createBasicCardResponseData({
cardholderName = "",
cardNumber,
expiryMonth = "",
expiryYear = "",
cardSecurityCode = "",
billingAddress = null,
}) {
const basicCardResponseData = Cc["@mozilla.org/dom/payments/basiccard-response-data;1"]
.createInstance(Ci.nsIBasicCardResponseData);
basicCardResponseData.initData(cardholderName,
cardNumber,
expiryMonth,
expiryYear,
cardSecurityCode,
billingAddress);
return basicCardResponseData;
},
createPaymentAddress({
country = "",
addressLines = [],
region = "",
city = "",
dependentLocality = "",
postalCode = "",
sortingCode = "",
organization = "",
recipient = "",
phone = "",
}) {
const paymentAddress = Cc["@mozilla.org/dom/payments/payment-address;1"]
.createInstance(Ci.nsIPaymentAddress);
const addressLine = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
for (let line of addressLines) {
const address = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
address.data = line;
addressLine.appendElement(address);
}
paymentAddress.init(country,
addressLine,
region,
city,
dependentLocality,
postalCode,
sortingCode,
organization,
recipient,
phone);
return paymentAddress;
},
createComponentInstance(componentInterface) {
let componentName;
switch (componentInterface) {
case Ci.nsIPaymentShowActionResponse: {
componentName = "@mozilla.org/dom/payments/payment-show-action-response;1";
break;
}
case Ci.nsIGeneralResponseData: {
componentName = "@mozilla.org/dom/payments/general-response-data;1";
break;
}
}
let component = this.componentsLoaded.get(componentName);
if (!component) {
component = Cc[componentName];
this.componentsLoaded.set(componentName, component);
}
return component.createInstance(componentInterface);
},
async fetchSavedAddresses() {
let savedAddresses = {};
for (let address of await formAutofillStorage.addresses.getAll()) {
savedAddresses[address.guid] = address;
}
return savedAddresses;
},
async fetchSavedPaymentCards() {
let savedBasicCards = {};
for (let card of await formAutofillStorage.creditCards.getAll()) {
savedBasicCards[card.guid] = card;
// Filter out the encrypted card number since the dialog content is
// considered untrusted and runs in a content process.
delete card["cc-number-encrypted"];
// ensure each card has a methodName property
if (!card.methodName) {
card.methodName = "basic-card";
}
}
return savedBasicCards;
},
async onAutofillStorageChange() {
let [savedAddresses, savedBasicCards] =
await Promise.all([this.fetchSavedAddresses(), this.fetchSavedPaymentCards()]);
this.sendMessageToContent("updateState", {
savedAddresses,
savedBasicCards,
});
},
sendMessageToContent(messageType, data = {}) {
this.mm.sendAsyncMessage("paymentChromeToContent", {
data,
messageType,
});
},
updateRequest() {
// There is no need to update this.request since the object is live
// and will automatically get updated if event.updateWith is used.
let requestSerialized = this._serializeRequest(this.request);
this.sendMessageToContent("updateState", {
request: requestSerialized,
});
},
/**
* Recursively convert and filter input to the subset of data types supported by JSON
*
* @param {*} value - any type of input to serialize
* @param {string?} name - name or key associated with this input.
* E.g. property name or array index.
* @returns {*} serialized deep copy of the value
*/
_serializeRequest(value, name = null) {
// Primitives: String, Number, Boolean, null
let type = typeof value;
if (value === null ||
type == "string" ||
type == "number" ||
type == "boolean") {
return value;
}
if (name == "topLevelPrincipal") {
// Manually serialize the nsIPrincipal.
let displayHost = value.URI.displayHost;
return {
URI: {
displayHost,
},
};
}
if (type == "function" || type == "undefined") {
return undefined;
}
// Structures: nsIArray
if (value instanceof Ci.nsIArray) {
let iface;
let items = [];
switch (name) {
case "displayItems": // falls through
case "additionalDisplayItems":
iface = Ci.nsIPaymentItem;
break;
case "shippingOptions":
iface = Ci.nsIPaymentShippingOption;
break;
case "paymentMethods":
iface = Ci.nsIPaymentMethodData;
break;
case "modifiers":
iface = Ci.nsIPaymentDetailsModifier;
break;
}
if (!iface) {
throw new Error(`No interface associated with the members of the ${name} nsIArray`);
}
for (let i = 0; i < value.length; i++) {
let item = value.queryElementAt(i, iface);
let result = this._serializeRequest(item, i);
if (result !== undefined) {
items.push(result);
}
}
return items;
}
// Structures: Arrays
if (Array.isArray(value)) {
let items = value.map(item => { this._serializeRequest(item); })
.filter(item => item !== undefined);
return items;
}
// Structures: Objects
let obj = {};
for (let [key, item] of Object.entries(value)) {
let result = this._serializeRequest(item, key);
if (result !== undefined) {
obj[key] = result;
}
}
return obj;
},
async initializeFrame() {
Services.obs.addObserver(this, "formautofill-storage-changed", true);
let requestSerialized = this._serializeRequest(this.request);
let chromeWindow = Services.wm.getMostRecentWindow("navigator:browser");
let isPrivate = PrivateBrowsingUtils.isWindowPrivate(chromeWindow);
let [savedAddresses, savedBasicCards] =
await Promise.all([this.fetchSavedAddresses(), this.fetchSavedPaymentCards()]);
this.sendMessageToContent("showPaymentRequest", {
request: requestSerialized,
savedAddresses,
tempAddresses: this.temporaryStore.addresses.getAll(),
savedBasicCards,
tempBasicCards: this.temporaryStore.creditCards.getAll(),
isPrivate,
});
},
debugFrame() {
// To avoid self-XSS-type attacks, ensure that Browser Chrome debugging is enabled.
if (!Services.prefs.getBoolPref("devtools.chrome.enabled", false)) {
Cu.reportError("devtools.chrome.enabled must be enabled to debug the frame");
return;
}
let chromeWindow = Services.wm.getMostRecentWindow(null);
let {
gDevToolsBrowser,
} = ChromeUtils.import("resource://devtools/client/framework/gDevTools.jsm", {});
gDevToolsBrowser.openContentProcessToolbox({
selectedBrowser: chromeWindow.document.getElementById("paymentRequestFrame").frameLoader,
});
},
onPaymentCancel() {
const showResponse = this.createShowResponse({
acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_REJECTED,
});
paymentSrv.respondPayment(showResponse);
window.close();
},
async onPay({
selectedPayerAddressGUID: payerGUID,
selectedPaymentCardGUID: paymentCardGUID,
selectedPaymentCardSecurityCode: cardSecurityCode,
}) {
let methodData = await this._convertProfileBasicCardToPaymentMethodData(paymentCardGUID,
cardSecurityCode);
if (!methodData) {
// TODO (Bug 1429265/Bug 1429205): Handle when a user hits cancel on the
// Master Password dialog.
Cu.reportError("Bug 1429265/Bug 1429205: User canceled master password entry");
return;
}
let {
payerName,
payerEmail,
payerPhone,
} = await this._convertProfileAddressToPayerData(payerGUID);
this.pay({
methodName: "basic-card",
methodData,
payerName,
payerEmail,
payerPhone,
});
},
pay({
payerName,
payerEmail,
payerPhone,
methodName,
methodData,
}) {
const showResponse = this.createShowResponse({
acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_ACCEPTED,
payerName,
payerEmail,
payerPhone,
methodName,
methodData,
});
paymentSrv.respondPayment(showResponse);
this.sendMessageToContent("responseSent");
},
async onChangeShippingAddress({shippingAddressGUID}) {
if (shippingAddressGUID) {
// If a shipping address was de-selected e.g. the selected address was deleted, we'll
// just wait to send the address change when the shipping address is eventually selected
// before clicking Pay since it's a required field.
let address = await this._convertProfileAddressToPaymentAddress(shippingAddressGUID);
paymentSrv.changeShippingAddress(this.request.requestId, address);
}
},
onChangeShippingOption({optionID}) {
// Note, failing here on browser_host_name.js because the test closes
// the dialog before the onChangeShippingOption is called, thus
// deleting the request and making the requestId invalid. Unclear
// why we aren't seeing the same issue with onChangeShippingAddress.
paymentSrv.changeShippingOption(this.request.requestId, optionID);
},
onCloseDialogMessage() {
// The PR is complete(), just close the dialog
window.close();
},
async onUpdateAutofillRecord(collectionName, record, guid, messageID) {
let responseMessage = {
guid,
messageID,
stateChange: {},
};
try {
let isTemporary = record.isTemporary;
let collection = isTemporary ? this.temporaryStore[collectionName] :
formAutofillStorage[collectionName];
if (guid) {
let preserveOldProperties = true;
await collection.update(guid, record, preserveOldProperties);
} else {
responseMessage.guid = await collection.add(record);
}
if (isTemporary && collectionName == "addresses") {
// there will be no formautofill-storage-changed event to update state
// so add updated collection here
Object.assign(responseMessage.stateChange, {
tempAddresses: this.temporaryStore.addresses.getAll(),
});
}
if (isTemporary && collectionName == "creditCards") {
// there will be no formautofill-storage-changed event to update state
// so add updated collection here
Object.assign(responseMessage.stateChange, {
tempBasicCards: this.temporaryStore.creditCards.getAll(),
});
}
} catch (ex) {
responseMessage.error = true;
} finally {
this.sendMessageToContent("updateAutofillRecord:Response", responseMessage);
}
},
/**
* @implement {nsIDOMEventListener}
* @param {Event} event
*/
handleEvent(event) {
switch (event.type) {
case "unload": {
// Remove the observer to avoid message manager errors while the dialog
// is closing and tests are cleaning up autofill storage.
Services.obs.removeObserver(this, "formautofill-storage-changed");
break;
}
default: {
throw new Error("Unexpected event handled");
}
}
},
/**
* @implements {nsIObserver}
* @param {nsISupports} subject
* @param {string} topic
* @param {string} data
*/
observe(subject, topic, data) {
switch (topic) {
case "formautofill-storage-changed": {
if (data == "notifyUsed") {
break;
}
this.onAutofillStorageChange();
break;
}
}
},
receiveMessage({data}) {
let {messageType} = data;
switch (messageType) {
case "debugFrame": {
this.debugFrame();
break;
}
case "initializeRequest": {
this.initializeFrame();
break;
}
case "changeShippingAddress": {
this.onChangeShippingAddress(data);
break;
}
case "changeShippingOption": {
this.onChangeShippingOption(data);
break;
}
case "closeDialog": {
this.onCloseDialogMessage();
break;
}
case "paymentCancel": {
this.onPaymentCancel();
break;
}
case "pay": {
this.onPay(data);
break;
}
case "updateAutofillRecord": {
this.onUpdateAutofillRecord(data.collectionName, data.record, data.guid, data.messageID);
break;
}
}
},
};
if ("document" in this) {
// Running in a browser, not a unit test
let frame = document.getElementById("paymentRequestFrame");
let requestId = (new URLSearchParams(window.location.search)).get("requestId");
paymentDialogWrapper.init(requestId, frame);
}