/* 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, "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; }); /** * 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(`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"], dependentLocality: addressData["address-level3"], 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 OSKeyStore.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 = "", regionCode = "", 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, 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; }, 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, }); }, onOpenPreferences() { let prefsURL = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString); prefsURL.data = "about:preferences#privacy-form-autofill"; Services.ww.openWindow(null, AppConstants.BROWSER_CHROME_URL, "_blank", "chrome,all,dialog=no", prefsURL); }, 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 "openPreferences": { this.onOpenPreferences(); 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); }