1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5/*
6 * Form Autofill content process module.
7 */
8
9/* eslint-disable no-use-before-define */
10
11"use strict";
12
13var EXPORTED_SYMBOLS = ["FormAutofillContent"];
14
15const Cm = Components.manager;
16
17ChromeUtils.import("resource://gre/modules/PrivateBrowsingUtils.jsm");
18ChromeUtils.import("resource://gre/modules/Services.jsm");
19ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
20ChromeUtils.import("resource://formautofill/FormAutofillUtils.jsm");
21
22ChromeUtils.defineModuleGetter(this, "AddressResult",
23                               "resource://formautofill/ProfileAutoCompleteResult.jsm");
24ChromeUtils.defineModuleGetter(this, "CreditCardResult",
25                               "resource://formautofill/ProfileAutoCompleteResult.jsm");
26ChromeUtils.defineModuleGetter(this, "FormAutofillHandler",
27                               "resource://formautofill/FormAutofillHandler.jsm");
28ChromeUtils.defineModuleGetter(this, "FormLikeFactory",
29                               "resource://gre/modules/FormLikeFactory.jsm");
30ChromeUtils.defineModuleGetter(this, "InsecurePasswordUtils",
31                               "resource://gre/modules/InsecurePasswordUtils.jsm");
32
33const formFillController = Cc["@mozilla.org/satchel/form-fill-controller;1"]
34                             .getService(Ci.nsIFormFillController);
35const autocompleteController = Cc["@mozilla.org/autocomplete/controller;1"]
36                             .getService(Ci.nsIAutoCompleteController);
37
38const {ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME, FIELD_STATES} = FormAutofillUtils;
39
40// Register/unregister a constructor as a factory.
41function AutocompleteFactory() {}
42AutocompleteFactory.prototype = {
43  register(targetConstructor) {
44    let proto = targetConstructor.prototype;
45    this._classID = proto.classID;
46
47    let factory = XPCOMUtils._getFactory(targetConstructor);
48    this._factory = factory;
49
50    let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
51    registrar.registerFactory(proto.classID, proto.classDescription,
52                              proto.contractID, factory);
53
54    if (proto.classID2) {
55      this._classID2 = proto.classID2;
56      registrar.registerFactory(proto.classID2, proto.classDescription,
57                                proto.contractID2, factory);
58    }
59  },
60
61  unregister() {
62    let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
63    registrar.unregisterFactory(this._classID, this._factory);
64    if (this._classID2) {
65      registrar.unregisterFactory(this._classID2, this._factory);
66    }
67    this._factory = null;
68  },
69};
70
71
72/**
73 * @constructor
74 *
75 * @implements {nsIAutoCompleteSearch}
76 */
77function AutofillProfileAutoCompleteSearch() {
78  FormAutofillUtils.defineLazyLogGetter(this, "AutofillProfileAutoCompleteSearch");
79}
80AutofillProfileAutoCompleteSearch.prototype = {
81  classID: Components.ID("4f9f1e4c-7f2c-439e-9c9e-566b68bc187d"),
82  contractID: "@mozilla.org/autocomplete/search;1?name=autofill-profiles",
83  classDescription: "AutofillProfileAutoCompleteSearch",
84  QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch]),
85
86  // Begin nsIAutoCompleteSearch implementation
87
88  /**
89   * Searches for a given string and notifies a listener (either synchronously
90   * or asynchronously) of the result
91   *
92   * @param {string} searchString the string to search for
93   * @param {string} searchParam
94   * @param {Object} previousResult a previous result to use for faster searchinig
95   * @param {Object} listener the listener to notify when the search is complete
96   */
97  startSearch(searchString, searchParam, previousResult, listener) {
98    let {activeInput, activeSection, activeFieldDetail, savedFieldNames} = FormAutofillContent;
99    this.forceStop = false;
100
101    this.log.debug("startSearch: for", searchString, "with input", activeInput);
102
103    let isAddressField = FormAutofillUtils.isAddressField(activeFieldDetail.fieldName);
104    let isInputAutofilled = activeFieldDetail.state == FIELD_STATES.AUTO_FILLED;
105    let allFieldNames = activeSection.allFieldNames;
106    let filledRecordGUID = activeSection.filledRecordGUID;
107    let searchPermitted = isAddressField ?
108                          FormAutofillUtils.isAutofillAddressesEnabled :
109                          FormAutofillUtils.isAutofillCreditCardsEnabled;
110    let AutocompleteResult = isAddressField ? AddressResult : CreditCardResult;
111    let pendingSearchResult = null;
112
113    ProfileAutocomplete.lastProfileAutoCompleteFocusedInput = activeInput;
114    // Fallback to form-history if ...
115    //   - specified autofill feature is pref off.
116    //   - no profile can fill the currently-focused input.
117    //   - the current form has already been populated.
118    //   - (address only) less than 3 inputs are covered by all saved fields in the storage.
119    if (!searchPermitted || !savedFieldNames.has(activeFieldDetail.fieldName) ||
120        (!isInputAutofilled && filledRecordGUID) || (isAddressField &&
121        allFieldNames.filter(field => savedFieldNames.has(field)).length < FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD)) {
122      if (activeInput.autocomplete == "off") {
123        // Create a dummy result as an empty search result.
124        pendingSearchResult = new AutocompleteResult("", "", [], [], {});
125      } else {
126        pendingSearchResult = new Promise(resolve => {
127          let formHistory = Cc["@mozilla.org/autocomplete/search;1?name=form-history"]
128                            .createInstance(Ci.nsIAutoCompleteSearch);
129          formHistory.startSearch(searchString, searchParam, previousResult, {
130            onSearchResult: (_, result) => resolve(result),
131          });
132        });
133      }
134    } else if (isInputAutofilled) {
135      pendingSearchResult = new AutocompleteResult(searchString, "", [], [], {isInputAutofilled});
136    } else {
137      let infoWithoutElement = {...activeFieldDetail};
138      delete infoWithoutElement.elementWeakRef;
139
140      let data = {
141        collectionName: isAddressField ? ADDRESSES_COLLECTION_NAME : CREDITCARDS_COLLECTION_NAME,
142        info: infoWithoutElement,
143        searchString,
144      };
145
146      pendingSearchResult = this._getRecords(data).then((records) => {
147        if (this.forceStop) {
148          return null;
149        }
150        // Sort addresses by timeLastUsed for showing the lastest used address at top.
151        records.sort((a, b) => b.timeLastUsed - a.timeLastUsed);
152
153        let adaptedRecords = activeSection.getAdaptedProfiles(records);
154        let handler = FormAutofillContent.activeHandler;
155        let isSecure = InsecurePasswordUtils.isFormSecure(handler.form);
156
157        return new AutocompleteResult(searchString,
158                                      activeFieldDetail.fieldName,
159                                      allFieldNames,
160                                      adaptedRecords,
161                                      {isSecure, isInputAutofilled});
162      });
163    }
164
165    Promise.resolve(pendingSearchResult).then((result) => {
166      listener.onSearchResult(this, result);
167      ProfileAutocomplete.lastProfileAutoCompleteResult = result;
168      // Reset AutoCompleteController's state at the end of startSearch to ensure that
169      // none of form autofill result will be cached in other places and make the
170      // result out of sync.
171      autocompleteController.resetInternalState();
172    });
173  },
174
175  /**
176   * Stops an asynchronous search that is in progress
177   */
178  stopSearch() {
179    ProfileAutocomplete.lastProfileAutoCompleteResult = null;
180    this.forceStop = true;
181  },
182
183  /**
184   * Get the records from parent process for AutoComplete result.
185   *
186   * @private
187   * @param  {Object} data
188   *         Parameters for querying the corresponding result.
189   * @param  {string} data.collectionName
190   *         The name used to specify which collection to retrieve records.
191   * @param  {string} data.searchString
192   *         The typed string for filtering out the matched records.
193   * @param  {string} data.info
194   *         The input autocomplete property's information.
195   * @returns {Promise}
196   *          Promise that resolves when addresses returned from parent process.
197   */
198  _getRecords(data) {
199    this.log.debug("_getRecords with data:", data);
200    return new Promise((resolve) => {
201      Services.cpmm.addMessageListener("FormAutofill:Records", function getResult(result) {
202        Services.cpmm.removeMessageListener("FormAutofill:Records", getResult);
203        resolve(result.data);
204      });
205
206      Services.cpmm.sendAsyncMessage("FormAutofill:GetRecords", data);
207    });
208  },
209};
210
211let ProfileAutocomplete = {
212  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
213
214  lastProfileAutoCompleteResult: null,
215  lastProfileAutoCompleteFocusedInput: null,
216  _registered: false,
217  _factory: null,
218
219  ensureRegistered() {
220    if (this._registered) {
221      return;
222    }
223
224    FormAutofillUtils.defineLazyLogGetter(this, "ProfileAutocomplete");
225    this.log.debug("ensureRegistered");
226    this._factory = new AutocompleteFactory();
227    this._factory.register(AutofillProfileAutoCompleteSearch);
228    this._registered = true;
229
230    Services.obs.addObserver(this, "autocomplete-will-enter-text");
231  },
232
233  ensureUnregistered() {
234    if (!this._registered) {
235      return;
236    }
237
238    this.log.debug("ensureUnregistered");
239    this._factory.unregister();
240    this._factory = null;
241    this._registered = false;
242    this._lastAutoCompleteResult = null;
243
244    Services.obs.removeObserver(this, "autocomplete-will-enter-text");
245  },
246
247  observe(subject, topic, data) {
248    switch (topic) {
249      case "autocomplete-will-enter-text": {
250        if (!FormAutofillContent.activeInput) {
251          // The observer notification is for autocomplete in a different process.
252          break;
253        }
254        this._fillFromAutocompleteRow(FormAutofillContent.activeInput);
255        break;
256      }
257    }
258  },
259
260  _frameMMFromWindow(contentWindow) {
261    return contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
262                        .getInterface(Ci.nsIDocShell)
263                        .QueryInterface(Ci.nsIInterfaceRequestor)
264                        .getInterface(Ci.nsIContentFrameMessageManager);
265  },
266
267  _getSelectedIndex(contentWindow) {
268    let mm = this._frameMMFromWindow(contentWindow);
269    let selectedIndexResult = mm.sendSyncMessage("FormAutoComplete:GetSelectedIndex", {});
270    if (selectedIndexResult.length != 1 || !Number.isInteger(selectedIndexResult[0])) {
271      throw new Error("Invalid autocomplete selectedIndex");
272    }
273
274    return selectedIndexResult[0];
275  },
276
277  _fillFromAutocompleteRow(focusedInput) {
278    this.log.debug("_fillFromAutocompleteRow:", focusedInput);
279    let formDetails = FormAutofillContent.activeFormDetails;
280    if (!formDetails) {
281      // The observer notification is for a different frame.
282      return;
283    }
284
285    let selectedIndex = this._getSelectedIndex(focusedInput.ownerGlobal);
286    if (selectedIndex == -1 ||
287        !this.lastProfileAutoCompleteResult ||
288        this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile") {
289      return;
290    }
291
292    let profile = JSON.parse(this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex));
293
294    FormAutofillContent.activeHandler.autofillFormFields(profile);
295  },
296
297  _clearProfilePreview() {
298    if (!this.lastProfileAutoCompleteFocusedInput || !FormAutofillContent.activeSection) {
299      return;
300    }
301
302    FormAutofillContent.activeSection.clearPreviewedFormFields();
303  },
304
305  _previewSelectedProfile(selectedIndex) {
306    if (!FormAutofillContent.activeInput || !FormAutofillContent.activeFormDetails) {
307      // The observer notification is for a different process/frame.
308      return;
309    }
310
311    if (!this.lastProfileAutoCompleteResult ||
312        this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile") {
313      return;
314    }
315
316    let profile = JSON.parse(this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex));
317    FormAutofillContent.activeSection.previewFormFields(profile);
318  },
319};
320
321/**
322 * Handles content's interactions for the process.
323 *
324 * NOTE: Declares it by "var" to make it accessible in unit tests.
325 */
326var FormAutofillContent = {
327  QueryInterface: XPCOMUtils.generateQI([Ci.nsIFormSubmitObserver]),
328  /**
329   * @type {WeakMap} mapping FormLike root HTML elements to FormAutofillHandler objects.
330   */
331  _formsDetails: new WeakMap(),
332
333  /**
334   * @type {Set} Set of the fields with usable values in any saved profile.
335   */
336  savedFieldNames: null,
337
338  /**
339   * @type {Object} The object where to store the active items, e.g. element,
340   * handler, section, and field detail.
341   */
342  _activeItems: {},
343
344  init() {
345    FormAutofillUtils.defineLazyLogGetter(this, "FormAutofillContent");
346
347    Services.cpmm.addMessageListener("FormAutofill:enabledStatus", this);
348    Services.cpmm.addMessageListener("FormAutofill:savedFieldNames", this);
349    Services.obs.addObserver(this, "earlyformsubmit");
350
351    let autofillEnabled = Services.cpmm.initialProcessData.autofillEnabled;
352    // If storage hasn't be initialized yet autofillEnabled is undefined but we need to ensure
353    // autocomplete is registered before the focusin so register it in this case as long as the
354    // pref is true.
355    let shouldEnableAutofill = autofillEnabled === undefined &&
356                               (FormAutofillUtils.isAutofillAddressesEnabled ||
357                               FormAutofillUtils.isAutofillCreditCardsEnabled);
358    if (autofillEnabled || shouldEnableAutofill) {
359      ProfileAutocomplete.ensureRegistered();
360    }
361
362    this.savedFieldNames =
363      Services.cpmm.initialProcessData.autofillSavedFieldNames;
364  },
365
366  /**
367   * Send the profile to parent for doorhanger and storage saving/updating.
368   *
369   * @param {Object} profile Submitted form's address/creditcard guid and record.
370   * @param {Object} domWin Current content window.
371   * @param {int} timeStartedFillingMS Time of form filling started.
372   */
373  _onFormSubmit(profile, domWin, timeStartedFillingMS) {
374    let mm = this._messageManagerFromWindow(domWin);
375    mm.sendAsyncMessage("FormAutofill:OnFormSubmit",
376                        {profile, timeStartedFillingMS});
377  },
378
379  /**
380   * Handle earlyformsubmit event and early return when:
381   * 1. In private browsing mode.
382   * 2. Could not map any autofill handler by form element.
383   * 3. Number of filled fields is less than autofill threshold
384   *
385   * @param {HTMLElement} formElement Root element which receives earlyformsubmit event.
386   * @param {Object} domWin Content window
387   * @returns {boolean} Should always return true so form submission isn't canceled.
388   */
389  notify(formElement, domWin) {
390    this.log.debug("Notifying form early submission");
391
392    if (!FormAutofillUtils.isAutofillEnabled) {
393      this.log.debug("Form Autofill is disabled");
394      return true;
395    }
396
397    if (domWin && PrivateBrowsingUtils.isContentWindowPrivate(domWin)) {
398      this.log.debug("Ignoring submission in a private window");
399      return true;
400    }
401
402    let handler = this._formsDetails.get(formElement);
403    if (!handler) {
404      this.log.debug("Form element could not map to an existing handler");
405      return true;
406    }
407
408    let records = handler.createRecords();
409    if (!Object.values(records).some(typeRecords => typeRecords.length)) {
410      return true;
411    }
412
413    this._onFormSubmit(records, domWin, handler.timeStartedFillingMS);
414    return true;
415  },
416
417  receiveMessage({name, data}) {
418    switch (name) {
419      case "FormAutofill:enabledStatus": {
420        if (data) {
421          ProfileAutocomplete.ensureRegistered();
422        } else {
423          ProfileAutocomplete.ensureUnregistered();
424        }
425        break;
426      }
427      case "FormAutofill:savedFieldNames": {
428        this.savedFieldNames = data;
429      }
430    }
431  },
432
433  /**
434   * Get the form's handler from cache which is created after page identified.
435   *
436   * @param {HTMLInputElement} element Focused input which triggered profile searching
437   * @returns {Array<Object>|null}
438   *          Return target form's handler from content cache
439   *          (or return null if the information is not found in the cache).
440   *
441   */
442  _getFormHandler(element) {
443    if (!element) {
444      return null;
445    }
446    let rootElement = FormLikeFactory.findRootForField(element);
447    return this._formsDetails.get(rootElement);
448  },
449
450  /**
451   * Get the active form's information from cache which is created after page
452   * identified.
453   *
454   * @returns {Array<Object>|null}
455   *          Return target form's information from content cache
456   *          (or return null if the information is not found in the cache).
457   *
458   */
459  get activeFormDetails() {
460    let formHandler = this.activeHandler;
461    return formHandler ? formHandler.fieldDetails : null;
462  },
463
464  /**
465   * All active items should be updated according the active element of
466   * `formFillController.focusedInput`. All of them including element,
467   * handler, section, and field detail, can be retrieved by their own getters.
468   *
469   * @param {HTMLElement|null} element The active item should be updated based
470   * on this or `formFillController.focusedInput` will be taken.
471   */
472  updateActiveInput(element) {
473    element = element || formFillController.focusedInput;
474    if (!element) {
475      this._activeItems = {};
476      return;
477    }
478    let handler = this._getFormHandler(element);
479    if (handler) {
480      handler.focusedInput = element;
481    }
482    this._activeItems = {
483      handler,
484      elementWeakRef: Cu.getWeakReference(element),
485      section: handler ? handler.activeSection : null,
486      fieldDetail: null,
487    };
488  },
489
490  get activeInput() {
491    let elementWeakRef = this._activeItems.elementWeakRef;
492    return elementWeakRef ? elementWeakRef.get() : null;
493  },
494
495  get activeHandler() {
496    return this._activeItems.handler;
497  },
498
499  get activeSection() {
500    return this._activeItems.section;
501  },
502
503  /**
504   * Get the active input's information from cache which is created after page
505   * identified.
506   *
507   * @returns {Object|null}
508   *          Return the active input's information that cloned from content cache
509   *          (or return null if the information is not found in the cache).
510   */
511  get activeFieldDetail() {
512    if (!this._activeItems.fieldDetail) {
513      let formDetails = this.activeFormDetails;
514      if (!formDetails) {
515        return null;
516      }
517      for (let detail of formDetails) {
518        let detailElement = detail.elementWeakRef.get();
519        if (detailElement && this.activeInput == detailElement) {
520          this._activeItems.fieldDetail = detail;
521          break;
522        }
523      }
524    }
525    return this._activeItems.fieldDetail;
526  },
527
528  identifyAutofillFields(element) {
529    this.log.debug("identifyAutofillFields:", "" + element.ownerDocument.location);
530
531    if (!this.savedFieldNames) {
532      this.log.debug("identifyAutofillFields: savedFieldNames are not known yet");
533      Services.cpmm.sendAsyncMessage("FormAutofill:InitStorage");
534    }
535
536    let formHandler = this._getFormHandler(element);
537    if (!formHandler) {
538      let formLike = FormLikeFactory.createFromField(element);
539      formHandler = new FormAutofillHandler(formLike);
540    } else if (!formHandler.updateFormIfNeeded(element)) {
541      this.log.debug("No control is removed or inserted since last collection.");
542      return;
543    }
544
545    let validDetails = formHandler.collectFormFields();
546
547    this._formsDetails.set(formHandler.form.rootElement, formHandler);
548    this.log.debug("Adding form handler to _formsDetails:", formHandler);
549
550    validDetails.forEach(detail =>
551      this._markAsAutofillField(detail.elementWeakRef.get())
552    );
553  },
554
555  clearForm() {
556    let focusedInput = this.activeInput || ProfileAutocomplete._lastAutoCompleteFocusedInput;
557    if (!focusedInput) {
558      return;
559    }
560
561    this.activeSection.clearPopulatedForm();
562  },
563
564  previewProfile(doc) {
565    let docWin = doc.ownerGlobal;
566    let selectedIndex = ProfileAutocomplete._getSelectedIndex(docWin);
567    let lastAutoCompleteResult = ProfileAutocomplete.lastProfileAutoCompleteResult;
568    let focusedInput = this.activeInput;
569    let mm = this._messageManagerFromWindow(docWin);
570
571    if (selectedIndex === -1 ||
572        !focusedInput ||
573        !lastAutoCompleteResult ||
574        lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile") {
575      mm.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {});
576
577      ProfileAutocomplete._clearProfilePreview();
578    } else {
579      let focusedInputDetails = this.activeFieldDetail;
580      let profile = JSON.parse(lastAutoCompleteResult.getCommentAt(selectedIndex));
581      let allFieldNames = FormAutofillContent.activeSection.allFieldNames;
582      let profileFields = allFieldNames.filter(fieldName => !!profile[fieldName]);
583
584      let focusedCategory = FormAutofillUtils.getCategoryFromFieldName(focusedInputDetails.fieldName);
585      let categories = FormAutofillUtils.getCategoriesFromFieldNames(profileFields);
586      mm.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {
587        focusedCategory,
588        categories,
589      });
590
591      ProfileAutocomplete._previewSelectedProfile(selectedIndex);
592    }
593  },
594
595  onPopupClosed() {
596    ProfileAutocomplete._clearProfilePreview();
597  },
598
599  _markAsAutofillField(field) {
600    // Since Form Autofill popup is only for input element, any non-Input
601    // element should be excluded here.
602    if (!field || !(field instanceof Ci.nsIDOMHTMLInputElement)) {
603      return;
604    }
605
606    formFillController.markAsAutofillField(field);
607  },
608
609  _messageManagerFromWindow(win) {
610    return win.QueryInterface(Ci.nsIInterfaceRequestor)
611              .getInterface(Ci.nsIWebNavigation)
612              .QueryInterface(Ci.nsIDocShell)
613              .QueryInterface(Ci.nsIInterfaceRequestor)
614              .getInterface(Ci.nsIContentFrameMessageManager);
615  },
616
617  _onKeyDown(e) {
618    let lastAutoCompleteResult = ProfileAutocomplete.lastProfileAutoCompleteResult;
619    let focusedInput = FormAutofillContent.activeInput;
620
621    if (e.keyCode != e.DOM_VK_RETURN || !lastAutoCompleteResult ||
622        !focusedInput || focusedInput != ProfileAutocomplete.lastProfileAutoCompleteFocusedInput) {
623      return;
624    }
625
626    let selectedIndex = ProfileAutocomplete._getSelectedIndex(e.target.ownerGlobal);
627    let selectedRowStyle = lastAutoCompleteResult.getStyleAt(selectedIndex);
628    focusedInput.addEventListener("DOMAutoComplete", () => {
629      if (selectedRowStyle == "autofill-footer") {
630        Services.cpmm.sendAsyncMessage("FormAutofill:OpenPreferences");
631      } else if (selectedRowStyle == "autofill-clear-button") {
632        FormAutofillContent.clearForm();
633      }
634    }, {once: true});
635  },
636};
637
638
639FormAutofillContent.init();
640