Mypal68/browser/components/payments/content/paymentDialogWrapper.js
2023-05-31 17:50:19 +03:00

927 lines
27 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.
*/
/* exported paymentDialogWrapper */
"use strict";
const paymentSrv = Cc[
"@mozilla.org/dom/payments/payment-request-service;1"
].getService(Ci.nsIPaymentRequestService);
const paymentUISrv = Cc[
"@mozilla.org/dom/payments/payment-ui-service;1"
].getService(Ci.nsIPaymentUIService);
const { AppConstants } = ChromeUtils.import(
"resource://gre/modules/AppConstants.jsm"
);
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"BrowserWindowTracker",
"resource:///modules/BrowserWindowTracker.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"FormAutofillUtils",
"resource://formautofill/FormAutofillUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"OSKeyStore",
"resource://formautofill/OSKeyStore.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;
});
XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => {
const brandShortName = FormAutofillUtils.brandBundle.GetStringFromName(
"brandShortName"
);
return FormAutofillUtils.stringBundle.formatStringFromName(
`useCreditCardPasswordPrompt.${AppConstants.platform}`,
[brandShortName]
);
});
/**
* 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 timeLastModified = Date.now();
let recordToSave = Object.assign({ guid, timeLastModified }, record);
await this._formAutofillCollection.computeFields(recordToSave);
this._data[guid] = recordToSave;
return guid;
}
getAll() {
return this._data;
}
}
var paymentDialogWrapper = {
componentsLoaded: new Map(),
frameWeakRef: 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(`Address not found: ${guid}`);
}
let address = this.createPaymentAddress({
addressLines: addressData["street-address"].split("\n"),
city: addressData["address-level2"],
country: addressData.country,
dependentLocality: addressData["address-level3"],
organization: addressData.organization,
phone: addressData.tel,
postalCode: addressData["postal-code"],
recipient: addressData.name,
region: addressData["address-level1"],
// TODO (bug 1474905), The regionCode will be available when bug 1474905 is fixed
// and the region text box is changed to a dropdown with the regionCode being the
// value of the option and the region being the label for the option.
// A regionCode should be either the empty string or one to three code points
// that represent a region as the code element of an [ISO3166-2] country subdivision
// name (i.e., the characters after the hyphen in an ISO3166-2 country subdivision
// code element, such as "CA" for the state of California in the USA, or "11" for
// the Lisbon district of Portugal).
regionCode: "",
});
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 there is 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 OSKeyStore.decrypt(
cardData["cc-number-encrypted"],
reauthPasswordPromptMessage
);
} 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");
}
// 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._attachToFrame(frame);
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);
frame.setAttribute("src", "resource://payments/paymentRequest.xhtml");
this.temporaryStore = {
addresses: new TempCollection("addresses"),
creditCards: new TempCollection("creditCards"),
};
},
uninit() {
try {
Services.obs.removeObserver(this, "message-manager-close");
Services.obs.removeObserver(this, "formautofill-storage-changed");
} catch (ex) {
// Observers may not have been added yet
}
},
/**
* Code here will be re-run at various times, e.g. initial show and
* when a tab is detached to a different window.
*
* Code that should only run once belongs in `init`.
* Code to only run upon detaching should be in `changeAttachedFrame`.
*
* @param {Element} frame
*/
_attachToFrame(frame) {
this.frameWeakRef = Cu.getWeakReference(frame);
this.mm = frame.frameLoader.messageManager;
this.mm.addMessageListener("paymentContentToChrome", this);
Services.obs.addObserver(this, "message-manager-close", true);
},
/**
* Called only when a frame is changed from one to another.
*
* @param {Element} frame
*/
changeAttachedFrame(frame) {
this.mm.removeMessageListener("paymentContentToChrome", this);
this._attachToFrame(frame);
// This isn't in `attachToFrame` because we only want to do it once we've sent records.
Services.obs.addObserver(this, "formautofill-storage-changed", true);
},
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({
addressLines = [],
city = "",
country = "",
dependentLocality = "",
organization = "",
postalCode = "",
phone = "",
recipient = "",
region = "",
regionCode = "",
sortingCode = "",
}) {
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,
regionCode,
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;
},
fetchTempPaymentCards() {
let creditCards = this.temporaryStore.creditCards.getAll();
for (let card of Object.values(creditCards)) {
// Ensure each card has a methodName property.
if (!card.methodName) {
card.methodName = "basic-card";
}
}
return creditCards;
},
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() {
// We don't do this earlier as it's only necessary once this function sends
// the initial saved records.
Services.obs.addObserver(this, "formautofill-storage-changed", true);
let requestSerialized = this._serializeRequest(this.request);
let chromeWindow = this.frameWeakRef.get().ownerGlobal;
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.fetchTempPaymentCards(),
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 { gDevToolsBrowser } = ChromeUtils.import(
"resource://devtools/client/framework/gDevTools.jsm"
);
gDevToolsBrowser.openContentProcessToolbox({
selectedBrowser: this.frameWeakRef.get(),
});
},
onOpenPreferences() {
BrowserWindowTracker.getTopWindow().openPreferences(
"privacy-form-autofill"
);
},
onPaymentCancel() {
const showResponse = this.createShowResponse({
acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_REJECTED,
});
paymentSrv.respondPayment(showResponse);
paymentUISrv.closePayment(this.request.requestId);
},
async onPay({
selectedPayerAddressGUID: payerGUID,
selectedPaymentCardGUID: paymentCardGUID,
selectedPaymentCardSecurityCode: cardSecurityCode,
selectedShippingAddressGUID: shippingGUID,
}) {
let methodData;
try {
methodData = await this._convertProfileBasicCardToPaymentMethodData(
paymentCardGUID,
cardSecurityCode
);
} catch (ex) {
// TODO (Bug 1498403): Some kind of "credit card storage error" here, perhaps asking user
// to re-enter credit card # from management UI.
Cu.reportError(ex);
return;
}
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 = "";
let payerEmail = "";
let payerPhone = "";
if (payerGUID) {
let payerData = await this._convertProfileAddressToPayerData(payerGUID);
payerName = payerData.payerName;
payerEmail = payerData.payerEmail;
payerPhone = payerData.payerPhone;
}
// Update the lastUsedTime for the payerAddress and paymentCard. Check if
// the record exists in formAutofillStorage because it may be temporary.
if (
shippingGUID &&
(await formAutofillStorage.addresses.get(shippingGUID))
) {
formAutofillStorage.addresses.notifyUsed(shippingGUID);
}
if (payerGUID && (await formAutofillStorage.addresses.get(payerGUID))) {
formAutofillStorage.addresses.notifyUsed(payerGUID);
}
if (await formAutofillStorage.creditCards.get(paymentCardGUID)) {
formAutofillStorage.creditCards.notifyUsed(paymentCardGUID);
}
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 onChangePayerAddress({ payerAddressGUID }) {
if (payerAddressGUID) {
// If a payer address was de-selected e.g. the selected address was deleted, we'll
// just wait to send the address change when the payer address is eventually selected
// before clicking Pay since it's a required field.
let {
payerName,
payerEmail,
payerPhone,
} = await this._convertProfileAddressToPayerData(payerAddressGUID);
paymentSrv.changePayerDetail(
this.request.requestId,
payerName,
payerEmail,
payerPhone
);
}
},
async onChangePaymentMethod({
selectedPaymentCardBillingAddressGUID: billingAddressGUID,
}) {
const methodName = "basic-card";
let methodDetails;
try {
let billingAddress = await this._convertProfileAddressToPaymentAddress(
billingAddressGUID
);
const basicCardChangeDetails = Cc[
"@mozilla.org/dom/payments/basiccard-change-details;1"
].createInstance(Ci.nsIBasicCardChangeDetails);
basicCardChangeDetails.initData(billingAddress);
methodDetails = basicCardChangeDetails.QueryInterface(
Ci.nsIMethodChangeDetails
);
} catch (ex) {
// TODO (Bug 1498403): Some kind of "credit card storage error" here, perhaps asking user
// to re-enter credit card # from management UI.
Cu.reportError(ex);
return;
}
paymentSrv.changePaymentMethod(
this.request.requestId,
methodName,
methodDetails
);
},
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
paymentUISrv.closePayment(this.request.requestId);
},
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) {
// We want to preserve old properties since the edit forms are often
// shown without all fields visible/enabled and we don't want those
// fields to be blanked upon saving. Examples of hidden/disabled fields:
// email, cc-number, mailing-address on the payer forms, and payer fields
// not requested in the payer form.
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.fetchTempPaymentCards(),
});
}
} catch (ex) {
responseMessage.error = true;
Cu.reportError(ex);
} finally {
this.sendMessageToContent(
"updateAutofillRecord:Response",
responseMessage
);
}
},
/**
* @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;
}
case "message-manager-close": {
if (this.mm && subject == this.mm) {
// 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;
}
}
},
receiveMessage({ data }) {
let { messageType } = data;
switch (messageType) {
case "debugFrame": {
this.debugFrame();
break;
}
case "initializeRequest": {
this.initializeFrame();
break;
}
case "changePayerAddress": {
this.onChangePayerAddress(data);
break;
}
case "changePaymentMethod": {
this.onChangePaymentMethod(data);
break;
}
case "changeShippingAddress": {
this.onChangeShippingAddress(data);
break;
}
case "changeShippingOption": {
this.onChangeShippingOption(data);
break;
}
case "closeDialog": {
this.onCloseDialogMessage();
break;
}
case "openPreferences": {
this.onOpenPreferences();
break;
}
case "paymentCancel": {
this.onPaymentCancel();
break;
}
case "paymentDialogReady": {
this.frameWeakRef.get().dispatchEvent(
new Event("tabmodaldialogready", {
bubbles: true,
})
);
break;
}
case "pay": {
this.onPay(data);
break;
}
case "updateAutofillRecord": {
this.onUpdateAutofillRecord(
data.collectionName,
data.record,
data.guid,
data.messageID
);
break;
}
default: {
throw new Error(
`paymentDialogWrapper: Unexpected messageType: ${messageType}`
);
}
}
},
};