From 0582cc11984949066c4bc901d009523add852afd Mon Sep 17 00:00:00 2001 From: spalger Date: Fri, 19 Aug 2016 17:26:15 -0700 Subject: [PATCH] [state] store url states into session storage Kibana currently stores it's entire application state in the URL by rison encoding it and sticking it into a query string parameter, _a for AppState and _g for GlobalState. This has functioned fine for a while, but Internet Explorer's short URL length restriction are starting to become a problem for more and more users. To provide these users with a workaround this adds an advanced config option that will store the state in sessionStorage instead of the URL. This is accomplished by hashing the serialized version of the state, storing a short version of the hash in the URL, and storing the whole serialized state in sessionStorage using the hash + state-type as a key. Since sessionStorage is limited in size, we must clean up old stored states after they become unreachable to the application. This is done using the new `LazyLruStore` class, a wrapper around sessionStorage. This wrapper helps us maintain the list of stored states based on the time they are accessed (On each set the access time is updates). It's cleanup style is configured with it's maxItems, idealClearRatio, and maxIdealClearPercent configurations. The defaults for which should be sufficient. `maxItems`: limits the store to n items, removing the oldest item when the list overflows `idealClearRatio+maxIdealClearPercent`: when `store.setItem(key, value)` throws an error we try to clear space equal to `idealClearRatio * (key+value).length`, but no more space than `totalSize * maxIdealClearPercent` --- .../public/discover/controllers/discover.js | 23 +- src/ui/public/chrome/directives/kbn_chrome.js | 6 +- src/ui/public/crypto/index.js | 1 + src/ui/public/crypto/sha256.js | 194 ++++++++++++ .../share/directives/share_object_url.js | 11 +- .../__tests__/hashing_store.js | 139 +++++++++ .../__tests__/lazy_lru_store.js | 291 ++++++++++++++++++ .../state_management/__tests__/state.js | 119 +++++-- .../__tests__/unhash_states.js | 87 ++++++ .../public/state_management/hashing_store.js | 103 +++++++ .../public/state_management/lazy_lru_store.js | 276 +++++++++++++++++ src/ui/public/state_management/state.js | 124 +++++++- .../public/state_management/unhash_states.js | 43 +++ src/ui/public/url/__tests__/url.js | 17 +- src/ui/public/url/url.js | 2 +- src/ui/settings/defaults.js | 6 + 16 files changed, 1394 insertions(+), 48 deletions(-) create mode 100644 src/ui/public/crypto/index.js create mode 100644 src/ui/public/crypto/sha256.js create mode 100644 src/ui/public/state_management/__tests__/hashing_store.js create mode 100644 src/ui/public/state_management/__tests__/lazy_lru_store.js create mode 100644 src/ui/public/state_management/__tests__/unhash_states.js create mode 100644 src/ui/public/state_management/hashing_store.js create mode 100644 src/ui/public/state_management/lazy_lru_store.js create mode 100644 src/ui/public/state_management/unhash_states.js 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/ui/public/chrome/directives/kbn_chrome.js b/src/ui/public/chrome/directives/kbn_chrome.js index 3a839f2b9e6b3..5491164c7d391 100644 --- a/src/ui/public/chrome/directives/kbn_chrome.js +++ b/src/ui/public/chrome/directives/kbn_chrome.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import './kbn_chrome.less'; import UiModules from 'ui/modules'; +import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; export default function (chrome, internals) { @@ -26,7 +27,8 @@ export default function (chrome, internals) { }, controllerAs: 'chrome', - controller($scope, $rootScope, $location, $http) { + controller($scope, $rootScope, $location, $http, Private) { + const unhashStates = Private(UnhashStatesProvider); // are we showing the embedded version of the chrome? internals.setVisibleDefault(!$location.search().embed); @@ -34,7 +36,7 @@ export default function (chrome, internals) { // listen for route changes, propogate to tabs const onRouteChange = function () { let { href } = window.location; - internals.trackPossibleSubUrl(href); + internals.trackPossibleSubUrl(unhashStates.inAbsUrl(href)); }; $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..697798a261a9a --- /dev/null +++ b/src/ui/public/crypto/sha256.js @@ -0,0 +1,194 @@ +// 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 + * + */ + +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/share/directives/share_object_url.js b/src/ui/public/share/directives/share_object_url.js index d20a1c4c00305..c92d292d9772e 100644 --- a/src/ui/public/share/directives/share_object_url.js +++ b/src/ui/public/share/directives/share_object_url.js @@ -4,7 +4,8 @@ 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 { UnhashStatesProvider } from 'ui/state_management/unhash_states'; +import { memoize } from 'lodash'; app.directive('shareObjectUrl', function (Private, Notifier) { const urlShortener = Private(LibUrlShortenerProvider); @@ -69,8 +70,14 @@ app.directive('shareObjectUrl', function (Private, Notifier) { }); }; + // since getUrl() is called within a watcher we cache the unhashing step + const unhashStatesInAbsUrl = memoize((absUrl) => { + return Private(UnhashStatesProvider).inAbsUrl(absUrl); + }); + $scope.getUrl = function () { - let url = $location.absUrl(); + let url = unhashStatesInAbsUrl($location.absUrl()); + if ($scope.shareAsEmbed) { url = url.replace('?', '?embed=true&'); } diff --git a/src/ui/public/state_management/__tests__/hashing_store.js b/src/ui/public/state_management/__tests__/hashing_store.js new file mode 100644 index 0000000000000..ce15fcae34827 --- /dev/null +++ b/src/ui/public/state_management/__tests__/hashing_store.js @@ -0,0 +1,139 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { encode as encodeRison } from 'rison-node'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import { HashingStore } from 'ui/state_management/hashing_store'; + +const setup = ({ createHash } = {}) => { + const store = new StubBrowserStorage(); + const hashingStore = new HashingStore({ store, createHash }); + return { store, hashingStore }; +}; + +describe('State Management Hashing Store', () => { + describe('#add', () => { + it('adds a value to the store and returns its hash', () => { + const { hashingStore, store } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hash).to.be.a('string'); + expect(hash).to.be.ok(); + expect(store).to.have.length(1); + }); + + it('json encodes the values it stores', () => { + const { hashingStore, store } = setup(); + const val = { toJSON() { return 1; } }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(hash)).to.eql(1); + }); + + it('addresses values with a short hash', () => { + const val = { foo: 'bar' }; + const longHash = 'longlonglonglonglonglonglonglonglonglonghash'; + const { hashingStore } = setup({ + createHash: () => longHash + }); + + const hash = hashingStore.add(val); + expect(hash.length < longHash.length).to.be.ok(); + }); + + it('addresses values with a slightly longer hash when short hashes collide', () => { + const fixtures = [ + { + hash: '1234567890-1', + val: { foo: 'bar' } + }, + { + hash: '1234567890-2', + val: { foo: 'baz' } + }, + { + hash: '1234567890-3', + val: { foo: 'boo' } + } + ]; + + const matchVal = json => f => JSON.stringify(f.val) === json; + const { hashingStore } = setup({ + createHash: val => { + const fixture = fixtures.find(matchVal(val)); + return fixture.hash; + } + }); + + const hash1 = hashingStore.add(fixtures[0].val); + const hash2 = hashingStore.add(fixtures[1].val); + const hash3 = hashingStore.add(fixtures[2].val); + + expect(hash3).to.have.length(hash2.length + 1); + expect(hash2).to.have.length(hash1.length + 1); + }); + + it('bubbles up the error if the store fails to setItem', () => { + const { store, hashingStore } = setup(); + const err = new Error(); + sinon.stub(store, 'setItem').throws(err); + expect(() => { + hashingStore.add({}); + }).to.throwError(e => expect(e).to.be(err)); + }); + }); + + describe('#lookup', () => { + it('reads a value from the store by its hash', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(hash)).to.eql(val); + }); + + it('returns null when the value is not in the store', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(`${hash} break`)).to.be(null); + }); + }); + + describe('#remove', () => { + it('removes the value by its hash', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.lookup(hash)).to.eql(val); + hashingStore.remove(hash); + expect(hashingStore.lookup(hash)).to.be(null); + }); + }); + + describe('#isHash', () => { + it('can identify values that look like hashes', () => { + const { hashingStore } = setup(); + const val = { foo: 'bar' }; + const hash = hashingStore.add(val); + expect(hashingStore.isHash(hash)).to.be(true); + }); + + describe('rison', () => { + const tests = [ + ['object', { foo: 'bar' }], + ['number', 1], + ['number', 1000], + ['number', Math.round(Math.random() * 10000000)], + ['string', 'this is a string'], + ['array', [1,2,3]], + ]; + + tests.forEach(([type, val]) => { + it(`is not fooled by rison ${type} "${val}"`, () => { + const { hashingStore } = setup(); + const rison = encodeRison(val); + expect(hashingStore.isHash(rison)).to.be(false); + }); + }); + }); + }); +}); diff --git a/src/ui/public/state_management/__tests__/lazy_lru_store.js b/src/ui/public/state_management/__tests__/lazy_lru_store.js new file mode 100644 index 0000000000000..175dfe014db2e --- /dev/null +++ b/src/ui/public/state_management/__tests__/lazy_lru_store.js @@ -0,0 +1,291 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import { times, sum, padLeft } from 'lodash'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import { LazyLruStore } from '../lazy_lru_store'; + +const setup = (opts = {}) => { + const { + id = 'testLru', + store = new StubBrowserStorage(), + maxItems, + maxSetAttempts, + idealClearRatio, + maxIdealClearPercent + } = opts; + + const lru = new LazyLruStore({ + id, + store, + maxItems, + maxSetAttempts, + idealClearRatio, + maxIdealClearPercent + }); + + return { lru, store }; +}; + +describe('State Management LazyLruStore', () => { + describe('#getItem()', () => { + it('returns null when item not found', () => { + const { lru } = setup(); + expect(lru.getItem('item1')).to.be(null); + }); + + it('returns stored value when item found', () => { + const { lru } = setup(); + lru.setItem('item1', '1'); + expect(lru.getItem('item1')).to.be('1'); + }); + }); + + describe('#setItem()', () => { + it('stores the item in the underlying store', () => { + const { lru, store } = setup(); + expect(store).to.have.length(0); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + }); + + it('makes space for new item when necessary', () => { + const { lru, store } = setup({ idealClearRatio: 1 }); + store._setSizeLimit(lru.getStorageOverhead() + 6); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + lru.setItem('item2', '2'); + expect(store).to.have.length(1); + + expect(lru.getItem('item1')).to.be(null); + expect(lru.getItem('item2')).to.be('2'); + }); + + it('overwrites existing values', () => { + const { lru, store } = setup(); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + lru.setItem('item1', '2'); + expect(store).to.have.length(1); + expect(lru.getItem('item1')).to.be('2'); + }); + + it('stores items as strings', () => { + const { lru } = setup(); + lru.setItem('item1', 1); + expect(lru.getItem('item1')).to.be('1'); + }); + + it('bubbles up the error when unable to clear the necessary space', () => { + const { lru, store } = setup(); + store._setSizeLimit(lru.getStorageOverhead() + 2); + lru.setItem('1', '1'); + sinon.stub(store, 'removeItem'); + expect(() => { + lru.setItem('2', '2'); + }).to.throwError(/quota/); + }); + }); + + describe('#removeItem()', () => { + it('removes items from the underlying store', () => { + const { lru, store } = setup(); + lru.setItem('item1', '1'); + expect(store).to.have.length(1); + lru.removeItem('item1'); + expect(store).to.have.length(0); + expect(lru.getItem('item1')).to.be(null); + }); + + it('ignores unknown items', () => { + const { lru, store } = setup(); + expect(store).to.have.length(0); + expect(() => { + lru.removeItem('item1'); + }).to.not.throwError(); + expect(store).to.have.length(0); + }); + }); + + describe('#getStorageOverhead()', () => { + it('returns the number of bytes added to each storage item, used for testing', () => { + const { store } = setup(); + const id1 = new LazyLruStore({ id: '1', store }); + const id11 = new LazyLruStore({ id: '11', store }); + expect(id1.getStorageOverhead()).to.be(id11.getStorageOverhead() - 1); + }); + }); + + describe('space management', () => { + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('tries to clear space if setItem fails because the quota was exceeded', () => { + const { lru, store } = setup(); + const itemSize = lru.getStorageOverhead() + 10; // each item key length + val length is 10 + + store._setSizeLimit(itemSize * 3); + + lru.setItem('item1', 'item1'); + clock.tick(1); // move clock forward so removal based on time is predictable + lru.setItem('item2', 'item2'); + clock.tick(1); + lru.setItem('item3', 'item3'); + clock.tick(1); + lru.setItem('item4', 'item4'); + clock.tick(1); + lru.setItem('item5', 'item5'); + clock.tick(1); + + expect(store).to.have.length(3); + expect(lru.getItem('item1')).to.be(null); + expect(lru.getItem('item2')).to.be(null); + expect(lru.getItem('item3')).to.be('item3'); + expect(lru.getItem('item4')).to.be('item4'); + expect(lru.getItem('item5')).to.be('item5'); + }); + + context('when small items are being written to a large existing collection', () => { + context('with idealClearRatio = 6', () => { + it('clears 6 times the amount of space necessary', () => { + const { lru, store } = setup({ idealClearRatio: 6 }); + + const overhead = lru.getStorageOverhead(); + const getItemSize = i => overhead + `${i.key}${i.value}`.length; + + const items = times(100, i => { + // pad n so that 1 and 100 take up equal space in the store + const n = padLeft(i + 1, 3, '0'); + return { key: `key${n}`, value: `value${n}` }; + }); + const lastItem = items[items.length - 1]; + + // set the size limit so that the last item causes a cleanup, which + store._setSizeLimit(sum(items.map(getItemSize)) - getItemSize(lastItem)); + + for (const i of items) { + lru.setItem(i.key, i.value); + clock.tick(1); // move clock forward so removal based on time is predictable + } + + // the current ratio is 6:1, so when the last item fails + // to set, 6 items are cleared to make space for it + expect(store).to.have.length(94); + expect(lru.getItem('key001')).to.be(null); + expect(lru.getItem('key002')).to.be(null); + expect(lru.getItem('key003')).to.be(null); + expect(lru.getItem('key004')).to.be(null); + expect(lru.getItem('key005')).to.be(null); + expect(lru.getItem('key006')).to.be(null); + expect(lru.getItem('key007')).to.be('value007'); + }); + }); + + context('with idealClearRatio = 100 and maxIdealClearPercent = 0.1', () => { + it('clears 10% of the store', () => { + const { lru, store } = setup({ idealClearRatio: 100, maxIdealClearPercent: 0.1 }); + + const overhead = lru.getStorageOverhead(); + const getItemSize = i => overhead + `${i.key}${i.value}`.length; + + const items = times(100, i => { + // pad n so that 1 and 100 take up equal space in the store + const n = padLeft(i + 1, 3, '0'); + return { key: `key${n}`, value: `value${n}` }; + }); + const lastItem = items[items.length - 1]; + + // set the size limit so that the last item causes a cleanup, which + store._setSizeLimit(sum(items.map(getItemSize)) - getItemSize(lastItem)); + + for (const i of items) { + lru.setItem(i.key, i.value); + clock.tick(1); // move clock forward so removal based on time is predictable + } + + // with the ratio set to 100:1 the store will try to clear + // 100x the stored values, but that could be the entire store + // so it is limited by the maxIdealClearPercent (10% here) + // so the store should now contain values 11-100 + expect(store).to.have.length(90); + expect(lru.getItem('key001')).to.be(null); + expect(lru.getItem('key002')).to.be(null); + expect(lru.getItem('key003')).to.be(null); + expect(lru.getItem('key004')).to.be(null); + expect(lru.getItem('key005')).to.be(null); + expect(lru.getItem('key006')).to.be(null); + expect(lru.getItem('key007')).to.be(null); + expect(lru.getItem('key008')).to.be(null); + expect(lru.getItem('key009')).to.be(null); + expect(lru.getItem('key010')).to.be(null); + expect(lru.getItem('key011')).to.be('value011'); + expect(lru.getItem('key012')).to.be('value012'); + expect(lru.getItem('key100')).to.be('value100'); + }); + }); + }); + }); + + describe('maxSetAttempts setting', () => { + it('must be >= 1', () => { + expect(() => setup({ maxSetAttempts: 0 })).to.throwError(TypeError); + expect(() => setup({ maxSetAttempts: -1 })).to.throwError(TypeError); + expect(() => setup({ maxSetAttempts: 0.9 })).to.throwError(TypeError); + expect(() => setup({ maxSetAttempts: 1 })).to.not.throwError(TypeError); + }); + + context('= 1', () => { + it('will cause sets to a full storage to throw', () => { + const { lru, store } = setup({ maxSetAttempts: 1 }); + store._setSizeLimit(lru.getStorageOverhead() + 2); + lru.setItem('1', '1'); + expect(() => { + lru.setItem('2', '2'); + }).to.throwError(/quota/i); + }); + }); + + context('= 5', () => { + it('will try to set 5 times and remove 4', () => { + const { store, lru } = setup({ maxSetAttempts: 5 }); + + // trick lru into thinking it can clear space + lru.setItem('1', '1'); + // but prevent removing items + const removeStub = sinon.stub(store, 'removeItem'); + + // throw on the first 4 set attempts + const setStub = sinon.stub(store, 'setItem') + .onCall(0).throws() + .onCall(1).throws() + .onCall(2).throws() + .onCall(3).throws() + .stub; + + lru.setItem('1', '1'); + sinon.assert.callCount(removeStub, 4); + sinon.assert.callCount(setStub, 5); + }); + }); + }); + + context('with maxItems set', () => { + it('trims the list when starting with more than max items', () => { + const { store, lru: lruNoMax } = setup(); + lruNoMax.setItem('1', '1'); + lruNoMax.setItem('2', '2'); + lruNoMax.setItem('3', '3'); + lruNoMax.setItem('4', '4'); + expect(store).to.have.length(4); + + const { lru } = setup({ store, maxItems: 3 }); + expect(store).to.have.length(3); + }); + }); +}); diff --git a/src/ui/public/state_management/__tests__/state.js b/src/ui/public/state_management/__tests__/state.js index d0233594e5b44..62ffc09adf677 100644 --- a/src/ui/public/state_management/__tests__/state.js +++ b/src/ui/public/state_management/__tests__/state.js @@ -3,46 +3,71 @@ 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 { UnhashStatesProvider } from 'ui/state_management/unhash_states'; +import { HashingStore } from 'ui/state_management/hashing_store'; +import StubBrowserStorage from 'test_utils/stub_browser_storage'; import EventsProvider from 'ui/events'; describe('State Management', function () { + const notify = 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 hashingStore = new HashingStore({ store }); + const state = new State(param, initial, { hashingStore, notify }); + + const getUnhashedSearch = (state) => { + const unhashStates = Private(UnhashStatesProvider); + return unhashStates.inParsedQueryString($location.search(), [ state ]); + }; + + return { notify, store, hashingStore, 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 +79,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 +92,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(); + let 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 +126,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 +136,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 +148,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 +156,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 +164,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 +185,62 @@ describe('State Management', function () { expect(stateObj).to.eql({}); }); }); + + + describe('Hashing', () => { + it('stores state values in a hashingStore, writing the hash to the url', () => { + const { state, hashingStore } = setup({ storeInHash: true }); + state.foo = 'bar'; + state.save(); + const urlVal = $location.search()[state.getQueryParamName()]; + + expect(hashingStore.isHash(urlVal)).to.be(true); + expect(hashingStore.lookup(urlVal)).to.eql({ foo: 'bar' }); + }); + + it('should replace rison in the URL with a hash', () => { + const { state, hashingStore } = 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(hashingStore.isHash(urlVal)).to.be(true); + expect(hashingStore.lookup(urlVal)).to.eql(obj); + }); + + context('error handling', () => { + it('notifies the user when a hash value does not map to a stored value', () => { + const { state, hashingStore, notify } = setup({ storeInHash: true }); + const search = $location.search(); + const badHash = hashingStore.add({}); + hashingStore.remove(badHash); + + search[state.getQueryParamName()] = badHash; + $location.search(search); + + expect(notify._notifs).to.have.length(0); + state.fetch(); + expect(notify._notifs).to.have.length(1); + expect(notify._notifs[0].content).to.match(/use the share functionality/i); + }); + + it('presents fatal error linking to github when hashingStore.add fails', () => { + const { state, hashingStore, notify } = setup({ storeInHash: true }); + const fatalStub = sinon.stub(notify, 'fatal').throws(); + sinon.stub(hashingStore, 'add').throws(); + + 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/__tests__/unhash_states.js b/src/ui/public/state_management/__tests__/unhash_states.js new file mode 100644 index 0000000000000..19b613d57853c --- /dev/null +++ b/src/ui/public/state_management/__tests__/unhash_states.js @@ -0,0 +1,87 @@ +import expect from 'expect.js'; +import ngMock from 'ng_mock'; +import sinon from 'auto-release-sinon'; + +import StateProvider from 'ui/state_management/state'; +import { UnhashStatesProvider } from 'ui/state_management/unhash_states'; + +describe('State Management Unhash States', () => { + let setup; + + beforeEach(ngMock.module('kibana')); + beforeEach(ngMock.inject(Private => { + setup = () => { + const unhashStates = Private(UnhashStatesProvider); + + const State = Private(StateProvider); + const testState = new State('testParam'); + sinon.stub(testState, 'translateHashToRison').withArgs('hash').returns('replacement'); + + return { unhashStates, testState }; + }; + })); + + describe('#inAbsUrl()', () => { + it('does nothing if missing input', () => { + const { unhashStates } = setup(); + expect(() => { + unhashStates.inAbsUrl(); + }).to.not.throwError(); + }); + + it('does nothing if just a host and port', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if just a path', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if just a path and query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana?foo=bar'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if empty hash with query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana?foo=bar#'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if empty hash without query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if empty hash without query', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if hash is just a path', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#/discover'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('does nothing if hash does not have matching query string vals', () => { + const { unhashStates } = setup(); + const url = 'https://localhost:5601/app/kibana#/discover?foo=bar'; + expect(unhashStates.inAbsUrl(url)).to.be(url); + }); + + it('replaces query string vals in hash for matching states with output of state.toRISON()', () => { + const { unhashStates, testState } = setup(); + const url = 'https://localhost:5601/#/?foo=bar&testParam=hash'; + const exp = 'https://localhost:5601/#/?foo=bar&testParam=replacement'; + expect(unhashStates.inAbsUrl(url, [testState])).to.be(exp); + }); + }); +}); diff --git a/src/ui/public/state_management/hashing_store.js b/src/ui/public/state_management/hashing_store.js new file mode 100644 index 0000000000000..ace62df06d209 --- /dev/null +++ b/src/ui/public/state_management/hashing_store.js @@ -0,0 +1,103 @@ +import angular from 'angular'; +import { sortBy } from 'lodash'; +import { Sha256 } from 'ui/crypto'; + +import StubBrowserStorage from 'test_utils/stub_browser_storage'; +import { LazyLruStore } from './lazy_lru_store'; + +const TAG = 'h@'; + +/** + * The HashingStore is a wrapper around a browser store object + * that hashes the items added to it and stores them by their + * hash. This hash is then returned so that the item can be received + * at a later time. + */ +export class HashingStore { + constructor({ store, createHash, maxItems } = {}) { + this._store = store || window.sessionStorage; + if (createHash) this._createHash = createHash; + } + + /** + * Determine if the passed value looks like a hash + * + * @param {string} hash + * @return {boolean} + */ + isHash(hash) { + return String(hash).slice(0, TAG.length) === TAG; + } + + /** + * Find the value stored for the given hash + * + * @param {string} hash + * @return {any} + */ + lookup(hash) { + try { + return JSON.parse(this._store.getItem(hash)); + } catch (err) { + return null; + } + } + + /** + * Compute the hash of an object, store the object, and return + * the hash + * + * @param {any} the value to hash + * @return {string} the hash of the value + */ + add(object) { + const json = angular.toJson(object); + const hash = this._getShortHash(json); + this._store.setItem(hash, json); + return hash; + } + + /** + * Remove a value identified by the hash from the store + * + * @param {string} hash + * @return {undefined} + */ + remove(hash) { + this._store.removeItem(hash); + } + + // private api + + /** + * calculate the full hash of a json object + * + * @private + * @param {string} json + * @return {string} hash + */ + _createHash(json) { + return new Sha256().update(json, 'utf8').digest('hex'); + } + + /** + * Calculate the full hash for a json blob and then shorten in until + * it until it doesn't collide with other short hashes in the store + * + * @private + * @param {string} json + * @param {string} shortHash + */ + _getShortHash(json) { + const fullHash = `${TAG}${this._createHash(json)}`; + + let short; + for (let i = 7; i < fullHash.length; i++) { + short = fullHash.slice(0, i); + const existing = this._store.getItem(short); + if (existing === null || existing === json) break; + } + + return short; + } +} diff --git a/src/ui/public/state_management/lazy_lru_store.js b/src/ui/public/state_management/lazy_lru_store.js new file mode 100644 index 0000000000000..d9b00253250ea --- /dev/null +++ b/src/ui/public/state_management/lazy_lru_store.js @@ -0,0 +1,276 @@ +import { sortBy } from 'lodash'; + +import Notifier from 'ui/notify/notifier'; + +/** + * The maximum number of times that we will try to + * clear space after a call to setItem on the store fails + * + * @type {Number} + */ +const DEFAULT_MAX_SET_ATTEMPTS = 3; + +/** + * When trying to clear enough space for a key+chunk, + * multiply the necessary space by this to produce the + * "ideal" amount of space to clear. + * + * By clearing the "ideal" amount instead of just the + * necessary amount we prevent extra calls cleanup calls. + * + * The "ideal" amount is limited by the MAX_IDEAL_CLEAR_PERCENT + * + * @type {Number} + */ +const DEFAULT_IDEAL_CLEAR_RATIO = 100; + +/** + * A limit to the amount of space that can be cleared + * by the inflation caused by the IDEAL_CLEAR_RATIO + * @type {Number} + */ +const DEFAULT_MAX_IDEAL_CLEAR_PERCENT = 0.3; + +export class LazyLruStore { + constructor(opts = {}) { + const { + id, + store, + notify = new Notifier(`LazyLruStore (re: probably history hashing)`), + maxItems = Infinity, + maxSetAttempts = DEFAULT_MAX_SET_ATTEMPTS, + idealClearRatio = DEFAULT_IDEAL_CLEAR_RATIO, + maxIdealClearPercent = DEFAULT_MAX_IDEAL_CLEAR_PERCENT, + } = opts; + + if (!id) throw new TypeError('id is required'); + if (!store) throw new TypeError('store is required'); + if (maxSetAttempts < 1) throw new TypeError('maxSetAttempts must be >= 1'); + if (idealClearRatio < 1) throw new TypeError('idealClearRatio must be >= 1'); + if (maxIdealClearPercent < 0 || maxIdealClearPercent > 1) { + throw new TypeError('maxIdealClearPercent must be between 0 and 1'); + } + + this._id = id; + this._prefix = `lru:${this._id}:`; + this._store = store; + this._notify = notify; + this._maxItems = maxItems; + this._itemCountGuess = this._getItemCount(); + this._maxSetAttempts = maxSetAttempts; + this._idealClearRatio = idealClearRatio; + this._maxIdealClearPercent = maxIdealClearPercent; + + this._verifyMaxItems(); + } + + getItem(key) { + const chunk = this._store.getItem(this._getStoreKey(key)); + if (chunk === null) return null; + const { val } = this._parseChunk(chunk); + return val; + } + + setItem(key, val) { + const newKey = !this._storeHasKey(key); + this._attemptToSet(this._getStoreKey(key), this._getChunk(val)); + if (newKey) this._itemCountGuess += 1; + this._verifyMaxItems(); + } + + removeItem(key) { + if (!this._storeHasKey(key)) return; + this._store.removeItem(this._getStoreKey(key)); + this._itemCountGuess -= 1; + this._verifyMaxItems(); + } + + getStorageOverhead() { + return (this._getStoreKey('') + this._getChunk('')).length; + } + + // private api + + _getStoreKey(key) { + return `${this._prefix}${key}`; + } + + _storeHasKey(key) { + return this._store.getItem(this._getStoreKey(key)) !== null; + } + + /** + * Convert a JSON blob into a chunk, the wrapper around values + * that tells us when they were last stored + * + * @private + * @param {string} val + * @return {string} chunk + */ + _getChunk(val) { + return `${Date.now()}/${val}`; + } + + /** + * Parse a chunk into it's store time and val values + * + * @private + * @param {string} the chunk, probably read from the store + * @return {object} parsed + * @property {number} parsed.time + * @property {string} parsed.val + */ + _parseChunk(chunk) { + const splitIndex = chunk.indexOf('/'); + const time = parseInt(chunk.slice(0, splitIndex), 10); + const val = chunk.slice(splitIndex + 1); + return { time, val }; + } + + /** + * Attempt to a set a key on the store, if the setItem call + * fails then the assumption is that the store is out of space + * so we call this._makeSpaceFor(key, chunk). If this call + * reports that enough space for the key and chunk were cleared, + * then this function will call itself again, this time sending + * attempt + 1 as the attempt number. If this loop continues + * and attempt meets or exceeds the this._maxSetAttempts then a fatal + * error will be sent to notify, as the users session is no longer + * usable. + * + * @private + * @param {string} key + * @param {string} chunk + * @param {number} [attempt=1] + */ + _attemptToSet(key, chunk, attempt = 1) { + try { + this._store.setItem(key, chunk); + } catch (error) { + if (attempt >= this._maxSetAttempts) { + throw error; + } + + const madeEnoughSpace = this._makeSpaceFor(key, chunk); + if (madeEnoughSpace) { + this._attemptToSet(key, chunk, attempt + 1); + } else { + throw error; + } + } + } + + /** + * Walk all items in the store to find items stored using the same + * this._prefix. Collect the time that key was last set, and the + * byte-size of that item, and report all values found along + * with the total bytes + * + * @private + * @return {object} index + * @property {object[]} index.itemsByOldestAccess + * @property {number} index.totalBytes + */ + _indexStoredItems() { + const store = this._store; + const notify = this._notify; + + const items = []; + let totalBytes = 0; + + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + + if (key.slice(0, this._prefix.length) !== this._prefix) { + continue; + } + + const chunk = store.getItem(key); + const { time } = this._parseChunk(chunk); + const bytes = key.length + chunk.length; + items.push({ key, time, bytes }); + totalBytes += bytes; + } + + const itemsByOldestAccess = sortBy(items, 'time'); + return { itemsByOldestAccess, totalBytes }; + } + + _getItemCount() { + const { itemsByOldestAccess } = this._indexStoredItems(); + return itemsByOldestAccess.length; + } + + /** + * Check that the itemCountGuess has not exceeded the maxItems, + * if it has, trim the item list to meet the maxItem count + */ + _verifyMaxItems() { + if (this._maxItems > this._itemCountGuess) return; + + const { itemsByOldestAccess } = this._indexStoredItems(); + // update our guess to make sure it's accurate + this._itemCountGuess = itemsByOldestAccess.length; + // remove all items from the beginning of the list, leaving this._maxItems in the list + itemsByOldestAccess + .slice(0, -this._maxItems) + .forEach(item => this._doItemAutoRemoval(item)); + } + + /** + * Determine how much space to clear so that we can store the specified + * key and chunk into the store. Then clear that data and return true of + * false if we were successfull + * + * @private + * @param {string} key + * @param {string} chunk + * @return {boolean} success + */ + _makeSpaceFor(key, chunk) { + const notify = this._notify; + return notify.event(`trying to make room in lru ${this._id}`, () => { + const { totalBytes, itemsByOldestAccess } = this._indexStoredItems(); + + // pick how much space we are going to try to clear + // by finding a value that is at least the size of + // the key + chunk but up to the key + chunk * IDEAL_CLEAR_RATIO + const freeMin = key.length + chunk.length; + const freeIdeal = freeMin * this._idealClearRatio; + const toClear = Math.max(freeMin, Math.min(freeIdeal, totalBytes * this._maxIdealClearPercent)); + notify.log(`PLAN: min ${freeMin} bytes, target ${toClear} bytes`); + + let remainingToClear = toClear; + let removedItemCount = 0; + while (itemsByOldestAccess.length > 0 && remainingToClear > 0) { + const item = itemsByOldestAccess.shift(); + remainingToClear -= item.bytes; + removedItemCount += 1; + this._doItemAutoRemoval(item); + } + + const success = remainingToClear <= 0; + + const label = success ? 'SUCCESS' : 'FAILURE'; + const removedBytes = toClear - remainingToClear; + notify.log(`${label}: removed ${removedItemCount} items for ${removedBytes} bytes`); + return success; + }); + } + + /** + * Extracted helper for automated removal of items with logging + * + * @private + * @param {object} item + * @property {string} item.key + * @property {number} item.time + * @property {number} item.bytes + */ + _doItemAutoRemoval(item) { + const timeString = new Date(item.time).toISOString(); + this._notify.log(`REMOVE: entry "${item.key}" from ${timeString}, freeing ${item.bytes} bytes`); + this._store.removeItem(item.key); + this._itemCountGuess -= 1; + } +} diff --git a/src/ui/public/state_management/state.js b/src/ui/public/state_management/state.js index 7b934567e01ff..7196c389bc82f 100644 --- a/src/ui/public/state_management/state.js +++ b/src/ui/public/state_management/state.js @@ -1,4 +1,5 @@ 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'; @@ -6,17 +7,29 @@ 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 { HashingStore } from './hashing_store'; +import { LazyLruStore } from './lazy_lru_store'; + +const MAX_BROWSER_HISTORY = 50; + +export default function StateProvider(Private, $rootScope, $location, config) { const Events = Private(EventsProvider); _.class(State).inherits(Events); - function State(urlParam, defaults) { + function State(urlParam, defaults, { hashingStore, notify } = {}) { State.Super.call(this); let self = this; self.setDefaults(defaults); self._urlParam = urlParam || '_s'; + this._notify = notify || new Notifier(); + self._hasher = hashingStore || new HashingStore({ + store: new LazyLruStore({ + id: `${this._urlParam}:state`, + store: window.sessionStorage, + maxItems: MAX_BROWSER_HISTORY + }) + }); // When the URL updates we need to fetch the values from the URL self._cleanUpListeners = _.partial(_.callEach, [ @@ -45,15 +58,38 @@ export default function StateProvider(Private, $rootScope, $location) { } State.prototype._readFromURL = function () { - let search = $location.search(); + const search = $location.search(); + const urlVal = search[this._urlParam]; + + if (!urlVal) { + return null; + } + + if (this._hasher.isHash(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._notify.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 +131,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 +140,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 +184,73 @@ 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 (!this._hasher.isHash(queryParam)) { + return rison.decode(queryParam); + } + + const stored = this._hasher.lookup(queryParam); + if (stored === null) { + this._notify.error('Unable to completely restore the URL, be sure to use the share functionality.'); + } + + return stored; + }; + + /** + * 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); + } + + try { + return this._hasher.add(state); + } catch (err) { + this._notify.log('Unable to create hash of State due to error: ' + (state.stack || state.message)); + this._notify.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/unhash_states.js b/src/ui/public/state_management/unhash_states.js new file mode 100644 index 0000000000000..e007a29d6d718 --- /dev/null +++ b/src/ui/public/state_management/unhash_states.js @@ -0,0 +1,43 @@ +import { parse as parseUrl, format as formatUrl } from 'url'; +import { mapValues } from 'lodash'; + +export function UnhashStatesProvider(getAppState, globalState) { + const getDefaultStates = () => [getAppState(), globalState].filter(Boolean); + + this.inAbsUrl = (urlWithHashes, states = getDefaultStates()) => { + 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 `unhashStates.inAbsUrl()`. ' + + '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 = this.inParsedQueryString(appUrlParsed.query || {}, states); + return formatUrl({ + ...urlWithHashesParsed, + hash: formatUrl({ + pathname: appUrlParsed.pathname, + query: appQueryWithoutHashes, + }) + }); + }; + + this.inParsedQueryString = (parsedQueryString, states = getDefaultStates()) => { + 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/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 e98eaab16a210..02fa8089a9dab 100644 --- a/src/ui/settings/defaults.js +++ b/src/ui/settings/defaults.js @@ -245,5 +245,11 @@ export default function defaultSettingsProvider() { description: 'The time in milliseconds which an information notification ' + 'will be displayed on-screen for. Setting to Infinity will disable.' }, + '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!' + } }; };