1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/toolkit/modules/sessionstore/FormData.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,364 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this file, 1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +this.EXPORTED_SYMBOLS = ["FormData"]; 1.11 + 1.12 +const Cu = Components.utils; 1.13 +const Ci = Components.interfaces; 1.14 + 1.15 +Cu.import("resource://gre/modules/Timer.jsm"); 1.16 +Cu.import("resource://gre/modules/XPathGenerator.jsm"); 1.17 + 1.18 +/** 1.19 + * Returns whether the given URL very likely has input 1.20 + * fields that contain serialized session store data. 1.21 + */ 1.22 +function isRestorationPage(url) { 1.23 + return url == "about:sessionrestore" || url == "about:welcomeback"; 1.24 +} 1.25 + 1.26 +/** 1.27 + * Returns whether the given form |data| object contains nested restoration 1.28 + * data for a page like about:sessionrestore or about:welcomeback. 1.29 + */ 1.30 +function hasRestorationData(data) { 1.31 + if (isRestorationPage(data.url) && data.id) { 1.32 + return typeof(data.id.sessionData) == "object"; 1.33 + } 1.34 + 1.35 + return false; 1.36 +} 1.37 + 1.38 +/** 1.39 + * Returns the given document's current URI and strips 1.40 + * off the URI's anchor part, if any. 1.41 + */ 1.42 +function getDocumentURI(doc) { 1.43 + return doc.documentURI.replace(/#.*$/, ""); 1.44 +} 1.45 + 1.46 +/** 1.47 + * The public API exported by this module that allows to collect 1.48 + * and restore form data for a document and its subframes. 1.49 + */ 1.50 +this.FormData = Object.freeze({ 1.51 + collect: function (frame) { 1.52 + return FormDataInternal.collect(frame); 1.53 + }, 1.54 + 1.55 + restore: function (frame, data) { 1.56 + FormDataInternal.restore(frame, data); 1.57 + }, 1.58 + 1.59 + restoreTree: function (root, data) { 1.60 + FormDataInternal.restoreTree(root, data); 1.61 + } 1.62 +}); 1.63 + 1.64 +/** 1.65 + * This module's internal API. 1.66 + */ 1.67 +let FormDataInternal = { 1.68 + /** 1.69 + * Collect form data for a given |frame| *not* including any subframes. 1.70 + * 1.71 + * The returned object may have an "id", "xpath", or "innerHTML" key or a 1.72 + * combination of those three. Form data stored under "id" is for input 1.73 + * fields with id attributes. Data stored under "xpath" is used for input 1.74 + * fields that don't have a unique id and need to be queried using XPath. 1.75 + * The "innerHTML" key is used for editable documents (designMode=on). 1.76 + * 1.77 + * Example: 1.78 + * { 1.79 + * id: {input1: "value1", input3: "value3"}, 1.80 + * xpath: { 1.81 + * "/xhtml:html/xhtml:body/xhtml:input[@name='input2']" : "value2", 1.82 + * "/xhtml:html/xhtml:body/xhtml:input[@name='input4']" : "value4" 1.83 + * } 1.84 + * } 1.85 + * 1.86 + * @param doc 1.87 + * DOMDocument instance to obtain form data for. 1.88 + * @return object 1.89 + * Form data encoded in an object. 1.90 + */ 1.91 + collect: function ({document: doc}) { 1.92 + let formNodes = doc.evaluate( 1.93 + XPathGenerator.restorableFormNodes, 1.94 + doc, 1.95 + XPathGenerator.resolveNS, 1.96 + Ci.nsIDOMXPathResult.UNORDERED_NODE_ITERATOR_TYPE, null 1.97 + ); 1.98 + 1.99 + let node; 1.100 + let ret = {}; 1.101 + 1.102 + // Limit the number of XPath expressions for performance reasons. See 1.103 + // bug 477564. 1.104 + const MAX_TRAVERSED_XPATHS = 100; 1.105 + let generatedCount = 0; 1.106 + 1.107 + while (node = formNodes.iterateNext()) { 1.108 + let hasDefaultValue = true; 1.109 + let value; 1.110 + 1.111 + // Only generate a limited number of XPath expressions for perf reasons 1.112 + // (cf. bug 477564) 1.113 + if (!node.id && generatedCount > MAX_TRAVERSED_XPATHS) { 1.114 + continue; 1.115 + } 1.116 + 1.117 + if (node instanceof Ci.nsIDOMHTMLInputElement || 1.118 + node instanceof Ci.nsIDOMHTMLTextAreaElement || 1.119 + node instanceof Ci.nsIDOMXULTextBoxElement) { 1.120 + switch (node.type) { 1.121 + case "checkbox": 1.122 + case "radio": 1.123 + value = node.checked; 1.124 + hasDefaultValue = value == node.defaultChecked; 1.125 + break; 1.126 + case "file": 1.127 + value = { type: "file", fileList: node.mozGetFileNameArray() }; 1.128 + hasDefaultValue = !value.fileList.length; 1.129 + break; 1.130 + default: // text, textarea 1.131 + value = node.value; 1.132 + hasDefaultValue = value == node.defaultValue; 1.133 + break; 1.134 + } 1.135 + } else if (!node.multiple) { 1.136 + // <select>s without the multiple attribute are hard to determine the 1.137 + // default value, so assume we don't have the default. 1.138 + hasDefaultValue = false; 1.139 + value = { selectedIndex: node.selectedIndex, value: node.value }; 1.140 + } else { 1.141 + // <select>s with the multiple attribute are easier to determine the 1.142 + // default value since each <option> has a defaultSelected property 1.143 + let options = Array.map(node.options, opt => { 1.144 + hasDefaultValue = hasDefaultValue && (opt.selected == opt.defaultSelected); 1.145 + return opt.selected ? opt.value : -1; 1.146 + }); 1.147 + value = options.filter(ix => ix > -1); 1.148 + } 1.149 + 1.150 + // In order to reduce XPath generation (which is slow), we only save data 1.151 + // for form fields that have been changed. (cf. bug 537289) 1.152 + if (hasDefaultValue) { 1.153 + continue; 1.154 + } 1.155 + 1.156 + if (node.id) { 1.157 + ret.id = ret.id || {}; 1.158 + ret.id[node.id] = value; 1.159 + } else { 1.160 + generatedCount++; 1.161 + ret.xpath = ret.xpath || {}; 1.162 + ret.xpath[XPathGenerator.generate(node)] = value; 1.163 + } 1.164 + } 1.165 + 1.166 + // designMode is undefined e.g. for XUL documents (as about:config) 1.167 + if ((doc.designMode || "") == "on" && doc.body) { 1.168 + ret.innerHTML = doc.body.innerHTML; 1.169 + } 1.170 + 1.171 + // Return |null| if no form data has been found. 1.172 + if (Object.keys(ret).length === 0) { 1.173 + return null; 1.174 + } 1.175 + 1.176 + // Store the frame's current URL with its form data so that we can compare 1.177 + // it when restoring data to not inject form data into the wrong document. 1.178 + ret.url = getDocumentURI(doc); 1.179 + 1.180 + // We want to avoid saving data for about:sessionrestore as a string. 1.181 + // Since it's stored in the form as stringified JSON, stringifying further 1.182 + // causes an explosion of escape characters. cf. bug 467409 1.183 + if (isRestorationPage(ret.url)) { 1.184 + ret.id.sessionData = JSON.parse(ret.id.sessionData); 1.185 + } 1.186 + 1.187 + return ret; 1.188 + }, 1.189 + 1.190 + /** 1.191 + * Restores form |data| for the given frame. The data is expected to be in 1.192 + * the same format that FormData.collect() returns. 1.193 + * 1.194 + * @param frame (DOMWindow) 1.195 + * The frame to restore form data to. 1.196 + * @param data (object) 1.197 + * An object holding form data. 1.198 + */ 1.199 + restore: function ({document: doc}, data) { 1.200 + // Don't restore any data for the given frame if the URL 1.201 + // stored in the form data doesn't match its current URL. 1.202 + if (!data.url || data.url != getDocumentURI(doc)) { 1.203 + return; 1.204 + } 1.205 + 1.206 + // For about:{sessionrestore,welcomeback} we saved the field as JSON to 1.207 + // avoid nested instances causing humongous sessionstore.js files. 1.208 + // cf. bug 467409 1.209 + if (hasRestorationData(data)) { 1.210 + data.id.sessionData = JSON.stringify(data.id.sessionData); 1.211 + } 1.212 + 1.213 + if ("id" in data) { 1.214 + let retrieveNode = id => doc.getElementById(id); 1.215 + this.restoreManyInputValues(data.id, retrieveNode); 1.216 + } 1.217 + 1.218 + if ("xpath" in data) { 1.219 + let retrieveNode = xpath => XPathGenerator.resolve(doc, xpath); 1.220 + this.restoreManyInputValues(data.xpath, retrieveNode); 1.221 + } 1.222 + 1.223 + if ("innerHTML" in data) { 1.224 + // We know that the URL matches data.url right now, but the user 1.225 + // may navigate away before the setTimeout handler runs. We do 1.226 + // a simple comparison against savedURL to check for that. 1.227 + let savedURL = doc.documentURI; 1.228 + 1.229 + setTimeout(() => { 1.230 + if (doc.body && doc.designMode == "on" && doc.documentURI == savedURL) { 1.231 + doc.body.innerHTML = data.innerHTML; 1.232 + } 1.233 + }); 1.234 + } 1.235 + }, 1.236 + 1.237 + /** 1.238 + * Iterates the given form data, retrieving nodes for all the keys and 1.239 + * restores their appropriate values. 1.240 + * 1.241 + * @param data (object) 1.242 + * A subset of the form data as collected by FormData.collect(). This 1.243 + * is either data stored under "id" or under "xpath". 1.244 + * @param retrieve (function) 1.245 + * The function used to retrieve the input field belonging to a key 1.246 + * in the given |data| object. 1.247 + */ 1.248 + restoreManyInputValues: function (data, retrieve) { 1.249 + for (let key of Object.keys(data)) { 1.250 + let input = retrieve(key); 1.251 + if (input) { 1.252 + this.restoreSingleInputValue(input, data[key]); 1.253 + } 1.254 + } 1.255 + }, 1.256 + 1.257 + /** 1.258 + * Restores a given form value to a given DOMNode and takes care of firing 1.259 + * the appropriate DOM event should the input's value change. 1.260 + * 1.261 + * @param aNode 1.262 + * DOMNode to set form value on. 1.263 + * @param aValue 1.264 + * Value to set form element to. 1.265 + */ 1.266 + restoreSingleInputValue: function (aNode, aValue) { 1.267 + let eventType; 1.268 + 1.269 + if (typeof aValue == "string" && aNode.type != "file") { 1.270 + // Don't dispatch an input event if there is no change. 1.271 + if (aNode.value == aValue) { 1.272 + return; 1.273 + } 1.274 + 1.275 + aNode.value = aValue; 1.276 + eventType = "input"; 1.277 + } else if (typeof aValue == "boolean") { 1.278 + // Don't dispatch a change event for no change. 1.279 + if (aNode.checked == aValue) { 1.280 + return; 1.281 + } 1.282 + 1.283 + aNode.checked = aValue; 1.284 + eventType = "change"; 1.285 + } else if (aValue && aValue.selectedIndex >= 0 && aValue.value) { 1.286 + // Don't dispatch a change event for no change 1.287 + if (aNode.options[aNode.selectedIndex].value == aValue.value) { 1.288 + return; 1.289 + } 1.290 + 1.291 + // find first option with matching aValue if possible 1.292 + for (let i = 0; i < aNode.options.length; i++) { 1.293 + if (aNode.options[i].value == aValue.value) { 1.294 + aNode.selectedIndex = i; 1.295 + eventType = "change"; 1.296 + break; 1.297 + } 1.298 + } 1.299 + } else if (aValue && aValue.fileList && aValue.type == "file" && 1.300 + aNode.type == "file") { 1.301 + aNode.mozSetFileNameArray(aValue.fileList, aValue.fileList.length); 1.302 + eventType = "input"; 1.303 + } else if (Array.isArray(aValue) && aNode.options) { 1.304 + Array.forEach(aNode.options, function(opt, index) { 1.305 + // don't worry about malformed options with same values 1.306 + opt.selected = aValue.indexOf(opt.value) > -1; 1.307 + 1.308 + // Only fire the event here if this wasn't selected by default 1.309 + if (!opt.defaultSelected) { 1.310 + eventType = "change"; 1.311 + } 1.312 + }); 1.313 + } 1.314 + 1.315 + // Fire events for this node if applicable 1.316 + if (eventType) { 1.317 + let doc = aNode.ownerDocument; 1.318 + let event = doc.createEvent("UIEvents"); 1.319 + event.initUIEvent(eventType, true, true, doc.defaultView, 0); 1.320 + aNode.dispatchEvent(event); 1.321 + } 1.322 + }, 1.323 + 1.324 + /** 1.325 + * Restores form data for the current frame hierarchy starting at |root| 1.326 + * using the given form |data|. 1.327 + * 1.328 + * If the given |root| frame's hierarchy doesn't match that of the given 1.329 + * |data| object we will silently discard data for unreachable frames. For 1.330 + * security reasons we will never restore form data to the wrong frames as 1.331 + * we bail out silently if the stored URL doesn't match the frame's current 1.332 + * URL. 1.333 + * 1.334 + * @param root (DOMWindow) 1.335 + * @param data (object) 1.336 + * { 1.337 + * formdata: {id: {input1: "value1"}}, 1.338 + * children: [ 1.339 + * {formdata: {id: {input2: "value2"}}}, 1.340 + * null, 1.341 + * {formdata: {xpath: { ... }}, children: [ ... ]} 1.342 + * ] 1.343 + * } 1.344 + */ 1.345 + restoreTree: function (root, data) { 1.346 + // Don't restore any data for the root frame and its subframes if there 1.347 + // is a URL stored in the form data and it doesn't match its current URL. 1.348 + if (data.url && data.url != getDocumentURI(root.document)) { 1.349 + return; 1.350 + } 1.351 + 1.352 + if (data.url) { 1.353 + this.restore(root, data); 1.354 + } 1.355 + 1.356 + if (!data.hasOwnProperty("children")) { 1.357 + return; 1.358 + } 1.359 + 1.360 + let frames = root.frames; 1.361 + for (let index of Object.keys(data.children)) { 1.362 + if (index < frames.length) { 1.363 + this.restoreTree(frames[index], data.children[index]); 1.364 + } 1.365 + } 1.366 + } 1.367 +};