diff --git a/src/core_plugins/state_session_storage_redirect/index.js b/src/core_plugins/state_session_storage_redirect/index.js new file mode 100644 index 0000000000000..ba0fcfa844f24 --- /dev/null +++ b/src/core_plugins/state_session_storage_redirect/index.js @@ -0,0 +1,13 @@ +export default function (kibana) { + return new kibana.Plugin({ + uiExports: { + app: { + require: ['kibana'], + title: 'Redirecting', + id: 'stateSessionStorageRedirect', + main: 'plugins/state_session_storage_redirect', + listed: false, + } + } + }); +} diff --git a/src/core_plugins/state_session_storage_redirect/package.json b/src/core_plugins/state_session_storage_redirect/package.json new file mode 100644 index 0000000000000..7eedbddb08325 --- /dev/null +++ b/src/core_plugins/state_session_storage_redirect/package.json @@ -0,0 +1,5 @@ +{ + "name": "state_session_storage_redirect", + "version": "kibana", + "description": "When using the state:storeInSessionStorage setting with the short-urls, we need some way to get the full URL's hashed states into sessionStorage, this app will grab the URL from the kbn-initial-state and and put the URL hashed states into sessionStorage before redirecting the user." +} diff --git a/src/core_plugins/state_session_storage_redirect/public/index.js b/src/core_plugins/state_session_storage_redirect/public/index.js new file mode 100644 index 0000000000000..027f240e1fc66 --- /dev/null +++ b/src/core_plugins/state_session_storage_redirect/public/index.js @@ -0,0 +1,19 @@ +import 'ui/autoload/styles'; +import chrome from 'ui/chrome'; +import { hashUrl } from 'ui/state_management/state_hashing'; +import uiRoutes from 'ui/routes'; + +uiRoutes.enable(); +uiRoutes +.when('/', { + resolve: { + url: function (AppState, globalState, $window) { + const redirectUrl = chrome.getInjected('redirectUrl'); + + const hashedUrl = hashUrl([new AppState(), globalState], redirectUrl); + const url = chrome.addBasePath(hashedUrl); + + $window.location = url; + } + } +}); diff --git a/src/core_plugins/state_session_storage_redirect/public/styles.less b/src/core_plugins/state_session_storage_redirect/public/styles.less new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/server/http/index.js b/src/server/http/index.js index ab1cf7a21b101..5564bb4ed6d50 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -119,7 +119,18 @@ module.exports = async function (kbnServer, server, config) { try { const url = await shortUrlLookup.getUrl(request.params.urlId, request); shortUrlAssertValid(url); - reply().redirect(config.get('server.basePath') + url); + + const uiSettings = server.uiSettings(); + const stateStoreInSessionStorage = await uiSettings.get(request, 'state:storeInSessionStorage'); + if (!stateStoreInSessionStorage) { + reply().redirect(config.get('server.basePath') + url); + return; + } + + const app = kbnServer.uiExports.apps.byId.stateSessionStorageRedirect; + reply.renderApp(app, { + redirectUrl: url, + }); } catch (err) { reply(handleShortUrlError(err)); } diff --git a/src/ui/index.js b/src/ui/index.js index 0de674878ed18..b93ac26932127 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -64,7 +64,7 @@ export default async (kbnServer, server, config) => { } }); - async function getKibanaPayload({ app, request, includeUserProvidedConfig }) { + async function getKibanaPayload({ app, request, includeUserProvidedConfig, injectedVarsOverrides }) { const uiSettings = server.uiSettings(); const translations = await uiI18n.getTranslationsForRequest(request); @@ -85,12 +85,12 @@ export default async (kbnServer, server, config) => { vars: await reduceAsync( uiExports.injectedVarsReplacers, async (acc, replacer) => await replacer(acc, request, server), - defaults(await app.getInjectedVars() || {}, uiExports.defaultInjectedVars) + defaults(injectedVarsOverrides, await app.getInjectedVars() || {}, uiExports.defaultInjectedVars) ), }; } - async function renderApp({ app, reply, includeUserProvidedConfig = true }) { + async function renderApp({ app, reply, includeUserProvidedConfig = true, injectedVarsOverrides = {} }) { try { const request = reply.request; const translations = await uiI18n.getTranslationsForRequest(request); @@ -100,7 +100,8 @@ export default async (kbnServer, server, config) => { kibanaPayload: await getKibanaPayload({ app, request, - includeUserProvidedConfig + includeUserProvidedConfig, + injectedVarsOverrides }), bundlePath: `${config.get('server.basePath')}/bundles`, i18n: key => _.get(translations, key, ''), @@ -110,11 +111,12 @@ export default async (kbnServer, server, config) => { } } - server.decorate('reply', 'renderApp', function (app) { + server.decorate('reply', 'renderApp', function (app, injectedVarsOverrides) { return renderApp({ app, reply: this, includeUserProvidedConfig: true, + injectedVarsOverrides, }); }); diff --git a/src/ui/public/state_management/state_hashing/__tests__/hash_url.js b/src/ui/public/state_management/state_hashing/__tests__/hash_url.js new file mode 100644 index 0000000000000..8ec747c325a8a --- /dev/null +++ b/src/ui/public/state_management/state_hashing/__tests__/hash_url.js @@ -0,0 +1,144 @@ +import expect from 'expect.js'; +import ngMock from 'ng_mock'; +import sinon from 'auto-release-sinon'; +import { parse as parseUrl } from 'url'; + +import StateProvider from 'ui/state_management/state'; +import { hashUrl } from 'ui/state_management/state_hashing'; + +describe('hashUrl', function () { + let State; + + beforeEach(ngMock.module('kibana')); + + beforeEach(ngMock.inject((Private, config) => { + State = Private(StateProvider); + sinon.stub(config, 'get').withArgs('state:storeInSessionStorage').returns(true); + })); + + describe('throws error', () => { + it('if states parameter is null', () => { + expect(() => { + hashUrl(null, ''); + }).to.throwError(); + }); + + it('if states parameter is empty array', () => { + expect(() => { + hashUrl([], ''); + }).to.throwError(); + }); + }); + + describe('does nothing', () => { + let states; + beforeEach(() => { + states = [new State('testParam')]; + }); + it('if url is empty', () => { + const url = ''; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if just a host and port', () => { + const url = 'https://localhost:5601'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if just a path', () => { + const url = 'https://localhost:5601/app/kibana'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if just a path and query', () => { + const url = 'https://localhost:5601/app/kibana?foo=bar'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if empty hash with query', () => { + const url = 'https://localhost:5601/app/kibana?foo=bar#'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if query parameter matches and there is no hash', () => { + const url = 'https://localhost:5601/app/kibana?testParam=(yes:!t)'; + expect(hashUrl(states, url)).to.be(url); + }); + + it(`if query parameter matches and it's before the hash`, () => { + const url = 'https://localhost:5601/app/kibana?testParam=(yes:!t)'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if empty hash without query', () => { + const url = 'https://localhost:5601/app/kibana#'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if empty hash without query', () => { + const url = 'https://localhost:5601/app/kibana#'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if hash is just a path', () => { + const url = 'https://localhost:5601/app/kibana#/discover'; + expect(hashUrl(states, url)).to.be(url); + }); + + it('if hash does not have matching query string vals', () => { + const url = 'https://localhost:5601/app/kibana#/discover?foo=bar'; + expect(hashUrl(states, url)).to.be(url); + }); + }); + + describe('replaces querystring value with hash', () => { + const getAppQuery = (url) => { + const parsedUrl = parseUrl(url); + const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true); + + return parsedAppUrl.query; + }; + + it('if using a single State', () => { + const stateParamKey = 'testParam'; + const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey}=(yes:!t)`; + const mockHashedItemStore = { + getItem: () => null, + setItem: sinon.stub().returns(true) + }; + const state = new State(stateParamKey, {}, mockHashedItemStore); + + const actualUrl = hashUrl([state], url); + + expect(mockHashedItemStore.setItem.calledOnce).to.be(true); + + const appQuery = getAppQuery(actualUrl); + + const hashKey = mockHashedItemStore.setItem.firstCall.args[0]; + expect(appQuery[stateParamKey]).to.eql(hashKey); + }); + + it('if using multiple States', () => { + const stateParamKey1 = 'testParam1'; + const stateParamKey2 = 'testParam2'; + const url = `https://localhost:5601/app/kibana#/discover?foo=bar&${stateParamKey1}=(yes:!t)&${stateParamKey2}=(yes:!f)`; + const mockHashedItemStore = { + getItem: () => null, + setItem: sinon.stub().returns(true) + }; + const state1 = new State(stateParamKey1, {}, mockHashedItemStore); + const state2 = new State(stateParamKey2, {}, mockHashedItemStore); + + const actualUrl = hashUrl([state1, state2], url); + + expect(mockHashedItemStore.setItem.calledTwice).to.be(true); + + const appQuery = getAppQuery(actualUrl); + + const hashKey1 = mockHashedItemStore.setItem.firstCall.args[0]; + const hashKey2 = mockHashedItemStore.setItem.secondCall.args[0]; + expect(appQuery[stateParamKey1]).to.eql(hashKey1); + expect(appQuery[stateParamKey2]).to.eql(hashKey2); + }); + }); +}); diff --git a/src/ui/public/state_management/state_hashing/hash_url.js b/src/ui/public/state_management/state_hashing/hash_url.js new file mode 100644 index 0000000000000..e34829f19bef3 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/hash_url.js @@ -0,0 +1,75 @@ +import encodeUriQuery from 'encode-uri-query'; +import rison from 'rison-node'; +import { parse as parseUrl, format as formatUrl } from 'url'; +import { stringify as stringifyQuerystring } from 'querystring'; + +const conservativeStringifyQuerystring = (query) => { + return stringifyQuerystring(query, null, null, { + encodeURIComponent: encodeUriQuery + }); +}; + +const hashStateInQuery = (state, query) => { + const name = state.getQueryParamName(); + const value = query[name]; + if (!value) { + return { name, value }; + } + + const decodedValue = rison.decode(value); + const hashedValue = state.toQueryParam(decodedValue); + return { name, value: hashedValue }; +}; + +const hashStatesInQuery = (states, query) => { + const hashedQuery = states.reduce((result, state) => { + const { name, value } = hashStateInQuery(state, query); + if (value) { + result[name] = value; + } + return result; + }, {}); + + + return Object.assign({}, query, hashedQuery); +}; + +export const hashUrl = (states, redirectUrl) => { + // we need states to proceed, throwing an error if we don't have any + if (states === null || !states.length) { + throw new Error('states parameter must be an Array with length greater than 0'); + } + + const parsedUrl = parseUrl(redirectUrl); + // if we don't have a hash, we return the redirectUrl without hashing anything + if (!parsedUrl.hash) { + return redirectUrl; + } + + // The URLs that we use aren't "conventional" and the hash is sometimes appearing before + // the querystring, even though conventionally they appear after it. The parsedUrl + // is the entire URL, and the parsedAppUrl is everything after the hash. + // + // EXAMPLE + // parsedUrl: /app/kibana#/visualize/edit/somelongguid?g=()&a=() + // parsedAppUrl: /visualize/edit/somelongguid?g=()&a=() + const parsedAppUrl = parseUrl(parsedUrl.hash.slice(1), true); + + // the parsedAppUrl actually has the query that we care about + const query = parsedAppUrl.query; + + const newQuery = hashStatesInQuery(states, query); + + const newHash = formatUrl({ + search: conservativeStringifyQuerystring(newQuery), + pathname: parsedAppUrl.pathname + }); + + return formatUrl({ + hash: `#${newHash}`, + host: parsedUrl.host, + search: parsedUrl.search, + pathname: parsedUrl.pathname, + protocol: parsedUrl.protocol, + }); +}; diff --git a/src/ui/public/state_management/state_hashing/index.js b/src/ui/public/state_management/state_hashing/index.js index 6905a1fd28b61..438dc4430b103 100644 --- a/src/ui/public/state_management/state_hashing/index.js +++ b/src/ui/public/state_management/state_hashing/index.js @@ -2,6 +2,10 @@ export { default as getUnhashableStatesProvider, } from './get_unhashable_states_provider'; +export { + hashUrl, +} from './hash_url'; + export { default as unhashQueryString, } from './unhash_query_string';