diff options
Diffstat (limited to 'toolkit/components/formautofill/FormAutofillParent.sys.mjs')
-rw-r--r-- | toolkit/components/formautofill/FormAutofillParent.sys.mjs | 716 |
1 files changed, 716 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/FormAutofillParent.sys.mjs b/toolkit/components/formautofill/FormAutofillParent.sys.mjs new file mode 100644 index 0000000000..ba0d769906 --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillParent.sys.mjs @@ -0,0 +1,716 @@ +/* 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 a service used to access storage and communicate with content. + * + * A "fields" array is used to communicate with FormAutofillContent. Each item + * represents a single input field in the content page as well as its + * @autocomplete properties. The schema is as below. Please refer to + * FormAutofillContent.js for more details. + * + * [ + * { + * section, + * addressType, + * contactType, + * fieldName, + * value, + * index + * }, + * { + * // ... + * } + * ] + */ + +// We expose a singleton from this module. Some tests may import the +// constructor via a backstage pass. +import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs"; +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddressComponent: "resource://gre/modules/shared/AddressComponent.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + FormAutofillPreferences: + "resource://autofill/FormAutofillPreferences.sys.mjs", + FormAutofillPrompter: "resource://autofill/FormAutofillPrompter.sys.mjs", + FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs", + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => + FormAutofill.defineLogGetter(lazy, "FormAutofillParent") +); + +const { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF } = + FormAutofill; + +const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } = + FormAutofillUtils; + +let gMessageObservers = new Set(); + +export let FormAutofillStatus = { + _initialized: false, + + /** + * Cache of the Form Autofill status (considering preferences and storage). + */ + _active: null, + + /** + * Initializes observers and registers the message handler. + */ + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + Services.obs.addObserver(this, "privacy-pane-loaded"); + + // Observing the pref and storage changes + Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.obs.addObserver(this, "formautofill-storage-changed"); + + // Only listen to credit card related preference if it is available + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + + // We have to use empty window type to get all opened windows here because the + // window type parameter may not be available during startup. + for (let win of Services.wm.getEnumerator("")) { + let { documentElement } = win.document; + if (documentElement?.getAttribute("windowtype") == "navigator:browser") { + this.injectElements(win.document); + } else { + // Manually call onOpenWindow for windows that are already opened but not + // yet have the window type set. This ensures we inject the elements we need + // when its docuemnt is ready. + this.onOpenWindow(win); + } + } + Services.wm.addListener(this); + + Services.telemetry.setEventRecordingEnabled("creditcard", true); + Services.telemetry.setEventRecordingEnabled("address", true); + }, + + /** + * Uninitializes FormAutofillStatus. This is for testing only. + * + * @private + */ + uninit() { + lazy.gFormAutofillStorage._saveImmediately(); + + if (!this._initialized) { + return; + } + this._initialized = false; + + this._active = null; + + Services.obs.removeObserver(this, "privacy-pane-loaded"); + Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this); + Services.wm.removeListener(this); + + if (FormAutofill.isAutofillCreditCardsAvailable) { + Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this); + } + }, + + get formAutofillStorage() { + return lazy.gFormAutofillStorage; + }, + + /** + * Broadcast the status to frames when the form autofill status changes. + */ + onStatusChanged() { + lazy.log.debug("onStatusChanged: Status changed to", this._active); + Services.ppmm.sharedData.set("FormAutofill:enabled", this._active); + // Sync autofill enabled to make sure the value is up-to-date + // no matter when the new content process is initialized. + Services.ppmm.sharedData.flush(); + }, + + /** + * Query preference and storage status to determine the overall status of the + * form autofill feature. + * + * @returns {boolean} whether form autofill is active (enabled and has data) + */ + computeStatus() { + const savedFieldNames = Services.ppmm.sharedData.get( + "FormAutofill:savedFieldNames" + ); + + return ( + (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) || + Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) && + savedFieldNames && + savedFieldNames.size > 0 + ); + }, + + /** + * Update the status and trigger onStatusChanged, if necessary. + */ + updateStatus() { + lazy.log.debug("updateStatus"); + let wasActive = this._active; + this._active = this.computeStatus(); + if (this._active !== wasActive) { + this.onStatusChanged(); + } + }, + + async updateSavedFieldNames() { + lazy.log.debug("updateSavedFieldNames"); + + let savedFieldNames; + const addressNames = + await lazy.gFormAutofillStorage.addresses.getSavedFieldNames(); + + // Don't access the credit cards store unless it is enabled. + if (FormAutofill.isAutofillCreditCardsAvailable) { + const creditCardNames = + await lazy.gFormAutofillStorage.creditCards.getSavedFieldNames(); + savedFieldNames = new Set([...addressNames, ...creditCardNames]); + } else { + savedFieldNames = addressNames; + } + + Services.ppmm.sharedData.set( + "FormAutofill:savedFieldNames", + savedFieldNames + ); + Services.ppmm.sharedData.flush(); + + this.updateStatus(); + }, + + injectElements(doc) { + Services.scriptloader.loadSubScript( + "chrome://formautofill/content/customElements.js", + doc.ownerGlobal + ); + }, + + onOpenWindow(xulWindow) { + const win = xulWindow.docShell.domWindow; + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") == + "navigator:browser" + ) { + this.injectElements(win.document); + } + }, + { once: true } + ); + }, + + onCloseWindow() {}, + + async observe(subject, topic, data) { + lazy.log.debug("observe:", topic, "with data:", data); + switch (topic) { + case "privacy-pane-loaded": { + let formAutofillPreferences = new lazy.FormAutofillPreferences(); + let document = subject.document; + let prefFragment = formAutofillPreferences.init(document); + let formAutofillGroupBox = document.getElementById( + "formAutofillGroupBox" + ); + formAutofillGroupBox.appendChild(prefFragment); + break; + } + + case "nsPref:changed": { + // Observe pref changes and update _active cache if status is changed. + this.updateStatus(); + break; + } + + case "formautofill-storage-changed": { + // Early exit if only metadata is changed + if (data == "notifyUsed") { + break; + } + + await this.updateSavedFieldNames(); + break; + } + + default: { + throw new Error( + `FormAutofillStatus: Unexpected topic observed: ${topic}` + ); + } + } + }, +}; + +// Lazily load the storage JSM to avoid disk I/O until absolutely needed. +// Once storage is loaded we need to update saved field names and inform content processes. +ChromeUtils.defineLazyGetter(lazy, "gFormAutofillStorage", () => { + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + lazy.log.debug("Loading formAutofillStorage"); + + formAutofillStorage.initialize().then(() => { + // Update the saved field names to compute the status and update child processes. + FormAutofillStatus.updateSavedFieldNames(); + }); + + return formAutofillStorage; +}); + +export class FormAutofillParent extends JSWindowActorParent { + constructor() { + super(); + FormAutofillStatus.init(); + } + + static addMessageObserver(observer) { + gMessageObservers.add(observer); + } + + static removeMessageObserver(observer) { + gMessageObservers.delete(observer); + } + + /** + * Handles the message coming from FormAutofillContent. + * + * @param {object} message + * @param {string} message.name The name of the message. + * @param {object} message.data The data of the message. + */ + async receiveMessage({ name, data }) { + switch (name) { + case "FormAutofill:InitStorage": { + await lazy.gFormAutofillStorage.initialize(); + await FormAutofillStatus.updateSavedFieldNames(); + break; + } + case "FormAutofill:GetRecords": { + const relayPromise = lazy.FirefoxRelay.autocompleteItemsAsync({ + formOrigin: this.formOrigin, + scenarioName: data.scenarioName, + hasInput: !!data.searchString?.length, + }); + const recordsPromise = FormAutofillParent._getRecords(data); + const [records, externalEntries] = await Promise.all([ + recordsPromise, + relayPromise, + ]); + return { records, externalEntries }; + } + case "FormAutofill:OnFormSubmit": { + this.notifyMessageObservers("onFormSubmitted", data); + await this._onFormSubmit(data); + break; + } + case "FormAutofill:OpenPreferences": { + const win = lazy.BrowserWindowTracker.getTopWindow(); + win.openPreferences("privacy-form-autofill"); + break; + } + case "FormAutofill:GetDecryptedString": { + let { cipherText, reauth } = data; + if (!FormAutofillUtils._reauthEnabledByUser) { + lazy.log.debug("Reauth is disabled"); + reauth = false; + } + let string; + try { + string = await lazy.OSKeyStore.decrypt(cipherText, reauth); + } catch (e) { + if (e.result != Cr.NS_ERROR_ABORT) { + throw e; + } + lazy.log.warn("User canceled encryption login"); + } + return string; + } + case "FormAutofill:UpdateWarningMessage": + this.notifyMessageObservers("updateWarningNote", data); + break; + + case "FormAutofill:FieldsIdentified": + this.notifyMessageObservers("fieldsIdentified", data); + break; + + // The remaining Save and Remove messages are invoked only by tests. + case "FormAutofill:SaveAddress": { + if (data.guid) { + await lazy.gFormAutofillStorage.addresses.update( + data.guid, + data.address + ); + } else { + await lazy.gFormAutofillStorage.addresses.add(data.address); + } + break; + } + case "FormAutofill:SaveCreditCard": { + if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) { + lazy.log.warn("User canceled encryption login"); + return undefined; + } + await lazy.gFormAutofillStorage.creditCards.add(data.creditcard); + break; + } + case "FormAutofill:RemoveAddresses": { + data.guids.forEach(guid => + lazy.gFormAutofillStorage.addresses.remove(guid) + ); + break; + } + case "FormAutofill:RemoveCreditCards": { + data.guids.forEach(guid => + lazy.gFormAutofillStorage.creditCards.remove(guid) + ); + break; + } + case "PasswordManager:offerRelayIntegration": { + FirefoxRelayTelemetry.recordRelayOfferedEvent( + "clicked", + data.telemetry.flowId, + data.telemetry.scenarioName + ); + return this.#offerRelayIntegration(); + } + case "PasswordManager:generateRelayUsername": { + FirefoxRelayTelemetry.recordRelayUsernameFilledEvent( + "clicked", + data.telemetry.flowId + ); + return this.#generateRelayUsername(); + } + } + + return undefined; + } + + get formOrigin() { + return lazy.LoginHelper.getLoginOrigin( + this.manager.documentPrincipal?.originNoSuffix + ); + } + + getRootBrowser() { + return this.browsingContext.topFrameElement; + } + + async #offerRelayIntegration() { + const browser = this.getRootBrowser(); + return lazy.FirefoxRelay.offerRelayIntegration(browser, this.formOrigin); + } + + async #generateRelayUsername() { + const browser = this.getRootBrowser(); + return lazy.FirefoxRelay.generateUsername(browser, this.formOrigin); + } + + notifyMessageObservers(callbackName, data) { + for (let observer of gMessageObservers) { + try { + if (callbackName in observer) { + observer[callbackName]( + data, + this.manager.browsingContext.topChromeWindow + ); + } + } catch (ex) { + console.error(ex); + } + } + } + + /** + * Get the records from profile store and return results back to content + * process. It will decrypt the credit card number and append + * "cc-number-decrypted" to each record if OSKeyStore isn't set. + * + * This is static as a unit test calls this. + * + * @private + * @param {object} data + * @param {string} data.collectionName + * The name used to specify which collection to retrieve records. + * @param {string} data.searchString + * The typed string for filtering out the matched records. + * @param {string} data.info + * The input autocomplete property's information. + */ + static async _getRecords({ collectionName, searchString, info }) { + let collection = lazy.gFormAutofillStorage[collectionName]; + if (!collection) { + return []; + } + + let recordsInCollection = await collection.getAll(); + if (!info || !info.fieldName || !recordsInCollection.length) { + return recordsInCollection; + } + + let isCC = collectionName == CREDITCARDS_COLLECTION_NAME; + // We don't filter "cc-number" + if (isCC && info.fieldName == "cc-number") { + recordsInCollection = recordsInCollection.filter( + record => !!record["cc-number"] + ); + return recordsInCollection; + } + + let records = []; + let lcSearchString = searchString.toLowerCase(); + + for (let record of recordsInCollection) { + let fieldValue = record[info.fieldName]; + if (!fieldValue) { + continue; + } + + if ( + collectionName == ADDRESSES_COLLECTION_NAME && + record.country && + !FormAutofill.isAutofillAddressesAvailableInCountry(record.country) + ) { + // Address autofill isn't supported for the record's country so we don't + // want to attempt to potentially incorrectly fill the address fields. + continue; + } + + if ( + lcSearchString && + !String(fieldValue).toLowerCase().startsWith(lcSearchString) + ) { + continue; + } + records.push(record); + } + + return records; + } + + async _onAddressSubmit(address, browser) { + const storage = lazy.gFormAutofillStorage.addresses; + + // Make sure record is normalized before comparing with records in the storage + try { + storage._normalizeRecord(address.record); + } catch (_e) { + return false; + } + + const newAddress = new lazy.AddressComponent( + address.record, + // Invalid address fields in the address form will not be captured. + { ignoreInvalid: true } + ); + + // Exams all stored record to determine whether to show the prompt or not. + let mergeableFields = []; + let preserveFields = []; + let oldRecord = {}; + + for (const record of await storage.getAll()) { + const savedAddress = new lazy.AddressComponent(record); + // filter invalid field + const result = newAddress.compare(savedAddress); + + // If any of the fields in the new address are different from the corresponding fields + // in the saved address, the two addresses are considered different. For example, if + // the name, email, country are the same but the street address is different, the two + // addresses are not considered the same. + if (Object.values(result).includes("different")) { + continue; + } + + // If none of the fields in the new address are mergeable, the new address is considered + // a duplicate of a local address. Therefore, we don't need to capture this address. + const fields = Object.entries(result) + .filter(v => ["superset", "similar"].includes(v[1])) + .map(v => v[0]); + if (!fields.length) { + lazy.log.debug( + "A duplicated address record is found, do not show the prompt" + ); + storage.notifyUsed(record.guid); + return false; + } + + // If the new address is neither a duplicate of the saved address nor a different address. + // There must be at least one field we can merge, show the update doorhanger + lazy.log.debug( + "A mergeable address record is found, show the update prompt" + ); + + // If one record has fewer mergeable fields compared to another, it suggests greater similarity + // to the merged record. In such cases, we opt for the record with the fewest mergeable fields. + // TODO: Bug 1830841. Add a testcase + if (!mergeableFields.length || mergeableFields > fields.length) { + mergeableFields = fields; + preserveFields = Object.entries(result) + .filter(v => ["same", "subset"].includes(v[1])) + .map(v => v[0]); + oldRecord = record; + } + } + + // Find a mergeable old record, construct the new record by only copying mergeable fields + // from the new address. + let newRecord = {}; + if (mergeableFields.length) { + // TODO: This is only temporarily, should be removed after Bug 1836438 is fixed + if (mergeableFields.includes("name")) { + mergeableFields.push("given-name", "additional-name", "family-name"); + } + mergeableFields.forEach(f => { + if (f in newAddress.record) { + newRecord[f] = newAddress.record[f]; + } + }); + + if (preserveFields.includes("name")) { + preserveFields.push("given-name", "additional-name", "family-name"); + } + preserveFields.forEach(f => { + if (f in oldRecord) { + newRecord[f] = oldRecord[f]; + } + }); + } else { + newRecord = newAddress.record; + } + + if (!this._shouldShowSaveAddressPrompt(newAddress.record)) { + return false; + } + + return async () => { + await lazy.FormAutofillPrompter.promptToSaveAddress( + browser, + storage, + address.flowId, + { oldRecord, newRecord } + ); + }; + } + + async _onCreditCardSubmit(creditCard, browser) { + const storage = lazy.gFormAutofillStorage.creditCards; + + // Make sure record is normalized before comparing with records in the storage + try { + storage._normalizeRecord(creditCard.record); + } catch (_e) { + return false; + } + + // If the record alreay exists in the storage, don't bother showing the prompt + const matchRecord = ( + await storage.getMatchRecords(creditCard.record).next() + ).value; + if (matchRecord) { + storage.notifyUsed(matchRecord.guid); + return false; + } + + // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger. + if (!FormAutofill.isAutofillCreditCardsEnabled) { + return false; + } + + // Overwrite the guid if there is a duplicate + const duplicateRecord = + (await storage.getDuplicateRecords(creditCard.record).next()).value ?? {}; + + return async () => { + await lazy.FormAutofillPrompter.promptToSaveCreditCard( + browser, + storage, + creditCard.flowId, + { oldRecord: duplicateRecord, newRecord: creditCard.record } + ); + }; + } + + async _onFormSubmit(data) { + let { address, creditCard } = data; + + let browser = this.manager.browsingContext.top.embedderElement; + + // Transmit the telemetry immediately in the meantime form submitted, and handle these pending + // doorhangers at a later. + await Promise.all( + [ + await Promise.all( + address.map(addrRecord => this._onAddressSubmit(addrRecord, browser)) + ), + await Promise.all( + creditCard.map(ccRecord => + this._onCreditCardSubmit(ccRecord, browser) + ) + ), + ] + .map(pendingDoorhangers => { + return pendingDoorhangers.filter( + pendingDoorhanger => + !!pendingDoorhanger && typeof pendingDoorhanger == "function" + ); + }) + .map(pendingDoorhangers => + (async () => { + for (const showDoorhanger of pendingDoorhangers) { + await showDoorhanger(); + } + })() + ) + ); + } + + _shouldShowSaveAddressPrompt(record) { + if (!FormAutofill.isAutofillAddressesCaptureEnabled) { + return false; + } + + // Do not save address for regions that we don't support + if ( + FormAutofill._isAutofillAddressesAvailable == "detect" && + !FormAutofill.isAutofillAddressesAvailableInCountry(record.country) + ) { + lazy.log.debug( + `Do not show the address capture prompt for unsupported regions - ${record.country}` + ); + return false; + } + + // Display the address capture doorhanger only when the submitted form contains all + // the required fields. This approach is implemented to prevent excessive prompting. + const requiredFields = FormAutofill.addressCaptureRequiredFields ?? []; + if (!requiredFields.every(field => field in record)) { + lazy.log.debug( + "Do not show the address capture prompt when the submitted form doesn't contain all the required fields" + ); + return false; + } + + return true; + } +} |