/* 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/. */ import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs", CreditCard: "resource://gre/modules/CreditCard.sys.mjs", FormAutofillNameUtils: "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", }); const { FIELD_STATES } = FormAutofillUtils; export class FormAutofillSection { static SHOULD_FOCUS_ON_AUTOFILL = true; #focusedInput = null; #fieldDetails = []; constructor(fieldDetails, handler) { this.#fieldDetails = fieldDetails; if (!this.isValidSection()) { return; } this.handler = handler; this.filledRecordGUID = null; ChromeUtils.defineLazyGetter(this, "log", () => FormAutofill.defineLogGetter(this, "FormAutofillHandler") ); this._cacheValue = { allFieldNames: null, matchingSelectOption: null, }; // Identifier used to correlate events relating to the same form this.flowId = Services.uuid.generateUUID().toString(); this.log.debug( "Creating new credit card section with flowId =", this.flowId ); } get fieldDetails() { return this.#fieldDetails; } /* * Examine the section is a valid section or not based on its fieldDetails or * other information. This method must be overrided. * * @returns {boolean} True for a valid section, otherwise false * */ isValidSection() { throw new TypeError("isValidSection method must be overrided"); } /* * Examine the section is an enabled section type or not based on its * preferences. This method must be overrided. * * @returns {boolean} True for an enabled section type, otherwise false * */ isEnabled() { throw new TypeError("isEnabled method must be overrided"); } /* * Examine the section is createable for storing the profile. This method * must be overrided. * * @param {Object} _record The record for examining createable * @returns {boolean} True for the record is createable, otherwise false * */ isRecordCreatable(_record) { throw new TypeError("isRecordCreatable method must be overridden"); } /** * Override this method if the profile is needed to apply some transformers. * * @param {object} _profile * A profile should be converted based on the specific requirement. */ applyTransformers(_profile) {} /** * Override this method if the profile is needed to be customized for * previewing values. * * @param {object} _profile * A profile for pre-processing before previewing values. */ preparePreviewProfile(_profile) {} /** * Override this method if the profile is needed to be customized for filling * values. * * @param {object} _profile * A profile for pre-processing before filling values. * @returns {boolean} Whether the profile should be filled. */ async prepareFillingProfile(_profile) { return true; } /** * Override this method if the profile is needed to be customized for filling * values. * * @param {object} fieldDetail A fieldDetail of the related element. * @param {object} profile The profile to fill. * @returns {string} The value to fill for the given field. */ getFilledValueFromProfile(fieldDetail, profile) { return ( profile[`${fieldDetail.fieldName}-formatted`] || profile[fieldDetail.fieldName] ); } /* * Override this method if there is any field value needs to compute for a * specific case. Return the original value in the default case. * @param {String} value * The original field value. * @param {Object} fieldDetail * A fieldDetail of the related element. * @param {HTMLElement} element * A element for checking converting value. * * @returns {String} * A string of the converted value. */ computeFillingValue(value, fieldName, element) { return value; } set focusedInput(element) { this.#focusedInput = element; } getFieldDetailByElement(element) { return this.fieldDetails.find(detail => detail.element == element); } getFieldDetailByName(fieldName) { return this.fieldDetails.find(detail => detail.fieldName == fieldName); } get allFieldNames() { if (!this._cacheValue.allFieldNames) { this._cacheValue.allFieldNames = this.fieldDetails.map( record => record.fieldName ); } return this._cacheValue.allFieldNames; } matchSelectOptions(profile) { if (!this._cacheValue.matchingSelectOption) { this._cacheValue.matchingSelectOption = new WeakMap(); } for (const fieldName in profile) { const fieldDetail = this.getFieldDetailByName(fieldName); const element = fieldDetail?.element; if (!HTMLSelectElement.isInstance(element)) { continue; } const cache = this._cacheValue.matchingSelectOption.get(element) || {}; const value = profile[fieldName]; if (cache[value] && cache[value].deref()) { continue; } const option = FormAutofillUtils.findSelectOption( element, profile, fieldName ); if (option) { cache[value] = new WeakRef(option); this._cacheValue.matchingSelectOption.set(element, cache); } else { if (cache[value]) { delete cache[value]; this._cacheValue.matchingSelectOption.set(element, cache); } // Skip removing cc-type since this is needed for displaying the icon for credit card network // TODO(Bug 1874339): Cleanup transformation and normalization of data to not remove any // fields and be more consistent if (!["cc-type"].includes(fieldName)) { // Delete the field so the phishing hint won't treat it as a "also fill" // field. delete profile[fieldName]; } } } } adaptFieldMaxLength(profile) { for (let key in profile) { let detail = this.getFieldDetailByName(key); if (!detail) { continue; } let element = detail.element; if (!element) { continue; } let maxLength = element.maxLength; if ( maxLength === undefined || maxLength < 0 || profile[key].toString().length <= maxLength ) { continue; } if (maxLength) { switch (typeof profile[key]) { case "string": // If this is an expiration field and our previous // adaptations haven't resulted in a string that is // short enough to satisfy the field length, and the // field is constrained to a length of 4 or 5, then we // assume it is intended to hold an expiration of the // form "MMYY" or "MM/YY". if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) { const month2Digits = ( "0" + profile["cc-exp-month"].toString() ).slice(-2); const year2Digits = profile["cc-exp-year"].toString().slice(-2); const separator = maxLength == 5 ? "/" : ""; profile[key] = `${month2Digits}${separator}${year2Digits}`; } else if (key == "cc-number") { // We want to show the last four digits of credit card so that // the masked credit card previews correctly and appears correctly // in the autocomplete menu profile[key] = profile[key].substr( profile[key].length - maxLength ); } else { profile[key] = profile[key].substr(0, maxLength); } break; case "number": // There's no way to truncate a number smaller than a // single digit. if (maxLength < 1) { maxLength = 1; } // The only numbers we store are expiration month/year, // and if they truncate, we want the final digits, not // the initial ones. profile[key] = profile[key] % Math.pow(10, maxLength); break; default: } } else { delete profile[key]; delete profile[`${key}-formatted`]; } } } fillFieldValue(element, value) { if (FormAutofillUtils.focusOnAutofill) { element.focus({ preventScroll: true }); } if (HTMLInputElement.isInstance(element)) { element.setUserInput(value); } else if (HTMLSelectElement.isInstance(element)) { // Set the value of the select element so that web event handlers can react accordingly element.value = value; element.dispatchEvent( new element.ownerGlobal.Event("input", { bubbles: true }) ); element.dispatchEvent( new element.ownerGlobal.Event("change", { bubbles: true }) ); } } getAdaptedProfiles(originalProfiles) { for (let profile of originalProfiles) { this.applyTransformers(profile); } return originalProfiles; } /** * Processes form fields that can be autofilled, and populates them with the * profile provided by backend. * * @param {object} profile * A profile to be filled in. * @returns {boolean} * True if successful, false if failed */ async autofillFields(profile) { if (!this.#focusedInput) { throw new Error("No focused input."); } const focusedDetail = this.getFieldDetailByElement(this.#focusedInput); if (!focusedDetail) { throw new Error("No fieldDetail for the focused input."); } this.getAdaptedProfiles([profile]); if (!(await this.prepareFillingProfile(profile))) { this.log.debug("profile cannot be filled"); return false; } this.filledRecordGUID = profile.guid; for (const fieldDetail of this.fieldDetails) { // Avoid filling field value in the following cases: // 1. a non-empty input field for an unfocused input // 2. the invalid value set // 3. value already chosen in select element const element = fieldDetail.element; // Skip the field if it is null or readonly or disabled if (!FormAutofillUtils.isFieldAutofillable(element)) { continue; } element.previewValue = ""; // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field // that is generated when presentation ready data doesn't fit into the autofilling element. // For example, autofilling expiration month into an input element will not work as expected if // the month is less than 10, since the input is expected a zero-padded string. // See Bug 1722941 for follow up. const value = this.getFilledValueFromProfile(fieldDetail, profile); if (HTMLInputElement.isInstance(element) && value) { // For the focused input element, it will be filled with a valid value // anyway. // For the others, the fields should be only filled when their values are empty // or their values are equal to the site prefill value // or are the result of an earlier auto-fill. if ( element == this.#focusedInput || (element != this.#focusedInput && (!element.value || element.value == element.defaultValue)) || this.handler.getFilledStateByElement(element) == FIELD_STATES.AUTO_FILLED ) { this.fillFieldValue(element, value); this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); } } else if (HTMLSelectElement.isInstance(element)) { let cache = this._cacheValue.matchingSelectOption.get(element) || {}; let option = cache[value] && cache[value].deref(); if (!option) { continue; } // Do not change value or dispatch events if the option is already selected. // Use case for multiple select is not considered here. if (!option.selected) { option.selected = true; this.fillFieldValue(element, option.value); } // Autofill highlight appears regardless if value is changed or not this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); } } this.#focusedInput.focus({ preventScroll: true }); lazy.AutofillTelemetry.recordFormInteractionEvent("filled", this, { profile, }); return true; } /** * Populates result to the preview layers with given profile. * * @param {object} profile * A profile to be previewed with */ previewFormFields(profile) { this.preparePreviewProfile(profile); for (const fieldDetail of this.fieldDetails) { let element = fieldDetail.element; // Skip the field if it is null or readonly or disabled if (!FormAutofillUtils.isFieldAutofillable(element)) { continue; } let value = profile[`${fieldDetail.fieldName}-formatted`] || profile[fieldDetail.fieldName] || ""; if (HTMLSelectElement.isInstance(element)) { // Unlike text input, select element is always previewed even if // the option is already selected. if (value) { const cache = this._cacheValue.matchingSelectOption.get(element) ?? {}; const option = cache[value]?.deref(); value = option?.text ?? ""; } } else if (element.value && element.value != element.defaultValue) { // Skip the field if the user has already entered text and that text is not the site prefilled value. continue; } element.previewValue = value?.toString().replaceAll("*", "•"); this.handler.changeFieldState( fieldDetail, value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL ); } } /** * Clear a previously autofilled field in this section */ clearFilled(fieldDetail) { lazy.AutofillTelemetry.recordFormInteractionEvent("filled_modified", this, { fieldName: fieldDetail.fieldName, }); let isAutofilled = false; const dimFieldDetails = []; for (const fieldDetail of this.fieldDetails) { const element = fieldDetail.element; if (HTMLSelectElement.isInstance(element)) { // Dim fields are those we don't attempt to revert their value // when clear the target set, such as element to its selected option or the first option if there is none selected. * * @param {HTMLElement} element * @memberof FormAutofillSection */ _resetSelectElementValue(element) { if (!element.options.length) { return; } let selected = [...element.options].find(option => option.hasAttribute("selected") ); element.value = selected ? selected.value : element.options[0].value; element.dispatchEvent( new element.ownerGlobal.Event("input", { bubbles: true }) ); element.dispatchEvent( new element.ownerGlobal.Event("change", { bubbles: true }) ); } } export class FormAutofillAddressSection extends FormAutofillSection { constructor(fieldDetails, handler) { super(fieldDetails, handler); if (!this.isValidSection()) { return; } this._cacheValue.oneLineStreetAddress = null; lazy.AutofillTelemetry.recordDetectedSectionCount(this); lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); } isValidSection() { const fields = new Set(this.fieldDetails.map(f => f.fieldName)); return fields.size >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; } isEnabled() { return FormAutofill.isAutofillAddressesEnabled; } isRecordCreatable(record) { const country = FormAutofillUtils.identifyCountryCode( record.country || record["country-name"] ); if ( country && !FormAutofill.isAutofillAddressesAvailableInCountry(country) ) { // We don't want to save data in the wrong fields due to not having proper // heuristic regexes in countries we don't yet support. this.log.warn( "isRecordCreatable: Country not supported:", record.country ); return false; } // Multiple name or tel fields are treat as 1 field while countng whether // the number of fields exceed the valid address secton threshold const categories = Object.entries(record) .filter(e => !!e[1]) .map(e => FormAutofillUtils.getCategoryFromFieldName(e[0])); return ( categories.reduce( (acc, category) => ["name", "tel"].includes(category) && acc.includes(category) ? acc : [...acc, category], [] ).length >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD ); } _getOneLineStreetAddress(address) { if (!this._cacheValue.oneLineStreetAddress) { this._cacheValue.oneLineStreetAddress = {}; } if (!this._cacheValue.oneLineStreetAddress[address]) { this._cacheValue.oneLineStreetAddress[address] = FormAutofillUtils.toOneLineAddress(address); } return this._cacheValue.oneLineStreetAddress[address]; } addressTransformer(profile) { if (profile["street-address"]) { // "-moz-street-address-one-line" is used by the labels in // ProfileAutoCompleteResult. profile["-moz-street-address-one-line"] = this._getOneLineStreetAddress( profile["street-address"] ); let streetAddressDetail = this.getFieldDetailByName("street-address"); if ( streetAddressDetail && HTMLInputElement.isInstance(streetAddressDetail.element) ) { profile["street-address"] = profile["-moz-street-address-one-line"]; } let waitForConcat = []; for (let f of ["address-line3", "address-line2", "address-line1"]) { waitForConcat.unshift(profile[f]); if (this.getFieldDetailByName(f)) { if (waitForConcat.length > 1) { profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat); } waitForConcat = []; } } } } /** * Replace tel with tel-national if tel violates the input element's * restriction. * * @param {object} profile * A profile to be converted. */ telTransformer(profile) { if (!profile.tel || !profile["tel-national"]) { return; } let detail = this.getFieldDetailByName("tel"); if (!detail) { return; } let element = detail.element; let _pattern; let testPattern = str => { if (!_pattern) { // The pattern has to match the entire value. _pattern = new RegExp("^(?:" + element.pattern + ")$", "u"); } return _pattern.test(str); }; if (element.pattern) { if (testPattern(profile.tel)) { return; } } else if (element.maxLength) { if ( detail.reason == "autocomplete" && profile.tel.length <= element.maxLength ) { return; } } if (detail.reason != "autocomplete") { // Since we only target people living in US and using en-US websites in // MVP, it makes more sense to fill `tel-national` instead of `tel` // if the field is identified by heuristics and no other clues to // determine which one is better. // TODO: [Bug 1407545] This should be improved once more countries are // supported. profile.tel = profile["tel-national"]; } else if (element.pattern) { if (testPattern(profile["tel-national"])) { profile.tel = profile["tel-national"]; } } else if (element.maxLength) { if (profile["tel-national"].length <= element.maxLength) { profile.tel = profile["tel-national"]; } } } /* * Apply all address related transformers. * * @param {Object} profile * A profile for adjusting address related value. * @override */ applyTransformers(profile) { this.addressTransformer(profile); this.telTransformer(profile); this.matchSelectOptions(profile); this.adaptFieldMaxLength(profile); } computeFillingValue(value, fieldDetail, element) { // Try to abbreviate the value of select element. if ( fieldDetail.fieldName == "address-level1" && HTMLSelectElement.isInstance(element) ) { // Don't save the record when the option value is empty *OR* there // are multiple options being selected. The empty option is usually // assumed to be default along with a meaningless text to users. if (!value || element.selectedOptions.length != 1) { // Keep the property and preserve more information for address updating value = ""; } else { let text = element.selectedOptions[0].text.trim(); value = FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text; } } else if (fieldDetail.fieldName == "country") { // This is a temporary fix. Ideally we should have either case-insensitive comparaison of country codes // or handle this elsewhere see Bug 1889234 for more context. value = value.toUpperCase(); } return value; } } export class FormAutofillCreditCardSection extends FormAutofillSection { /** * Credit Card Section Constructor * * @param {Array} fieldDetails * The fieldDetail objects for the fields in this section * @param {Object} handler * The handler responsible for this section */ constructor(fieldDetails, handler) { super(fieldDetails, handler); if (!this.isValidSection()) { return; } lazy.AutofillTelemetry.recordDetectedSectionCount(this); lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); // Check whether the section is in an