diff options
Diffstat (limited to 'toolkit/modules/sessionstore')
-rw-r--r-- | toolkit/modules/sessionstore/FormData.jsm | 412 | ||||
-rw-r--r-- | toolkit/modules/sessionstore/ScrollPosition.jsm | 103 | ||||
-rw-r--r-- | toolkit/modules/sessionstore/Utils.jsm | 107 | ||||
-rw-r--r-- | toolkit/modules/sessionstore/XPathGenerator.jsm | 119 |
4 files changed, 741 insertions, 0 deletions
diff --git a/toolkit/modules/sessionstore/FormData.jsm b/toolkit/modules/sessionstore/FormData.jsm new file mode 100644 index 000000000..f90ba5825 --- /dev/null +++ b/toolkit/modules/sessionstore/FormData.jsm @@ -0,0 +1,412 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["FormData"]; + +const Cu = Components.utils; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/XPathGenerator.jsm"); + +/** + * Returns whether the given URL very likely has input + * fields that contain serialized session store data. + */ +function isRestorationPage(url) { + return url == "about:sessionrestore" || url == "about:welcomeback"; +} + +/** + * Returns whether the given form |data| object contains nested restoration + * data for a page like about:sessionrestore or about:welcomeback. + */ +function hasRestorationData(data) { + if (isRestorationPage(data.url) && data.id) { + return typeof(data.id.sessionData) == "object"; + } + + return false; +} + +/** + * Returns the given document's current URI and strips + * off the URI's anchor part, if any. + */ +function getDocumentURI(doc) { + return doc.documentURI.replace(/#.*$/, ""); +} + +/** + * Returns whether the given value is a valid credit card number based on + * the Luhn algorithm. See https://en.wikipedia.org/wiki/Luhn_algorithm. + */ +function isValidCCNumber(value) { + // Remove dashes and whitespace. + let ccNumber = value.replace(/[-\s]+/g, ""); + + // Check for non-alphanumeric characters. + if (/[^0-9]/.test(ccNumber)) { + return false; + } + + // Check for invalid length. + let length = ccNumber.length; + if (length != 9 && length != 15 && length != 16) { + return false; + } + + let total = 0; + for (let i = 0; i < length; i++) { + let currentChar = ccNumber.charAt(length - i - 1); + let currentDigit = parseInt(currentChar, 10); + + if (i % 2) { + // Double every other value. + total += currentDigit * 2; + // If the doubled value has two digits, add the digits together. + if (currentDigit > 4) { + total -= 9; + } + } else { + total += currentDigit; + } + } + return total % 10 == 0; +} + +/** + * The public API exported by this module that allows to collect + * and restore form data for a document and its subframes. + */ +this.FormData = Object.freeze({ + collect: function (frame) { + return FormDataInternal.collect(frame); + }, + + restoreTree: function (root, data) { + FormDataInternal.restoreTree(root, data); + } +}); + +/** + * This module's internal API. + */ +var FormDataInternal = { + /** + * Collect form data for a given |frame| *not* including any subframes. + * + * The returned object may have an "id", "xpath", or "innerHTML" key or a + * combination of those three. Form data stored under "id" is for input + * fields with id attributes. Data stored under "xpath" is used for input + * fields that don't have a unique id and need to be queried using XPath. + * The "innerHTML" key is used for editable documents (designMode=on). + * + * Example: + * { + * id: {input1: "value1", input3: "value3"}, + * xpath: { + * "/xhtml:html/xhtml:body/xhtml:input[@name='input2']" : "value2", + * "/xhtml:html/xhtml:body/xhtml:input[@name='input4']" : "value4" + * } + * } + * + * @param doc + * DOMDocument instance to obtain form data for. + * @return object + * Form data encoded in an object. + */ + collect: function ({document: doc}) { + let formNodes = doc.evaluate( + XPathGenerator.restorableFormNodes, + doc, + XPathGenerator.resolveNS, + Ci.nsIDOMXPathResult.UNORDERED_NODE_ITERATOR_TYPE, null + ); + + let node; + let ret = {}; + + // Limit the number of XPath expressions for performance reasons. See + // bug 477564. + const MAX_TRAVERSED_XPATHS = 100; + let generatedCount = 0; + + while ((node = formNodes.iterateNext())) { + let hasDefaultValue = true; + let value; + + // Only generate a limited number of XPath expressions for perf reasons + // (cf. bug 477564) + if (!node.id && generatedCount > MAX_TRAVERSED_XPATHS) { + continue; + } + + // We do not want to collect credit card numbers. + if (node instanceof Ci.nsIDOMHTMLInputElement && + isValidCCNumber(node.value)) { + continue; + } + + if (node instanceof Ci.nsIDOMHTMLInputElement || + node instanceof Ci.nsIDOMHTMLTextAreaElement || + node instanceof Ci.nsIDOMXULTextBoxElement) { + switch (node.type) { + case "checkbox": + case "radio": + value = node.checked; + hasDefaultValue = value == node.defaultChecked; + break; + case "file": + value = { type: "file", fileList: node.mozGetFileNameArray() }; + hasDefaultValue = !value.fileList.length; + break; + default: // text, textarea + value = node.value; + hasDefaultValue = value == node.defaultValue; + break; + } + } else if (!node.multiple) { + // <select>s without the multiple attribute are hard to determine the + // default value, so assume we don't have the default. + hasDefaultValue = false; + value = { selectedIndex: node.selectedIndex, value: node.value }; + } else { + // <select>s with the multiple attribute are easier to determine the + // default value since each <option> has a defaultSelected property + let options = Array.map(node.options, opt => { + hasDefaultValue = hasDefaultValue && (opt.selected == opt.defaultSelected); + return opt.selected ? opt.value : -1; + }); + value = options.filter(ix => ix > -1); + } + + // In order to reduce XPath generation (which is slow), we only save data + // for form fields that have been changed. (cf. bug 537289) + if (hasDefaultValue) { + continue; + } + + if (node.id) { + ret.id = ret.id || {}; + ret.id[node.id] = value; + } else { + generatedCount++; + ret.xpath = ret.xpath || {}; + ret.xpath[XPathGenerator.generate(node)] = value; + } + } + + // designMode is undefined e.g. for XUL documents (as about:config) + if ((doc.designMode || "") == "on" && doc.body) { + ret.innerHTML = doc.body.innerHTML; + } + + // Return |null| if no form data has been found. + if (Object.keys(ret).length === 0) { + return null; + } + + // Store the frame's current URL with its form data so that we can compare + // it when restoring data to not inject form data into the wrong document. + ret.url = getDocumentURI(doc); + + // We want to avoid saving data for about:sessionrestore as a string. + // Since it's stored in the form as stringified JSON, stringifying further + // causes an explosion of escape characters. cf. bug 467409 + if (isRestorationPage(ret.url)) { + ret.id.sessionData = JSON.parse(ret.id.sessionData); + } + + return ret; + }, + + /** + * Restores form |data| for the given frame. The data is expected to be in + * the same format that FormData.collect() returns. + * + * @param frame (DOMWindow) + * The frame to restore form data to. + * @param data (object) + * An object holding form data. + */ + restore: function ({document: doc}, data) { + // Don't restore any data for the given frame if the URL + // stored in the form data doesn't match its current URL. + if (!data.url || data.url != getDocumentURI(doc)) { + return; + } + + // For about:{sessionrestore,welcomeback} we saved the field as JSON to + // avoid nested instances causing humongous sessionstore.js files. + // cf. bug 467409 + if (hasRestorationData(data)) { + data.id.sessionData = JSON.stringify(data.id.sessionData); + } + + if ("id" in data) { + let retrieveNode = id => doc.getElementById(id); + this.restoreManyInputValues(data.id, retrieveNode); + } + + if ("xpath" in data) { + let retrieveNode = xpath => XPathGenerator.resolve(doc, xpath); + this.restoreManyInputValues(data.xpath, retrieveNode); + } + + if ("innerHTML" in data) { + if (doc.body && doc.designMode == "on") { + doc.body.innerHTML = data.innerHTML; + this.fireEvent(doc.body, "input"); + } + } + }, + + /** + * Iterates the given form data, retrieving nodes for all the keys and + * restores their appropriate values. + * + * @param data (object) + * A subset of the form data as collected by FormData.collect(). This + * is either data stored under "id" or under "xpath". + * @param retrieve (function) + * The function used to retrieve the input field belonging to a key + * in the given |data| object. + */ + restoreManyInputValues: function (data, retrieve) { + for (let key of Object.keys(data)) { + let input = retrieve(key); + if (input) { + this.restoreSingleInputValue(input, data[key]); + } + } + }, + + /** + * Restores a given form value to a given DOMNode and takes care of firing + * the appropriate DOM event should the input's value change. + * + * @param aNode + * DOMNode to set form value on. + * @param aValue + * Value to set form element to. + */ + restoreSingleInputValue: function (aNode, aValue) { + let eventType; + + if (typeof aValue == "string" && aNode.type != "file") { + // Don't dispatch an input event if there is no change. + if (aNode.value == aValue) { + return; + } + + aNode.value = aValue; + eventType = "input"; + } else if (typeof aValue == "boolean") { + // Don't dispatch a change event for no change. + if (aNode.checked == aValue) { + return; + } + + aNode.checked = aValue; + eventType = "change"; + } else if (aValue && aValue.selectedIndex >= 0 && aValue.value) { + // Don't dispatch a change event for no change + if (aNode.options[aNode.selectedIndex].value == aValue.value) { + return; + } + + // find first option with matching aValue if possible + for (let i = 0; i < aNode.options.length; i++) { + if (aNode.options[i].value == aValue.value) { + aNode.selectedIndex = i; + eventType = "change"; + break; + } + } + } else if (aValue && aValue.fileList && aValue.type == "file" && + aNode.type == "file") { + try { + // FIXME (bug 1122855): This won't work in content processes. + aNode.mozSetFileNameArray(aValue.fileList, aValue.fileList.length); + } catch (e) { + Cu.reportError("mozSetFileNameArray: " + e); + } + eventType = "input"; + } else if (Array.isArray(aValue) && aNode.options) { + Array.forEach(aNode.options, function(opt, index) { + // don't worry about malformed options with same values + opt.selected = aValue.indexOf(opt.value) > -1; + + // Only fire the event here if this wasn't selected by default + if (!opt.defaultSelected) { + eventType = "change"; + } + }); + } + + // Fire events for this node if applicable + if (eventType) { + this.fireEvent(aNode, eventType); + } + }, + + /** + * Dispatches an event of type |type| to the given |node|. + * + * @param node (DOMNode) + * @param type (string) + */ + fireEvent: function (node, type) { + let doc = node.ownerDocument; + let event = doc.createEvent("UIEvents"); + event.initUIEvent(type, true, true, doc.defaultView, 0); + node.dispatchEvent(event); + }, + + /** + * Restores form data for the current frame hierarchy starting at |root| + * using the given form |data|. + * + * If the given |root| frame's hierarchy doesn't match that of the given + * |data| object we will silently discard data for unreachable frames. For + * security reasons we will never restore form data to the wrong frames as + * we bail out silently if the stored URL doesn't match the frame's current + * URL. + * + * @param root (DOMWindow) + * @param data (object) + * { + * formdata: {id: {input1: "value1"}}, + * children: [ + * {formdata: {id: {input2: "value2"}}}, + * null, + * {formdata: {xpath: { ... }}, children: [ ... ]} + * ] + * } + */ + restoreTree: function (root, data) { + // Don't restore any data for the root frame and its subframes if there + // is a URL stored in the form data and it doesn't match its current URL. + if (data.url && data.url != getDocumentURI(root.document)) { + return; + } + + if (data.url) { + this.restore(root, data); + } + + if (!data.hasOwnProperty("children")) { + return; + } + + let frames = root.frames; + for (let index of Object.keys(data.children)) { + if (index < frames.length) { + this.restoreTree(frames[index], data.children[index]); + } + } + } +}; diff --git a/toolkit/modules/sessionstore/ScrollPosition.jsm b/toolkit/modules/sessionstore/ScrollPosition.jsm new file mode 100644 index 000000000..5267f332a --- /dev/null +++ b/toolkit/modules/sessionstore/ScrollPosition.jsm @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this file, +* You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["ScrollPosition"]; + +const Ci = Components.interfaces; + +/** + * It provides methods to collect scroll positions from single frames and to + * restore scroll positions for frame trees. + * + * This is a child process module. + */ +this.ScrollPosition = Object.freeze({ + collect(frame) { + return ScrollPositionInternal.collect(frame); + }, + + restoreTree(root, data) { + ScrollPositionInternal.restoreTree(root, data); + } +}); + +/** + * This module's internal API. + */ +var ScrollPositionInternal = { + /** + * Collects scroll position data for any given |frame| in the frame hierarchy. + * + * @param frame (DOMWindow) + * + * @return {scroll: "x,y"} e.g. {scroll: "100,200"} + * Returns null when there is no scroll data we want to store for the + * given |frame|. + */ + collect: function (frame) { + let ifreq = frame.QueryInterface(Ci.nsIInterfaceRequestor); + let utils = ifreq.getInterface(Ci.nsIDOMWindowUtils); + let scrollX = {}, scrollY = {}; + utils.getScrollXY(false /* no layout flush */, scrollX, scrollY); + + if (scrollX.value || scrollY.value) { + return {scroll: scrollX.value + "," + scrollY.value}; + } + + return null; + }, + + /** + * Restores scroll position data for any given |frame| in the frame hierarchy. + * + * @param frame (DOMWindow) + * @param value (object, see collect()) + */ + restore: function (frame, value) { + let match; + + if (value && (match = /(\d+),(\d+)/.exec(value))) { + frame.scrollTo(match[1], match[2]); + } + }, + + /** + * Restores scroll position data for the current frame hierarchy starting at + * |root| using the given scroll position |data|. + * + * If the given |root| frame's hierarchy doesn't match that of the given + * |data| object we will silently discard data for unreachable frames. We + * may as well assign scroll positions to the wrong frames if some were + * reordered or removed. + * + * @param root (DOMWindow) + * @param data (object) + * { + * scroll: "100,200", + * children: [ + * {scroll: "100,200"}, + * null, + * {scroll: "200,300", children: [ ... ]} + * ] + * } + */ + restoreTree: function (root, data) { + if (data.hasOwnProperty("scroll")) { + this.restore(root, data.scroll); + } + + if (!data.hasOwnProperty("children")) { + return; + } + + let frames = root.frames; + data.children.forEach((child, index) => { + if (child && index < frames.length) { + this.restoreTree(frames[index], child); + } + }); + } +}; diff --git a/toolkit/modules/sessionstore/Utils.jsm b/toolkit/modules/sessionstore/Utils.jsm new file mode 100644 index 000000000..863bca6f5 --- /dev/null +++ b/toolkit/modules/sessionstore/Utils.jsm @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["Utils"]; + +const Cu = Components.utils; +const Cc = Components.classes; +const Ci = Components.interfaces; + +Cu.import("resource://gre/modules/Services.jsm", this); +Cu.import("resource://gre/modules/XPCOMUtils.jsm", this); + +XPCOMUtils.defineLazyServiceGetter(this, "serializationHelper", + "@mozilla.org/network/serialization-helper;1", + "nsISerializationHelper"); + +function debug(msg) { + Services.console.logStringMessage("Utils: " + msg); +} + +this.Utils = Object.freeze({ + makeURI: function (url) { + return Services.io.newURI(url, null, null); + }, + + makeInputStream: function (aString) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"]. + createInstance(Ci.nsISupportsCString); + stream.data = aString; + return stream; // XPConnect will QI this to nsIInputStream for us. + }, + + /** + * Returns true if the |url| passed in is part of the given root |domain|. + * For example, if |url| is "www.mozilla.org", and we pass in |domain| as + * "mozilla.org", this will return true. It would return false the other way + * around. + */ + hasRootDomain: function (url, domain) { + let host; + + try { + host = this.makeURI(url).host; + } catch (e) { + // The given URL probably doesn't have a host. + return false; + } + + let index = host.indexOf(domain); + if (index == -1) + return false; + + if (host == domain) + return true; + + let prevChar = host[index - 1]; + return (index == (host.length - domain.length)) && + (prevChar == "." || prevChar == "/"); + }, + + shallowCopy: function (obj) { + let retval = {}; + + for (let key of Object.keys(obj)) { + retval[key] = obj[key]; + } + + return retval; + }, + + /** + * Serialize principal data. + * + * @param {nsIPrincipal} principal The principal to serialize. + * @return {String} The base64 encoded principal data. + */ + serializePrincipal(principal) { + if (!principal) + return null; + + return serializationHelper.serializeToString(principal); + }, + + /** + * Deserialize a base64 encoded principal (serialized with + * Utils::serializePrincipal). + * + * @param {String} principal_b64 A base64 encoded serialized principal. + * @return {nsIPrincipal} A deserialized principal. + */ + deserializePrincipal(principal_b64) { + if (!principal_b64) + return null; + + try { + let principal = serializationHelper.deserializeObject(principal_b64); + principal.QueryInterface(Ci.nsIPrincipal); + return principal; + } catch (e) { + debug(`Failed to deserialize principal_b64 '${principal_b64}' ${e}`); + } + return null; + } +}); diff --git a/toolkit/modules/sessionstore/XPathGenerator.jsm b/toolkit/modules/sessionstore/XPathGenerator.jsm new file mode 100644 index 000000000..33f397cdf --- /dev/null +++ b/toolkit/modules/sessionstore/XPathGenerator.jsm @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["XPathGenerator"]; + +this.XPathGenerator = { + // these two hashes should be kept in sync + namespaceURIs: { + "xhtml": "http://www.w3.org/1999/xhtml", + "xul": "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + }, + namespacePrefixes: { + "http://www.w3.org/1999/xhtml": "xhtml", + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": "xul" + }, + + /** + * Generates an approximate XPath query to an (X)HTML node + */ + generate: function sss_xph_generate(aNode) { + // have we reached the document node already? + if (!aNode.parentNode) + return ""; + + // Access localName, namespaceURI just once per node since it's expensive. + let nNamespaceURI = aNode.namespaceURI; + let nLocalName = aNode.localName; + + let prefix = this.namespacePrefixes[nNamespaceURI] || null; + let tag = (prefix ? prefix + ":" : "") + this.escapeName(nLocalName); + + // stop once we've found a tag with an ID + if (aNode.id) + return "//" + tag + "[@id=" + this.quoteArgument(aNode.id) + "]"; + + // count the number of previous sibling nodes of the same tag + // (and possible also the same name) + let count = 0; + let nName = aNode.name || null; + for (let n = aNode; (n = n.previousSibling); ) + if (n.localName == nLocalName && n.namespaceURI == nNamespaceURI && + (!nName || n.name == nName)) + count++; + + // recurse until hitting either the document node or an ID'd node + return this.generate(aNode.parentNode) + "/" + tag + + (nName ? "[@name=" + this.quoteArgument(nName) + "]" : "") + + (count ? "[" + (count + 1) + "]" : ""); + }, + + /** + * Resolves an XPath query generated by XPathGenerator.generate + */ + resolve: function sss_xph_resolve(aDocument, aQuery) { + let xptype = Components.interfaces.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE; + return aDocument.evaluate(aQuery, aDocument, this.resolveNS, xptype, null).singleNodeValue; + }, + + /** + * Namespace resolver for the above XPath resolver + */ + resolveNS: function sss_xph_resolveNS(aPrefix) { + return XPathGenerator.namespaceURIs[aPrefix] || null; + }, + + /** + * @returns valid XPath for the given node (usually just the local name itself) + */ + escapeName: function sss_xph_escapeName(aName) { + // we can't just use the node's local name, if it contains + // special characters (cf. bug 485482) + return /^\w+$/.test(aName) ? aName : + "*[local-name()=" + this.quoteArgument(aName) + "]"; + }, + + /** + * @returns a properly quoted string to insert into an XPath query + */ + quoteArgument: function sss_xph_quoteArgument(aArg) { + if (!/'/.test(aArg)) + return "'" + aArg + "'"; + if (!/"/.test(aArg)) + return '"' + aArg + '"'; + return "concat('" + aArg.replace(/'+/g, "',\"$&\",'") + "')"; + }, + + /** + * @returns an XPath query to all savable form field nodes + */ + get restorableFormNodes() { + // for a comprehensive list of all available <INPUT> types see + // https://dxr.mozilla.org/mozilla-central/search?q=kInputTypeTable&redirect=false + let ignoreInputs = new Map([ + ["type", ["password", "hidden", "button", "image", "submit", "reset"]], + ["autocomplete", ["off"]] + ]); + // XXXzeniko work-around until lower-case has been implemented (bug 398389) + let toLowerCase = '"ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"'; + let ignores = []; + for (let [attrName, attrValues] of ignoreInputs) { + for (let attrValue of attrValues) + ignores.push(`translate(@${attrName}, ${toLowerCase})='${attrValue}'`); + } + let ignore = `not(${ignores.join(" or ")})`; + + let formNodesXPath = `//textarea[${ignore}]|//xhtml:textarea[${ignore}]|` + + `//select[${ignore}]|//xhtml:select[${ignore}]|` + + `//input[${ignore}]|//xhtml:input[${ignore}]`; + + // Special case for about:config's search field. + formNodesXPath += '|/xul:window[@id="config"]//xul:textbox[@id="textbox"]'; + + delete this.restorableFormNodes; + return (this.restorableFormNodes = formNodesXPath); + } +}; |