toolkit/modules/sessionstore/FormData.jsm

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     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 file,
     3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
     5 "use strict";
     7 this.EXPORTED_SYMBOLS = ["FormData"];
     9 const Cu = Components.utils;
    10 const Ci = Components.interfaces;
    12 Cu.import("resource://gre/modules/Timer.jsm");
    13 Cu.import("resource://gre/modules/XPathGenerator.jsm");
    15 /**
    16  * Returns whether the given URL very likely has input
    17  * fields that contain serialized session store data.
    18  */
    19 function isRestorationPage(url) {
    20   return url == "about:sessionrestore" || url == "about:welcomeback";
    21 }
    23 /**
    24  * Returns whether the given form |data| object contains nested restoration
    25  * data for a page like about:sessionrestore or about:welcomeback.
    26  */
    27 function hasRestorationData(data) {
    28   if (isRestorationPage(data.url) && data.id) {
    29     return typeof(data.id.sessionData) == "object";
    30   }
    32   return false;
    33 }
    35 /**
    36  * Returns the given document's current URI and strips
    37  * off the URI's anchor part, if any.
    38  */
    39 function getDocumentURI(doc) {
    40   return doc.documentURI.replace(/#.*$/, "");
    41 }
    43 /**
    44  * The public API exported by this module that allows to collect
    45  * and restore form data for a document and its subframes.
    46  */
    47 this.FormData = Object.freeze({
    48   collect: function (frame) {
    49     return FormDataInternal.collect(frame);
    50   },
    52   restore: function (frame, data) {
    53     FormDataInternal.restore(frame, data);
    54   },
    56   restoreTree: function (root, data) {
    57     FormDataInternal.restoreTree(root, data);
    58   }
    59 });
    61 /**
    62  * This module's internal API.
    63  */
    64 let FormDataInternal = {
    65   /**
    66    * Collect form data for a given |frame| *not* including any subframes.
    67    *
    68    * The returned object may have an "id", "xpath", or "innerHTML" key or a
    69    * combination of those three. Form data stored under "id" is for input
    70    * fields with id attributes. Data stored under "xpath" is used for input
    71    * fields that don't have a unique id and need to be queried using XPath.
    72    * The "innerHTML" key is used for editable documents (designMode=on).
    73    *
    74    * Example:
    75    *   {
    76    *     id: {input1: "value1", input3: "value3"},
    77    *     xpath: {
    78    *       "/xhtml:html/xhtml:body/xhtml:input[@name='input2']" : "value2",
    79    *       "/xhtml:html/xhtml:body/xhtml:input[@name='input4']" : "value4"
    80    *     }
    81    *   }
    82    *
    83    * @param  doc
    84    *         DOMDocument instance to obtain form data for.
    85    * @return object
    86    *         Form data encoded in an object.
    87    */
    88   collect: function ({document: doc}) {
    89     let formNodes = doc.evaluate(
    90       XPathGenerator.restorableFormNodes,
    91       doc,
    92       XPathGenerator.resolveNS,
    93       Ci.nsIDOMXPathResult.UNORDERED_NODE_ITERATOR_TYPE, null
    94     );
    96     let node;
    97     let ret = {};
    99     // Limit the number of XPath expressions for performance reasons. See
   100     // bug 477564.
   101     const MAX_TRAVERSED_XPATHS = 100;
   102     let generatedCount = 0;
   104     while (node = formNodes.iterateNext()) {
   105       let hasDefaultValue = true;
   106       let value;
   108       // Only generate a limited number of XPath expressions for perf reasons
   109       // (cf. bug 477564)
   110       if (!node.id && generatedCount > MAX_TRAVERSED_XPATHS) {
   111         continue;
   112       }
   114       if (node instanceof Ci.nsIDOMHTMLInputElement ||
   115           node instanceof Ci.nsIDOMHTMLTextAreaElement ||
   116           node instanceof Ci.nsIDOMXULTextBoxElement) {
   117         switch (node.type) {
   118           case "checkbox":
   119           case "radio":
   120             value = node.checked;
   121             hasDefaultValue = value == node.defaultChecked;
   122             break;
   123           case "file":
   124             value = { type: "file", fileList: node.mozGetFileNameArray() };
   125             hasDefaultValue = !value.fileList.length;
   126             break;
   127           default: // text, textarea
   128             value = node.value;
   129             hasDefaultValue = value == node.defaultValue;
   130             break;
   131         }
   132       } else if (!node.multiple) {
   133         // <select>s without the multiple attribute are hard to determine the
   134         // default value, so assume we don't have the default.
   135         hasDefaultValue = false;
   136         value = { selectedIndex: node.selectedIndex, value: node.value };
   137       } else {
   138         // <select>s with the multiple attribute are easier to determine the
   139         // default value since each <option> has a defaultSelected property
   140         let options = Array.map(node.options, opt => {
   141           hasDefaultValue = hasDefaultValue && (opt.selected == opt.defaultSelected);
   142           return opt.selected ? opt.value : -1;
   143         });
   144         value = options.filter(ix => ix > -1);
   145       }
   147       // In order to reduce XPath generation (which is slow), we only save data
   148       // for form fields that have been changed. (cf. bug 537289)
   149       if (hasDefaultValue) {
   150         continue;
   151       }
   153       if (node.id) {
   154         ret.id = ret.id || {};
   155         ret.id[node.id] = value;
   156       } else {
   157         generatedCount++;
   158         ret.xpath = ret.xpath || {};
   159         ret.xpath[XPathGenerator.generate(node)] = value;
   160       }
   161     }
   163     // designMode is undefined e.g. for XUL documents (as about:config)
   164     if ((doc.designMode || "") == "on" && doc.body) {
   165       ret.innerHTML = doc.body.innerHTML;
   166     }
   168     // Return |null| if no form data has been found.
   169     if (Object.keys(ret).length === 0) {
   170       return null;
   171     }
   173     // Store the frame's current URL with its form data so that we can compare
   174     // it when restoring data to not inject form data into the wrong document.
   175     ret.url = getDocumentURI(doc);
   177     // We want to avoid saving data for about:sessionrestore as a string.
   178     // Since it's stored in the form as stringified JSON, stringifying further
   179     // causes an explosion of escape characters. cf. bug 467409
   180     if (isRestorationPage(ret.url)) {
   181       ret.id.sessionData = JSON.parse(ret.id.sessionData);
   182     }
   184     return ret;
   185   },
   187   /**
   188    * Restores form |data| for the given frame. The data is expected to be in
   189    * the same format that FormData.collect() returns.
   190    *
   191    * @param frame (DOMWindow)
   192    *        The frame to restore form data to.
   193    * @param data (object)
   194    *        An object holding form data.
   195    */
   196   restore: function ({document: doc}, data) {
   197     // Don't restore any data for the given frame if the URL
   198     // stored in the form data doesn't match its current URL.
   199     if (!data.url || data.url != getDocumentURI(doc)) {
   200       return;
   201     }
   203     // For about:{sessionrestore,welcomeback} we saved the field as JSON to
   204     // avoid nested instances causing humongous sessionstore.js files.
   205     // cf. bug 467409
   206     if (hasRestorationData(data)) {
   207       data.id.sessionData = JSON.stringify(data.id.sessionData);
   208     }
   210     if ("id" in data) {
   211       let retrieveNode = id => doc.getElementById(id);
   212       this.restoreManyInputValues(data.id, retrieveNode);
   213     }
   215     if ("xpath" in data) {
   216       let retrieveNode = xpath => XPathGenerator.resolve(doc, xpath);
   217       this.restoreManyInputValues(data.xpath, retrieveNode);
   218     }
   220     if ("innerHTML" in data) {
   221       // We know that the URL matches data.url right now, but the user
   222       // may navigate away before the setTimeout handler runs. We do
   223       // a simple comparison against savedURL to check for that.
   224       let savedURL = doc.documentURI;
   226       setTimeout(() => {
   227         if (doc.body && doc.designMode == "on" && doc.documentURI == savedURL) {
   228           doc.body.innerHTML = data.innerHTML;
   229         }
   230       });
   231     }
   232   },
   234   /**
   235    * Iterates the given form data, retrieving nodes for all the keys and
   236    * restores their appropriate values.
   237    *
   238    * @param data (object)
   239    *        A subset of the form data as collected by FormData.collect(). This
   240    *        is either data stored under "id" or under "xpath".
   241    * @param retrieve (function)
   242    *        The function used to retrieve the input field belonging to a key
   243    *        in the given |data| object.
   244    */
   245   restoreManyInputValues: function (data, retrieve) {
   246     for (let key of Object.keys(data)) {
   247       let input = retrieve(key);
   248       if (input) {
   249         this.restoreSingleInputValue(input, data[key]);
   250       }
   251     }
   252   },
   254   /**
   255    * Restores a given form value to a given DOMNode and takes care of firing
   256    * the appropriate DOM event should the input's value change.
   257    *
   258    * @param  aNode
   259    *         DOMNode to set form value on.
   260    * @param  aValue
   261    *         Value to set form element to.
   262    */
   263   restoreSingleInputValue: function (aNode, aValue) {
   264     let eventType;
   266     if (typeof aValue == "string" && aNode.type != "file") {
   267       // Don't dispatch an input event if there is no change.
   268       if (aNode.value == aValue) {
   269         return;
   270       }
   272       aNode.value = aValue;
   273       eventType = "input";
   274     } else if (typeof aValue == "boolean") {
   275       // Don't dispatch a change event for no change.
   276       if (aNode.checked == aValue) {
   277         return;
   278       }
   280       aNode.checked = aValue;
   281       eventType = "change";
   282     } else if (aValue && aValue.selectedIndex >= 0 && aValue.value) {
   283       // Don't dispatch a change event for no change
   284       if (aNode.options[aNode.selectedIndex].value == aValue.value) {
   285         return;
   286       }
   288       // find first option with matching aValue if possible
   289       for (let i = 0; i < aNode.options.length; i++) {
   290         if (aNode.options[i].value == aValue.value) {
   291           aNode.selectedIndex = i;
   292           eventType = "change";
   293           break;
   294         }
   295       }
   296     } else if (aValue && aValue.fileList && aValue.type == "file" &&
   297       aNode.type == "file") {
   298       aNode.mozSetFileNameArray(aValue.fileList, aValue.fileList.length);
   299       eventType = "input";
   300     } else if (Array.isArray(aValue) && aNode.options) {
   301       Array.forEach(aNode.options, function(opt, index) {
   302         // don't worry about malformed options with same values
   303         opt.selected = aValue.indexOf(opt.value) > -1;
   305         // Only fire the event here if this wasn't selected by default
   306         if (!opt.defaultSelected) {
   307           eventType = "change";
   308         }
   309       });
   310     }
   312     // Fire events for this node if applicable
   313     if (eventType) {
   314       let doc = aNode.ownerDocument;
   315       let event = doc.createEvent("UIEvents");
   316       event.initUIEvent(eventType, true, true, doc.defaultView, 0);
   317       aNode.dispatchEvent(event);
   318     }
   319   },
   321   /**
   322    * Restores form data for the current frame hierarchy starting at |root|
   323    * using the given form |data|.
   324    *
   325    * If the given |root| frame's hierarchy doesn't match that of the given
   326    * |data| object we will silently discard data for unreachable frames. For
   327    * security reasons we will never restore form data to the wrong frames as
   328    * we bail out silently if the stored URL doesn't match the frame's current
   329    * URL.
   330    *
   331    * @param root (DOMWindow)
   332    * @param data (object)
   333    *        {
   334    *          formdata: {id: {input1: "value1"}},
   335    *          children: [
   336    *            {formdata: {id: {input2: "value2"}}},
   337    *            null,
   338    *            {formdata: {xpath: { ... }}, children: [ ... ]}
   339    *          ]
   340    *        }
   341    */
   342   restoreTree: function (root, data) {
   343     // Don't restore any data for the root frame and its subframes if there
   344     // is a URL stored in the form data and it doesn't match its current URL.
   345     if (data.url && data.url != getDocumentURI(root.document)) {
   346       return;
   347     }
   349     if (data.url) {
   350       this.restore(root, data);
   351     }
   353     if (!data.hasOwnProperty("children")) {
   354       return;
   355     }
   357     let frames = root.frames;
   358     for (let index of Object.keys(data.children)) {
   359       if (index < frames.length) {
   360         this.restoreTree(frames[index], data.children[index]);
   361       }
   362     }
   363   }
   364 };

mercurial