From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../default/FormAutofillPrompter.sys.mjs | 677 +++++++++++++++++++++ .../default/FormAutofillStorage.sys.mjs | 274 +++++++++ 2 files changed, 951 insertions(+) create mode 100644 toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs create mode 100644 toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs (limited to 'toolkit/components/formautofill/default') diff --git a/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs new file mode 100644 index 0000000000..31975cd968 --- /dev/null +++ b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs @@ -0,0 +1,677 @@ +/* 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/. */ + +/* + * Implements doorhanger singleton that wraps up the PopupNotifications and handles + * the doorhager UI for formautofill related features. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const { AutofillTelemetry } = ChromeUtils.import( + "resource://autofill/AutofillTelemetry.jsm" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => + FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter") +); + +const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill; + +const GetStringFromName = FormAutofillUtils.stringBundle.GetStringFromName; +const formatStringFromName = + FormAutofillUtils.stringBundle.formatStringFromName; +const brandShortName = + FormAutofillUtils.brandBundle.GetStringFromName("brandShortName"); +let changeAutofillOptsKey = "changeAutofillOptions"; +let autofillOptsKey = "autofillOptionsLink"; +if (AppConstants.platform == "macosx") { + changeAutofillOptsKey += "OSX"; + autofillOptsKey += "OSX"; +} + +const CONTENT = { + addFirstTimeUse: { + notificationId: "autofill-address", + message: formatStringFromName("saveAddressesMessage", [brandShortName]), + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName(changeAutofillOptsKey), + accessKey: GetStringFromName("changeAutofillOptionsAccessKey"), + callbackState: "open-pref", + }, + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-save.svg", + checkbox: { + get checked() { + return Services.prefs.getBoolPref("services.sync.engine.addresses"); + }, + get label() { + // If sync account is not set, return null label to hide checkbox + return Services.prefs.prefHasUserValue("services.sync.username") + ? GetStringFromName("addressesSyncCheckbox") + : null; + }, + callback(event) { + let checked = event.target.checked; + Services.prefs.setBoolPref("services.sync.engine.addresses", checked); + lazy.log.debug("Set addresses sync to", checked); + }, + }, + hideClose: true, + }, + }, + addAddress: { + notificationId: "autofill-address", + message: formatStringFromName("saveAddressesMessage", [brandShortName]), + descriptionLabel: GetStringFromName("saveAddressDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-address-autofill", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("saveAddressLabel"), + accessKey: GetStringFromName("saveAddressAccessKey"), + callbackState: "create", + }, + secondaryActions: [ + { + label: GetStringFromName("cancelAddressLabel"), + accessKey: GetStringFromName("cancelAddressAccessKey"), + callbackState: "cancel", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-update.svg", + hideClose: true, + }, + }, + updateAddress: { + notificationId: "autofill-address", + message: GetStringFromName("updateAddressMessage"), + descriptionLabel: GetStringFromName("updateAddressNewDescriptionLabel"), + additionalDescriptionLabel: GetStringFromName( + "updateAddressOldDescriptionLabel" + ), + descriptionIcon: false, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-address-autofill", + anchor: { + id: "autofill-address-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("updateAddressLabel"), + accessKey: GetStringFromName("updateAddressAccessKey"), + callbackState: "update", + }, + secondaryActions: [ + { + label: GetStringFromName("createAddressLabel"), + accessKey: GetStringFromName("createAddressAccessKey"), + callbackState: "create", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-address-update.svg", + hideClose: true, + }, + }, + addCreditCard: { + notificationId: "autofill-credit-card", + message: formatStringFromName("saveCreditCardMessage", [brandShortName]), + descriptionLabel: GetStringFromName("saveCreditCardDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-credit-card-autofill", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("saveCreditCardLabel"), + accessKey: GetStringFromName("saveCreditCardAccessKey"), + callbackState: "save", + }, + secondaryActions: [ + { + label: GetStringFromName("cancelCreditCardLabel"), + accessKey: GetStringFromName("cancelCreditCardAccessKey"), + callbackState: "cancel", + }, + { + label: GetStringFromName("neverSaveCreditCardLabel"), + accessKey: GetStringFromName("neverSaveCreditCardAccessKey"), + callbackState: "disable", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + checkbox: { + get checked() { + return Services.prefs.getBoolPref("services.sync.engine.creditcards"); + }, + get label() { + // Only set the label when the fallowing conditions existed: + // - sync account is set + // - credit card sync is disabled + // - credit card sync is available + // otherwise return null label to hide checkbox. + return Services.prefs.prefHasUserValue("services.sync.username") && + !Services.prefs.getBoolPref("services.sync.engine.creditcards") && + Services.prefs.getBoolPref( + "services.sync.engine.creditcards.available" + ) + ? GetStringFromName("creditCardsSyncCheckbox") + : null; + }, + callback(event) { + let { secondaryButton, menubutton } = + event.target.closest("popupnotification"); + let checked = event.target.checked; + Services.prefs.setBoolPref( + "services.sync.engine.creditcards", + checked + ); + secondaryButton.disabled = checked; + menubutton.disabled = checked; + lazy.log.debug("Set creditCard sync to", checked); + }, + }, + }, + }, + updateCreditCard: { + notificationId: "autofill-credit-card", + message: GetStringFromName("updateCreditCardMessage"), + descriptionLabel: GetStringFromName("updateCreditCardDescriptionLabel"), + descriptionIcon: true, + linkMessage: GetStringFromName(autofillOptsKey), + spotlightURL: "about:preferences#privacy-credit-card-autofill", + anchor: { + id: "autofill-credit-card-notification-icon", + URL: "chrome://formautofill/content/formfill-anchor.svg", + tooltiptext: GetStringFromName("openAutofillMessagePanel"), + }, + mainAction: { + label: GetStringFromName("updateCreditCardLabel"), + accessKey: GetStringFromName("updateCreditCardAccessKey"), + callbackState: "update", + }, + secondaryActions: [ + { + label: GetStringFromName("createCreditCardLabel"), + accessKey: GetStringFromName("createCreditCardAccessKey"), + callbackState: "create", + }, + ], + options: { + persistWhileVisible: true, + popupIconURL: "chrome://formautofill/content/icon-credit-card.svg", + hideClose: true, + }, + }, +}; + +export let FormAutofillPrompter = { + /** + * Generate the main action and secondary actions from content parameters and + * promise resolve. + * + * @private + * @param {object} mainActionParams + * Parameters for main action. + * @param {Array} secondaryActionParams + * Array of the parameters for secondary actions. + * @param {Function} resolve Should be called in action callback. + * @returns {Array} + Return the mainAction and secondary actions in an array for showing doorhanger + */ + _createActions(mainActionParams, secondaryActionParams, resolve) { + if (!mainActionParams) { + return [null, null]; + } + + let { label, accessKey, callbackState } = mainActionParams; + let callback = resolve.bind(null, callbackState); + let mainAction = { label, accessKey, callback }; + + if (!secondaryActionParams) { + return [mainAction, null]; + } + + let secondaryActions = []; + for (let params of secondaryActionParams) { + let cb = resolve.bind(null, params.callbackState); + secondaryActions.push({ + label: params.label, + accessKey: params.accessKey, + callback: cb, + }); + } + + return [mainAction, secondaryActions]; + }, + _getNotificationElm(browser, id) { + let notificationId = id + "-notification"; + let chromeDoc = browser.ownerDocument; + return chromeDoc.getElementById(notificationId); + }, + /** + * Append the link label element to the popupnotificationcontent. + * + * @param {XULElement} content + * popupnotificationcontent + * @param {string} message + * The localized string for link title. + * @param {string} link + * Makes it possible to open and highlight a section in preferences + */ + _appendPrivacyPanelLink(content, message, link) { + let chromeDoc = content.ownerDocument; + let privacyLinkElement = chromeDoc.createXULElement("label", { + is: "text-link", + }); + privacyLinkElement.setAttribute("useoriginprincipal", true); + privacyLinkElement.setAttribute( + "href", + link || "about:preferences#privacy-form-autofill" + ); + privacyLinkElement.setAttribute("value", message); + content.appendChild(privacyLinkElement); + }, + + /** + * Append the description section to the popupnotificationcontent. + * + * @param {XULElement} content + * popupnotificationcontent + * @param {string} descriptionLabel + * The label showing above description. + * @param {string} descriptionIcon + * The src of description icon. + * @param {string} descriptionId + * The id of description + */ + _appendDescription( + content, + descriptionLabel, + descriptionIcon, + descriptionId + ) { + let chromeDoc = content.ownerDocument; + let docFragment = chromeDoc.createDocumentFragment(); + + let descriptionLabelElement = chromeDoc.createXULElement("label"); + descriptionLabelElement.setAttribute("value", descriptionLabel); + docFragment.appendChild(descriptionLabelElement); + + let descriptionWrapper = chromeDoc.createXULElement("hbox"); + descriptionWrapper.className = "desc-message-box"; + + if (descriptionIcon) { + let descriptionIconElement = chromeDoc.createXULElement("image"); + if ( + typeof descriptionIcon == "string" && + (descriptionIcon.includes("cc-logo") || + descriptionIcon.includes("icon-credit")) + ) { + descriptionIconElement.setAttribute("src", descriptionIcon); + } + descriptionWrapper.appendChild(descriptionIconElement); + } + + let descriptionElement = chromeDoc.createXULElement(descriptionId); + descriptionWrapper.appendChild(descriptionElement); + docFragment.appendChild(descriptionWrapper); + + content.appendChild(docFragment); + }, + + _updateDescription(content, descriptionId, description) { + let element = content.querySelector(descriptionId); + element.textContent = description; + }, + + /** + * Create an image element for notification anchor if it doesn't already exist. + * + * @param {XULElement} browser + * Target browser element for showing doorhanger. + * @param {object} anchor + * Anchor options for setting the anchor element. + * @param {string} anchor.id + * ID of the anchor element. + * @param {string} anchor.URL + * Path of the icon asset. + * @param {string} anchor.tooltiptext + * Tooltip string for the anchor. + */ + _setAnchor(browser, anchor) { + let chromeDoc = browser.ownerDocument; + let { id, URL, tooltiptext } = anchor; + let anchorEt = chromeDoc.getElementById(id); + if (!anchorEt) { + let notificationPopupBox = chromeDoc.getElementById( + "notification-popup-box" + ); + // Icon shown on URL bar + let anchorElement = chromeDoc.createXULElement("image"); + anchorElement.id = id; + anchorElement.setAttribute("src", URL); + anchorElement.classList.add("notification-anchor-icon"); + anchorElement.setAttribute("role", "button"); + anchorElement.setAttribute("tooltiptext", tooltiptext); + notificationPopupBox.appendChild(anchorElement); + } + }, + _addCheckboxListener(browser, { notificationId, options }) { + if (!options.checkbox) { + return; + } + let { checkbox } = this._getNotificationElm(browser, notificationId); + + if (checkbox && !checkbox.hidden) { + checkbox.addEventListener("command", options.checkbox.callback); + } + }, + + _removeCheckboxListener(browser, { notificationId, options }) { + if (!options.checkbox) { + return; + } + let { checkbox } = this._getNotificationElm(browser, notificationId); + + if (checkbox && !checkbox.hidden) { + checkbox.removeEventListener("command", options.checkbox.callback); + } + }, + + /** + * Show save or update address doorhanger + * + * @param {Element} browser Browser to show the save/update address prompt + * @param {object} storage Address storage + * @param {object} newRecord Address record to save + * @param {string} flowId Unique GUID to record a series of the same user action + * @param {object} options + * @param {object} [options.mergeableRecord] Record to be merged + * @param {Array} [options.mergeableFields] List of field name that can be merged + */ + async promptToSaveAddress( + browser, + storage, + newRecord, + flowId, + { mergeableRecord, mergeableFields } + ) { + // Overwrite the guid if there is a duplicate + let doorhangerType; + if (mergeableRecord) { + doorhangerType = "updateAddress"; + } else if (FormAutofill.isAutofillAddressesCaptureV2Enabled) { + doorhangerType = "addAddress"; + } else { + doorhangerType = "addFirstTimeUse"; + this._updateStorageAfterInteractWithPrompt("save", storage, newRecord); + + // Show first time use doorhanger + if (FormAutofill.isAutofillAddressesFirstTimeUse) { + Services.prefs.setBoolPref( + FormAutofill.ADDRESSES_FIRST_TIME_USE_PREF, + false + ); + } else { + return; + } + } + + const description = FormAutofillUtils.getAddressLabel(newRecord); + const additionalDescription = mergeableRecord + ? FormAutofillUtils.getAddressLabel(mergeableRecord) + : null; + + const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger( + browser, + doorhangerType, + description, + flowId, + { additionalDescription } + ); + + if (state == "cancel") { + return; + } else if (state == "open-pref") { + browser.ownerGlobal.openPreferences("privacy-address-autofill"); + return; + } + + this._updateStorageAfterInteractWithPrompt( + state, + storage, + newRecord, + mergeableRecord?.guid + ); + }, + + async promptToSaveCreditCard(browser, storage, record, flowId) { + // Overwrite the guid if there is a duplicate + let doorhangerType; + const duplicateRecord = (await storage.getDuplicateRecords(record).next()) + .value; + if (duplicateRecord) { + doorhangerType = "updateCreditCard"; + } else { + doorhangerType = "addCreditCard"; + } + + const number = record["cc-number"] || record["cc-number-decrypted"]; + const name = record["cc-name"]; + const network = lazy.CreditCard.getType(number); + const maskedNumber = lazy.CreditCard.getMaskedNumber(number); + const description = `${maskedNumber}` + (name ? `, ${name}` : ``); + const descriptionIcon = lazy.CreditCard.getCreditCardLogo(network); + + const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger( + browser, + doorhangerType, + description, + flowId, + { descriptionIcon } + ); + + if (state == "cancel") { + return; + } else if (state == "disable") { + Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, false); + return; + } + + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + lazy.log.warn("User canceled encryption login"); + return; + } + + this._updateStorageAfterInteractWithPrompt( + state, + storage, + record, + duplicateRecord?.guid + ); + }, + + async _updateStorageAfterInteractWithPrompt( + state, + storage, + record, + guid = null + ) { + let changedGUID = null; + if (state == "create" || state == "save") { + changedGUID = await storage.add(record); + } else if (state == "update") { + await storage.update(guid, record, true); + changedGUID = guid; + } + storage.notifyUsed(changedGUID); + }, + + _getUpdatedCCIcon(network) { + return FormAutofillUtils.getCreditCardLogo(network); + }, + + /** + * Show different types of doorhanger by leveraging PopupNotifications. + * + * @param {XULElement} browser Target browser element for showing doorhanger. + * @param {string} type The type of the doorhanger. There will have first time use/update/credit card. + * @param {string} description The message that provides more information on doorhanger. + * @param {string} flowId guid used to correlate events relating to the same form + * @param {object} [options = {}] a list of options for this method + * @param {string} options.descriptionIcon The icon for descriotion + * @param {string} options.additionalDescription The message that provides more information on doorhanger. + * @returns {Promise} Resolved with action type when action callback is triggered. + */ + async _showCCorAddressCaptureDoorhanger( + browser, + type, + description, + flowId, + { descriptionIcon = null, additionalDescription = null } + ) { + const telemetryType = type.endsWith("CreditCard") + ? AutofillTelemetry.CREDIT_CARD + : AutofillTelemetry.ADDRESS; + const isCapture = type.startsWith("add"); + + AutofillTelemetry.recordDoorhangerShown(telemetryType, flowId, isCapture); + + lazy.log.debug("show doorhanger with type:", type); + return new Promise(resolve => { + let { + notificationId, + message, + descriptionLabel, + additionalDescriptionLabel, + linkMessage, + spotlightURL, + anchor, + mainAction, + secondaryActions, + options, + } = CONTENT[type]; + descriptionIcon = descriptionIcon ?? CONTENT[type].descriptionIcon; + + const { ownerGlobal: chromeWin, ownerDocument: chromeDoc } = browser; + options.eventCallback = topic => { + lazy.log.debug("eventCallback:", topic); + + if (topic == "removed" || topic == "dismissed") { + this._removeCheckboxListener(browser, { notificationId, options }); + return; + } + + // The doorhanger is customizable only when notification box is shown + if (topic != "shown") { + return; + } + this._addCheckboxListener(browser, { notificationId, options }); + + // There's no preferences link or other customization in first time use doorhanger. + if (type == "addFirstTimeUse") { + return; + } + + const DESCRIPTION_ID = "description"; + const ADDITIONAL_DESCRIPTION_ID = "additional-description"; + const NOTIFICATION_ID = notificationId + "-notification"; + + const notification = chromeDoc.getElementById(NOTIFICATION_ID); + const notificationContent = + notification.querySelector("popupnotificationcontent") || + chromeDoc.createXULElement("popupnotificationcontent"); + if (!notification.contains(notificationContent)) { + notificationContent.setAttribute("orient", "vertical"); + + this._appendDescription( + notificationContent, + descriptionLabel, + descriptionIcon, + DESCRIPTION_ID + ); + + if (additionalDescription) { + this._appendDescription( + notificationContent, + additionalDescriptionLabel, + descriptionIcon, + ADDITIONAL_DESCRIPTION_ID + ); + } + + this._appendPrivacyPanelLink( + notificationContent, + linkMessage, + spotlightURL + ); + + notification.appendNotificationContent(notificationContent); + } + + this._updateDescription( + notificationContent, + DESCRIPTION_ID, + description + ); + if (additionalDescription) { + this._updateDescription( + notificationContent, + ADDITIONAL_DESCRIPTION_ID, + additionalDescription + ); + } + }; + this._setAnchor(browser, anchor); + chromeWin.PopupNotifications.show( + browser, + notificationId, + message, + anchor.id, + ...this._createActions(mainAction, secondaryActions, resolve), + options + ); + }).then(state => { + AutofillTelemetry.recordDoorhangerClicked( + telemetryType, + state, + flowId, + isCapture + ); + return state; + }); + }, +}; diff --git a/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs new file mode 100644 index 0000000000..c45186453d --- /dev/null +++ b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs @@ -0,0 +1,274 @@ +/* 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/. */ + +/* + * Implements an interface of the storage of Form Autofill. + */ + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +import { + AddressesBase, + CreditCardsBase, + FormAutofillStorageBase, +} from "resource://autofill/FormAutofillStorageBase.sys.mjs"; +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +const PROFILE_JSON_FILE_NAME = "autofill-profiles.json"; + +class Addresses extends AddressesBase { + /** + * Merge new address into the specified address if mergeable. + * + * @param {string} guid + * Indicates which address to merge. + * @param {object} address + * The new address used to merge into the old one. + * @param {boolean} strict + * In strict merge mode, we'll treat the subset record with empty field + * as unable to be merged, but mergeable if in non-strict mode. + * @returns {Promise} + * Return true if address is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, address, strict) { + this.log.debug(`mergeIfPossible: ${guid}`); + + let addressFound = this._findByGUID(guid); + if (!addressFound) { + throw new Error("No matching address."); + } + + let addressToMerge = this._clone(address); + this._normalizeRecord(addressToMerge, strict); + let hasMatchingField = false; + + let country = + addressFound.country || + addressToMerge.country || + FormAutofill.DEFAULT_REGION; + let collators = lazy.FormAutofillUtils.getSearchCollators(country); + for (let field of this.VALID_FIELDS) { + let existingField = addressFound[field]; + let incomingField = addressToMerge[field]; + if (incomingField !== undefined && existingField !== undefined) { + if (incomingField != existingField) { + // Treat "street-address" as mergeable if their single-line versions + // match each other. + if ( + field == "street-address" && + lazy.FormAutofillUtils.compareStreetAddress( + existingField, + incomingField, + collators + ) + ) { + // Keep the street-address in storage if its amount of lines is greater than + // or equal to the incoming one. + if ( + existingField.split("\n").length >= + incomingField.split("\n").length + ) { + // Replace the incoming field with the one in storage so it will + // be further merged back to storage. + addressToMerge[field] = existingField; + } + } else if ( + field != "street-address" && + lazy.FormAutofillUtils.strCompare( + existingField, + incomingField, + collators + ) + ) { + addressToMerge[field] = existingField; + } else { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + hasMatchingField = true; + } + } + + // We merge the address only when at least one field has the same value. + if (!hasMatchingField) { + this.log.debug("Unable to merge because no field has the same value"); + return false; + } + + // Early return if the data is the same or subset. + let noNeedToUpdate = this.VALID_FIELDS.every(field => { + // When addressFound doesn't contain a field, it's unnecessary to update + // if the same field in addressToMerge is omitted or an empty string. + if (addressFound[field] === undefined) { + return !addressToMerge[field]; + } + + // When addressFound contains a field, it's unnecessary to update if + // the same field in addressToMerge is omitted or a duplicate. + return ( + addressToMerge[field] === undefined || + addressFound[field] === addressToMerge[field] + ); + }); + if (noNeedToUpdate) { + return true; + } + + await this.update(guid, addressToMerge, true); + return true; + } +} + +class CreditCards extends CreditCardsBase { + constructor(store) { + super(store); + } + + async _encryptNumber(creditCard) { + if (!("cc-number-encrypted" in creditCard)) { + if ("cc-number" in creditCard) { + let ccNumber = creditCard["cc-number"]; + if (lazy.CreditCard.isValidNumber(ccNumber)) { + creditCard["cc-number"] = + lazy.CreditCard.getLongMaskedNumber(ccNumber); + } else { + // Credit card numbers can be entered on versions of Firefox that don't validate + // the number and then synced to this version of Firefox. Therefore, mask the + // full number if the number is invalid on this version. + creditCard["cc-number"] = "*".repeat(ccNumber.length); + } + creditCard["cc-number-encrypted"] = await lazy.OSKeyStore.encrypt( + ccNumber + ); + } else { + creditCard["cc-number-encrypted"] = ""; + } + } + } + + /** + * Merge new credit card into the specified record if cc-number is identical. + * (Note that credit card records always do non-strict merge.) + * + * @param {string} guid + * Indicates which credit card to merge. + * @param {object} creditCard + * The new credit card used to merge into the old one. + * @returns {boolean} + * Return true if credit card is merged into target with specific guid or false if not. + */ + async mergeIfPossible(guid, creditCard) { + this.log.debug(`mergeIfPossible: ${guid}`); + + // Credit card number is required since it also must match. + if (!creditCard["cc-number"]) { + return false; + } + + // Query raw data for comparing the decrypted credit card number + let creditCardFound = await this.get(guid, { rawData: true }); + if (!creditCardFound) { + throw new Error("No matching credit card."); + } + + let creditCardToMerge = this._clone(creditCard); + this._normalizeRecord(creditCardToMerge); + + for (let field of this.VALID_FIELDS) { + let existingField = creditCardFound[field]; + + // Make sure credit card field is existed and have value + if ( + field == "cc-number" && + (!existingField || !creditCardToMerge[field]) + ) { + return false; + } + + if (!creditCardToMerge[field] && typeof existingField != "undefined") { + creditCardToMerge[field] = existingField; + } + + let incomingField = creditCardToMerge[field]; + if (incomingField && existingField) { + if (incomingField != existingField) { + this.log.debug("Conflicts: field", field, "has different value."); + return false; + } + } + } + + // Early return if the data is the same. + let exactlyMatch = this.VALID_FIELDS.every( + field => creditCardFound[field] === creditCardToMerge[field] + ); + if (exactlyMatch) { + return true; + } + + await this.update(guid, creditCardToMerge, true); + return true; + } +} + +export class FormAutofillStorage extends FormAutofillStorageBase { + constructor(path) { + super(path); + } + + getAddresses() { + if (!this._addresses) { + this._store.ensureDataReady(); + this._addresses = new Addresses(this._store); + } + return this._addresses; + } + + getCreditCards() { + if (!this._creditCards) { + this._store.ensureDataReady(); + this._creditCards = new CreditCards(this._store); + } + return this._creditCards; + } + + /** + * Loads the profile data from file to memory. + * + * @returns {JSONFile} + * The JSONFile store. + */ + _initializeStore() { + return new lazy.JSONFile({ + path: this._path, + dataPostProcessor: this._dataPostProcessor.bind(this), + }); + } + + _dataPostProcessor(data) { + data.version = this.version; + if (!data.addresses) { + data.addresses = []; + } + if (!data.creditCards) { + data.creditCards = []; + } + return data; + } +} + +// The singleton exposed by this module. +export const formAutofillStorage = new FormAutofillStorage( + PathUtils.join(PathUtils.profileDir, PROFILE_JSON_FILE_NAME) +); -- cgit v1.2.3