1223 lines
41 KiB
TypeScript
1223 lines
41 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,
|
|
FormElementWithAttribute,
|
|
} from "../types";
|
|
|
|
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 noFieldsFound = false;
|
|
private domRecentlyMutated = true;
|
|
private autofillFormElements: AutofillFormElements = new Map();
|
|
private autofillFieldElements: AutofillFieldElements = new Map();
|
|
private currentLocationHref = "";
|
|
private mutationObserver: MutationObserver;
|
|
private updateAutofillElementsAfterMutationTimeout: NodeJS.Timeout;
|
|
private readonly updateAfterMutationTimeoutDelay = 1000;
|
|
|
|
constructor(
|
|
domElementVisibilityService: DomElementVisibilityService,
|
|
autofillOverlayContentService?: AutofillOverlayContentService,
|
|
) {
|
|
this.domElementVisibilityService = domElementVisibilityService;
|
|
this.autofillOverlayContentService = autofillOverlayContentService;
|
|
}
|
|
|
|
/**
|
|
* 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.domRecentlyMutated && this.noFieldsFound) {
|
|
return this.getFormattedPageDetails({}, []);
|
|
}
|
|
|
|
if (!this.domRecentlyMutated && this.autofillFieldElements.size) {
|
|
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[],
|
|
);
|
|
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 the DOM for all the nodes that match the given filter callback
|
|
* and returns a collection of nodes.
|
|
* @param {Node} rootNode
|
|
* @param {Function} filterCallback
|
|
* @param {boolean} isObservingShadowRoot
|
|
* @returns {Node[]}
|
|
*/
|
|
queryAllTreeWalkerNodes(
|
|
rootNode: Node,
|
|
filterCallback: CallableFunction,
|
|
isObservingShadowRoot = true,
|
|
): Node[] {
|
|
const treeWalkerQueryResults: Node[] = [];
|
|
|
|
this.buildTreeWalkerNodesQueryResults(
|
|
rootNode,
|
|
treeWalkerQueryResults,
|
|
filterCallback,
|
|
isObservingShadowRoot,
|
|
);
|
|
|
|
return treeWalkerQueryResults;
|
|
}
|
|
|
|
/**
|
|
* 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 {Record<string, AutofillForm>} autofillFormsData
|
|
* @param {AutofillField[]} autofillFieldsData
|
|
* @returns {AutofillPageDetails}
|
|
* @private
|
|
*/
|
|
private getFormattedPageDetails(
|
|
autofillFormsData: Record<string, AutofillForm>,
|
|
autofillFieldsData: AutofillField[],
|
|
): AutofillPageDetails {
|
|
return {
|
|
title: document.title,
|
|
url: (document.defaultView || window).location.href,
|
|
documentUrl: document.location.href,
|
|
forms: autofillFormsData,
|
|
fields: autofillFieldsData,
|
|
collectedTimestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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"), window.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[] {
|
|
const formFieldElements =
|
|
previouslyFoundFormFieldElements ||
|
|
(this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) =>
|
|
this.isNodeFormFieldElement(node),
|
|
) as FormFieldElement[]);
|
|
|
|
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 {ElementWithOpId<FormFieldElement>} element
|
|
* @param {number} index
|
|
* @returns {Promise<AutofillField>}
|
|
* @private
|
|
*/
|
|
private buildAutofillFieldItem = async (
|
|
element: ElementWithOpId<FormFieldElement>,
|
|
index: number,
|
|
): Promise<AutofillField> => {
|
|
element.opid = `__${index}`;
|
|
|
|
const existingAutofillField = this.autofillFieldElements.get(element);
|
|
if (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 (element instanceof HTMLSpanElement) {
|
|
this.autofillFieldElements.set(element, autofillFieldBase);
|
|
this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(
|
|
element,
|
|
autofillFieldBase,
|
|
);
|
|
return autofillFieldBase;
|
|
}
|
|
|
|
let autofillFieldLabels = {};
|
|
const elementType = this.getAttributeLowerCase(element, "type");
|
|
if (elementType !== "hidden") {
|
|
autofillFieldLabels = {
|
|
"label-tag": this.createAutofillFieldLabelTag(element),
|
|
"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 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:
|
|
element instanceof HTMLSelectElement ? this.getSelectElementOptions(element) : null,
|
|
form: element.form ? this.getPropertyOrAttribute(element.form, "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.autofillFieldElements.set(element, autofillField);
|
|
this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(element, autofillField);
|
|
return autofillField;
|
|
};
|
|
|
|
/**
|
|
* 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 a boolean representing the attribute value of an element.
|
|
* @param {ElementWithOpId<FormFieldElement>} element
|
|
* @param {string} attributeName
|
|
* @param {boolean} checkString
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private getAttributeBoolean(
|
|
element: ElementWithOpId<FormFieldElement>,
|
|
attributeName: string,
|
|
checkString = false,
|
|
): boolean {
|
|
if (checkString) {
|
|
return this.getPropertyOrAttribute(element, attributeName) === "true";
|
|
}
|
|
|
|
return Boolean(this.getPropertyOrAttribute(element, attributeName));
|
|
}
|
|
|
|
/**
|
|
* 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 (currentElement instanceof HTMLLabelElement) {
|
|
labelElementsSet.add(currentElement);
|
|
}
|
|
|
|
currentElement = currentElement.parentElement?.closest("label");
|
|
}
|
|
|
|
if (
|
|
!labelElementsSet.size &&
|
|
element.parentElement?.tagName.toLowerCase() === "dd" &&
|
|
element.parentElement.previousElementSibling?.tagName.toLowerCase() === "dt"
|
|
) {
|
|
labelElementsSet.add(element.parentElement.previousElementSibling as HTMLElement);
|
|
}
|
|
|
|
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 =
|
|
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement;
|
|
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;
|
|
|
|
let siblingElement =
|
|
currentElement instanceof HTMLElement
|
|
? 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);
|
|
}
|
|
|
|
/**
|
|
* Get the value of a property or attribute from a FormFieldElement.
|
|
* @param {HTMLElement} element
|
|
* @param {string} attributeName
|
|
* @returns {string | null}
|
|
* @private
|
|
*/
|
|
private getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null {
|
|
if (attributeName in element) {
|
|
return (element as FormElementWithAttribute)[attributeName];
|
|
}
|
|
|
|
return element.getAttribute(attributeName);
|
|
}
|
|
|
|
/**
|
|
* 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 (element instanceof HTMLSpanElement) {
|
|
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.
|
|
* @returns {{formElements: Node[], formFieldElements: Node[]}}
|
|
* @private
|
|
*/
|
|
private queryAutofillFormAndFieldElements(): {
|
|
formElements: Node[];
|
|
formFieldElements: Node[];
|
|
} {
|
|
const formElements: Node[] = [];
|
|
const formFieldElements: Node[] = [];
|
|
this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => {
|
|
if (node instanceof HTMLFormElement) {
|
|
formElements.push(node);
|
|
return true;
|
|
}
|
|
|
|
if (this.isNodeFormFieldElement(node)) {
|
|
formFieldElements.push(node);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
return { formElements, formFieldElements };
|
|
}
|
|
|
|
/**
|
|
* Checks if the passed node is a form field element.
|
|
* @param {Node} node
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private isNodeFormFieldElement(node: Node): boolean {
|
|
const nodeIsSpanElementWithAutofillAttribute =
|
|
node instanceof HTMLSpanElement && node.hasAttribute("data-bwautofill");
|
|
|
|
const ignoredInputTypes = new Set(["hidden", "submit", "reset", "button", "image", "file"]);
|
|
const nodeIsValidInputElement =
|
|
node instanceof HTMLInputElement && !ignoredInputTypes.has(node.type);
|
|
|
|
const nodeIsTextAreaOrSelectElement =
|
|
node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement;
|
|
|
|
const nodeIsNonIgnoredFillableControlElement =
|
|
(nodeIsTextAreaOrSelectElement || nodeIsValidInputElement) &&
|
|
!node.hasAttribute("data-bwignore");
|
|
|
|
return nodeIsSpanElementWithAutofillAttribute || nodeIsNonIgnoredFillableControlElement;
|
|
}
|
|
|
|
/**
|
|
* Attempts to get the ShadowRoot of the passed node. If support for the
|
|
* extension based openOrClosedShadowRoot API is available, it will be used.
|
|
* @param {Node} node
|
|
* @returns {ShadowRoot | null}
|
|
* @private
|
|
*/
|
|
private getShadowRoot(node: Node): ShadowRoot | null {
|
|
if (!(node instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
if ((chrome as any).dom?.openOrClosedShadowRoot) {
|
|
return (chrome as any).dom.openOrClosedShadowRoot(node);
|
|
}
|
|
|
|
return (node as any).openOrClosedShadowRoot || node.shadowRoot;
|
|
}
|
|
|
|
/**
|
|
* 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 {Node} rootNode
|
|
* @param {Node[]} treeWalkerQueryResults
|
|
* @param {Function} filterCallback
|
|
* @param {boolean} isObservingShadowRoot
|
|
* @private
|
|
*/
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
for (let mutationsIndex = 0; mutationsIndex < mutations.length; mutationsIndex++) {
|
|
const mutation = mutations[mutationsIndex];
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (this.domRecentlyMutated) {
|
|
this.updateAutofillElementsAfterMutation();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* Checks if the passed nodes either contain or are autofill elements.
|
|
* @param {NodeList} nodes
|
|
* @param {boolean} isRemovingNodes
|
|
* @returns {boolean}
|
|
* @private
|
|
*/
|
|
private isAutofillElementNodeMutated(nodes: NodeList, isRemovingNodes = false): boolean {
|
|
if (!nodes.length) {
|
|
return false;
|
|
}
|
|
|
|
let isElementMutated = false;
|
|
const mutatedElements = [];
|
|
for (let index = 0; index < nodes.length; index++) {
|
|
const node = nodes[index];
|
|
if (!(node instanceof HTMLElement)) {
|
|
continue;
|
|
}
|
|
|
|
if (node instanceof HTMLFormElement || this.isNodeFormFieldElement(node)) {
|
|
isElementMutated = true;
|
|
mutatedElements.push(node);
|
|
continue;
|
|
}
|
|
|
|
const childNodes = this.queryAllTreeWalkerNodes(
|
|
node,
|
|
(node: Node) => node instanceof HTMLFormElement || this.isNodeFormFieldElement(node),
|
|
) as HTMLElement[];
|
|
if (childNodes.length) {
|
|
isElementMutated = true;
|
|
mutatedElements.push(...childNodes);
|
|
}
|
|
}
|
|
|
|
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
|
|
const node = mutatedElements[elementIndex];
|
|
if (isRemovingNodes) {
|
|
this.deleteCachedAutofillElement(
|
|
node as ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
this.autofillOverlayContentService &&
|
|
this.isNodeFormFieldElement(node) &&
|
|
!this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
|
|
) {
|
|
// 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.
|
|
this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1);
|
|
}
|
|
}
|
|
|
|
return isElementMutated;
|
|
}
|
|
|
|
/**
|
|
* 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 (element instanceof HTMLFormElement && 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 (!(targetElement instanceof HTMLElement)) {
|
|
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]();
|
|
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
|
|
* @returns {Promise<void>}
|
|
* @private
|
|
*/
|
|
private async 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]();
|
|
|
|
const visibilityAttributesSet = new Set(["class", "style"]);
|
|
if (
|
|
visibilityAttributesSet.has(attributeName) &&
|
|
!dataTarget.htmlClass?.includes("com-bitwarden-browser-animated-fill")
|
|
) {
|
|
dataTarget.viewable = await this.domElementVisibilityService.isFormFieldViewable(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;
|
|
}
|
|
}
|
|
|
|
export default CollectAutofillContentService;
|