diff --git a/package.json b/package.json index 82d78b03005780..d312662c5514ac 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "bootstrap": "3.3.5", "brace": "0.5.1", "bunyan": "1.4.0", + "clipboard": "1.5.5", "commander": "2.8.1", "css-loader": "0.17.0", "d3": "3.5.6", diff --git a/src/plugins/kibana/public/dashboard/index.js b/src/plugins/kibana/public/dashboard/index.js index 45e247b498755f..d3464adca65be3 100644 --- a/src/plugins/kibana/public/dashboard/index.js +++ b/src/plugins/kibana/public/dashboard/index.js @@ -10,6 +10,7 @@ define(function (require) { require('ui/config'); require('ui/notify'); require('ui/typeahead'); + require('ui/share'); require('plugins/kibana/dashboard/directives/grid'); require('plugins/kibana/dashboard/components/panel/panel'); @@ -233,15 +234,7 @@ define(function (require) { ui: $state.options, save: $scope.save, addVis: $scope.addVis, - addSearch: $scope.addSearch, - shareData: function () { - return { - link: $location.absUrl(), - // This sucks, but seems like the cleanest way. Uhg. - embed: '' - }; - } + addSearch: $scope.addSearch }; init(); diff --git a/src/plugins/kibana/public/dashboard/partials/share.html b/src/plugins/kibana/public/dashboard/partials/share.html index bf34366604db1d..046acbb5c95b8e 100644 --- a/src/plugins/kibana/public/dashboard/partials/share.html +++ b/src/plugins/kibana/public/dashboard/partials/share.html @@ -1,21 +1,4 @@ -
- -

-

- -
{{opts.shareData().embed}}
-
-

- -

-

- -
{{opts.shareData().link}}
-
-

-
\ No newline at end of file + + diff --git a/src/plugins/kibana/public/discover/controllers/discover.js b/src/plugins/kibana/public/discover/controllers/discover.js index f144362cd7a8e9..a457131965d307 100644 --- a/src/plugins/kibana/public/discover/controllers/discover.js +++ b/src/plugins/kibana/public/discover/controllers/discover.js @@ -20,6 +20,7 @@ define(function (require) { require('ui/state_management/app_state'); require('ui/timefilter'); require('ui/highlight/highlight_tags'); + require('ui/share'); var app = require('ui/modules').get('apps/discover', [ 'kibana/notify', @@ -91,7 +92,8 @@ define(function (require) { // config panel templates $scope.configTemplate = new ConfigTemplate({ load: require('plugins/kibana/discover/partials/load_search.html'), - save: require('plugins/kibana/discover/partials/save_search.html') + save: require('plugins/kibana/discover/partials/save_search.html'), + share: require('plugins/kibana/discover/partials/share_search.html') }); $scope.timefilter = timefilter; diff --git a/src/plugins/kibana/public/discover/index.html b/src/plugins/kibana/public/discover/index.html index ac17f7bf80d43c..89099697b93386 100644 --- a/src/plugins/kibana/public/discover/index.html +++ b/src/plugins/kibana/public/discover/index.html @@ -50,6 +50,16 @@ + + + diff --git a/src/plugins/kibana/public/discover/partials/share_search.html b/src/plugins/kibana/public/discover/partials/share_search.html new file mode 100644 index 00000000000000..69fee7ad756d0b --- /dev/null +++ b/src/plugins/kibana/public/discover/partials/share_search.html @@ -0,0 +1,5 @@ + + diff --git a/src/plugins/kibana/public/visualize/editor/editor.js b/src/plugins/kibana/public/visualize/editor/editor.js index d37eb924345aa1..9f7c628493c0e0 100644 --- a/src/plugins/kibana/public/visualize/editor/editor.js +++ b/src/plugins/kibana/public/visualize/editor/editor.js @@ -6,6 +6,7 @@ define(function (require) { require('ui/visualize'); require('ui/collapsible_sidebar'); + require('ui/share'); require('ui/routes') .when('/visualize/create', { @@ -234,15 +235,6 @@ define(function (require) { }, notify.fatal); }; - $scope.shareData = function () { - return { - link: $location.absUrl(), - // This sucks, but seems like the cleanest way. Uhg. - embed: '' - }; - }; - $scope.unlink = function () { if (!$state.linked) return; diff --git a/src/plugins/kibana/public/visualize/editor/panels/share.html b/src/plugins/kibana/public/visualize/editor/panels/share.html index a356060024e006..016109cfff6f52 100644 --- a/src/plugins/kibana/public/visualize/editor/panels/share.html +++ b/src/plugins/kibana/public/visualize/editor/panels/share.html @@ -1,22 +1,4 @@ -
- -

-

- -
{{conf.shareData().embed}}
-
-

- -

-

- -
{{conf.shareData().link}}
-
-

- -
\ No newline at end of file + + diff --git a/src/server/http/index.js b/src/server/http/index.js index 26ecf8ed41767f..4208d7f01eaa83 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -10,6 +10,8 @@ module.exports = function (kbnServer, server, config) { server = kbnServer.server = new Hapi.Server(); + const shortUrlLookup = require('./short_url_lookup')(server); + // Create a new connection var connectionOptions = { host: config.get('server.host'), @@ -122,5 +124,23 @@ module.exports = function (kbnServer, server, config) { } }); + server.route({ + method: 'GET', + path: '/goto/{urlId}', + handler: async function (request, reply) { + const url = await shortUrlLookup.getUrl(request.params.urlId); + reply().redirect(url); + } + }); + + server.route({ + method: 'POST', + path: '/shorten', + handler: async function (request, reply) { + const urlId = await shortUrlLookup.generateUrlId(request.payload.url); + reply(urlId); + } + }); + return kbnServer.mixin(require('./xsrf')); }; diff --git a/src/server/http/short_url_lookup.js b/src/server/http/short_url_lookup.js new file mode 100644 index 00000000000000..4ced5e5d812a9e --- /dev/null +++ b/src/server/http/short_url_lookup.js @@ -0,0 +1,101 @@ +const crypto = require('crypto'); + +export default function (server) { + async function updateMetadata(urlId, urlDoc) { + const client = server.plugins.elasticsearch.client; + + try { + await client.update({ + index: '.kibana', + type: 'url', + id: urlId, + body: { + doc: { + 'accessDate': new Date(), + 'accessCount': urlDoc._source.accessCount + 1 + } + } + }); + } catch (err) { + server.log('Warning: Error updating url metadata', err); + //swallow errors. It isn't critical if there is no update. + } + } + + async function getUrlDoc(urlId) { + const urlDoc = await new Promise((resolve, reject) => { + const client = server.plugins.elasticsearch.client; + + client.get({ + index: '.kibana', + type: 'url', + id: urlId + }) + .then(response => { + resolve(response); + }) + .catch(err => { + resolve(); + }); + }); + + return urlDoc; + } + + async function createUrlDoc(url, urlId) { + const newUrlId = await new Promise((resolve, reject) => { + const client = server.plugins.elasticsearch.client; + + client.index({ + index: '.kibana', + type: 'url', + id: urlId, + body: { + url, + 'accessCount': 0, + 'createDate': new Date(), + 'accessDate': new Date() + } + }) + .then(response => { + resolve(response._id); + }) + .catch(err => { + reject(err); + }); + }); + + return newUrlId; + } + + function createUrlId(url) { + const urlId = crypto.createHash('md5') + .update(url) + .digest('hex'); + + return urlId; + } + + return { + async generateUrlId(url) { + const urlId = createUrlId(url); + + const urlDoc = await getUrlDoc(urlId); + if (urlDoc) return urlId; + + return createUrlDoc(url, urlId); + }, + async getUrl(urlId) { + try { + const urlDoc = await getUrlDoc(urlId); + if (!urlDoc) throw new Error('Requested shortened url does note exist in kibana index'); + + updateMetadata(urlId, urlDoc); + + return urlDoc._source.url; + } catch (err) { + return '/'; + } + } + }; +}; diff --git a/src/ui/public/share/directives/share.js b/src/ui/public/share/directives/share.js new file mode 100644 index 00000000000000..49ebb95afba34c --- /dev/null +++ b/src/ui/public/share/directives/share.js @@ -0,0 +1,16 @@ +const app = require('ui/modules').get('kibana'); + +app.directive('share', function () { + return { + restrict: 'E', + scope: { + objectType: '@', + objectId: '@', + setAllowEmbed: '&?allowEmbed' + }, + template: require('ui/share/views/share.html'), + controller: function ($scope) { + $scope.allowEmbed = $scope.setAllowEmbed ? $scope.setAllowEmbed() : true; + } + }; +}); diff --git a/src/ui/public/share/directives/share_object_url.js b/src/ui/public/share/directives/share_object_url.js new file mode 100644 index 00000000000000..ee65c86345d442 --- /dev/null +++ b/src/ui/public/share/directives/share_object_url.js @@ -0,0 +1,76 @@ +const app = require('ui/modules').get('kibana'); +const Clipboard = require('clipboard'); + +require('../styles/index.less'); + +app.directive('shareObjectUrl', function (Private, Notifier) { + const urlShortener = Private(require('../lib/url_shortener')); + + return { + restrict: 'E', + scope: { + getShareAsEmbed: '&shareAsEmbed' + }, + template: require('ui/share/views/share_object_url.html'), + link: function ($scope, $el) { + const notify = new Notifier({ + location: `Share ${$scope.$parent.objectType}` + }); + + $scope.textbox = $el.find('input.url')[0]; + $scope.clipboardButton = $el.find('button.clipboard-button')[0]; + + const clipboard = new Clipboard($scope.clipboardButton, { + target(trigger) { + return $scope.textbox; + } + }); + + clipboard.on('success', e => { + notify.info('URL copied to clipboard.'); + e.clearSelection(); + }); + + clipboard.on('error', () => { + notify.info('URL selected. Press Ctrl+C to copy.'); + }); + + $scope.$on('$destroy', () => { + clipboard.destroy(); + }); + + $scope.clipboard = clipboard; + }, + controller: function ($scope, $location) { + function updateUrl(url) { + $scope.url = url; + + if ($scope.shareAsEmbed) { + $scope.formattedUrl = ``; + } else { + $scope.formattedUrl = $scope.url; + } + + $scope.shortGenerated = false; + } + + $scope.shareAsEmbed = $scope.getShareAsEmbed(); + + $scope.generateShortUrl = function () { + if ($scope.shortGenerated) return; + + urlShortener.shortenUrl($scope.url) + .then(shortUrl => { + updateUrl(shortUrl); + $scope.shortGenerated = true; + }); + }; + + $scope.getUrl = function () { + return $location.absUrl(); + }; + + $scope.$watch('getUrl()', updateUrl); + } + }; +}); diff --git a/src/ui/public/share/index.js b/src/ui/public/share/index.js new file mode 100644 index 00000000000000..2dec91dcc725d5 --- /dev/null +++ b/src/ui/public/share/index.js @@ -0,0 +1,2 @@ +require('./directives/share'); +require('./directives/share_object_url'); diff --git a/src/ui/public/share/lib/url_shortener.js b/src/ui/public/share/lib/url_shortener.js new file mode 100644 index 00000000000000..3e29500d8d2f09 --- /dev/null +++ b/src/ui/public/share/lib/url_shortener.js @@ -0,0 +1,24 @@ +export default function createUrlShortener(Notifier, $http, $location) { + const notify = new Notifier({ + location: 'Url Shortener' + }); + const baseUrl = `${$location.protocol()}://${$location.host()}:${$location.port()}`; + + async function shortenUrl(url) { + const relativeUrl = url.replace(baseUrl, ''); + const formData = { url: relativeUrl }; + + try { + const result = await $http.post('/shorten', formData); + + return `${baseUrl}/goto/${result.data}`; + } catch (err) { + notify.error(err); + throw err; + } + } + + return { + shortenUrl + }; +}; diff --git a/src/ui/public/share/styles/index.less b/src/ui/public/share/styles/index.less new file mode 100644 index 00000000000000..5acb20bb3449a9 --- /dev/null +++ b/src/ui/public/share/styles/index.less @@ -0,0 +1,21 @@ +share-object-url { + .input-group { + display: flex; + + .clipboard-button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .shorten-button { + border-top-right-radius: 0; + border-top-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + .form-control.url { + cursor: text; + } + } +} diff --git a/src/ui/public/share/views/share.html b/src/ui/public/share/views/share.html new file mode 100644 index 00000000000000..5f7ffdd0a67974 --- /dev/null +++ b/src/ui/public/share/views/share.html @@ -0,0 +1,15 @@ +
+
+ + +
+
+ + +
+
diff --git a/src/ui/public/share/views/share_object_url.html b/src/ui/public/share/views/share_object_url.html new file mode 100644 index 00000000000000..6c1b1438e9e902 --- /dev/null +++ b/src/ui/public/share/views/share_object_url.html @@ -0,0 +1,21 @@ +
+ + + + +