Skip to content

Commit

Permalink
Adding an app for redirects when storing state in session storage (#1…
Browse files Browse the repository at this point in the history
…0822) (#11305)

* Adding an app for redirects when storing state in session storage

* Removing errant console.log

* Adding early return after reply().redirect

* No longer using the router

* Renaming vars to injectedVarsOverrides

* Putting uiRoutes back in so the code is only executed for this app

* Extracting hash_url to it's own module, and adding tests

* Addressing peer-review comments
  • Loading branch information
kobelb authored Apr 18, 2017
1 parent 70a71f1 commit 9ca5059
Show file tree
Hide file tree
Showing 9 changed files with 279 additions and 6 deletions.
13 changes: 13 additions & 0 deletions src/core_plugins/state_session_storage_redirect/index.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
});
}
5 changes: 5 additions & 0 deletions src/core_plugins/state_session_storage_redirect/package.json
Original file line number Diff line number Diff line change
@@ -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."
}
19 changes: 19 additions & 0 deletions src/core_plugins/state_session_storage_redirect/public/index.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
});
Empty file.
13 changes: 12 additions & 1 deletion src/server/http/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
12 changes: 7 additions & 5 deletions src/ui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
Expand All @@ -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, ''),
Expand All @@ -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,
});
});

Expand Down
144 changes: 144 additions & 0 deletions src/ui/public/state_management/state_hashing/__tests__/hash_url.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
75 changes: 75 additions & 0 deletions src/ui/public/state_management/state_hashing/hash_url.js
Original file line number Diff line number Diff line change
@@ -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,
});
};
4 changes: 4 additions & 0 deletions src/ui/public/state_management/state_hashing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit 9ca5059

Please sign in to comment.