From decb07eb2ef7a6bb8725307f79707db3ea0e3b4e Mon Sep 17 00:00:00 2001 From: Lars Haferkamp Date: Tue, 19 May 2020 09:17:17 +0200 Subject: [PATCH] Integrated AWS (account) as cloud provider via AWS Amplify Signed-off-by: Lars Haferkamp --- examples/demo-app/README.md | 53 ++- examples/demo-app/package.json | 6 +- examples/demo-app/src/aws-exports.js | 2 + .../src/cloud-providers/aws/aws-icon.js | 37 ++ .../src/cloud-providers/aws/aws-login.js | 43 +++ .../src/cloud-providers/aws/aws-provider.js | 349 ++++++++++++++++++ .../demo-app/src/cloud-providers/index.js | 6 +- .../src/constants/default-settings.js | 3 +- examples/demo-app/src/main.js | 3 +- examples/demo-app/webpack.config.js | 3 +- examples/webpack.config.local.js | 3 +- 11 files changed, 500 insertions(+), 8 deletions(-) create mode 100644 examples/demo-app/src/aws-exports.js create mode 100644 examples/demo-app/src/cloud-providers/aws/aws-icon.js create mode 100644 examples/demo-app/src/cloud-providers/aws/aws-login.js create mode 100644 examples/demo-app/src/cloud-providers/aws/aws-provider.js diff --git a/examples/demo-app/README.md b/examples/demo-app/README.md index b97f7a5e80..5e88604abf 100644 --- a/examples/demo-app/README.md +++ b/examples/demo-app/README.md @@ -23,7 +23,58 @@ export MapboxAccessToken= ``` #### 3. Start the app - ```sh npm start ``` + +## Cloud Providers + +### Connecting your AWS Account + +#### 1. Setup AWS Amplify CLI +[Install and configure](https://docs.amplify.aws/cli/start/install) the Amplify CLI: +```sh +npm install -g @aws-amplify/cli +``` +```sh +amplify configure +``` + +#### 2. Setup AWS services needed + +Setup AWS [Authentication](https://docs.amplify.aws/cli/auth/overview) and [Storage](https://docs.amplify.aws/lib/storage/getting-started/q/platform/js) + +These steps create a new Cloudformation stack, a Cognito user pool and a S3 bucket and connect both via policies. + +```sh +amplify add auth +``` +```sh +amplify add storage +``` + +Always finish Amplify changes by: +```sh +amplify push +``` + +If Amplify services only needs to be updated run +```sh +amplify update auth +``` +```sh +amplify update storage +``` + +#### 3. AWS Amplify configuration for Kepler.gl + +Copy the file `aws-exports.js` generated by the previous step into the `demo-app/src` folder (there is already an empty one to avoid startup problems) + +Finally, set the AWSAccountName (just for display) in the environment: +```sh +export AWSAccountName=demo-account +``` + +#### Notes on AWS as cloud provider +- URLs of shared maps expire after one hour. + diff --git a/examples/demo-app/package.json b/examples/demo-app/package.json index ddc4763b78..f554074705 100644 --- a/examples/demo-app/package.json +++ b/examples/demo-app/package.json @@ -9,7 +9,10 @@ "start-local-https": "webpack-dev-server --mode development --https --env.es6 --progress --hot --open" }, "dependencies": { + "@aws-amplify/storage": "3.2.0", + "@aws-amplify/ui-react": "^0.2.5", "@carto/toolkit": "0.0.1-rc.18", + "aws-amplify": "3.0.11", "d3-request": "^1.0.6", "dropbox": "^4.0.12", "global": "^4.3.0", @@ -55,6 +58,7 @@ "babel-loader": "^8.0.0", "babel-plugin-module-resolver": "^3.0.0", "babel-plugin-transform-builtin-extend": "^1.1.0", + "json-loader": "^0.5.7", "webpack": "^4.29.0", "webpack-cli": "^3.2.1", "webpack-dev-middleware": "^3.5.1", @@ -62,4 +66,4 @@ "webpack-hot-middleware": "^2.24.3", "webpack-stats-plugin": "^0.2.1" } -} \ No newline at end of file +} diff --git a/examples/demo-app/src/aws-exports.js b/examples/demo-app/src/aws-exports.js new file mode 100644 index 0000000000..666b7a25d5 --- /dev/null +++ b/examples/demo-app/src/aws-exports.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +// WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten. diff --git a/examples/demo-app/src/cloud-providers/aws/aws-icon.js b/examples/demo-app/src/cloud-providers/aws/aws-icon.js new file mode 100644 index 0000000000..a447d62ce9 --- /dev/null +++ b/examples/demo-app/src/cloud-providers/aws/aws-icon.js @@ -0,0 +1,37 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React, {Component} from 'react'; + +class AwsIcon extends Component { + render() { + return ( +
+ Powered by AWS Cloud Computing +
+ ); + } +} + +export default AwsIcon; diff --git a/examples/demo-app/src/cloud-providers/aws/aws-login.js b/examples/demo-app/src/cloud-providers/aws/aws-login.js new file mode 100644 index 0000000000..a30ab6e786 --- /dev/null +++ b/examples/demo-app/src/cloud-providers/aws/aws-login.js @@ -0,0 +1,43 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import React, {useEffect} from 'react'; +import Amplify, {Hub} from 'aws-amplify'; +import awsconfig from '../../aws-exports'; +import {AmplifyAuthenticator} from '@aws-amplify/ui-react'; + +Amplify.configure(awsconfig); +export const AWS_LOGIN_URL = 'aws/aws-login'; +export const AWS_WEB_CLIENT_ID = awsconfig && awsconfig.aws_cognito_identity_pool_id; + +const AwsLogin = () => { + useEffect(() => { + Hub.listen('auth', data => { + const {payload} = data; + if (payload.event === 'signIn') { + window.opener.postMessage({success: true}, location.origin); + } + }); + }, []); + + return ; +}; + +export default AwsLogin; diff --git a/examples/demo-app/src/cloud-providers/aws/aws-provider.js b/examples/demo-app/src/cloud-providers/aws/aws-provider.js new file mode 100644 index 0000000000..686725080c --- /dev/null +++ b/examples/demo-app/src/cloud-providers/aws/aws-provider.js @@ -0,0 +1,349 @@ +// Copyright (c) 2020 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import AwsIcon from './aws-icon'; +import {Provider} from 'kepler.gl/cloud-providers'; +import window from 'global'; +import {Auth, Storage} from 'aws-amplify'; +import {MAP_URI} from '../../constants/default-settings'; +import {AWS_LOGIN_URL, AWS_WEB_CLIENT_ID} from './aws-login'; + +const PROVIDER_NAME = 'aws'; +const DISPLAY_NAME = 'AWS'; +const PRIVATE_STORAGE_ENABLED = true; +const SHARING_ENABLED = true; + +// Here you can configure if share url uses mapUrl or loadParams, e.g.: +// Sharing with map url: http://localhost:8080/demo/map?mapUrl='' +// Sharing with loadParams (only works if user is logged in): +// http://localhost:8080/demo/map/aws?level=protected&mapId=''&identityId='' +const SHARING_WITH_MAP_URL = true; + +// If sharing url with mapUrl, here you can configure the expiration time in seconds: +// Per default in AWS backend maximal time is one hour +const EXPIRE_TIME_IN_SECONDS = 60 * 60; + +export default class AwsProvider extends Provider { + constructor(accountName) { + super({name: PROVIDER_NAME, displayName: accountName || DISPLAY_NAME, icon: AwsIcon}); + this.clientId = AWS_WEB_CLIENT_ID; + + if (this.clientId) { + this._getUserInfo().then(userInfo => { + this._currentUser = userInfo; + }); + } else { + this._currentUser = {id: '', username: ''}; + } + + this._loadParam = {level: '', mapId: '', identityId: ''}; + this._shareUrl = ''; + } + + /** + * + * @param onCloudLoginSuccess + */ + async login(onCloudLoginSuccess) { + if (!this.clientId) { + // throws error if amplify configuration not set: + Auth.currentUserInfo().catch(); + return; + } + if (this._currentUser && this._currentUser.id) { + // throws error if user logged in: + throw new Error( + 'You are already logged in, please reload the page (sign out to log in with another user).' + ); + } + const link = `${window.location.protocol}//${window.location.host}/${AWS_LOGIN_URL}`; + const style = `location, toolbar, resizable, scrollbars, status, width=500, height=440, top=200, left=400`; + const authWindow = window.open(link, 'awsCognito', style); + + // if authWindow is null, it could not be opened + const handleLogin = e => { + if (authWindow.location.href === link) { + if (authWindow) { + authWindow.close(); + } + + window.removeEventListener('message', handleLogin); + + if (e.data.success) { + this._getUserInfo().then(currentUser => { + this._currentUser = currentUser; + }); + onCloudLoginSuccess(); + } + } + }; + window.addEventListener('message', handleLogin); + } + + /** + * + * @returns {Array} + */ + async listMaps() { + const publicMaps = this._getMapListFromStorage('public'); + const privateMaps = this._getMapListFromStorage('private'); + return Promise.all([publicMaps, privateMaps]) + .then(values => values.flat()) + .catch(e => AwsProvider._handleError(e)); + } + + _getMapListFromStorage(level) { + return Storage.list('', {level}) + .then(result => AwsProvider._prepareFileList(result, level)) + .catch(e => { + const capitalizedLevel = level.charAt(0).toUpperCase() + level.slice(1); + AwsProvider._handleError(`${capitalizedLevel} maps failed to load`, e); + }); + } + + /** + * Generates array of viz objects from list of files from storage + * @returns {Array} + */ + static async _prepareFileList(fileList, level) { + const mapExtension = '.map.json'; + const mapFileList = fileList.filter(file => file.key.endsWith(mapExtension)); + + const updatedFileList = mapFileList.map(file => { + const title = file.key.slice(0, -mapExtension.length); + const thumbnailKey = `${title}.thumbnail.png`; + const metaKey = `${title}.meta.json`; + + const loadThumbnail = fileList.some(f => f.key === thumbnailKey) + ? this._getFile(thumbnailKey, 'image', {level, download: false}) + : null; + const loadDescription = fileList.some(f => f.key === metaKey) + ? this._getFile(metaKey, 'meta data', {level, download: true}) + : 'No description available'; + + return Promise.all([loadThumbnail, loadDescription]) + .then(([thumbnail, description]) => ({ + id: file.key, + title, + privateMap: level === 'private', + lastModification: new Date(Date.parse(file.lastModified)), + loadParams: { + identityId: '', + mapId: file.key, + level + }, + thumbnail, + description + })) + .catch(e => this._handleError(e)); + }); + return Promise.all(updatedFileList); + } + + /** + * + * @returns {MapResponse} + */ + async downloadMap(loadParams) { + const {level, mapId, identityId} = loadParams; + return Storage.get(mapId, {level, ...(level === 'private' ? {} : {identityId})}) + .then(fetch) + .then(response => response.json()) + .then(mapData => { + if (this._loadParam !== loadParams) { + this._loadParam = loadParams; + } + return { + map: mapData, + format: 'keplergl' + }; + }) + .catch(e => AwsProvider._handleError(`Map downloading failed`, e)); + } + + /** + * Save if isPublic false or + * ShareUrl if isPublic true + * @returns {Promise<{level, mapId, identityId} || {shareUrl}>} + * You can share url with saved map through public map link (as defined) + * or through loadParams used in downloadMap (set SHARING_WITH_MAP_URL to false to use) + * in second case, the user has to be logged in to open the map + */ + async uploadMap({mapData, options = {}}) { + const {isPublic} = options; + const {map, thumbnail} = mapData; + const {title, description} = map && map.info; + const name = title; + // Since we share through a map link, this could be private as well + const level = isPublic ? 'protected' : 'private'; + const saveThumbnail = this._saveFile(name, 'thumbnail.png', thumbnail, level); + const saveMeta = this._saveFile(name, 'meta.json', {description}, level); + const saveMap = this._saveFile(name, 'map.json', map, level); + + return Promise.all([saveThumbnail, saveMeta, saveMap]) + .then(([thumbnailSaved, metaSaved, mapSaved]) => { + const key = mapSaved && mapSaved.key; + this._loadParam = {level, mapId: key}; + // if public, url for sharing is created: + if (isPublic) { + if (SHARING_WITH_MAP_URL) { + const config = {download: false, level, expires: EXPIRE_TIME_IN_SECONDS}; + return AwsProvider._getFile(key, 'map', config).then(url => { + this._shareUrl = encodeURIComponent(url); + return {shareUrl: this.getShareUrl(true)}; + }); + } + this._loadParam.identityId = this._currentUser && this._currentUser.id; + return {shareUrl: this.getShareUrl(true)}; + } + // if not public, map is saved and private map url is created + return this._loadParam; + }) + .catch(e => AwsProvider._handleError(`Error at saving ${name} files`, e)); + } + + /** + * + * @param onCloudLogoutSuccess + */ + async logout(onCloudLogoutSuccess) { + Auth.signOut() + .then(() => { + this._currentUser = {id: '', username: ''}; + onCloudLogoutSuccess(); + }) + .catch(e => AwsProvider._handleError('Signing out failed', e)); + } + + /** + * + * @returns {boolean} + */ + hasPrivateStorage() { + return PRIVATE_STORAGE_ENABLED; + } + + isEnabled() { + return Boolean(this.clientId); + } + + /** + * + * @returns {boolean} + */ + hasSharingUrl() { + return SHARING_ENABLED; + } + + getAccessToken() { + return Boolean(this._currentUser && this._currentUser.id); + } + + getUserName() { + return (this._currentUser && this._currentUser.username) || ''; + } + + getShareUrl(fullUrl) { + let shareUrl; + if (SHARING_WITH_MAP_URL) { + shareUrl = `${MAP_URI}${this._shareUrl}`; + } else { + const {level, mapId, identityId} = this._loadParam; + shareUrl = `demo/map/${PROVIDER_NAME}?level=${level}&mapId=${mapId}&identityId=${identityId}`; + } + return fullUrl + ? `${window.location.protocol}//${window.location.host}/${shareUrl}` + : `/${shareUrl}`; + } + + getMapUrl(fullURL) { + const {level, mapId, identityId} = this._loadParam; + let mapUrl = `demo/map/${PROVIDER_NAME}?level=${level}&mapId=${mapId}`; + if (identityId && identityId !== (this._currentUser && this._currentUser.id)) { + mapUrl = `${mapUrl}&identityId=${identityId}`; + } + return fullURL + ? `${window.location.protocol}//${window.location.host}/${mapUrl}` + : `/${mapUrl}`; + } + + _getUserInfo() { + return Auth.currentUserInfo() + .then(userInfo => { + return { + id: userInfo && userInfo.id, + username: userInfo && userInfo.attributes && userInfo.attributes.email + }; + }) + .then(currentUserInfo => currentUserInfo) + .catch(e => { + AwsProvider._handleError(`User information failed to load`, e); + }); + } + + _saveFile(name, suffix, content, level, metadata) { + let contentType = ''; + if (suffix === 'thumbnail.png') { + contentType = 'images/png'; + } + if (suffix === 'map.json') { + contentType = 'application/json'; + } + if (suffix === 'meta.json') { + contentType = 'application/json'; + } + + return Storage.put(`${name}.${suffix}`, content, { + level, + contentType, + metadata + }) + .then(resp => resp) + .catch(e => { + AwsProvider._handleError(`Saving ${name}.${suffix} file failed`, e); + }); + } + + static _getFile(key, fileType, config) { + const {level, download, expires} = config; + return Storage.get(key, { + level, + download, + expires + }) + .then(file => { + if (fileType === 'meta data') { + return file.Body && file.Body.description + ? file.Body.description + : 'No description available.'; + } + return file; + }) + .then(resp => resp) + .catch(e => { + AwsProvider._handleError(`Getting ${fileType} file ${key} failed`, e); + }); + } + + static _handleError(message, error) { + throw new Error(`${message}, error message: + ${error && error.message}`); + } +} diff --git a/examples/demo-app/src/cloud-providers/index.js b/examples/demo-app/src/cloud-providers/index.js index 203a312e3a..01294843ae 100644 --- a/examples/demo-app/src/cloud-providers/index.js +++ b/examples/demo-app/src/cloud-providers/index.js @@ -22,15 +22,17 @@ import {AUTH_TOKENS} from '../constants/default-settings'; import DropboxProvider from './dropbox/dropbox-provider'; import CartoProvider from './carto/carto-provider'; +import AwsProvider from './aws/aws-provider'; -const {DROPBOX_CLIENT_ID, CARTO_CLIENT_ID} = AUTH_TOKENS; +const {DROPBOX_CLIENT_ID, CARTO_CLIENT_ID, AWS_ACCOUNT_NAME} = AUTH_TOKENS; const DROPBOX_CLIENT_NAME = 'Kepler.gl%20(managed%20by%20Uber%20Technologies%2C%20Inc.)'; export const DEFAULT_CLOUD_PROVIDER = 'dropbox'; export const CLOUD_PROVIDERS = [ new DropboxProvider(DROPBOX_CLIENT_ID, DROPBOX_CLIENT_NAME), - new CartoProvider(CARTO_CLIENT_ID) + new CartoProvider(CARTO_CLIENT_ID), + new AwsProvider(AWS_ACCOUNT_NAME) ]; export function getCloudProvider(providerName) { diff --git a/examples/demo-app/src/constants/default-settings.js b/examples/demo-app/src/constants/default-settings.js index d1129e8424..ec8ab262a6 100644 --- a/examples/demo-app/src/constants/default-settings.js +++ b/examples/demo-app/src/constants/default-settings.js @@ -64,5 +64,6 @@ export const AUTH_TOKENS = { MAPBOX_TOKEN: process.env.MapboxAccessToken, // eslint-disable-line DROPBOX_CLIENT_ID: process.env.DropboxClientId, // eslint-disable-line EXPORT_MAPBOX_TOKEN: process.env.MapboxExportToken, // eslint-disable-line - CARTO_CLIENT_ID: process.env.CartoClientId // eslint-disable-line + CARTO_CLIENT_ID: process.env.CartoClientId, // eslint-disable-line + AWS_ACCOUNT_NAME: process.env.AWSAccountName // eslint-disable-line }; diff --git a/examples/demo-app/src/main.js b/examples/demo-app/src/main.js index 3bcad48f2a..3b21c5e017 100644 --- a/examples/demo-app/src/main.js +++ b/examples/demo-app/src/main.js @@ -27,14 +27,15 @@ import {render} from 'react-dom'; import store from './store'; import App from './app'; import {buildAppRoutes} from './utils/routes'; +import AwsLogin, {AWS_LOGIN_URL} from './cloud-providers/aws/aws-login'; const history = syncHistoryWithStore(browserHistory, store); - const appRoute = buildAppRoutes(App); const Root = () => ( + {appRoute} diff --git a/examples/demo-app/webpack.config.js b/examples/demo-app/webpack.config.js index f527171943..643dcfb4a8 100644 --- a/examples/demo-app/webpack.config.js +++ b/examples/demo-app/webpack.config.js @@ -71,7 +71,8 @@ const CONFIG = { 'MapboxAccessToken', 'DropboxClientId', 'MapboxExportToken', - 'CartoClientId' + 'CartoClientId', + 'AWSAccountName' ]) ] }; diff --git a/examples/webpack.config.local.js b/examples/webpack.config.local.js index 6a04cdf9ec..29a3c6beae 100644 --- a/examples/webpack.config.local.js +++ b/examples/webpack.config.local.js @@ -123,7 +123,8 @@ function makeLocalDevConfig(env, EXAMPLE_DIR = LIB_DIR, externals = {}) { 'MapboxAccessToken', 'DropboxClientId', 'MapboxExportToken', - 'CartoClientId' + 'CartoClientId', + 'AWSAccountName' ]) ] };