blob: de4133692c6387d522d6bc09217e64b1e50f6ccb [file] [log] [blame]
// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Contains method needed to access the forms and their elements.
*/
import {RENDERER_ID_NOT_SET} from '//components/autofill/ios/form_util/resources/fill_constants.js';
import {getRemoteFrameToken, getUniqueID} from '//components/autofill/ios/form_util/resources/fill_util.js';
import {isFormControlElement} from '//components/autofill/ios/form_util/resources/form_utils.js';
import {gCrWeb, gCrWebLegacy} from '//ios/web/public/js_messaging/resources/gcrweb.js';
import {sendWebKitMessage, trim} from '//ios/web/public/js_messaging/resources/utils.js';
/**
* Prefix used in references to form elements that have no 'id' or 'name'
*/
const kNamelessFormIDPrefix = 'gChrome~form~';
/**
* Prefix used in references to field elements that have no 'id' or 'name' but
* are included in a form.
*/
const kNamelessFieldIDPrefix = 'gChrome~field~';
/**
* A WeakMap to track if the current value of a field was entered by user or
* programmatically.
* If the map is null, the source of changed is not track.
*/
const wasEditedByUser: WeakMap<any, any> = new WeakMap();
/**
* Registry that tracks the forms that were submitted during the frame's
* lifetime. Elements that are garbage collected will be removed from the
* registry so this can't memory leak. In the worst case the registry will get
* as big as the number of submitted forms that aren't yet deleted and we don't
* expect a lot of those.
*/
const formSubmissionRegistry: WeakSet<any> = new WeakSet();
/**
* Retrieves the registered 'autofill_form_features' CrWebApi
* instance for use in this file.
*/
const autofillFormFeaturesApi =
gCrWeb.getRegisteredApi('autofill_form_features');
// LINT.IfChange(autofill_count_form_submission_in_renderer)
// The source that triggered the sending of the form submission report.
enum FormSubmissionReportSource {
// Report was sent immediately because quota was available.
INSTANT,
// Report was sent from the scheduled task.
SCHEDULED_TASK,
// Report was sent from unloading the page content.
UNLOAD_PAGE,
}
// LINT.ThenChange(//components/autofill/ios/form_util/form_activity_tab_helper.mm:autofill_count_form_submission_in_renderer)
/**
* Represent the number of form submissions split by type.
*/
interface FormSubmissionCountReport {
// From a submit event.
htmlEvent: number;
// Triggered via `form.submit()`.
programmatic: number;
}
/**
* Manager of form submission reports. Takes care of throttling form submission
* reports via quota and schedules batches of aggregated reports.
*/
class FormSubmissionReportManager {
/**
* Time period for refreshing the report quota.
*/
private static readonly QUOTA_REFRESH_PERIOD_MS = 4000; // 4 seconds
/**
* Time period in milliseconds between each form submission count report.
*/
private static readonly REPORT_PERIOD_MS = 2000; // 2 seconds
/**
* Number of reports allowed by the quota.
*/
private static readonly QUOTA_SIZE = 2;
// Maps the message handler to the pending reports to send to that handler.
private formSubmissionCountReportMap: Map<string, FormSubmissionCountReport> =
new Map();
/**
* Quota of form submission reports that can be sent before using throttling.
* Reports sent under the quota are sent directly to the browser without
* the need for scheduling a report which is much faster and reliable.
*/
private formSubmissionReportQuotaRemaining =
FormSubmissionReportManager.QUOTA_SIZE;
constructor() {
window.addEventListener('unload', () => {
// Send the submission count report right now as the document is about to
// be unloaded, meaning that the reporting scheduled task is likely to be
// cancelled. This doesn't work when the entire tab is closed.
this.sendFormSubmissionCountReports(
FormSubmissionReportSource.UNLOAD_PAGE);
});
}
sendReport(isProgrammatic: boolean, handler: string): void {
if (!autofillFormFeaturesApi.getFunction(
'isAutofillCountFormSubmissionInRendererEnabled')()) {
// Do not report anything if the feature is disabled.
return;
}
const scheduleReport = this.formSubmissionCountReportMap.size === 0;
// Initialize the report if there isn't already one for the `handler`.
if (!this.formSubmissionCountReportMap.has(handler)) {
this.formSubmissionCountReportMap.set(
handler, {htmlEvent: 0, programmatic: 0});
}
const report: FormSubmissionCountReport =
this.formSubmissionCountReportMap.get(handler)!;
if (isProgrammatic) {
++report.programmatic;
} else {
++report.htmlEvent;
}
if (this.formSubmissionReportQuotaRemaining > 0) {
--this.formSubmissionReportQuotaRemaining;
// Report right away if the quota wasn't reached yet.
this.sendFormSubmissionCountReports(FormSubmissionReportSource.INSTANT);
// Reset the quota after a cooldown period.
setTimeout(
() => ++this.formSubmissionReportQuotaRemaining,
FormSubmissionReportManager.QUOTA_REFRESH_PERIOD_MS);
return;
}
if (scheduleReport) {
// If no quota is available, schedule a report if there isn't already
// one pending.
const reportFn = () => this.sendFormSubmissionCountReports(
FormSubmissionReportSource.SCHEDULED_TASK);
setTimeout(reportFn, FormSubmissionReportManager.REPORT_PERIOD_MS);
}
}
/**
* Sends the `formSubmissionCountReport` (if there is) to the browser.
*/
private sendFormSubmissionCountReports(source: FormSubmissionReportSource):
void {
this.formSubmissionCountReportMap.forEach(
(report: FormSubmissionCountReport, handler: string) => {
const message = {
command: 'form.submit.count',
...report,
source,
};
sendWebKitMessage(handler, message);
});
this.formSubmissionCountReportMap.clear();
}
}
const gFormSubmissionReportManager = new FormSubmissionReportManager();
/**
* Returns an array of control elements in a form.
*
* This method is based on the logic in method
* void WebFormElement::getFormControlElements(
* WebVector<WebFormControlElement>&) const
* in chromium/src/third_party/WebKit/Source/WebKit/chromium/src/
* WebFormElement.cpp.
*
* @param form A form element for which the control elements are returned.
* @return An array of form control elements.
*/
function getFormControlElements(form: HTMLFormElement|null): Element[] {
if (!form) {
return [];
}
const results: Element[] = [];
// Get input and select elements from form.elements.
// According to
// http://www.w3.org/TR/2011/WD-html5-20110525/forms.html, form.elements are
// the "listed elements whose form owner is the form element, with the
// exception of input elements whose type attribute is in the Image Button
// state, which must, for historical reasons, be excluded from this
// particular collection." In WebFormElement.cpp, this method is implemented
// by returning elements in form's associated elements that have tag 'INPUT'
// or 'SELECT'. Check if input Image Buttons are excluded in that
// implementation. Note for Autofill, as input Image Button is not
// considered as autofillable elements, there is no impact on Autofill
// feature.
for (const element of form.elements) {
if (isFormControlElement(element)) {
results.push(element);
}
}
return results;
}
/**
* Returns an array of iframe elements that are descendents of `root`.
*
* @param root The node under which to search for iframe elements.
* @return An array of iframe elements.
*/
function getIframeElements(root: Element|null): HTMLIFrameElement[] {
return Array.from(root?.querySelectorAll('iframe') ?? []) as
HTMLIFrameElement[];
}
/**
* Returns the field's `id` attribute if not space only; otherwise the
* form's |name| attribute if the field is part of a form. Otherwise,
* generate a technical identifier
*
* It is the identifier that should be used for the specified |element| when
* storing Autofill data. This identifier will be used when filling the field
* to lookup this field. The pair (getFormIdentifier, getFieldIdentifier) must
* be unique on the page.
* The following elements are considered to generate the identifier:
* - the id of the element
* - the name of the element if the element is part of a form
* - the order of the element in the form if the element is part of the form.
* - generate a xpath to the element and use it as an ID.
*
* Note: if this method returns '', the field will not be accessible and
* cannot be autofilled.
*
* It aims to provide the logic in
* WebString nameForAutofill() const;
* in chromium/src/third_party/WebKit/Source/WebKit/chromium/public/
* WebFormControlElement.h
*
* @param element An element of which the name for Autofill will be
* returned.
* @return the name for Autofill.
*/
function getFieldIdentifier(element: Element|null): string {
if (!element) {
return '';
}
let trimmedIdentifier: string|null = element.id;
if (trimmedIdentifier) {
return trim(trimmedIdentifier);
}
if ('form' in element && element.form) {
const form = element.form as HTMLFormElement;
// The name of an element is only relevant as an identifier if the element
// is part of a form.
trimmedIdentifier = 'name' in element ? element.name as string : null;
if (trimmedIdentifier) {
trimmedIdentifier = trim(trimmedIdentifier);
if (trimmedIdentifier!.length > 0) {
return trimmedIdentifier!;
}
}
const elements = getFormControlElements(form);
for (let index = 0; index < elements.length; index++) {
if (elements[index] === element) {
return kNamelessFieldIDPrefix + index;
}
}
}
// Element is not part of a form and has no name or id, or usable attribute.
// As best effort, try to find the closest ancestor with an id, then
// check the index of the element in the descendants of the ancestors with
// the same type.
let ancestor: ParentNode|Element|null = element.parentNode;
while (ancestor && ancestor instanceof Element &&
(!ancestor.hasAttribute('id') || trim(ancestor.id) === '')) {
ancestor = ancestor.parentNode;
}
let ancestorId = '';
if (!ancestor || !(ancestor instanceof Element)) {
ancestor = document.body;
}
if (ancestor instanceof Element && ancestor.hasAttribute('id')) {
ancestorId = '#' + trim(ancestor.id);
}
const descendants = ancestor.querySelectorAll(element.tagName);
let i = 0;
for (i = 0; i < descendants.length; i++) {
if (descendants[i] === element) {
return kNamelessFieldIDPrefix + ancestorId + '~' + element.tagName + '~' +
i;
}
}
return '';
}
/**
* Returns the field's `name` attribute if not space only; otherwise the
* field's `id` attribute.
*
* The name will be used as a hint to infer the autofill type of the field.
*
* It aims to provide the logic in
* WebString nameForAutofill() const;
* in chromium/src/third_party/WebKit/Source/WebKit/chromium/public/
* WebFormControlElement.h
*
* @param element An element of which the name for Autofill will be returned.
* @return the name for Autofill.
*/
function getFieldName(element: Element|null): string {
if (!element) {
return '';
}
if ('name' in element && element.name) {
const trimmedName = trim(element.name as string);
if (trimmedName.length > 0) {
return trimmedName;
}
}
if (element.id) {
return trim(element.id);
}
return '';
}
/**
* Returns the form's `name` attribute if non-empty; otherwise the form's `id`
* attribute, or the index of the form (with prefix) in document.forms.
*
* It is partially based on the logic in
* const string16 GetFormIdentifier(const blink::WebFormElement& form)
* in chromium/src/components/autofill/renderer/form_autofill_util.h.
*
* @param form An element for which the identifier is returned.
* @return a string that represents the element's identifier.
*/
function getFormIdentifier(form: Element|null): string {
if (!form) {
return '';
}
let name = form.getAttribute('name');
if (name && name.length !== 0 &&
form.ownerDocument.forms.namedItem(name) === form) {
return name;
}
name = form.getAttribute('id');
if (name && name.length !== 0 &&
form.ownerDocument.getElementById(name) === form) {
return name;
}
// A form name must be supplied, because the element will later need to be
// identified from the name. A last resort is to take the index number of
// the form in document.forms. ids are not supposed to begin with digits (by
// HTML 4 spec) so this is unlikely to match a true id.
for (let idx = 0; idx !== document.forms.length; idx++) {
if (document.forms[idx] === form) {
return kNamelessFormIDPrefix + idx;
}
}
return '';
}
/**
* Returns the form element from an ID obtained from getFormIdentifier.
*
* This works on a 'best effort' basis since DOM changes can always change the
* actual element that the ID refers to.
*
* @param name An ID string obtained via getFormIdentifier.
* @return The original form element, if it can be determined.
*/
function getFormElementFromIdentifier(name: string): HTMLFormElement|null {
// First attempt is from the name / id supplied.
const form = document.forms.namedItem(name);
if (form) {
return form.nodeType === Node.ELEMENT_NODE ? form : null;
}
// Second attempt is from the prefixed index position of the form in
// document.forms.
if (name.indexOf(kNamelessFormIDPrefix) === 0) {
const nameAsInteger =
0 | name.substring(kNamelessFieldIDPrefix.length).length;
if (kNamelessFormIDPrefix + nameAsInteger === name &&
nameAsInteger < document.forms.length) {
const form = document.forms[nameAsInteger];
return form ? form : null;
}
}
return null;
}
/**
* Returns the form element from an form renderer id.
*
* @param identifier An ID string obtained via getFormIdentifier.
* @return The original form element, if it can be determined.
*/
function getFormElementFromRendererId(identifier: number): HTMLFormElement|
null {
if (identifier.toString() === RENDERER_ID_NOT_SET) {
return null;
}
for (const form of document.forms) {
if (identifier.toString() === getUniqueID(form)) {
return form;
}
}
return null;
}
/**
* Returns whether the last `input` or `change` event on `element` was
* triggered by a user action (was "trusted"). Returns true by default if the
* feature to fix the user edited bit isn't enabled which is the status quo.
* TODO(crbug.com/40941928): Match Blink's behavior so that only a 'reset' event
* makes an edited field unedited.
*/
function fieldWasEditedByUser(element: Element) {
return !autofillFormFeaturesApi.getFunction(
'isAutofillCorrectUserEditedBitInParsedField')() ||
(wasEditedByUser.get(element) ?? false);
}
/**
* @param originalURL A string containing a URL (absolute, relative...)
* @return A string containing a full URL (absolute with scheme)
*/
function getFullyQualifiedUrl(originalURL: string): string {
// A dummy anchor (never added to the document) is used to obtain the
// fully-qualified URL of `originalURL`.
const anchor = document.createElement('a');
anchor.href = originalURL;
return anchor.href;
}
// Send the form data to the browser.
function formSubmittedInternal(
form: HTMLFormElement,
messageHandler: string,
programmaticSubmission: boolean,
includeRemoteFrameToken: boolean = false,
): void {
if (autofillFormFeaturesApi.getFunction('isAutofillDedupeFormSubmissionEnabled')()) {
// Handle deduping when the feature allows it.
if (formSubmissionRegistry.has(form)) {
// Do not double submit the same form.
return;
}
formSubmissionRegistry.add(form);
}
// Default URL for action is the document's URL.
const action = form.getAttribute('action') || document.URL;
const message = {
command: 'form.submit',
frameID: gCrWeb.getFrameId(),
formName: gCrWebLegacy.form.getFormIdentifier(form),
href: getFullyQualifiedUrl(action),
formData: gCrWebLegacy.fill.autofillSubmissionData(form),
remoteFrameToken: includeRemoteFrameToken ? getRemoteFrameToken() :
undefined,
programmaticSubmission: programmaticSubmission,
};
sendWebKitMessage(messageHandler, message);
}
/**
* Sends the form data to the browser. Errors that are caught via the try/catch
* are reported to the browser. This is done before the error bubbles above
* `formSubmitted()` so the generic JS errors wrapper doesn't intercept the
* error before this custom error handler.
*
* @param form The form that was submitted.
* @param messageHandler The name of the message handler to send the message to.
* @param programmaticSubmission True if the form submission is programmatic.
* @includeRemoteFrameToken True if the remote frame token should be included
* in the payload of the message sent to the browser.
*/
function formSubmitted(
form: HTMLFormElement,
messageHandler: string,
programmaticSubmission: boolean,
includeRemoteFrameToken: boolean = false,
): void {
try {
formSubmittedInternal(
form, messageHandler, programmaticSubmission, includeRemoteFrameToken);
} catch (error) {
if (autofillFormFeaturesApi.getFunction('isAutofillReportFormSubmissionErrorsEnabled')()) {
reportFormSubmissionError(error, programmaticSubmission, messageHandler);
} else {
// Just let the error go through if not reported.
throw error;
}
}
}
/**
* Reports a form submission error to the browser.
* @param error Object that holds information on the error.
* @param programmaticSubmission True if the submission that errored was
* programmatic.
* @param handler The name of the handler to send the error message to.
*/
function reportFormSubmissionError(
error: any, programmaticSubmission: boolean, handler: string) {
let errorMessage = '';
let errorStack = '';
if (error && error instanceof Error) {
errorMessage = error.message;
if (error.stack) {
errorStack = error.stack;
}
}
const message = {
command: 'form.submit.error',
errorStack,
errorMessage,
programmaticSubmission,
};
sendWebKitMessage(handler, message);
}
/**
* Reports periodically (as needed) the form submission counts that were
* detected before doing any processing. The count for each type of event is
* provided (regular or programmatic).
* @param isProgrammatic True if the source of the form submission is
* programmatic (i.e. comes from the prototype override).
* @param handler Name of the browser handler to send the message with the count
* report to.
*/
function reportDetectedFormSubmission(
isProgrammatic: boolean, handler: string): void {
// Ignore reporting if there is an error as this isn't a critical function.
// Reporting form submission shouldn't have a side effect on processing the
// form submission.
try {
gFormSubmissionReportManager.sendReport(isProgrammatic, handler);
} catch {
// Ignore.
}
}
gCrWebLegacy.form = {
wasEditedByUser,
getFormControlElements,
getIframeElements,
getFieldIdentifier,
getFieldName,
getFormIdentifier,
getFormElementFromIdentifier,
getFormElementFromRendererId,
fieldWasEditedByUser,
formSubmitted,
reportFormSubmissionError,
reportDetectedFormSubmission,
};