1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 11:13:46 +00:00
Files
browser/apps/browser/src/autofill/services/collect-autofill-content.service.ts
2024-06-11 10:03:30 -05:00

1561 lines
52 KiB
TypeScript

import AutofillField from "../models/autofill-field";
import AutofillForm from "../models/autofill-form";
import AutofillPageDetails from "../models/autofill-page-details";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import {
elementIsDescriptionDetailsElement,
elementIsDescriptionTermElement,
elementIsFillableFormField,
elementIsFormElement,
elementIsLabelElement,
elementIsSelectElement,
elementIsSpanElement,
nodeIsElement,
elementIsInputElement,
elementIsTextAreaElement,
nodeIsFormElement,
nodeIsInputElement,
sendExtensionMessage,
getAttributeBoolean,
getPropertyOrAttribute,
} from "../utils";
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
import {
UpdateAutofillDataAttributeParams,
AutofillFieldElements,
AutofillFormElements,
CollectAutofillContentService as CollectAutofillContentServiceInterface,
} from "./abstractions/collect-autofill-content.service";
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
class CollectAutofillContentService implements CollectAutofillContentServiceInterface {
private readonly domElementVisibilityService: DomElementVisibilityService;
private readonly autofillOverlayContentService: AutofillOverlayContentService;
private readonly getAttributeBoolean = getAttributeBoolean;
private readonly getPropertyOrAttribute = getPropertyOrAttribute;
private noFieldsFound = false;
private domRecentlyMutated = true;
private autofillFormElements: AutofillFormElements = new Map();
private autofillFieldElements: AutofillFieldElements = new Map();
private currentLocationHref = "";
private intersectionObserver: IntersectionObserver;
private elementInitializingIntersectionObserver: Set<Element> = new Set();
private mutationObserver: MutationObserver;
private updateAutofillElementsAfterMutationTimeout: number | NodeJS.Timeout;
private mutationsQueue: MutationRecord[][] = [];
private readonly updateAfterMutationTimeoutDelay = 1000;
private readonly formFieldQueryString;
private readonly nonInputFormFieldTags = new Set(["textarea", "select"]);
private readonly ignoredInputTypes = new Set([
"hidden",
"submit",
"reset",
"button",
"image",
"file",
]);
private useTreeWalkerStrategyFlagSet = false;
constructor(
domElementVisibilityService: DomElementVisibilityService,
autofillOverlayContentService?: AutofillOverlayContentService,
) {
this.domElementVisibilityService = domElementVisibilityService;
this.autofillOverlayContentService = autofillOverlayContentService;
let inputQuery = "input:not([data-bwignore])";
for (const type of this.ignoredInputTypes) {
inputQuery += `:not([type="${type}"])`;
}
this.formFieldQueryString = `${inputQuery}, textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]`;
void sendExtensionMessage("getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag").then(
(useTreeWalkerStrategyFlag) =>
(this.useTreeWalkerStrategyFlagSet = !!useTreeWalkerStrategyFlag?.result),
);
}
/**
* Builds the data for all forms and fields found within the page DOM.
* Sets up a mutation observer to verify DOM changes and returns early
* with cached data if no changes are detected.
* @returns {Promise<AutofillPageDetails>}
* @public
*/
async getPageDetails(): Promise<AutofillPageDetails> {
if (!this.mutationObserver) {
this.setupMutationObserver();
}
if (!this.intersectionObserver) {
this.setupIntersectionObserver();
}
if (!this.domRecentlyMutated && this.noFieldsFound) {
return this.getFormattedPageDetails({}, []);
}
if (!this.domRecentlyMutated && this.autofillFieldElements.size) {
this.updateCachedAutofillFieldVisibility();
return this.getFormattedPageDetails(
this.getFormattedAutofillFormsData(),
this.getFormattedAutofillFieldsData(),
);
}
const { formElements, formFieldElements } = this.queryAutofillFormAndFieldElements();
const autofillFormsData: Record<string, AutofillForm> =
this.buildAutofillFormsData(formElements);
const autofillFieldsData: AutofillField[] = (
await this.buildAutofillFieldsData(formFieldElements as FormFieldElement[])
).filter((field) => !!field);
this.sortAutofillFieldElementsMap();
if (!autofillFieldsData.length) {
this.noFieldsFound = true;
}
this.domRecentlyMutated = false;
return this.getFormattedPageDetails(autofillFormsData, autofillFieldsData);
}
/**
* Find an AutofillField element by its opid, will only return the first
* element if there are multiple elements with the same opid. If no
* element is found, null will be returned.
* @param {string} opid
* @returns {FormFieldElement | null}
*/
getAutofillFieldElementByOpid(opid: string): FormFieldElement | null {
const cachedFormFieldElements = Array.from(this.autofillFieldElements.keys());
const formFieldElements = cachedFormFieldElements?.length
? cachedFormFieldElements
: this.getAutofillFieldElements();
const fieldElementsWithOpid = formFieldElements.filter(
(fieldElement) => (fieldElement as ElementWithOpId<FormFieldElement>).opid === opid,
) as ElementWithOpId<FormFieldElement>[];
if (!fieldElementsWithOpid.length) {
const elementIndex = parseInt(opid.split("__")[1], 10);
return formFieldElements[elementIndex] || null;
}
if (fieldElementsWithOpid.length > 1) {
// eslint-disable-next-line no-console
console.warn(`More than one element found with opid ${opid}`);
}
return fieldElementsWithOpid[0];
}
/**
* Queries all elements in the DOM that match the given query string.
* Also, recursively queries all shadow roots for the element.
*
* @param root - The root element to start the query from
* @param queryString - The query string to match elements against
* @param isObservingShadowRoot - Determines whether to observe shadow roots
*/
deepQueryElements<T>(
root: Document | ShadowRoot | Element,
queryString: string,
isObservingShadowRoot = false,
): T[] {
let elements = this.queryElements<T>(root, queryString);
const shadowRoots = this.recursivelyQueryShadowRoots(root, isObservingShadowRoot);
for (let index = 0; index < shadowRoots.length; index++) {
const shadowRoot = shadowRoots[index];
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
}
return elements;
}
/**
* Queries the DOM for elements based on the given query string.
*
* @param root - The root element to start the query from
* @param queryString - The query string to match elements against
*/
private queryElements<T>(root: Document | ShadowRoot | Element, queryString: string): T[] {
if (!root.querySelector(queryString)) {
return [];
}
return Array.from(root.querySelectorAll(queryString)) as T[];
}
/**
* Recursively queries all shadow roots found within the given root element.
* Will also set up a mutation observer on the shadow root if the
* `isObservingShadowRoot` parameter is set to true.
*
* @param root - The root element to start the query from
* @param isObservingShadowRoot - Determines whether to observe shadow roots
*/
private recursivelyQueryShadowRoots(
root: Document | ShadowRoot | Element,
isObservingShadowRoot = false,
): ShadowRoot[] {
let shadowRoots = this.queryShadowRoots(root);
for (let index = 0; index < shadowRoots.length; index++) {
const shadowRoot = shadowRoots[index];
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot));
if (isObservingShadowRoot) {
this.mutationObserver.observe(shadowRoot, {
attributes: true,
childList: true,
subtree: true,
});
}
}
return shadowRoots;
}
/**
* Queries any immediate shadow roots found within the given root element.
*
* @param root - The root element to start the query from
*/
private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] {
const shadowRoots: ShadowRoot[] = [];
const potentialShadowRoots = root.querySelectorAll(":defined");
for (let index = 0; index < potentialShadowRoots.length; index++) {
const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]);
if (shadowRoot) {
shadowRoots.push(shadowRoot);
}
}
return shadowRoots;
}
/**
* Sorts the AutofillFieldElements map by the elementNumber property.
* @private
*/
private sortAutofillFieldElementsMap() {
if (!this.autofillFieldElements.size) {
return;
}
this.autofillFieldElements = new Map(
[...this.autofillFieldElements].sort((a, b) => a[1].elementNumber - b[1].elementNumber),
);
}
/**
* Formats and returns the AutofillPageDetails object
*
* @param autofillFormsData - The data for all the forms found in the page
* @param autofillFieldsData - The data for all the fields found in the page
*/
private getFormattedPageDetails(
autofillFormsData: Record<string, AutofillForm>,
autofillFieldsData: AutofillField[],
): AutofillPageDetails {
return {
title: document.title,
url: (document.defaultView || globalThis).location.href,
documentUrl: document.location.href,
forms: autofillFormsData,
fields: autofillFieldsData,
collectedTimestamp: Date.now(),
};
}
/**
* Re-checks the visibility for all form fields and updates the
* cached data to reflect the most recent visibility state.
*
* @private
*/
private updateCachedAutofillFieldVisibility() {
this.autofillFieldElements.forEach(async (autofillField, element) => {
const currentViewableState = autofillField.viewable;
autofillField.viewable = await this.domElementVisibilityService.isFormFieldViewable(element);
if (!currentViewableState && autofillField.viewable) {
await this.autofillOverlayContentService?.setupInlineMenuListenerOnField(
element,
autofillField,
);
}
});
}
/**
* Queries the DOM for all the forms elements and
* returns a collection of AutofillForm objects.
* @returns {Record<string, AutofillForm>}
* @private
*/
private buildAutofillFormsData(formElements: Node[]): Record<string, AutofillForm> {
for (let index = 0; index < formElements.length; index++) {
const formElement = formElements[index] as ElementWithOpId<HTMLFormElement>;
formElement.opid = `__form__${index}`;
const existingAutofillForm = this.autofillFormElements.get(formElement);
if (existingAutofillForm) {
existingAutofillForm.opid = formElement.opid;
this.autofillFormElements.set(formElement, existingAutofillForm);
continue;
}
this.autofillFormElements.set(formElement, {
opid: formElement.opid,
htmlAction: this.getFormActionAttribute(formElement),
htmlName: this.getPropertyOrAttribute(formElement, "name"),
htmlID: this.getPropertyOrAttribute(formElement, "id"),
htmlMethod: this.getPropertyOrAttribute(formElement, "method"),
});
}
return this.getFormattedAutofillFormsData();
}
/**
* Returns the action attribute of the form element. If the action attribute
* is a relative path, it will be converted to an absolute path.
* @param {ElementWithOpId<HTMLFormElement>} element
* @returns {string}
* @private
*/
private getFormActionAttribute(element: ElementWithOpId<HTMLFormElement>): string {
return new URL(this.getPropertyOrAttribute(element, "action"), globalThis.location.href).href;
}
/**
* Iterates over all known form elements and returns an AutofillForm object
* containing a key value pair of the form element's opid and the form data.
* @returns {Record<string, AutofillForm>}
* @private
*/
private getFormattedAutofillFormsData(): Record<string, AutofillForm> {
const autofillForms: Record<string, AutofillForm> = {};
const autofillFormElements = Array.from(this.autofillFormElements);
for (let index = 0; index < autofillFormElements.length; index++) {
const [formElement, autofillForm] = autofillFormElements[index];
autofillForms[formElement.opid] = autofillForm;
}
return autofillForms;
}
/**
* Queries the DOM for all the field elements and
* returns a list of AutofillField objects.
* @returns {Promise<AutofillField[]>}
* @private
*/
private async buildAutofillFieldsData(
formFieldElements: FormFieldElement[],
): Promise<AutofillField[]> {
const autofillFieldElements = this.getAutofillFieldElements(100, formFieldElements);
const autofillFieldDataPromises = autofillFieldElements.map(this.buildAutofillFieldItem);
return Promise.all(autofillFieldDataPromises);
}
/**
* Queries the DOM for all the field elements that can be autofilled,
* and returns a list limited to the given `fieldsLimit` number that
* is ordered by priority.
* @param {number} fieldsLimit - The maximum number of fields to return
* @param {FormFieldElement[]} previouslyFoundFormFieldElements - The list of all the field elements
* @returns {FormFieldElement[]}
* @private
*/
private getAutofillFieldElements(
fieldsLimit?: number,
previouslyFoundFormFieldElements?: FormFieldElement[],
): FormFieldElement[] {
let formFieldElements = previouslyFoundFormFieldElements;
if (!formFieldElements) {
formFieldElements = this.useTreeWalkerStrategyFlagSet
? this.queryTreeWalkerForAutofillFormFieldElements()
: this.deepQueryElements(document, this.formFieldQueryString, true);
}
if (!fieldsLimit || formFieldElements.length <= fieldsLimit) {
return formFieldElements;
}
const priorityFormFields: FormFieldElement[] = [];
const unimportantFormFields: FormFieldElement[] = [];
const unimportantFieldTypesSet = new Set(["checkbox", "radio"]);
for (const element of formFieldElements) {
if (priorityFormFields.length >= fieldsLimit) {
return priorityFormFields;
}
const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase();
if (unimportantFieldTypesSet.has(fieldType)) {
unimportantFormFields.push(element);
continue;
}
priorityFormFields.push(element);
}
const numberUnimportantFieldsToInclude = fieldsLimit - priorityFormFields.length;
for (let index = 0; index < numberUnimportantFieldsToInclude; index++) {
priorityFormFields.push(unimportantFormFields[index]);
}
return priorityFormFields;
}
/**
* Builds an AutofillField object from the given form element. Will only return
* shared field values if the element is a span element. Will not return any label
* values if the element is a hidden input element.
*
* @param element - The form field element to build the AutofillField object from
* @param index - The index of the form field element
*/
private buildAutofillFieldItem = async (
element: ElementWithOpId<FormFieldElement>,
index: number,
): Promise<AutofillField | null> => {
if (element.closest("button[type='submit']")) {
return null;
}
element.opid = `__${index}`;
const existingAutofillField = this.autofillFieldElements.get(element);
if (index >= 0 && existingAutofillField) {
existingAutofillField.opid = element.opid;
existingAutofillField.elementNumber = index;
this.autofillFieldElements.set(element, existingAutofillField);
return existingAutofillField;
}
const autofillFieldBase = {
opid: element.opid,
elementNumber: index,
maxLength: this.getAutofillFieldMaxLength(element),
viewable: await this.domElementVisibilityService.isFormFieldViewable(element),
htmlID: this.getPropertyOrAttribute(element, "id"),
htmlName: this.getPropertyOrAttribute(element, "name"),
htmlClass: this.getPropertyOrAttribute(element, "class"),
tabindex: this.getPropertyOrAttribute(element, "tabindex"),
title: this.getPropertyOrAttribute(element, "title"),
tagName: this.getAttributeLowerCase(element, "tagName"),
};
if (!autofillFieldBase.viewable) {
this.elementInitializingIntersectionObserver.add(element);
this.intersectionObserver?.observe(element);
}
if (elementIsSpanElement(element)) {
this.cacheAutofillFieldElement(index, element, autofillFieldBase);
void this.autofillOverlayContentService?.setupInlineMenuListenerOnField(
element,
autofillFieldBase,
);
return autofillFieldBase;
}
let autofillFieldLabels = {};
const elementType = this.getAttributeLowerCase(element, "type");
if (elementType !== "hidden") {
autofillFieldLabels = {
"label-tag": this.createAutofillFieldLabelTag(element as FillableFormFieldElement),
"label-data": this.getPropertyOrAttribute(element, "data-label"),
"label-aria": this.getPropertyOrAttribute(element, "aria-label"),
"label-top": this.createAutofillFieldTopLabel(element),
"label-right": this.createAutofillFieldRightLabel(element),
"label-left": this.createAutofillFieldLeftLabel(element),
placeholder: this.getPropertyOrAttribute(element, "placeholder"),
};
}
const fieldFormElement = (element as ElementWithOpId<FillableFormFieldElement>).form;
const autofillField = {
...autofillFieldBase,
...autofillFieldLabels,
rel: this.getPropertyOrAttribute(element, "rel"),
type: elementType,
value: this.getElementValue(element),
checked: this.getAttributeBoolean(element, "checked"),
autoCompleteType: this.getAutoCompleteAttribute(element),
disabled: this.getAttributeBoolean(element, "disabled"),
readonly: this.getAttributeBoolean(element, "readonly"),
selectInfo: elementIsSelectElement(element)
? this.getSelectElementOptions(element as HTMLSelectElement)
: null,
form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null,
"aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true),
"aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true),
"aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true),
"data-stripe": this.getPropertyOrAttribute(element, "data-stripe"),
};
this.cacheAutofillFieldElement(index, element, autofillField);
void this.autofillOverlayContentService?.setupInlineMenuListenerOnField(element, autofillField);
return autofillField;
};
/**
* Caches the autofill field element and its data.
* Will not cache the element if the index is less than 0.
*
* @param index - The index of the autofill field element
* @param element - The autofill field element to cache
* @param autofillFieldData - The autofill field data to cache
*/
private cacheAutofillFieldElement(
index: number,
element: ElementWithOpId<FormFieldElement>,
autofillFieldData: AutofillField,
) {
if (index < 0) {
return;
}
this.autofillFieldElements.set(element, autofillFieldData);
}
/**
* Identifies the autocomplete attribute associated with an element and returns
* the value of the attribute if it is not set to "off".
* @param {ElementWithOpId<FormFieldElement>} element
* @returns {string}
* @private
*/
private getAutoCompleteAttribute(element: ElementWithOpId<FormFieldElement>): string {
const autoCompleteType =
this.getPropertyOrAttribute(element, "x-autocompletetype") ||
this.getPropertyOrAttribute(element, "autocompletetype") ||
this.getPropertyOrAttribute(element, "autocomplete");
return autoCompleteType !== "off" ? autoCompleteType : null;
}
/**
* Returns the attribute of an element as a lowercase value.
* @param {ElementWithOpId<FormFieldElement>} element
* @param {string} attributeName
* @returns {string}
* @private
*/
private getAttributeLowerCase(
element: ElementWithOpId<FormFieldElement>,
attributeName: string,
): string {
return this.getPropertyOrAttribute(element, attributeName)?.toLowerCase();
}
/**
* Returns the value of an element's property or attribute.
* @returns {AutofillField[]}
* @private
*/
private getFormattedAutofillFieldsData(): AutofillField[] {
return Array.from(this.autofillFieldElements.values());
}
/**
* Creates a label tag used to autofill the element pulled from a label
* associated with the element's id, name, parent element or from an
* associated description term element if no other labels can be found.
* Returns a string containing all the `textContent` or `innerText`
* values of the label elements.
* @param {FillableFormFieldElement} element
* @returns {string}
* @private
*/
private createAutofillFieldLabelTag(element: FillableFormFieldElement): string {
const labelElementsSet: Set<HTMLElement> = new Set(element.labels);
if (labelElementsSet.size) {
return this.createLabelElementsTag(labelElementsSet);
}
const labelElements: NodeListOf<HTMLLabelElement> | null = this.queryElementLabels(element);
for (let labelIndex = 0; labelIndex < labelElements?.length; labelIndex++) {
labelElementsSet.add(labelElements[labelIndex]);
}
let currentElement: HTMLElement | null = element;
while (currentElement && currentElement !== document.documentElement) {
if (elementIsLabelElement(currentElement)) {
labelElementsSet.add(currentElement);
}
currentElement = currentElement.parentElement?.closest("label");
}
if (
!labelElementsSet.size &&
elementIsDescriptionDetailsElement(element.parentElement) &&
elementIsDescriptionTermElement(element.parentElement.previousElementSibling)
) {
labelElementsSet.add(element.parentElement.previousElementSibling);
}
return this.createLabelElementsTag(labelElementsSet);
}
/**
* Queries the DOM for label elements associated with the given element
* by id or name. Returns a NodeList of label elements or null if none
* are found.
* @param {FillableFormFieldElement} element
* @returns {NodeListOf<HTMLLabelElement> | null}
* @private
*/
private queryElementLabels(
element: FillableFormFieldElement,
): NodeListOf<HTMLLabelElement> | null {
let labelQuerySelectors = element.id ? `label[for="${element.id}"]` : "";
if (element.name) {
const forElementNameSelector = `label[for="${element.name}"]`;
labelQuerySelectors = labelQuerySelectors
? `${labelQuerySelectors}, ${forElementNameSelector}`
: forElementNameSelector;
}
if (!labelQuerySelectors) {
return null;
}
return (element.getRootNode() as Document | ShadowRoot).querySelectorAll(
labelQuerySelectors.replace(/\n/g, ""),
);
}
/**
* Map over all the label elements and creates a
* string of the text content of each label element.
* @param {Set<HTMLElement>} labelElementsSet
* @returns {string}
* @private
*/
private createLabelElementsTag = (labelElementsSet: Set<HTMLElement>): string => {
return Array.from(labelElementsSet)
.map((labelElement) => {
const textContent: string | null = labelElement
? labelElement.textContent || labelElement.innerText
: null;
return this.trimAndRemoveNonPrintableText(textContent || "");
})
.join("");
};
/**
* Gets the maxLength property of the passed FormFieldElement and
* returns the value or null if the element does not have a
* maxLength property. If the element has a maxLength property
* greater than 999, it will return 999.
* @param {FormFieldElement} element
* @returns {number | null}
* @private
*/
private getAutofillFieldMaxLength(element: FormFieldElement): number | null {
const elementHasMaxLengthProperty =
elementIsInputElement(element) || elementIsTextAreaElement(element);
const elementMaxLength =
elementHasMaxLengthProperty && element.maxLength > -1 ? element.maxLength : 999;
return elementHasMaxLengthProperty ? Math.min(elementMaxLength, 999) : null;
}
/**
* Iterates over the next siblings of the passed element and
* returns a string of the text content of each element. Will
* stop iterating if it encounters a new section element.
* @param {FormFieldElement} element
* @returns {string}
* @private
*/
private createAutofillFieldRightLabel(element: FormFieldElement): string {
const labelTextContent: string[] = [];
let currentElement: ChildNode = element;
while (currentElement && currentElement.nextSibling) {
currentElement = currentElement.nextSibling;
if (this.isNewSectionElement(currentElement)) {
break;
}
const textContent = this.getTextContentFromElement(currentElement);
if (textContent) {
labelTextContent.push(textContent);
}
}
return labelTextContent.join("");
}
/**
* Recursively gets the text content from an element's previous siblings
* and returns a string of the text content of each element.
* @param {FormFieldElement} element
* @returns {string}
* @private
*/
private createAutofillFieldLeftLabel(element: FormFieldElement): string {
const labelTextContent: string[] = this.recursivelyGetTextFromPreviousSiblings(element);
return labelTextContent.reverse().join("");
}
/**
* Assumes that the input elements that are to be autofilled are within a
* table structure. Queries the previous sibling of the parent row that
* the input element is in and returns the text content of the cell that
* is in the same column as the input element.
* @param {FormFieldElement} element
* @returns {string | null}
* @private
*/
private createAutofillFieldTopLabel(element: FormFieldElement): string | null {
const tableDataElement = element.closest("td");
if (!tableDataElement) {
return null;
}
const tableDataElementIndex = tableDataElement.cellIndex;
const parentSiblingTableRowElement = tableDataElement.closest("tr")
?.previousElementSibling as HTMLTableRowElement;
return parentSiblingTableRowElement?.cells?.length > tableDataElementIndex
? this.getTextContentFromElement(parentSiblingTableRowElement.cells[tableDataElementIndex])
: null;
}
/**
* Check if the element's tag indicates that a transition to a new section of the
* page is occurring. If so, we should not use the element or its children in order
* to get autofill context for the previous element.
* @param {HTMLElement} currentElement
* @returns {boolean}
* @private
*/
private isNewSectionElement(currentElement: HTMLElement | Node): boolean {
if (!currentElement) {
return true;
}
const transitionalElementTagsSet = new Set([
"html",
"body",
"button",
"form",
"head",
"iframe",
"input",
"option",
"script",
"select",
"table",
"textarea",
]);
return (
"tagName" in currentElement &&
transitionalElementTagsSet.has(currentElement.tagName.toLowerCase())
);
}
/**
* Gets the text content from a passed element, regardless of whether it is a
* text node, an element node or an HTMLElement.
* @param {Node | HTMLElement} element
* @returns {string}
* @private
*/
private getTextContentFromElement(element: Node | HTMLElement): string {
if (element.nodeType === Node.TEXT_NODE) {
return this.trimAndRemoveNonPrintableText(element.nodeValue);
}
return this.trimAndRemoveNonPrintableText(
element.textContent || (element as HTMLElement).innerText,
);
}
/**
* Removes non-printable characters from the passed text
* content and trims leading and trailing whitespace.
* @param {string} textContent
* @returns {string}
* @private
*/
private trimAndRemoveNonPrintableText(textContent: string): string {
return (textContent || "")
.replace(/[^\x20-\x7E]+|\s+/g, " ") // Strip out non-primitive characters and replace multiple spaces with a single space
.trim(); // Trim leading and trailing whitespace
}
/**
* Get the text content from the previous siblings of the element. If
* no text content is found, recursively get the text content from the
* previous siblings of the parent element.
* @param {FormFieldElement} element
* @returns {string[]}
* @private
*/
private recursivelyGetTextFromPreviousSiblings(element: Node | HTMLElement): string[] {
const textContentItems: string[] = [];
let currentElement = element;
while (currentElement && currentElement.previousSibling) {
// Ensure we are capturing text content from nodes and elements.
currentElement = currentElement.previousSibling;
if (this.isNewSectionElement(currentElement)) {
return textContentItems;
}
const textContent = this.getTextContentFromElement(currentElement);
if (textContent) {
textContentItems.push(textContent);
}
}
if (!currentElement || textContentItems.length) {
return textContentItems;
}
// Prioritize capturing text content from elements rather than nodes.
currentElement = currentElement.parentElement || currentElement.parentNode;
if (!currentElement) {
return textContentItems;
}
let siblingElement = nodeIsElement(currentElement)
? currentElement.previousElementSibling
: currentElement.previousSibling;
while (siblingElement?.lastChild && !this.isNewSectionElement(siblingElement)) {
siblingElement = siblingElement.lastChild;
}
if (this.isNewSectionElement(siblingElement)) {
return textContentItems;
}
const textContent = this.getTextContentFromElement(siblingElement);
if (textContent) {
textContentItems.push(textContent);
return textContentItems;
}
return this.recursivelyGetTextFromPreviousSiblings(siblingElement);
}
/**
* Gets the value of the element. If the element is a checkbox, returns a checkmark if the
* checkbox is checked, or an empty string if it is not checked. If the element is a hidden
* input, returns the value of the input if it is less than 254 characters, or a truncated
* value if it is longer than 254 characters.
* @param {FormFieldElement} element
* @returns {string}
* @private
*/
private getElementValue(element: FormFieldElement): string {
if (!elementIsFillableFormField(element)) {
const spanTextContent = element.textContent || element.innerText;
return spanTextContent || "";
}
const elementValue = element.value || "";
const elementType = String(element.type).toLowerCase();
if ("checked" in element && elementType === "checkbox") {
return element.checked ? "✓" : "";
}
if (elementType === "hidden") {
const inputValueMaxLength = 254;
return elementValue.length > inputValueMaxLength
? `${elementValue.substring(0, inputValueMaxLength)}...SNIPPED`
: elementValue;
}
return elementValue;
}
/**
* Get the options from a select element and return them as an array
* of arrays indicating the select element option text and value.
* @param {HTMLSelectElement} element
* @returns {{options: (string | null)[][]}}
* @private
*/
private getSelectElementOptions(element: HTMLSelectElement): { options: (string | null)[][] } {
const options = Array.from(element.options).map((option) => {
const optionText = option.text
? String(option.text)
.toLowerCase()
.replace(/[\s~`!@$%^&#*()\-_+=:;'"[\]|\\,<.>?]/gm, "") // Remove whitespace and punctuation
: null;
return [optionText, option.value];
});
return { options };
}
/**
* Queries all potential form and field elements from the DOM and returns
* a collection of form and field elements. Leverages the TreeWalker API
* to deep query Shadow DOM elements.
*/
private queryAutofillFormAndFieldElements(): {
formElements: HTMLFormElement[];
formFieldElements: FormFieldElement[];
} {
if (this.useTreeWalkerStrategyFlagSet) {
return this.queryTreeWalkerForAutofillFormAndFieldElements();
}
const queriedElements = this.deepQueryElements<HTMLElement>(
document,
`form, ${this.formFieldQueryString}`,
true,
);
const formElements: HTMLFormElement[] = [];
const formFieldElements: FormFieldElement[] = [];
for (let index = 0; index < queriedElements.length; index++) {
const element = queriedElements[index];
if (elementIsFormElement(element)) {
formElements.push(element);
continue;
}
if (this.isNodeFormFieldElement(element)) {
formFieldElements.push(element);
}
}
return { formElements, formFieldElements };
}
/**
* Checks if the passed node is a form field element.
* @param {Node} node
* @returns {boolean}
* @private
*/
private isNodeFormFieldElement(node: Node): boolean {
if (!nodeIsElement(node)) {
return false;
}
const nodeTagName = node.tagName.toLowerCase();
const nodeIsSpanElementWithAutofillAttribute =
nodeTagName === "span" && node.hasAttribute("data-bwautofill");
if (nodeIsSpanElementWithAutofillAttribute) {
return true;
}
const nodeHasBwIgnoreAttribute = node.hasAttribute("data-bwignore");
const nodeIsValidInputElement =
nodeTagName === "input" && !this.ignoredInputTypes.has((node as HTMLInputElement).type);
if (nodeIsValidInputElement && !nodeHasBwIgnoreAttribute) {
return true;
}
return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute;
}
/**
* Attempts to get the ShadowRoot of the passed node. If support for the
* extension based openOrClosedShadowRoot API is available, it will be used.
* Will return null if the node is not an HTMLElement or if the node has
* child nodes.
*
* @param {Node} node
*/
private getShadowRoot(node: Node): ShadowRoot | null {
if (!nodeIsElement(node)) {
return null;
}
if (node.shadowRoot) {
return node.shadowRoot;
}
if ((chrome as any).dom?.openOrClosedShadowRoot) {
try {
return (chrome as any).dom.openOrClosedShadowRoot(node);
} catch (error) {
return null;
}
}
return (node as any).openOrClosedShadowRoot;
}
/**
* Sets up a mutation observer on the body of the document. Observes changes to
* DOM elements to ensure we have an updated set of autofill field data.
* @private
*/
private setupMutationObserver() {
this.currentLocationHref = globalThis.location.href;
this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation);
this.mutationObserver.observe(document.documentElement, {
attributes: true,
childList: true,
subtree: true,
});
}
/**
* Handles observed DOM mutations and identifies if a mutation is related to
* an autofill element. If so, it will update the autofill element data.
* @param {MutationRecord[]} mutations
* @private
*/
private handleMutationObserverMutation = (mutations: MutationRecord[]) => {
if (this.currentLocationHref !== globalThis.location.href) {
this.handleWindowLocationMutation();
return;
}
if (!this.mutationsQueue.length) {
globalThis.requestIdleCallback(this.processMutations, { timeout: 500 });
}
this.mutationsQueue.push(mutations);
};
/**
* Handles a mutation to the window location. Clears the autofill elements
* and updates the autofill elements after a timeout.
* @private
*/
private handleWindowLocationMutation() {
this.currentLocationHref = globalThis.location.href;
this.domRecentlyMutated = true;
if (this.autofillOverlayContentService) {
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
}
this.noFieldsFound = false;
this.autofillFormElements.clear();
this.autofillFieldElements.clear();
this.updateAutofillElementsAfterMutation();
}
/**
* Handles the processing of all mutations in the mutations queue. Will trigger
* within an idle callback to help with performance and prevent excessive updates.
*/
private processMutations = () => {
for (let queueIndex = 0; queueIndex < this.mutationsQueue.length; queueIndex++) {
this.processMutationRecord(this.mutationsQueue[queueIndex]);
}
if (this.domRecentlyMutated) {
this.updateAutofillElementsAfterMutation();
}
this.mutationsQueue = [];
};
/**
* Processes a mutation record and updates the autofill elements if necessary.
*
* @param mutations - The mutation record to process
*/
private processMutationRecord(mutations: MutationRecord[]) {
for (let mutationIndex = 0; mutationIndex < mutations.length; mutationIndex++) {
const mutation = mutations[mutationIndex];
if (
mutation.type === "childList" &&
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
this.isAutofillElementNodeMutated(mutation.addedNodes))
) {
this.domRecentlyMutated = true;
if (this.autofillOverlayContentService) {
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
}
this.noFieldsFound = false;
continue;
}
if (mutation.type === "attributes") {
this.handleAutofillElementAttributeMutation(mutation);
}
}
}
/**
* Checks if the passed nodes either contain or are autofill elements.
*
* @param nodes - The nodes to check
* @param isRemovingNodes - Whether the nodes are being removed
*/
private isAutofillElementNodeMutated(nodes: NodeList, isRemovingNodes = false): boolean {
if (!nodes.length) {
return false;
}
let isElementMutated = false;
let mutatedElements: HTMLElement[] = [];
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
if (!nodeIsElement(node)) {
continue;
}
if (
!this.useTreeWalkerStrategyFlagSet &&
(nodeIsFormElement(node) || this.isNodeFormFieldElement(node))
) {
mutatedElements.push(node as HTMLElement);
}
const autofillElements = this.useTreeWalkerStrategyFlagSet
? this.queryTreeWalkerForMutatedElements(node)
: this.deepQueryElements<HTMLElement>(node, `form, ${this.formFieldQueryString}`, true);
if (autofillElements.length) {
mutatedElements = mutatedElements.concat(autofillElements);
}
if (mutatedElements.length) {
isElementMutated = true;
}
}
if (isRemovingNodes) {
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
const element = mutatedElements[elementIndex];
this.deleteCachedAutofillElement(
element as ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
);
}
} else if (this.autofillOverlayContentService) {
this.setupOverlayListenersOnMutatedElements(mutatedElements);
}
return isElementMutated;
}
/**
* Sets up the overlay listeners on the passed mutated elements. This ensures
* that the overlay can appear on elements that are injected into the DOM after
* the initial page load.
*
* @param mutatedElements - HTML elements that have been mutated
*/
private setupOverlayListenersOnMutatedElements(mutatedElements: Node[]) {
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
const node = mutatedElements[elementIndex];
if (
!this.isNodeFormFieldElement(node) ||
this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
) {
continue;
}
globalThis.requestIdleCallback(
// We are setting this item to a -1 index because we do not know its position in the DOM.
// This value should be updated with the next call to collect page details.
() => void this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1),
{ timeout: 1000 },
);
}
}
/**
* Deletes any cached autofill elements that have been
* removed from the DOM.
* @param {ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>} element
* @private
*/
private deleteCachedAutofillElement(
element: ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
) {
if (elementIsFormElement(element) && this.autofillFormElements.has(element)) {
this.autofillFormElements.delete(element);
return;
}
if (this.autofillFieldElements.has(element)) {
this.autofillFieldElements.delete(element);
}
}
/**
* Updates the autofill elements after a DOM mutation has occurred.
* Is debounced to prevent excessive updates.
* @private
*/
private updateAutofillElementsAfterMutation() {
if (this.updateAutofillElementsAfterMutationTimeout) {
clearTimeout(this.updateAutofillElementsAfterMutationTimeout);
}
this.updateAutofillElementsAfterMutationTimeout = setTimeout(
this.getPageDetails.bind(this),
this.updateAfterMutationTimeoutDelay,
);
}
/**
* Handles observed DOM mutations related to an autofill element attribute.
* @param {MutationRecord} mutation
* @private
*/
private handleAutofillElementAttributeMutation(mutation: MutationRecord) {
const targetElement = mutation.target;
if (!nodeIsElement(targetElement)) {
return;
}
const attributeName = mutation.attributeName?.toLowerCase();
const autofillForm = this.autofillFormElements.get(
targetElement as ElementWithOpId<HTMLFormElement>,
);
if (autofillForm) {
this.updateAutofillFormElementData(
attributeName,
targetElement as ElementWithOpId<HTMLFormElement>,
autofillForm,
);
return;
}
const autofillField = this.autofillFieldElements.get(
targetElement as ElementWithOpId<FormFieldElement>,
);
if (!autofillField) {
return;
}
this.updateAutofillFieldElementData(
attributeName,
targetElement as ElementWithOpId<FormFieldElement>,
autofillField,
);
}
/**
* Updates the autofill form element data based on the passed attribute name.
* @param {string} attributeName
* @param {ElementWithOpId<HTMLFormElement>} element
* @param {AutofillForm} dataTarget
* @private
*/
private updateAutofillFormElementData(
attributeName: string,
element: ElementWithOpId<HTMLFormElement>,
dataTarget: AutofillForm,
) {
const updateAttribute = (dataTargetKey: string) => {
this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey });
};
const updateActions: Record<string, CallableFunction> = {
action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)),
name: () => updateAttribute("htmlName"),
id: () => updateAttribute("htmlID"),
method: () => updateAttribute("htmlMethod"),
};
if (!updateActions[attributeName]) {
return;
}
updateActions[attributeName]();
if (this.autofillFormElements.has(element)) {
this.autofillFormElements.set(element, dataTarget);
}
}
/**
* Updates the autofill field element data based on the passed attribute name.
*
* @param {string} attributeName
* @param {ElementWithOpId<FormFieldElement>} element
* @param {AutofillField} dataTarget
*/
private updateAutofillFieldElementData(
attributeName: string,
element: ElementWithOpId<FormFieldElement>,
dataTarget: AutofillField,
) {
const updateAttribute = (dataTargetKey: string) => {
this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey });
};
const updateActions: Record<string, CallableFunction> = {
maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)),
id: () => updateAttribute("htmlID"),
name: () => updateAttribute("htmlName"),
class: () => updateAttribute("htmlClass"),
tabindex: () => updateAttribute("tabindex"),
title: () => updateAttribute("tabindex"),
rel: () => updateAttribute("rel"),
tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")),
type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")),
value: () => (dataTarget.value = this.getElementValue(element)),
checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")),
disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")),
readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")),
autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)),
"data-label": () => updateAttribute("label-data"),
"aria-label": () => updateAttribute("label-aria"),
"aria-hidden": () =>
(dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)),
"aria-disabled": () =>
(dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)),
"aria-haspopup": () =>
(dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)),
"data-stripe": () => updateAttribute("data-stripe"),
};
if (!updateActions[attributeName]) {
return;
}
updateActions[attributeName]();
if (this.autofillFieldElements.has(element)) {
this.autofillFieldElements.set(element, dataTarget);
}
}
/**
* Gets the attribute value for the passed element, and returns it. If the dataTarget
* and dataTargetKey are passed, it will set the value of the dataTarget[dataTargetKey].
* @param UpdateAutofillDataAttributeParams
* @returns {string}
* @private
*/
private updateAutofillDataAttribute({
element,
attributeName,
dataTarget,
dataTargetKey,
}: UpdateAutofillDataAttributeParams) {
const attributeValue = this.getPropertyOrAttribute(element, attributeName);
if (dataTarget && dataTargetKey) {
dataTarget[dataTargetKey] = attributeValue;
}
return attributeValue;
}
/**
* Sets up an IntersectionObserver to observe found form
* field elements that are not viewable in the viewport.
*/
private setupIntersectionObserver() {
this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, {
root: null,
rootMargin: "0px",
threshold: 1.0,
});
}
/**
* Handles observed form field elements that are not viewable in the viewport.
* Will re-evaluate the visibility of the element and set up the autofill
* overlay listeners on the field if it is viewable.
*
* @param entries - The entries observed by the IntersectionObserver
*/
private handleFormElementIntersection = async (entries: IntersectionObserverEntry[]) => {
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
const entry = entries[entryIndex];
const formFieldElement = entry.target as ElementWithOpId<FormFieldElement>;
if (this.elementInitializingIntersectionObserver.has(formFieldElement)) {
this.elementInitializingIntersectionObserver.delete(formFieldElement);
continue;
}
const cachedAutofillFieldElement = this.autofillFieldElements.get(formFieldElement);
if (!cachedAutofillFieldElement) {
this.intersectionObserver.unobserve(entry.target);
continue;
}
const isViewable =
await this.domElementVisibilityService.isFormFieldViewable(formFieldElement);
if (!isViewable) {
continue;
}
cachedAutofillFieldElement.viewable = true;
void this.autofillOverlayContentService?.setupInlineMenuListenerOnField(
formFieldElement,
cachedAutofillFieldElement,
);
this.intersectionObserver?.unobserve(entry.target);
}
};
/**
* Destroys the CollectAutofillContentService. Clears all
* timeouts and disconnects the mutation observer.
*/
destroy() {
if (this.updateAutofillElementsAfterMutationTimeout) {
clearTimeout(this.updateAutofillElementsAfterMutationTimeout);
}
this.mutationObserver?.disconnect();
this.intersectionObserver?.disconnect();
}
/**
* Queries the DOM for all the nodes that match the given filter callback
* and returns a collection of nodes.
* @param rootNode
* @param filterCallback
* @param isObservingShadowRoot
*
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*/
private queryAllTreeWalkerNodes(
rootNode: Node,
filterCallback: CallableFunction,
isObservingShadowRoot = true,
): Node[] {
const treeWalkerQueryResults: Node[] = [];
this.buildTreeWalkerNodesQueryResults(
rootNode,
treeWalkerQueryResults,
filterCallback,
isObservingShadowRoot,
);
return treeWalkerQueryResults;
}
/**
* Recursively builds a collection of nodes that match the given filter callback.
* If a node has a ShadowRoot, it will be observed for mutations.
*
* @param rootNode
* @param treeWalkerQueryResults
* @param filterCallback
*
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*/
private buildTreeWalkerNodesQueryResults(
rootNode: Node,
treeWalkerQueryResults: Node[],
filterCallback: CallableFunction,
isObservingShadowRoot: boolean,
) {
const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT);
let currentNode = treeWalker?.currentNode;
while (currentNode) {
if (filterCallback(currentNode)) {
treeWalkerQueryResults.push(currentNode);
}
const nodeShadowRoot = this.getShadowRoot(currentNode);
if (nodeShadowRoot) {
if (isObservingShadowRoot) {
this.mutationObserver.observe(nodeShadowRoot, {
attributes: true,
childList: true,
subtree: true,
});
}
this.buildTreeWalkerNodesQueryResults(
nodeShadowRoot,
treeWalkerQueryResults,
filterCallback,
isObservingShadowRoot,
);
}
currentNode = treeWalker?.nextNode();
}
}
/**
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*/
private queryTreeWalkerForAutofillFormAndFieldElements(): {
formElements: HTMLFormElement[];
formFieldElements: FormFieldElement[];
} {
const formElements: HTMLFormElement[] = [];
const formFieldElements: FormFieldElement[] = [];
this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => {
if (nodeIsFormElement(node)) {
formElements.push(node);
return true;
}
if (this.isNodeFormFieldElement(node)) {
formFieldElements.push(node as FormFieldElement);
return true;
}
return false;
});
return { formElements, formFieldElements };
}
/**
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*/
private queryTreeWalkerForAutofillFormFieldElements(): FormFieldElement[] {
return this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) =>
this.isNodeFormFieldElement(node),
) as FormFieldElement[];
}
/**
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*
* @param node - The node to query
*/
private queryTreeWalkerForMutatedElements(node: Node): HTMLElement[] {
return this.queryAllTreeWalkerNodes(
node,
(walkerNode: Node) =>
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
) as HTMLElement[];
}
/**
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*/
private queryTreeWalkerForPasswordElements(): HTMLElement[] {
return this.queryAllTreeWalkerNodes(
document.documentElement,
(node: Node) => nodeIsInputElement(node) && node.type === "password",
false,
) as HTMLElement[];
}
/**
* This is a temporary method to maintain a fallback strategy for the tree walker API
*
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
*/
isPasswordFieldWithinDocument(): boolean {
if (this.useTreeWalkerStrategyFlagSet) {
return Boolean(this.queryTreeWalkerForPasswordElements()?.length);
}
return Boolean(this.deepQueryElements(document, `input[type="password"]`)?.length);
}
}
export default CollectAutofillContentService;