chromium/ios/chrome/browser/search_engines/model/resources/search_engine.ts

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Add functionality related to getting search engine details.
 */

import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js';

/**
 * Encodes `url` in "application/x-www-form-urlencoded" content type of <form>.
 * The standard is defined in:
 * https://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1
 * This solution comes from:
 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
 */
function encodeFormData(url: string): string {
  return encodeURIComponent(url).replace('%20', '+');
};

/**
 * Returns element if it's of a type that can submit a form or null otherwise.
 */
function asSubmitElement(element: Element): HTMLButtonElement|HTMLInputElement|
    null {
  if (element instanceof HTMLButtonElement) {
    return element;
  }
  if (element instanceof HTMLInputElement && element.type === 'submit') {
    return element;
  }
  return null;
};

/**
 * Returns the value stored in the element's `name` property. If the
 * element does not have the name property, then null is returned.
 */
function getElementName(element: Element): string|null {
  return (element as Element & {'name': string}).name;
}

/**
 * Returns whether the element is disabled. If the element does not have the
 * disabled property, then null is returned.
 */
function isDisabledElement(element: Element): boolean {
  return (element as Element & {'disabled': boolean}).disabled;
}

/**
 * Returns if `element` is checkable(i.e. <input type="radio"> or <input
 * type="checkbox">).
 * @param element An element inside a <form>.
 */
function isCheckableElement(element: Element): boolean {
  return element instanceof HTMLInputElement &&
      (element.type === 'radio' || element.type === 'checkbox');
};

// Records the active submit element of <form> being submitted.
let activeSubmitElement: HTMLButtonElement|HTMLInputElement|null = null;

/**
 * Returns the submit element which triggers the submission of `form`. If there
 * is no submit element clicked before `form`'s submission, the first submit
 * element of `form` will be returned. Otherwise, returns undefined if not
 * found.
 * @param form The <form> on submission.
 */
function getActiveSubmitElement(form: HTMLFormElement): HTMLButtonElement|
    HTMLInputElement|null {
  if (activeSubmitElement && activeSubmitElement.form === form) {
    return activeSubmitElement;
  }
  for (const element of form.elements) {
    const submitElement = asSubmitElement(element);
    if (submitElement) {
      return submitElement
    }
  }

  return null;
};

/**
 * A set of all the text categories of <input>'s type attribute.
 * This set is based on:
 *   https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/forms/text_field_input_type.h
 *   https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/forms/base_text_input_type.h
 * "password" is not in the map because it makes the <form> invalid.
 */
const TEXT_INPUT_TYPES =
    new Set(['email', 'search', 'tel', 'text', 'url', 'number']);

/**
 * Returns false if `element` is <input type="radio|checkbox"> or <select> and
 * it's not in its default state, otherwise true. The default state is the state
 * of the form element on initial load of the page, and leties depending upon
 * the form element.
 * @param element an Element in <form>.
 */
function isInDefaultState(element: Element): boolean {
  if (isCheckableElement(element)) {
    const inputElement = element as HTMLInputElement;
    return inputElement.checked === inputElement.defaultChecked;
  }

  if (element instanceof HTMLSelectElement) {
    for (const option of element.options) {
      if (option.selected != option.defaultSelected) {
        return false;
      }
    }
  }

  return true;
};

/**
 * Looks for a suitable search text field in |form|. Returns undefined if |form|
 * is not a valid searchable <form>. The code is based on the function with same
 * name in:
 *   https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/exported/web_searchable_form_data.cc
 *
 * The criteria for a valid searchable <form>:
 *   1. Has no <textarea>;
 *   2. Has no <input type="password">;
 *   3. Has no <input type="file">;
 *   4. Has exactly one <input> with "type" from `TEXT_INPUT_TYPES_`;
 *   5. Has no element that is not in default state;
 * Any element that doesn't have "name" attribute or has "disabled" attribute
 * will be ignored.
 * @param form The form being submitted.
 */
function findSuitableSearchInputElement(form: HTMLFormElement):
    HTMLInputElement|undefined {
  let result: HTMLInputElement|undefined = undefined;
  for (const element of form.elements) {
    if (isDisabledElement(element) || !getElementName(element)) {
      continue;
    }
    if (!isInDefaultState(element) || element instanceof HTMLTextAreaElement) {
      return;
    }
    if (element instanceof HTMLInputElement) {
      if (element.type === 'file' || element.type === 'password') {
        return;
      }
      if (TEXT_INPUT_TYPES.has(element.type)) {
        if (result) {
          return;
        }
        result = element;
      }
    }
  }
  return result;
};

/**
 * Generates a searchable URL from `form` if it's a valid searchable <form>.
 * The code is based on the function with same name in:
 *   https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/exported/web_searchable_form_data.cc
 * TODO(crbug.com/40394195): Use <form>'s "accept-charset" attribute to encode the
 *   searchableURL.
 */
function generateSearchableUrl(form: Element): string|undefined {
  if (!(form instanceof HTMLFormElement)) {
    return;
  }

  // Only consider <form> that navigates in current frame, because currently
  // TemplateURLs are created by SearchEngineTabHelper, which cannot handle
  // navigation across WebState.
  if (form.target && form.target !== '_self')
    return;

  // Only consider forms that GET data.
  if (form.method && form.method.toLowerCase() !== 'get') {
    return;
  }

  const searchInput = findSuitableSearchInputElement(form);
  if (!searchInput) {
    return;
  }

  const activeSubmitElement = getActiveSubmitElement(form);

  // The "name=value" pairs appended to the end of the action URL.
  const queryArgs: string[] = [];
  for (const element of form.elements) {
    const elementName = getElementName(element);
    if (isDisabledElement(element) || !elementName) {
      continue;
    }

    const submitElement = asSubmitElement(element);
    if (submitElement) {
      // Only append the active submit element's name-value pair.
      if (submitElement === activeSubmitElement) {
        let value = submitElement.value;
        // <input type="submit"> will have "Submit" as default "value" when
        // submitted with empty "value" and non-empty "name". This probably
        // comes from the default label text of <input type="submit">:
        //   https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/submit
        if (submitElement instanceof HTMLInputElement && !value) {
          value = 'Submit';
        }

        queryArgs.push(
            encodeFormData(elementName) + '=' + encodeFormData(value));
      }
      continue;
    }
    if (element === searchInput) {
      queryArgs.push(encodeFormData(elementName) + '={searchTerms}');
    } else {
      // Ignore unchecked checkable element.
      if (isCheckableElement(element) &&
          !(element as HTMLInputElement).checked) {
        continue;
      }
      const elementValue = (element as Element & {'value': string}).value;
      queryArgs.push(
          encodeFormData(elementName) + '=' + encodeFormData(elementValue));
    }
  }
  // If `form` uses "GET" method, appended query args in `form`.action should be
  // dropped. Use URL class to get rid of these query args.
  const url = new URL(form.action);
  return url.origin + url.pathname + '?' + queryArgs.join('&');
};

/**
 * Adds listener for 'click' event on `document`. When a submit element is
 * clicked, records it in `activeSubmitElement` for `generateSearchableUrl`,
 * which will be called in the 'submit' event callbacks within current call
 * stack. Appends a callback at the end of Js task queue with timeout=0ms that
 * sets `activeSubmitElement` back to undefined after the submission.
 *
 * The call stack of form submission:
 *   User clicks button.
 *     "click" event emitted and bubbles up to `document`.
 *       Records current button as `activeSubmitElement`.
 *       Posts callback that unsets active submit element by setTimeout(..., 0).
 *     "click" event ends.
 *     "submit" event emitted and bubbles up to `document`.
 *       Generates searchable URL based on `activeSubmitElement`.
 *     "submit" event ends.
 *   Call stack of user's click on button finishes.
 *   ...
 *   Js task queue running...
 *   ...
 *   Callback posted by setTimeout(..., 0) is invoked and clean up
 *   `activeSubmitElement`.
 */
document.addEventListener('click', function(event) {
  if (event.defaultPrevented) {
    return;
  }
  let element = event.target;

  if (!(element instanceof Element)) {
    return;
  }
  const submitElement = asSubmitElement(element);
  if (!submitElement) {
    return;
  }
  activeSubmitElement = submitElement;
  setTimeout(function() {
    if (activeSubmitElement === element) {
      activeSubmitElement = null;
    }
  }, 0);
});

/**
 * Adds listener for 'submit' event on `document`. When a <form> is submitted,
 * try to generate a searchableUrl. If succeeded, send it back to native code.
 * TODO(crbug.com/40394195): Refactor /components/autofill/ios/form_util to reuse
 *   FormActivityObserver, so that all the data about form submission can be
 *   sent in a single message.
 */
document.addEventListener('submit', function(event) {
  if (event.defaultPrevented || !(event.target instanceof Element)) {
    return;
  }
  const url = generateSearchableUrl(event.target);
  if (url) {
    sendWebKitMessage(
        'SearchEngineMessage', {'command': 'searchableUrl', 'url': url});
  }
}, false);

/**
 * Finds <link> of OSDD(Open Search Description Document) in the main frame. If
 * found, sends a message containing the page's URL and OSDD's URL to native
 * side. If the page has multiple OSDD <links>s (which should never happen on a
 * sane web site), only send the first <link>.
 */
function findOpenSearchLink(): void {
  const links = document.getElementsByTagName('link');
  for (const link of links) {
    if (link.type == 'application/opensearchdescription+xml') {
      sendWebKitMessage('SearchEngineMessage', {
        'command': 'openSearch',
        'pageUrl': document.URL,
        'osddUrl': link.href
      });
      return;
    }
  };
}

// If document is loaded, finds the Open Search <link>, otherwise waits until
// it's loaded and then starts finding.
if (document.readyState === 'complete') {
  findOpenSearchLink();
} else {
  window.addEventListener('load', findOpenSearchLink);
}