diff --git a/src/core_plugins/kibana/public/discover/controllers/discover.js b/src/core_plugins/kibana/public/discover/controllers/discover.js index 053e13766eaf8..111aa678b127b 100644 --- a/src/core_plugins/kibana/public/discover/controllers/discover.js +++ b/src/core_plugins/kibana/public/discover/controllers/discover.js @@ -2,7 +2,6 @@ import _ from 'lodash'; import angular from 'angular'; import moment from 'moment'; import getSort from 'ui/doc_table/lib/get_sort'; -import rison from 'rison-node'; import dateMath from '@elastic/datemath'; import 'ui/doc_table'; import 'ui/visualize'; @@ -26,8 +25,7 @@ import AggTypesBucketsIntervalOptionsProvider from 'ui/agg_types/buckets/_interv import uiRoutes from 'ui/routes'; import uiModules from 'ui/modules'; import indexTemplate from 'plugins/kibana/discover/index.html'; - - +import StateProvider from 'ui/state_management/state'; const app = uiModules.get('apps/discover', [ 'kibana/notify', @@ -43,18 +41,25 @@ uiRoutes template: indexTemplate, reloadOnSearch: false, resolve: { - ip: function (Promise, courier, config, $location) { + ip: function (Promise, courier, config, $location, Private) { + const State = Private(StateProvider); return courier.indexPatterns.getIds() .then(function (list) { - const stateRison = $location.search()._a; - - let state; - try { state = rison.decode(stateRison); } - catch (e) { state = {}; } + /** + * In making the indexPattern modifiable it was placed in appState. Unfortunately, + * the load order of AppState conflicts with the load order of many other things + * so in order to get the name of the index we should use, and to switch to the + * default if necessary, we parse the appState with a temporary State object and + * then destroy it immediatly after we're done + * + * @type {State} + */ + const state = new State('_a', {}); const specified = !!state.index; const exists = _.contains(list, state.index); const id = exists ? state.index : config.get('defaultIndex'); + state.destroy(); return Promise.props({ list: list, diff --git a/src/test_utils/__tests__/stub_browser_storage.js b/src/test_utils/__tests__/stub_browser_storage.js index 83e4a7440ddd3..215e564d3723a 100644 --- a/src/test_utils/__tests__/stub_browser_storage.js +++ b/src/test_utils/__tests__/stub_browser_storage.js @@ -49,10 +49,10 @@ describe('StubBrowserStorage', () => { }); }); - describe('size limiting', () => { + describe('#setStubbedSizeLimit', () => { it('allows limiting the storage size', () => { const store = new StubBrowserStorage(); - store._setSizeLimit(10); + store.setStubbedSizeLimit(10); store.setItem('abc', 'def'); // store size is 6, key.length + val.length expect(() => { store.setItem('ghi', 'jkl'); @@ -61,25 +61,41 @@ describe('StubBrowserStorage', () => { it('allows defining the limit as infinity', () => { const store = new StubBrowserStorage(); - store._setSizeLimit(Infinity); + store.setStubbedSizeLimit(Infinity); store.setItem('abc', 'def'); store.setItem('ghi', 'jkl'); // unlike the previous test, this doesn't throw }); - it('requires setting the limit before keys', () => { + it('throws an error if the limit is below the current size', () => { const store = new StubBrowserStorage(); store.setItem('key', 'val'); expect(() => { - store._setSizeLimit(10); - }).throwError(/before setting/); + store.setStubbedSizeLimit(5); + }).throwError(Error); }); it('respects removed items', () => { const store = new StubBrowserStorage(); - store._setSizeLimit(10); + store.setStubbedSizeLimit(10); store.setItem('abc', 'def'); store.removeItem('abc'); store.setItem('ghi', 'jkl'); // unlike the previous test, this doesn't throw }); }); + + describe('#getStubbedSizeLimit', () => { + it('returns the size limit', () => { + const store = new StubBrowserStorage(); + store.setStubbedSizeLimit(10); + expect(store.getStubbedSizeLimit()).to.equal(10); + }); + }); + + describe('#getStubbedSize', () => { + it('returns the size', () => { + const store = new StubBrowserStorage(); + store.setItem(1, 1); + expect(store.getStubbedSize()).to.equal(2); + }); + }); }); diff --git a/src/test_utils/stub_browser_storage.js b/src/test_utils/stub_browser_storage.js index cf628e0eca515..e601e78bfe813 100644 --- a/src/test_utils/stub_browser_storage.js +++ b/src/test_utils/stub_browser_storage.js @@ -1,92 +1,109 @@ -const keys = Symbol('keys'); -const values = Symbol('values'); -const remainingSize = Symbol('remainingSize'); - export default class StubBrowserStorage { constructor() { - this[keys] = []; - this[values] = []; - this[remainingSize] = 5000000; // 5mb, minimum browser storage size + this._keys = []; + this._values = []; + this._size = 0; + this._sizeLimit = 5000000; // 5mb, minimum browser storage size } + // ----------------------------------------------------------------------------------------------- + // Browser-specific methods. + // ----------------------------------------------------------------------------------------------- + get length() { - return this[keys].length; + return this._keys.length; } key(i) { - return this[keys][i]; + return this._keys[i]; } getItem(key) { key = String(key); - const i = this[keys].indexOf(key); + const i = this._keys.indexOf(key); if (i === -1) return null; - return this[values][i]; + return this._values[i]; } setItem(key, value) { key = String(key); value = String(value); - this._takeUpSpace(this._calcSizeOfAdd(key, value)); + const sizeOfAddition = this._getSizeOfAddition(key, value); + this._updateSize(sizeOfAddition); - const i = this[keys].indexOf(key); + const i = this._keys.indexOf(key); if (i === -1) { - this[keys].push(key); - this[values].push(value); + this._keys.push(key); + this._values.push(value); } else { - this[values][i] = value; + this._values[i] = value; } } removeItem(key) { key = String(key); - this._takeUpSpace(this._calcSizeOfRemove(key)); + const sizeOfRemoval = this._getSizeOfRemoval(key); + this._updateSize(sizeOfRemoval); - const i = this[keys].indexOf(key); + const i = this._keys.indexOf(key); if (i === -1) return; - this[keys].splice(i, 1); - this[values].splice(i, 1); + this._keys.splice(i, 1); + this._values.splice(i, 1); } - // non-standard api methods - _getKeys() { - return this[keys].slice(); + // ----------------------------------------------------------------------------------------------- + // Test-specific methods. + // ----------------------------------------------------------------------------------------------- + + getStubbedKeys() { + return this._keys.slice(); } - _getValues() { - return this[values].slice(); + getStubbedValues() { + return this._values.slice(); } - _setSizeLimit(limit) { - if (this[keys].length) { - throw new Error('You must call _setSizeLimit() before setting any values'); + setStubbedSizeLimit(sizeLimit) { + // We can't reconcile a size limit with the "stored" items, if the stored items size exceeds it. + if (sizeLimit < this._size) { + throw new Error(`You can't set a size limit smaller than the current size.`); } - this[remainingSize] = limit; + this._sizeLimit = sizeLimit; + } + + getStubbedSizeLimit() { + return this._sizeLimit; + } + + getStubbedSize() { + return this._size; } - _calcSizeOfAdd(key, value) { - const i = this[keys].indexOf(key); + _getSizeOfAddition(key, value) { + const i = this._keys.indexOf(key); if (i === -1) { return key.length + value.length; } - return value.length - this[values][i].length; + // Return difference of what's been stored, and what *will* be stored. + return value.length - this._values[i].length; } - _calcSizeOfRemove(key) { - const i = this[keys].indexOf(key); + _getSizeOfRemoval(key) { + const i = this._keys.indexOf(key); if (i === -1) { return 0; } - return 0 - (key.length + this[values][i].length); + // Return negative value. + return -(key.length + this._values[i].length); } - _takeUpSpace(delta) { - if (this[remainingSize] - delta < 0) { + _updateSize(delta) { + if (this._size + delta > this._sizeLimit) { throw new Error('something about quota exceeded, browsers are not consistent here'); } - this[remainingSize] -= delta; + this._size += delta; } } diff --git a/src/ui/public/chrome/api/__tests__/apps.js b/src/ui/public/chrome/api/__tests__/apps.js index 7e6040b1aa8ce..613920e0dcb86 100644 --- a/src/ui/public/chrome/api/__tests__/apps.js +++ b/src/ui/public/chrome/api/__tests__/apps.js @@ -152,8 +152,8 @@ describe('Chrome API :: apps', function () { expect(chrome.getLastUrlFor('app')).to.equal(null); chrome.setLastUrlFor('app', 'url'); expect(chrome.getLastUrlFor('app')).to.equal('url'); - expect(store._getKeys().length).to.equal(1); - expect(store._getValues().shift()).to.equal('url'); + expect(store.getStubbedKeys().length).to.equal(1); + expect(store.getStubbedValues().shift()).to.equal('url'); }); }); }); diff --git a/src/ui/public/chrome/api/angular.js b/src/ui/public/chrome/api/angular.js index e8f9b30115e6b..198ddfef4cdfd 100644 --- a/src/ui/public/chrome/api/angular.js +++ b/src/ui/public/chrome/api/angular.js @@ -5,7 +5,7 @@ import modules from 'ui/modules'; import Notifier from 'ui/notify/notifier'; import { UrlOverflowServiceProvider } from '../../error_url_overflow'; -const URL_LIMIT_WARN_WITHIN = 150; +const URL_LIMIT_WARN_WITHIN = 1000; module.exports = function (chrome, internals) { @@ -57,10 +57,20 @@ module.exports = function (chrome, internals) { try { if (urlOverflow.check($location.absUrl()) <= URL_LIMIT_WARN_WITHIN) { - notify.warning(` - The URL has gotten big and may cause Kibana - to stop working. Please simplify the data on screen. - `); + notify.directive({ + template: ` +

+ The URL has gotten big and may cause Kibana + to stop working. Please either enable the + state:storeInSessionStorage + option in the advanced + settings or simplify the onscreen visuals. +

+ ` + }, { + type: 'error', + actions: [{ text: 'close' }] + }); } } catch (e) { const { host, path, search, protocol } = parseUrl(window.location.href); diff --git a/src/ui/public/chrome/directives/kbn_chrome.js b/src/ui/public/chrome/directives/kbn_chrome.js index 1ff7435e94d9a..1b191dda6809a 100644 --- a/src/ui/public/chrome/directives/kbn_chrome.js +++ b/src/ui/public/chrome/directives/kbn_chrome.js @@ -4,6 +4,10 @@ import { remove } from 'lodash'; import './kbn_chrome.less'; import UiModules from 'ui/modules'; import { isSystemApiRequest } from 'ui/system_api'; +import { + getUnhashableStatesProvider, + unhashUrl, +} from 'ui/state_management/state_hashing'; export default function (chrome, internals) { @@ -28,15 +32,17 @@ export default function (chrome, internals) { }, controllerAs: 'chrome', - controller($scope, $rootScope, $location, $http) { + controller($scope, $rootScope, $location, $http, Private) { + const getUnhashableStates = Private(getUnhashableStatesProvider); // are we showing the embedded version of the chrome? internals.setVisibleDefault(!$location.search().embed); // listen for route changes, propogate to tabs const onRouteChange = function () { - let { href } = window.location; - internals.trackPossibleSubUrl(href); + const urlWithHashes = window.location.href; + const urlWithStates = unhashUrl(urlWithHashes, getUnhashableStates()); + internals.trackPossibleSubUrl(urlWithStates); }; $rootScope.$on('$routeChangeSuccess', onRouteChange); diff --git a/src/ui/public/crypto/index.js b/src/ui/public/crypto/index.js new file mode 100644 index 0000000000000..9951cf805cb85 --- /dev/null +++ b/src/ui/public/crypto/index.js @@ -0,0 +1 @@ +export { Sha256 } from './sha256'; diff --git a/src/ui/public/crypto/sha256.js b/src/ui/public/crypto/sha256.js new file mode 100644 index 0000000000000..1c6de18b1dfab --- /dev/null +++ b/src/ui/public/crypto/sha256.js @@ -0,0 +1,216 @@ +// ported from https://github.com/spalger/sha.js/blob/6557630d508873e262e94bff70c50bdd797c1df7/sha256.js +// and https://github.com/spalger/sha.js/blob/6557630d508873e262e94bff70c50bdd797c1df7/hash.js + +/** + * A JavaScript implementation of the Secure Hash Algorithm, SHA-256, as defined + * in FIPS 180-2 + * Version 2.2-beta Copyright Angel Marin, Paul Johnston 2000 - 2009. + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * + * Copyright (c) 2013-2014 sha.js contributors + * + * Permission is hereby granted, free of charge, + * to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to + * deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom + * the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice + * shall be included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR + * ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +const K = [ + 0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5, + 0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5, + 0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3, + 0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174, + 0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC, + 0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA, + 0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7, + 0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967, + 0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13, + 0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85, + 0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3, + 0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070, + 0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5, + 0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3, + 0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208, + 0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2, +]; + +const W = new Array(64); + +export class Sha256 { + constructor() { + this.init(); + + this._w = W; // new Array(64) + + const blockSize = 64; + const finalSize = 56; + this._block = new Buffer(blockSize); + this._finalSize = finalSize; + this._blockSize = blockSize; + this._len = 0; + this._s = 0; + } + + init() { + this._a = 0x6a09e667; + this._b = 0xbb67ae85; + this._c = 0x3c6ef372; + this._d = 0xa54ff53a; + this._e = 0x510e527f; + this._f = 0x9b05688c; + this._g = 0x1f83d9ab; + this._h = 0x5be0cd19; + + return this; + } + + update(data, enc) { + if (typeof data === 'string') { + enc = enc || 'utf8'; + data = new Buffer(data, enc); + } + + const l = this._len += data.length; + let s = this._s || 0; + let f = 0; + const buffer = this._block; + + while (s < l) { + const t = Math.min(data.length, f + this._blockSize - (s % this._blockSize)); + const ch = (t - f); + + for (let i = 0; i < ch; i++) { + buffer[(s % this._blockSize) + i] = data[i + f]; + } + + s += ch; + f += ch; + + if ((s % this._blockSize) === 0) { + this._update(buffer); + } + } + this._s = s; + + return this; + } + + digest(enc) { + // Suppose the length of the message M, in bits, is l + const l = this._len * 8; + + // Append the bit 1 to the end of the message + this._block[this._len % this._blockSize] = 0x80; + + // and then k zero bits, where k is the smallest non-negative solution to the equation (l + 1 + k) === finalSize mod blockSize + this._block.fill(0, this._len % this._blockSize + 1); + + if (l % (this._blockSize * 8) >= this._finalSize * 8) { + this._update(this._block); + this._block.fill(0); + } + + // to this append the block which is equal to the number l written in binary + // TODO: handle case where l is > Math.pow(2, 29) + this._block.writeInt32BE(l, this._blockSize - 4); + + const hash = this._update(this._block) || this._hash(); + + return enc ? hash.toString(enc) : hash; + } + + _update(M) { + const W = this._w; + + let a = this._a | 0; + let b = this._b | 0; + let c = this._c | 0; + let d = this._d | 0; + let e = this._e | 0; + let f = this._f | 0; + let g = this._g | 0; + let h = this._h | 0; + + let i; + for (i = 0; i < 16; ++i) W[i] = M.readInt32BE(i * 4); + for (; i < 64; ++i) W[i] = (gamma1(W[i - 2]) + W[i - 7] + gamma0(W[i - 15]) + W[i - 16]) | 0; + + for (let j = 0; j < 64; ++j) { + const T1 = (h + sigma1(e) + ch(e, f, g) + K[j] + W[j]) | 0; + const T2 = (sigma0(a) + maj(a, b, c)) | 0; + + h = g; + g = f; + f = e; + e = (d + T1) | 0; + d = c; + c = b; + b = a; + a = (T1 + T2) | 0; + } + + this._a = (a + this._a) | 0; + this._b = (b + this._b) | 0; + this._c = (c + this._c) | 0; + this._d = (d + this._d) | 0; + this._e = (e + this._e) | 0; + this._f = (f + this._f) | 0; + this._g = (g + this._g) | 0; + this._h = (h + this._h) | 0; + } + + _hash() { + const H = new Buffer(32); + + H.writeInt32BE(this._a, 0); + H.writeInt32BE(this._b, 4); + H.writeInt32BE(this._c, 8); + H.writeInt32BE(this._d, 12); + H.writeInt32BE(this._e, 16); + H.writeInt32BE(this._f, 20); + H.writeInt32BE(this._g, 24); + H.writeInt32BE(this._h, 28); + + return H; + } +} + +function ch(x, y, z) { + return z ^ (x & (y ^ z)); +} + +function maj(x, y, z) { + return (x & y) | (z & (x | y)); +} + +function sigma0(x) { + return (x >>> 2 | x << 30) ^ (x >>> 13 | x << 19) ^ (x >>> 22 | x << 10); +} + +function sigma1(x) { + return (x >>> 6 | x << 26) ^ (x >>> 11 | x << 21) ^ (x >>> 25 | x << 7); +} + +function gamma0(x) { + return (x >>> 7 | x << 25) ^ (x >>> 18 | x << 14) ^ (x >>> 3); +} + +function gamma1(x) { + return (x >>> 17 | x << 15) ^ (x >>> 19 | x << 13) ^ (x >>> 10); +} diff --git a/src/ui/public/error_url_overflow/error_url_overflow.html b/src/ui/public/error_url_overflow/error_url_overflow.html index a69ce9975352e..143ccb25e2db6 100644 --- a/src/ui/public/error_url_overflow/error_url_overflow.html +++ b/src/ui/public/error_url_overflow/error_url_overflow.html @@ -9,6 +9,7 @@

Ok, how do I fix this?

This usually only happens with big, complex dashboards, so you have some options:

    +
  1. Enable the state:storeInSessionStorage option in the advanced settings. This will prevent the URLs from getting long, but makes them a bit less portable.
  2. Remove some stuff from your dashboard. This will reduce the length of the URL and keep IE in a good place.
  3. Don't use IE. Every other supported browser we know of doesn't have this limit.
diff --git a/src/ui/public/share/directives/share_object_url.js b/src/ui/public/share/directives/share_object_url.js index d20a1c4c00305..74682dd3de33c 100644 --- a/src/ui/public/share/directives/share_object_url.js +++ b/src/ui/public/share/directives/share_object_url.js @@ -4,10 +4,15 @@ import '../styles/index.less'; import LibUrlShortenerProvider from '../lib/url_shortener'; import uiModules from 'ui/modules'; import shareObjectUrlTemplate from 'ui/share/views/share_object_url.html'; - +import { + getUnhashableStatesProvider, + unhashUrl, +} from 'ui/state_management/state_hashing'; +import { memoize } from 'lodash'; app.directive('shareObjectUrl', function (Private, Notifier) { const urlShortener = Private(LibUrlShortenerProvider); + const getUnhashableStates = Private(getUnhashableStatesProvider); return { restrict: 'E', @@ -70,11 +75,14 @@ app.directive('shareObjectUrl', function (Private, Notifier) { }; $scope.getUrl = function () { - let url = $location.absUrl(); + const urlWithHashes = $location.absUrl(); + const urlWithStates = unhashUrl(urlWithHashes, getUnhashableStates()); + if ($scope.shareAsEmbed) { - url = url.replace('?', '?embed=true&'); + return urlWithStates.replace('?', '?embed=true&'); } - return url; + + return urlWithStates; }; $scope.$watch('getUrl()', updateUrl); diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index d0233594e5b44..28b04c9dcc27a 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -1,48 +1,76 @@ - -import _ from 'lodash'; import sinon from 'sinon'; import expect from 'expect.js'; import ngMock from 'ng_mock'; +import { encode as encodeRison } from 'rison-node'; import 'ui/private'; +import Notifier from 'ui/notify/notifier'; import StateManagementStateProvider from 'ui/state_management/state'; +import { + unhashQueryString, +} from 'ui/state_management/state_hashing'; +import { + createStateHash, + isStateHash, +} from 'ui/state_management/state_storage'; +import HashedItemStore from 'ui/state_management/state_storage/hashed_item_store'; +import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; describe('State Management', function () { + const notifier = new Notifier(); let $rootScope; let $location; let State; let Events; + let setup; beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (_$rootScope_, _$location_, Private) { + beforeEach(ngMock.inject(function (_$rootScope_, _$location_, Private, config) { $location = _$location_; $rootScope = _$rootScope_; State = Private(StateManagementStateProvider); Events = Private(EventsProvider); + Notifier.prototype._notifs.splice(0); + + setup = opts => { + const { param, initial, storeInHash } = (opts || {}); + sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(!!storeInHash); + const store = new StubBrowserStorage(); + const hashedItemStore = new HashedItemStore(store); + const state = new State(param, initial, hashedItemStore, notifier); + + const getUnhashedSearch = state => { + return unhashQueryString($location.search(), [ state ]); + }; + + return { notifier, store, hashedItemStore, state, getUnhashedSearch }; + }; })); + afterEach(() => Notifier.prototype._notifs.splice(0)); + describe('Provider', function () { it('should reset the state to the defaults', function () { - let state = new State('_s', { message: ['test'] }); + const { state, getUnhashedSearch } = setup({ initial: { message: ['test'] } }); state.reset(); - let search = $location.search(); + let search = getUnhashedSearch(state); expect(search).to.have.property('_s'); expect(search._s).to.equal('(message:!(test))'); expect(state.message).to.eql(['test']); }); it('should apply the defaults upon initialization', function () { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: 'test' } }); expect(state).to.have.property('message', 'test'); }); it('should inherit from Events', function () { - let state = new State(); + const { state } = setup(); expect(state).to.be.an(Events); }); it('should emit an event if reset with changes', function (done) { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: ['test'] } }); state.on('reset_with_changes', function (keys) { expect(keys).to.eql(['message']); done(); @@ -54,7 +82,7 @@ describe('State Management', function () { }); it('should not emit an event if reset without changes', function () { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: 'test' } }); state.on('reset_with_changes', function () { expect().fail(); }); @@ -67,29 +95,29 @@ describe('State Management', function () { describe('Search', function () { it('should save to $location.search()', function () { - let state = new State('_s', { test: 'foo' }); + const { state, getUnhashedSearch } = setup({ initial: { test: 'foo' } }); state.save(); - let search = $location.search(); + let search = getUnhashedSearch(state); expect(search).to.have.property('_s'); expect(search._s).to.equal('(test:foo)'); }); it('should emit an event if changes are saved', function (done) { - let state = new State(); + const { state, getUnhashedSearch } = setup(); state.on('save_with_changes', function (keys) { expect(keys).to.eql(['test']); done(); }); state.test = 'foo'; state.save(); - let search = $location.search(); + getUnhashedSearch(state); $rootScope.$apply(); }); }); describe('Fetch', function () { it('should emit an event if changes are fetched', function (done) { - let state = new State(); + const { state } = setup(); state.on('fetch_with_changes', function (keys) { expect(keys).to.eql(['foo']); done(); @@ -101,7 +129,7 @@ describe('State Management', function () { }); it('should have events that attach to scope', function (done) { - let state = new State(); + const { state } = setup(); state.on('test', function (message) { expect(message).to.equal('foo'); done(); @@ -111,7 +139,7 @@ describe('State Management', function () { }); it('should fire listeners for #onUpdate() on #fetch()', function (done) { - let state = new State(); + const { state } = setup(); state.on('fetch_with_changes', function (keys) { expect(keys).to.eql(['foo']); done(); @@ -123,7 +151,7 @@ describe('State Management', function () { }); it('should apply defaults to fetches', function () { - let state = new State('_s', { message: 'test' }); + const { state } = setup({ initial: { message: 'test' } }); $location.search({ _s: '(foo:bar)' }); state.fetch(); expect(state).to.have.property('foo', 'bar'); @@ -131,7 +159,7 @@ describe('State Management', function () { }); it('should call fetch when $routeUpdate is fired on $rootScope', function () { - let state = new State(); + const { state } = setup(); let spy = sinon.spy(state, 'fetch'); $rootScope.$emit('$routeUpdate', 'test'); sinon.assert.calledOnce(spy); @@ -139,9 +167,9 @@ describe('State Management', function () { it('should clear state when missing form URL', function () { let stateObj; - let state = new State(); + const { state } = setup(); - // set satte via URL + // set state via URL $location.search({ _s: '(foo:(bar:baz))' }); state.fetch(); stateObj = state.toObject(); @@ -160,4 +188,60 @@ describe('State Management', function () { expect(stateObj).to.eql({}); }); }); + + describe('Hashing', () => { + it('stores state values in a hashedItemStore, writing the hash to the url', () => { + const { state, hashedItemStore } = setup({ storeInHash: true }); + state.foo = 'bar'; + state.save(); + const urlVal = $location.search()[state.getQueryParamName()]; + + expect(isStateHash(urlVal)).to.be(true); + expect(hashedItemStore.getItem(urlVal)).to.eql(JSON.stringify({ foo: 'bar' })); + }); + + it('should replace rison in the URL with a hash', () => { + const { state, hashedItemStore } = setup({ storeInHash: true }); + const obj = { foo: { bar: 'baz' } }; + const rison = encodeRison(obj); + + $location.search({ _s: rison }); + state.fetch(); + + const urlVal = $location.search()._s; + expect(urlVal).to.not.be(rison); + expect(isStateHash(urlVal)).to.be(true); + expect(hashedItemStore.getItem(urlVal)).to.eql(JSON.stringify(obj)); + }); + + context('error handling', () => { + it('notifies the user when a hash value does not map to a stored value', () => { + const { state, notifier } = setup({ storeInHash: true }); + const search = $location.search(); + const badHash = createStateHash('{"a": "b"}', () => null); + + search[state.getQueryParamName()] = badHash; + $location.search(search); + + expect(notifier._notifs).to.have.length(0); + state.fetch(); + expect(notifier._notifs).to.have.length(1); + expect(notifier._notifs[0].content).to.match(/use the share functionality/i); + }); + + it('presents fatal error linking to github when setting item fails', () => { + const { state, hashedItemStore, notifier } = setup({ storeInHash: true }); + const fatalStub = sinon.stub(notifier, 'fatal').throws(); + sinon.stub(hashedItemStore, 'setItem').returns(false); + + expect(() => { + state.toQueryParam(); + }).to.throwError(); + + sinon.assert.calledOnce(fatalStub); + expect(fatalStub.firstCall.args[0]).to.be.an(Error); + expect(fatalStub.firstCall.args[0].message).to.match(/github\.com/); + }); + }); + }); }); diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 7b934567e01ff..835f0e198cbcf 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -1,59 +1,92 @@ import _ from 'lodash'; +import angular from 'angular'; import rison from 'rison-node'; import applyDiff from 'ui/utils/diff_object'; -import qs from 'ui/utils/query_string'; import EventsProvider from 'ui/events'; import Notifier from 'ui/notify/notifier'; -import KbnUrlProvider from 'ui/url'; -const notify = new Notifier(); -export default function StateProvider(Private, $rootScope, $location) { +import { + createStateHash, + hashedItemStoreSingleton, + isStateHash, +} from './state_storage'; + +export default function StateProvider(Private, $rootScope, $location, config) { const Events = Private(EventsProvider); _.class(State).inherits(Events); - function State(urlParam, defaults) { + function State( + urlParam, + defaults, + hashedItemStore = hashedItemStoreSingleton, + notifier = new Notifier() + ) { State.Super.call(this); - let self = this; - self.setDefaults(defaults); - self._urlParam = urlParam || '_s'; + this.setDefaults(defaults); + this._urlParam = urlParam || '_s'; + this._notifier = notifier; + this._hashedItemStore = hashedItemStore; // When the URL updates we need to fetch the values from the URL - self._cleanUpListeners = _.partial(_.callEach, [ + this._cleanUpListeners = _.partial(_.callEach, [ // partial route update, no app reload - $rootScope.$on('$routeUpdate', function () { - self.fetch(); + $rootScope.$on('$routeUpdate', () => { + this.fetch(); }), // beginning of full route update, new app will be initialized before // $routeChangeSuccess or $routeChangeError - $rootScope.$on('$routeChangeStart', function () { - if (!self._persistAcrossApps) { - self.destroy(); + $rootScope.$on('$routeChangeStart', () => { + if (!this._persistAcrossApps) { + this.destroy(); } }), - $rootScope.$on('$routeChangeSuccess', function () { - if (self._persistAcrossApps) { - self.fetch(); + $rootScope.$on('$routeChangeSuccess', () => { + if (this._persistAcrossApps) { + this.fetch(); } }) ]); // Initialize the State with fetch - self.fetch(); + this.fetch(); } State.prototype._readFromURL = function () { - let search = $location.search(); + const search = $location.search(); + const urlVal = search[this._urlParam]; + + if (!urlVal) { + return null; + } + + if (isStateHash(urlVal)) { + return this._parseQueryParamValue(urlVal); + } + + let risonEncoded; + let unableToParse; try { - return search[this._urlParam] ? rison.decode(search[this._urlParam]) : null; + risonEncoded = rison.decode(urlVal); } catch (e) { - notify.error('Unable to parse URL'); - search[this._urlParam] = rison.encode(this._defaults); + unableToParse = true; + } + + if (unableToParse) { + this._notifier.error('Unable to parse URL'); + search[this._urlParam] = this.toQueryParam(this._defaults); $location.search(search).replace(); - return null; } + + if (risonEncoded) { + search[this._urlParam] = this.toQueryParam(risonEncoded); + $location.search(search).replace(); + return risonEncoded; + } + + return null; }; /** @@ -95,9 +128,8 @@ export default function StateProvider(Private, $rootScope, $location) { stash = {}; } - _.defaults(state, this._defaults); // apply diff to state from stash, will change state in place via side effect - let diffResults = applyDiff(stash, state); + let diffResults = applyDiff(stash, _.defaults({}, state, this._defaults)); if (diffResults.keys.length) { this.emit('save_with_changes', diffResults.keys); @@ -105,7 +137,7 @@ export default function StateProvider(Private, $rootScope, $location) { // persist the state in the URL let search = $location.search(); - search[this._urlParam] = this.toRISON(); + search[this._urlParam] = this.toQueryParam(state); if (replace) { $location.search(search).replace(); } else { @@ -149,6 +181,80 @@ export default function StateProvider(Private, $rootScope, $location) { this._defaults = defaults || {}; }; + /** + * Parse the query param value to it's unserialized + * value. Hashes are restored to their pre-hashed state. + * + * @param {string} queryParam - value from the query string + * @return {any} - the stored value, or null if hash does not resolve + */ + State.prototype._parseQueryParamValue = function (queryParam) { + if (!isStateHash(queryParam)) { + return rison.decode(queryParam); + } + + const json = this._hashedItemStore.getItem(queryParam); + if (json === null) { + this._notifier.error('Unable to completely restore the URL, be sure to use the share functionality.'); + } + + return JSON.parse(json); + }; + + /** + * Lookup the value for a hash and return it's value + * in rison format + * + * @param {string} hash + * @return {string} rison + */ + State.prototype.translateHashToRison = function (hash) { + return rison.encode(this._parseQueryParamValue(hash)); + }; + + /** + * Produce the hash version of the state in it's current position + * + * @return {string} + */ + State.prototype.toQueryParam = function (state = this.toObject()) { + if (!config.get('state:storeInSessionStorage')) { + return rison.encode(state); + } + + // We need to strip out Angular-specific properties. + const json = angular.toJson(state); + const hash = createStateHash(json, hash => { + return this._hashedItemStore.getItem(hash); + }); + const isItemSet = this._hashedItemStore.setItem(hash, json); + + if (isItemSet) { + return hash; + } + + // If we ran out of space trying to persist the state, notify the user. + this._notifier.fatal( + new Error( + 'Kibana is unable to store history items in your session ' + + 'because it is full and there don\'t seem to be items any items safe ' + + 'to delete.\n' + + '\n' + + 'This can usually be fixed by moving to a fresh tab, but could ' + + 'be caused by a larger issue. If you are seeing this message regularly, ' + + 'please file an issue at https://github.com/elastic/kibana/issues.' + ) + ); + }; + + /** + * Get the query string parameter name where this state writes and reads + * @return {string} + */ + State.prototype.getQueryParamName = function () { + return this._urlParam; + }; + return State; }; diff --git a/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js b/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js new file mode 100644 index 0000000000000..6cf07677b3c8e --- /dev/null +++ b/src/ui/public/state_management/state_hashing/__tests__/unhash_url.js @@ -0,0 +1,73 @@ +import expect from 'expect.js'; +import ngMock from 'ng_mock'; +import sinon from 'auto-release-sinon'; + +import StateProvider from 'ui/state_management/state'; +import { unhashUrl } from 'ui/state_management/state_hashing'; + +describe('unhashUrl', () => { + let unhashableStates; + + beforeEach(ngMock.module('kibana')); + + beforeEach(ngMock.inject(Private => { + const State = Private(StateProvider); + const unhashableState = new State('testParam'); + sinon.stub(unhashableState, 'translateHashToRison').withArgs('hash').returns('replacement'); + unhashableStates = [unhashableState]; + })); + + describe('does nothing', () => { + it('if missing input', () => { + expect(() => { + unhashUrl(); + }).to.not.throwError(); + }); + + it('if just a host and port', () => { + const url = 'https://localhost:5601'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if just a path', () => { + const url = 'https://localhost:5601/app/kibana'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if just a path and query', () => { + const url = 'https://localhost:5601/app/kibana?foo=bar'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if empty hash with query', () => { + const url = 'https://localhost:5601/app/kibana?foo=bar#'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if empty hash without query', () => { + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if empty hash without query', () => { + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if hash is just a path', () => { + const url = 'https://localhost:5601/app/kibana#/discover'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + + it('if hash does not have matching query string vals', () => { + const url = 'https://localhost:5601/app/kibana#/discover?foo=bar'; + expect(unhashUrl(url, unhashableStates)).to.be(url); + }); + }); + + it('replaces query string vals in hash for matching states with output of state.toRISON()', () => { + const urlWithHashes = 'https://localhost:5601/#/?foo=bar&testParam=hash'; + const exp = 'https://localhost:5601/#/?foo=bar&testParam=replacement'; + expect(unhashUrl(urlWithHashes, unhashableStates)).to.be(exp); + }); +}); diff --git a/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js b/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js new file mode 100644 index 0000000000000..7dfc64f3a4398 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/get_unhashable_states_provider.js @@ -0,0 +1,5 @@ +export default function getUnhashableStatesProvider(getAppState, globalState) { + return function getUnhashableStates() { + return [getAppState(), globalState].filter(Boolean); + }; +} diff --git a/src/ui/public/state_management/state_hashing/index.js b/src/ui/public/state_management/state_hashing/index.js new file mode 100644 index 0000000000000..6905a1fd28b61 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/index.js @@ -0,0 +1,11 @@ +export { + default as getUnhashableStatesProvider, +} from './get_unhashable_states_provider'; + +export { + default as unhashQueryString, +} from './unhash_query_string'; + +export { + default as unhashUrl, +} from './unhash_url'; diff --git a/src/ui/public/state_management/state_hashing/unhash_query_string.js b/src/ui/public/state_management/state_hashing/unhash_query_string.js new file mode 100644 index 0000000000000..f75dbf97e9042 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/unhash_query_string.js @@ -0,0 +1,8 @@ +import { mapValues } from 'lodash'; + +export default function unhashQueryString(parsedQueryString, states) { + return mapValues(parsedQueryString, (val, key) => { + const state = states.find(s => key === s.getQueryParamName()); + return state ? state.translateHashToRison(val) : val; + }); +} diff --git a/src/ui/public/state_management/state_hashing/unhash_url.js b/src/ui/public/state_management/state_hashing/unhash_url.js new file mode 100644 index 0000000000000..3671b653dee22 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/unhash_url.js @@ -0,0 +1,36 @@ +import { + parse as parseUrl, + format as formatUrl, +} from 'url'; + +import unhashQueryString from './unhash_query_string'; + +export default function unhashUrl(urlWithHashes, states) { + if (!urlWithHashes) return urlWithHashes; + + const urlWithHashesParsed = parseUrl(urlWithHashes, true); + if (!urlWithHashesParsed.hostname) { + // passing a url like "localhost:5601" or "/app/kibana" should be prevented + throw new TypeError( + 'Only absolute urls should be passed to `unhashUrl()`. ' + + 'Unable to detect url hostname.' + ); + } + + if (!urlWithHashesParsed.hash) return urlWithHashes; + + const appUrl = urlWithHashesParsed.hash.slice(1); // trim the # + if (!appUrl) return urlWithHashes; + + const appUrlParsed = parseUrl(urlWithHashesParsed.hash.slice(1), true); + if (!appUrlParsed.query) return urlWithHashes; + + const appQueryWithoutHashes = unhashQueryString(appUrlParsed.query || {}, states); + return formatUrl({ + ...urlWithHashesParsed, + hash: formatUrl({ + pathname: appUrlParsed.pathname, + query: appQueryWithoutHashes, + }) + }); +} diff --git a/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js b/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js new file mode 100644 index 0000000000000..fa6095ad01a8a --- /dev/null +++ b/src/ui/public/state_management/state_storage/__tests__/hashed_item_store.js @@ -0,0 +1,333 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import HashedItemStore from '../hashed_item_store'; + +describe('hashedItemStore', () => { + describe('interface', () => { + describe('#constructor', () => { + it('retrieves persisted index from sessionStorage', () => { + const sessionStorage = new StubBrowserStorage(); + sinon.spy(sessionStorage, 'getItem'); + + new HashedItemStore(sessionStorage); + sinon.assert.calledWith(sessionStorage.getItem, HashedItemStore.PERSISTED_INDEX_KEY); + sessionStorage.getItem.restore(); + }); + + it('sorts indexed items by touched property', () => { + const a = { + hash: 'a', + touched: 0, + }; + const b = { + hash: 'b', + touched: 2, + }; + const c = { + hash: 'c', + touched: 1, + }; + const sessionStorage = new StubBrowserStorage(); + if (!HashedItemStore.PERSISTED_INDEX_KEY) { + // This is very brittle and depends upon HashedItemStore implementation details, + // so let's protect ourselves from accidentally breaking this test. + throw new Error('Missing HashedItemStore.PERSISTED_INDEX_KEY'); + } + sessionStorage.setItem(HashedItemStore.PERSISTED_INDEX_KEY, JSON.stringify({a, b, c})); + + const hashedItemStore = new HashedItemStore(sessionStorage); + expect(hashedItemStore._indexedItems).to.eql([a, c, b]); + }); + }); + + describe('#setItem', () => { + describe('if the item exists in sessionStorage', () => { + let sessionStorage; + let hashedItemStore; + const hash = 'a'; + const item = JSON.stringify({}); + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it('persists the item in sessionStorage', () => { + hashedItemStore.setItem(hash, item); + expect(sessionStorage.getItem(hash)).to.equal(item); + }); + + it('returns true', () => { + const result = hashedItemStore.setItem(hash, item); + expect(result).to.equal(true); + }); + }); + + describe(`if the item doesn't exist in sessionStorage`, () => { + describe(`if there's storage space`, () => { + let sessionStorage; + let hashedItemStore; + const hash = 'a'; + const item = JSON.stringify({}); + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it('persists the item in sessionStorage', () => { + hashedItemStore.setItem(hash, item); + expect(sessionStorage.getItem(hash)).to.equal(item); + }); + + it('returns true', () => { + const result = hashedItemStore.setItem(hash, item); + expect(result).to.equal(true); + }); + }); + + describe(`if there isn't storage space`, () => { + let fakeTimer; + let sessionStorage; + let hashedItemStore; + let storageSizeLimit; + const hash = 'a'; + const item = JSON.stringify({}); + + function setItemLater(hash, item) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.setItem(hash, item); + } + + beforeEach(() => { + // Control time. + fakeTimer = sinon.useFakeTimers(Date.now()); + + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + + // Add some items that will be removed. + setItemLater('b', item); + + // Do this a little later so that this item is newer. + setItemLater('c', item); + + // Cap the storage at its current size. + storageSizeLimit = sessionStorage.getStubbedSize(); + sessionStorage.setStubbedSizeLimit(storageSizeLimit); + }); + + afterEach(() => { + // Stop controlling time. + fakeTimer.restore(); + }); + + describe('and the item will fit', () => { + it('removes older items until the new item fits', () => { + setItemLater(hash, item); + expect(sessionStorage.getItem('b')).to.equal(null); + expect(sessionStorage.getItem('c')).to.equal(item); + }); + + it('persists the item in sessionStorage', () => { + setItemLater(hash, item); + expect(sessionStorage.getItem(hash)).to.equal(item); + }); + + it('returns true', () => { + const result = setItemLater(hash, item); + expect(result).to.equal(true); + }); + }); + + describe(`and the item won't fit`, () => { + let itemTooBigToFit; + + beforeEach(() => { + // Make sure the item is longer than the storage size limit. + itemTooBigToFit = ''; + const length = storageSizeLimit + 1; + for (let i = 0; i < length; i++) { + itemTooBigToFit += 'a'; + } + }); + + it('removes all items', () => { + setItemLater(hash, itemTooBigToFit); + expect(sessionStorage.getItem('b')).to.equal(null); + expect(sessionStorage.getItem('c')).to.equal(null); + }); + + it(`doesn't persist the item in sessionStorage`, () => { + setItemLater(hash, itemTooBigToFit); + expect(sessionStorage.getItem(hash)).to.equal(null); + }); + + it('returns false', () => { + const result = setItemLater(hash, itemTooBigToFit); + expect(result).to.equal(false); + }); + }); + }); + }); + }); + + describe('#getItem', () => { + describe('if the item exists in sessionStorage', () => { + let fakeTimer; + let sessionStorage; + let hashedItemStore; + + function setItemLater(hash, item) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.setItem(hash, item); + } + + function getItemLater(hash) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.getItem(hash); + } + + beforeEach(() => { + // Control time. + fakeTimer = sinon.useFakeTimers(Date.now()); + + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + hashedItemStore.setItem('1', 'a'); + }); + + afterEach(() => { + // Stop controlling time. + fakeTimer.restore(); + }); + + it('returns the item', () => { + const retrievedItem = hashedItemStore.getItem('1'); + expect(retrievedItem).to.be('a'); + }); + + it('prevents the item from being first to be removed when freeing up storage spage', () => { + // Do this a little later so that this item is newer. + setItemLater('2', 'b'); + + // Wait a bit, then retrieve/touch the first item, making *it* newer, and 2 as the oldest. + getItemLater('1'); + + // Cap the storage at its current size. + const storageSizeLimit = sessionStorage.getStubbedSize(); + sessionStorage.setStubbedSizeLimit(storageSizeLimit); + + // Add a new item, causing the second item to be removed, but not the first. + setItemLater('3', 'c'); + expect(hashedItemStore.getItem('2')).to.equal(null); + expect(hashedItemStore.getItem('1')).to.equal('a'); + }); + }); + + describe(`if the item doesn't exist in sessionStorage`, () => { + let sessionStorage; + let hashedItemStore; + const hash = 'a'; + + beforeEach(() => { + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + it('returns null', () => { + const retrievedItem = hashedItemStore.getItem(hash); + expect(retrievedItem).to.be(null); + }); + }); + }); + }); + + describe('behavior', () => { + let fakeTimer; + let sessionStorage; + let hashedItemStore; + + function setItemLater(hash, item) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.setItem(hash, item); + } + + function getItemLater(hash) { + // Move time forward, so this item will be "touched" most recently. + fakeTimer.tick(1); + return hashedItemStore.getItem(hash); + } + + beforeEach(() => { + // Control time. + fakeTimer = sinon.useFakeTimers(Date.now()); + + sessionStorage = new StubBrowserStorage(); + hashedItemStore = new HashedItemStore(sessionStorage); + }); + + afterEach(() => { + // Stop controlling time. + fakeTimer.restore(); + }); + + it('orders items to be removed based on when they were last retrieved', () => { + setItemLater('1', 'a'); + setItemLater('2', 'b'); + setItemLater('3', 'c'); + setItemLater('4', 'd'); + + // Cap the storage at its current size. + const storageSizeLimit = sessionStorage.getStubbedSize(); + sessionStorage.setStubbedSizeLimit(storageSizeLimit); + + // Expect items to be removed in order: 1, 3, 2, 4. + getItemLater('1'); + getItemLater('3'); + getItemLater('2'); + getItemLater('4'); + + setItemLater('5', 'e'); + expect(hashedItemStore.getItem('1')).to.equal(null); + expect(hashedItemStore.getItem('3')).to.equal('c'); + expect(hashedItemStore.getItem('2')).to.equal('b'); + expect(hashedItemStore.getItem('4')).to.equal('d'); + expect(hashedItemStore.getItem('5')).to.equal('e'); + + setItemLater('6', 'f'); + expect(hashedItemStore.getItem('3')).to.equal(null); + expect(hashedItemStore.getItem('2')).to.equal('b'); + expect(hashedItemStore.getItem('4')).to.equal('d'); + expect(hashedItemStore.getItem('5')).to.equal('e'); + expect(hashedItemStore.getItem('6')).to.equal('f'); + + setItemLater('7', 'g'); + expect(hashedItemStore.getItem('2')).to.equal(null); + expect(hashedItemStore.getItem('4')).to.equal('d'); + expect(hashedItemStore.getItem('5')).to.equal('e'); + expect(hashedItemStore.getItem('6')).to.equal('f'); + expect(hashedItemStore.getItem('7')).to.equal('g'); + + setItemLater('8', 'h'); + expect(hashedItemStore.getItem('4')).to.equal(null); + expect(hashedItemStore.getItem('5')).to.equal('e'); + expect(hashedItemStore.getItem('6')).to.equal('f'); + expect(hashedItemStore.getItem('7')).to.equal('g'); + expect(hashedItemStore.getItem('8')).to.equal('h'); + + setItemLater('9', 'i'); + expect(hashedItemStore.getItem('5')).to.equal(null); + expect(hashedItemStore.getItem('6')).to.equal('f'); + expect(hashedItemStore.getItem('7')).to.equal('g'); + expect(hashedItemStore.getItem('8')).to.equal('h'); + expect(hashedItemStore.getItem('9')).to.equal('i'); + }); + }); +}); diff --git a/src/ui/public/state_management/state_storage/__tests__/state_hash.js b/src/ui/public/state_management/state_storage/__tests__/state_hash.js new file mode 100644 index 0000000000000..97e93fcab4d67 --- /dev/null +++ b/src/ui/public/state_management/state_storage/__tests__/state_hash.js @@ -0,0 +1,55 @@ +import expect from 'expect.js'; +import { encode as encodeRison } from 'rison-node'; + +import { + createStateHash, + isStateHash, +} from '../state_hash'; + +describe('stateHash', () => { + const existingJsonProvider = () => null; + + describe('#createStateHash', () => { + + describe('returns a hash', () => { + const json = JSON.stringify({a: 'a'}); + const hash = createStateHash(json, existingJsonProvider); + expect(isStateHash(hash)).to.be(true); + }); + + describe('returns the same hash for the same input', () => { + const json = JSON.stringify({a: 'a'}); + const hash1 = createStateHash(json, existingJsonProvider); + const hash2 = createStateHash(json, existingJsonProvider); + expect(hash1).to.equal(hash2); + }); + + describe('returns a different hash for different input', () => { + const json1 = JSON.stringify({a: 'a'}); + const hash1 = createStateHash(json1, existingJsonProvider); + + const json2 = JSON.stringify({a: 'b'}); + const hash2 = createStateHash(json2, existingJsonProvider); + expect(hash1).to.not.equal(hash2); + }); + }); + + describe('#isStateHash', () => { + it('returns true for values created using #createStateHash', () => { + const json = JSON.stringify({a: 'a'}); + const hash = createStateHash(json, existingJsonProvider); + expect(isStateHash(hash)).to.be(true); + }); + + it('returns false for values not created using #createStateHash', () => { + const json = JSON.stringify({a: 'a'}); + expect(isStateHash(json)).to.be(false); + }); + + it('returns false for RISON', () => { + // We're storing RISON in the URL, so let's test against this specifically. + const rison = encodeRison({a: 'a'}); + expect(isStateHash(rison)).to.be(false); + }); + }); +}); diff --git a/src/ui/public/state_management/state_storage/hashed_item_store.js b/src/ui/public/state_management/state_storage/hashed_item_store.js new file mode 100644 index 0000000000000..15a52629deb3a --- /dev/null +++ b/src/ui/public/state_management/state_storage/hashed_item_store.js @@ -0,0 +1,151 @@ +/** + * The HashedItemStore associates JSON objects with states in browser history and persists these + * objects in sessionStorage. We persist them so that when a tab is closed and re-opened, we can + * retain access to the state objects referenced by the browser history. + * + * Because there is a limit on how much data we can put into sessionStorage, the HashedItemStore + * will attempt to remove old items from storage once that limit is reached. + * + * ------------------------------------------------------------------------------------------------- + * + * Consideration 1: We can't (easily) mirror the browser history + * + * If we use letters to indicate a unique state object, and numbers to represent the same state + * occurring again (due to action by the user), a history could look like this: + * + * Old < - - - - - - - - > New + * A1 | B1 | C1 | A2 | D1 | E1 + * + * If the user navigates back to C1 and starts to create new states, persisted history states will + * become inaccessible: + * + * Old < - - - - - - - - - - -> New + * A1 | B1 | C1 | F1 | G1 | H1 | I1 (new history states) + * A2 | D1 | E1 (inaccessible persisted history states) + * + * Theoretically, we could build a mirror of the browser history. When the onpopstate event is + * dispatched, we could determine whether we have gone back or forward in history. Then, when + * a new state is persisted, we could delete all of the persisted items which are no longer + * accessible. (Note that this would require reference-counting so that A isn't removed while D and + * E are, since A would still have a remaining reference from A1). + * + * However, the History API doesn't allow us to read from the history beyond the current state. This + * means that if a session is restored, we can't rebuild this browser history mirror. + * + * Due to this imperfect implementation, HashedItemStore ignores the possibility of inaccessible + * history states. In the future, we could implement this history mirror and persist it in + * sessionStorage too. Then, when restoring a session, we can just retrieve it from sessionStorage. + * + * ------------------------------------------------------------------------------------------------- + * + * Consideration 2: We can't tell when we've hit the browser history limit + * + * Because some of our persisted history states may no longer be referenced by the browser history, + * and we have no way of knowing which ones, we have no way of knowing whether we've persisted a + * number of accessible states beyond the browser history length limit. + * + * More fundamentally, the browser history length limit is a browser implementation detail, so it + * can change from browser to browser, or over time. Respecting this limit would introduce a lot of + * (unnecessary?) complexity. + * + * For these reasons, HashedItemStore doesn't concern itself with this constraint. + */ + +import { pull, sortBy } from 'lodash'; + +export default class HashedItemStore { + + /** + * HashedItemStore uses objects called indexed items to refer to items that have been persisted + * in sessionStorage. An indexed item is shaped {hash, touched}. The touched date is when the item + * was last referenced by the browser history. + */ + constructor(sessionStorage) { + this._sessionStorage = sessionStorage; + + // Store indexed items in descending order by touched (oldest first, newest last). We'll use + // this to remove older items when we run out of storage space. + this._indexedItems = []; + + // Potentially restore a previously persisted index. This happens when + // we re-open a closed tab. + const persistedItemIndex = this._sessionStorage.getItem(HashedItemStore.PERSISTED_INDEX_KEY); + if (persistedItemIndex) { + this._indexedItems = sortBy(JSON.parse(persistedItemIndex) || [], 'touched'); + } + } + + setItem(hash, item) { + const isItemPersisted = this._persistItem(hash, item); + + if (isItemPersisted) { + this._touchHash(hash); + } + + return isItemPersisted; + } + + getItem(hash) { + const item = this._sessionStorage.getItem(hash); + + if (item !== null) { + this._touchHash(hash); + } + + return item; + } + + _getIndexedItem(hash) { + return this._indexedItems.find(indexedItem => indexedItem.hash === hash); + } + + _persistItem(hash, item) { + try { + this._sessionStorage.setItem(hash, item); + return true; + } catch (e) { + // If there was an error then we need to make some space for the item. + if (this._indexedItems.length === 0) { + // If there's nothing left to remove, then we've run out of space and we're trying to + // persist too large an item. + return false; + } + + // We need to try to make some space for the item by removing older items (i.e. items that + // haven't been accessed recently). + this._removeOldestItem(); + + // Try to persist again. + return this._persistItem(hash, item); + } + } + + _removeOldestItem() { + const oldestIndexedItem = this._indexedItems.shift(); + // Remove oldest item from storage. + this._sessionStorage.removeItem(oldestIndexedItem.hash); + } + + _touchHash(hash) { + // Touching a hash indicates that it's been used recently, so it won't be the first in line + // when we remove items to free up storage space. + + // either get or create an indexedItem + const indexedItem = this._getIndexedItem(hash) || { hash }; + + // set/update the touched time to now so that it's the "newest" item in the index + indexedItem.touched = Date.now(); + + // ensure that the item is last in the index + pull(this._indexedItems, indexedItem); + this._indexedItems.push(indexedItem); + + // Regardless of whether this is a new or updated item, we need to persist the index. + this._sessionStorage.setItem( + HashedItemStore.PERSISTED_INDEX_KEY, + JSON.stringify(this._indexedItems) + ); + } +} + +HashedItemStore.PERSISTED_INDEX_KEY = 'kbn.hashedItemsIndex.v1'; diff --git a/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js b/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js new file mode 100644 index 0000000000000..bd0094a24851a --- /dev/null +++ b/src/ui/public/state_management/state_storage/hashed_item_store_singleton.js @@ -0,0 +1,3 @@ +import HashedItemStore from './hashed_item_store'; + +export default new HashedItemStore(window.sessionStorage); diff --git a/src/ui/public/state_management/state_storage/index.js b/src/ui/public/state_management/state_storage/index.js new file mode 100644 index 0000000000000..91342388a3ab8 --- /dev/null +++ b/src/ui/public/state_management/state_storage/index.js @@ -0,0 +1,8 @@ +export { + default as hashedItemStoreSingleton, +} from './hashed_item_store_singleton'; + +export { + createStateHash, + isStateHash, +} from './state_hash'; diff --git a/src/ui/public/state_management/state_storage/state_hash.js b/src/ui/public/state_management/state_storage/state_hash.js new file mode 100644 index 0000000000000..9303f971830f9 --- /dev/null +++ b/src/ui/public/state_management/state_storage/state_hash.js @@ -0,0 +1,29 @@ +import { Sha256 } from 'ui/crypto'; + +// This prefix is used to identify hash strings that have been encoded in the URL. +const HASH_PREFIX = 'h@'; + +export function createStateHash(json, existingJsonProvider) { + if (typeof json !== 'string') { + throw new Error('createHash only accepts strings (JSON).'); + } + + const hash = new Sha256().update(json, 'utf8').digest('hex'); + + let shortenedHash; + + // Shorten the hash to at minimum 7 characters. We just need to make sure that it either: + // a) hasn't been used yet + // b) or has been used already, but with the JSON we're currently hashing. + for (let i = 7; i < hash.length; i++) { + shortenedHash = hash.slice(0, i); + const existingJson = existingJsonProvider(shortenedHash); + if (existingJson === null || existingJson === json) break; + } + + return `${HASH_PREFIX}${shortenedHash}`; +} + +export function isStateHash(str) { + return String(str).indexOf(HASH_PREFIX) === 0; +} diff --git a/src/ui/public/url/__tests__/url.js b/src/ui/public/url/__tests__/url.js index 57f2483b7beb4..d7385c748596a 100644 --- a/src/ui/public/url/__tests__/url.js +++ b/src/ui/public/url/__tests__/url.js @@ -15,6 +15,13 @@ let $location; let $rootScope; let appState; +class StubAppState { + constructor() { + this.getQueryParamName = () => '_a'; + this.toQueryParam = () => 'stateQueryParam'; + this.destroy = sinon.stub(); + } +} function init() { ngMock.module('kibana/url', 'kibana', function ($provide, PrivateProvider) { @@ -24,7 +31,7 @@ function init() { }; }); - appState = { destroy: sinon.stub() }; + appState = new StubAppState(); PrivateProvider.swap(AppStateProvider, $decorate => { const AppState = $decorate(); AppState.getAppState = () => appState; @@ -277,11 +284,11 @@ describe('kbnUrl', function () { expect($location.search()).to.eql(search); expect($location.hash()).to.be(hash); - kbnUrl.change(newPath, null, {foo: 'bar'}); + kbnUrl.change(newPath, null, new StubAppState()); // verify the ending state expect($location.path()).to.be(newPath); - expect($location.search()).to.eql({_a: '(foo:bar)'}); + expect($location.search()).to.eql({ _a: 'stateQueryParam' }); expect($location.hash()).to.be(''); }); }); @@ -344,11 +351,11 @@ describe('kbnUrl', function () { expect($location.search()).to.eql(search); expect($location.hash()).to.be(hash); - kbnUrl.redirect(newPath, null, {foo: 'bar'}); + kbnUrl.redirect(newPath, null, new StubAppState()); // verify the ending state expect($location.path()).to.be(newPath); - expect($location.search()).to.eql({_a: '(foo:bar)'}); + expect($location.search()).to.eql({ _a: 'stateQueryParam' }); expect($location.hash()).to.be(''); }); diff --git a/src/ui/public/url/url.js b/src/ui/public/url/url.js index ef2442730db7e..d26a8e8976e63 100644 --- a/src/ui/public/url/url.js +++ b/src/ui/public/url/url.js @@ -154,7 +154,7 @@ function KbnUrlProvider($injector, $location, $rootScope, $parse, Private) { if (replace) $location.replace(); if (appState) { - $location.search('_a', rison.encode(appState)); + $location.search(appState.getQueryParamName(), appState.toQueryParam()); } let next = { diff --git a/src/ui/settings/defaults.js b/src/ui/settings/defaults.js index 143e25032102e..fec88985c7d73 100644 --- a/src/ui/settings/defaults.js +++ b/src/ui/settings/defaults.js @@ -281,6 +281,12 @@ export default function defaultSettingsProvider() { 'timelion:quandl.key': { value: 'someKeyHere', description: 'Your API key from www.quandl.com' + }, + 'state:storeInSessionStorage': { + value: false, + description: 'The URL can sometimes grow to be too large for some browsers to ' + + 'handle. To counter-act this we are testing if storing parts of the URL in ' + + 'sessions storage could help. Please let us know how it goes!' } }; };