diff --git a/Dockerfile b/Dockerfile index 5b59bedec..d60f22427 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,4 +49,4 @@ RUN yarn run heroku-postbuild ENV PLOTLY_CONNECTOR_PORT 9494 EXPOSE 9494 -ENTRYPOINT yarn run build-web && yarn run start-headless +ENTRYPOINT yarn run start-headless diff --git a/app/components/Configuration.react.js b/app/components/Configuration.react.js index 85ea491cc..dbb17d785 100644 --- a/app/components/Configuration.react.js +++ b/app/components/Configuration.react.js @@ -2,20 +2,21 @@ import cookie from 'react-cookies'; import React, { Component, PropTypes } from 'react'; import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import Settings from './Settings/Settings.react'; -import {isElectron} from '../utils/utils'; -import * as SessionsActions from '../actions/sessions'; -import Login from './Login.react'; + +import Login from './Login.react.js'; +import * as SessionsActions from '../actions/sessions.js'; +import Settings from './Settings/Settings.react.js'; +import {isElectron, setUsernameListener} from '../utils/utils.js'; class Configuration extends Component { constructor(props) { super(props); this.state = { + authEnabled: (cookie.load('db-connector-auth-enabled') === 'true'), + clientId: cookie.load('db-connector-oauth2-client-id'), isMenuOpen: false, - username: cookie.load('db-connector-user'), - /* global PLOTLY_ENV */ - authDisabled: !PLOTLY_ENV.AUTH_ENABLED + username: cookie.load('db-connector-user') }; this.toggle = this.toggle.bind(this); this.close = this.close.bind(this); @@ -27,12 +28,9 @@ class Configuration extends Component { * In the browser, the username is set with a cookie but in electron * this is set using electron's ipcRenderer. */ - if (isElectron()) { - window.require('electron').ipcRenderer.once('username', - (event, message) => { - this.setState({username: message}); - }); - } + setUsernameListener((event, message) => { + this.setState({username: message});} + ); } toggle() { @@ -44,26 +42,24 @@ class Configuration extends Component { } logOut() { + /* + * Delete all the cookies and reset user state. This does not kill + * any running connections, but user will not be able to access them + * without logging in again. + */ + cookie.remove('db-connector-user'); + cookie.remove('plotly-auth-token'); + cookie.remove('db-connector-auth-token'); + this.setState({ username: ''}); - /* - * Delete all the cookies and reset user state. This does not kill - * any running connections, but user will not be able to access them - * without logging in again. - */ - cookie.remove('db-connector-user'); - cookie.remove('plotly-auth-token'); - cookie.remove('db-connector-auth-token'); - cookie.remove('db-connector-auth-disabled'); - this.setState({ username: ''}); - - // reload page when running in browser: - if (!isElectron()) { - window.location.reload(); - } + // reload page when running in browser: + if (!isElectron()) { + window.location.reload(); + } } render() { - return (isElectron() || this.state.authDisabled || this.state.username) ? ( + return (isElectron() || !this.state.authEnabled || this.state.username) ? (
diff --git a/app/components/Link.react.js b/app/components/Link.react.js index 7e16ebda7..06bf0aa37 100644 --- a/app/components/Link.react.js +++ b/app/components/Link.react.js @@ -1,31 +1,5 @@ -import R from 'ramda'; import React from 'react'; -import PropTypes from 'prop-types'; -import {dynamicRequireElectron} from '../utils/utils'; - - -let shell; -try { - shell = dynamicRequireElectron().shell; -} catch (e) { - shell = null; -} export function Link(props) { - const {href} = props; - if (shell) { - return ( - {shell.openExternal(href);}} - {...R.omit(['className'], props)} - /> - ); - } - return ; + return ; } - -Link.propTypes = { - href: PropTypes.string, - className: PropTypes.string -}; diff --git a/app/components/Login.react.js b/app/components/Login.react.js index 64168d263..b814d5c6d 100644 --- a/app/components/Login.react.js +++ b/app/components/Login.react.js @@ -4,7 +4,6 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import { baseUrl, - dynamicRequireElectron, homeUrl, isOnPrem, plotlyUrl @@ -21,35 +20,12 @@ const CLOUD = 'cloud'; const ONPREM = 'onprem'; window.document.title = `${build.productName} v${version}`; -let usernameLogged = ''; - -// http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen -const PopupCenter = (url, title, w, h) => { - // Fixes dual-screen position - const dualScreenLeft = 'screenLeft' in window ? window.screenLeft : screen.left; - const dualScreenTop = 'screenTop' in window ? window.screenTop : screen.top; - - const width = window.innerWidth - ? window.innerWidth - : document.documentElement.clientWidth - ? document.documentElement.clientWidth - : screen.width; - const height = window.innerHeight - ? window.innerHeight - : document.documentElement.clientHeight - ? document.documentElement.clientHeight - : screen.height; - - const left = ((width / 2) - (w / 2)) + dualScreenLeft; - const top = ((height / 2) - (h / 2)) + dualScreenTop; - const popupWindow = window.open(url, title, `scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}`); - return popupWindow; -}; class Login extends Component { constructor(props) { super(props); this.state = { + clientId: cookie.load('db-connector-oauth2-client-id'), domain: (isOnPrem() ? plotlyUrl() : 'https://plot.ly'), statusMessage: '', serverType: CLOUD, @@ -58,6 +34,11 @@ class Login extends Component { this.buildOauthUrl = this.buildOauthUrl.bind(this); this.oauthPopUp = this.oauthPopUp.bind(this); this.logIn = this.logIn.bind(this); + + // the web app: + // - sets this property to the popup window opened for authorization + // - and triggers a reload when `this.popup.closed` becomes true + this.popup = null; } componentDidMount() { @@ -69,8 +50,7 @@ class Login extends Component { * to check for authentication. */ setInterval(() => { - usernameLogged = cookie.load('db-connector-user'); - if (usernameLogged) { + if (this.popup && this.popup.closed) { if (serverType === ONPREM) { this.setState({ status: 'authorized', @@ -101,7 +81,6 @@ class Login extends Component { } saveDomainToSettings() { - const {domain} = this.state; let PLOTLY_API_SSL_ENABLED = true; let PLOTLY_API_DOMAIN = ''; @@ -125,35 +104,41 @@ class Login extends Component { } buildOauthUrl() { - const {domain} = this.state; - /* global PLOTLY_ENV */ - const oauthClientId = PLOTLY_ENV.OAUTH2_CLIENT_ID; - + const {clientId, domain} = this.state; const redirect_uri = baseUrlWrapped; return ( `${domain}/o/authorize/?response_type=token&` + - `client_id=${oauthClientId}&` + + `client_id=${clientId}&` + `redirect_uri=${redirect_uri}/oauth2/callback` ); } oauthPopUp() { - try { - const electron = dynamicRequireElectron(); - const oauthUrl = this.buildOauthUrl(); - electron.shell.openExternal(oauthUrl); - } catch (e) { - // eslint-disable-next-line no-console - console.log('Unable to openExternal, opening a popupWindow instead:'); - // eslint-disable-next-line no-console - console.log(e); - const popupWindow = PopupCenter( - this.buildOauthUrl(), 'Authorization', '500', '500' - ); - if (window.focus) { - popupWindow.focus(); - } - } + // http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen + const url = this.buildOauthUrl(); + const title = 'Authorization'; + const w = '600'; + const h = '600'; + + // Fixes dual-screen position + const dualScreenLeft = 'screenLeft' in window ? window.screenLeft : screen.left; + const dualScreenTop = 'screenTop' in window ? window.screenTop : screen.top; + + const width = window.innerWidth + ? window.innerWidth + : document.documentElement.clientWidth + ? document.documentElement.clientWidth + : screen.width; + const height = window.innerHeight + ? window.innerHeight + : document.documentElement.clientHeight + ? document.documentElement.clientHeight + : screen.height; + + const left = ((width / 2) - (w / 2)) + dualScreenLeft; + const top = ((height / 2) - (h / 2)) + dualScreenTop; + + this.popup = window.open(url, title, `scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}`); } logIn () { diff --git a/app/components/Settings/Preview/Preview.react.js b/app/components/Settings/Preview/Preview.react.js index f4155a519..7944220a7 100644 --- a/app/components/Settings/Preview/Preview.react.js +++ b/app/components/Settings/Preview/Preview.react.js @@ -171,7 +171,6 @@ class Preview extends Component { } fetchDatacache(payload, type) { - const {username} = this.props; const payloadJSON = JSON.stringify({ payload: payload, type: type, requestor: username}); @@ -187,7 +186,8 @@ class Preview extends Component { }).then(resp => { return resp.json(); }).then(data => { - const plotlyLinks = this.state.plotlyLinks; + const {plotlyLinks} = this.state; + let link; if (!('error' in data)) { @@ -456,6 +456,15 @@ class Preview extends Component {
+ } -
{link.url}
} - {link.type === 'csv' && -
-
💾 Your CSV has been saved ⬇️
- {link.url} -
- } {link.type === 'plot' &&
📈 Link to your chart on Chart Studio ⬇️
diff --git a/app/components/Settings/Settings.react.js b/app/components/Settings/Settings.react.js index b87c995e5..11d272546 100644 --- a/app/components/Settings/Settings.react.js +++ b/app/components/Settings/Settings.react.js @@ -60,10 +60,14 @@ class Settings extends Component { this.intervals.checkHTTPSEndpointInterval = setInterval(() => { if (this.state.urls.https) { - fetch(this.state.urls.https).then(() => { + fetch(this.state.urls.https) + .then(() => { this.setState({httpsServerIsOK: true}); clearInterval(this.intervals.checkHTTPSEndpointInterval); clearInterval(this.intervals.timeElapsedInterval); + }) + .catch(() => { + // silence fetch errors }); } }, 5 * 1000); @@ -247,6 +251,7 @@ class Settings extends Component { apacheDrillStorageRequest, apacheDrillS3KeysRequest, connections, + connectRequest, deleteTab, elasticsearchMappingsRequest, getSqlSchema, @@ -283,6 +288,11 @@ class Settings extends Component { const dialect = connections[selectedTab].dialect; + const queryPanelDisabled = ( + connectRequest.status !== 200 || + (!selectedTable && contains(dialect, SQL_DIALECTS_USING_EDITOR)) + ); + return (
Connection - {this.props.connectRequest.status === 200 && selectedTable ? ( - Query - ) : ( - Query - )} + Query {isOnPrem() || @@ -335,7 +341,11 @@ class Settings extends Component { - {this.props.connectRequest.status === 200 && selectedTable ? ( + {queryPanelDisabled ? ( +
+

Please connect to a data store in the Connection tab first.

+
+ ) : ( - ) : ( -
-

Please connect to a data store in the Connection tab first.

-
)}
diff --git a/app/components/Settings/UserConnections/UserConnections.react.js b/app/components/Settings/UserConnections/UserConnections.react.js index 04058fdb1..13b1cae5e 100644 --- a/app/components/Settings/UserConnections/UserConnections.react.js +++ b/app/components/Settings/UserConnections/UserConnections.react.js @@ -5,21 +5,12 @@ import Filedrop from './filedrop.jsx'; import {contains} from 'ramda'; import {CONNECTION_CONFIG, SAMPLE_DBS} from '../../../constants/constants'; -import {dynamicRequireElectron} from '../../../utils/utils'; - -let dialog; -try { - dialog = dynamicRequireElectron().remote.dialog; -} catch (e) { - dialog = null; -} +import {showOpenDialog} from '../../../utils/utils'; /* * Displays and alters user inputs for `configuration` * username, password, and local port number. */ - - export default class UserConnections extends Component { constructor(props) { super(props); @@ -47,7 +38,7 @@ export default class UserConnections extends Component { getStorageOnClick(setting) { // sqlite requires a path return () => { - dialog.showOpenDialog({ + showOpenDialog({ properties: ['openFile'], filters: [{ name: 'databases', diff --git a/app/utils/utils.js b/app/utils/utils.js index 5b030271e..11b49e1f1 100644 --- a/app/utils/utils.js +++ b/app/utils/utils.js @@ -1,7 +1,20 @@ import {contains} from 'ramda'; -export function dynamicRequireElectron() { - return window.require('electron'); +export function isElectron() { + // the electron's main process preloads a script that sets up `window.$falcon` + return Boolean(window.$falcon); +} + +export function setUsernameListener(callback) { + if (isElectron()) { + window.$falcon.setUsernameListener(callback); + } +} + +export function showOpenDialog(options, callback) { + if (isElectron()) { + return window.$falcon.showOpenDialog(options, callback); + } } export function baseUrl() { @@ -40,10 +53,6 @@ export function plotlyUrl() { return 'https://plot.ly'; } -export function isElectron() { - return window.process && window.process.type === 'renderer'; -} - export function homeUrl() { return (isOnPrem()) ? '/external-data-connector' : diff --git a/backend/constants.js b/backend/constants.js index 004b8d4a4..35d19a245 100644 --- a/backend/constants.js +++ b/backend/constants.js @@ -1,5 +1,11 @@ import {getSetting} from './settings'; +export function getUnsecuredCookieOptions() { + return { + path: getSetting('WEB_BASE_PATHNAME') + }; +} + export function getCookieOptions() { return { secure: getSetting('SSL_ENABLED'), diff --git a/backend/main.development.js b/backend/main.development.js index dbac65e34..90aa99ee2 100644 --- a/backend/main.development.js +++ b/backend/main.development.js @@ -1,8 +1,11 @@ -import {app, BrowserWindow} from 'electron'; +import {app, BrowserWindow, dialog, shell} from 'electron'; +import fs from 'fs'; +import path from 'path'; + import {contains} from 'ramda'; -import Logger from './logger'; -import {setupMenus} from './menus'; +import Logger from './logger.js'; +import {setupMenus} from './menus.js'; import Servers from './routes.js'; Logger.log('Starting application', 2); @@ -26,7 +29,10 @@ server.queryScheduler.loadQueries(); app.on('ready', () => { let mainWindow = new BrowserWindow({ - show: true, + webPreferences: { + nodeIntegration: false, + preload: path.resolve(path.join(__dirname, 'preload.js')) + }, width: 1024, height: 1024 }); @@ -62,7 +68,46 @@ app.on('ready', () => { // prevent navigation out of HTTP_URL // see https://electronjs.org/docs/api/web-contents#event-will-navigate mainWindow.webContents.on('will-navigate', (event, url) => { - if (!url.startsWith(HTTP_URL)) event.preventDefault(); + if (!url.startsWith(HTTP_URL)) { + Logger.log(`Preventing navigation to ${url}`, 2); + event.preventDefault(); + } + }); + + // prevent navigation out of HTTP_URL + // see https://electronjs.org/docs/api/web-contents#event-new-window + mainWindow.webContents.on('new-window', (event, url) => { + event.preventDefault(); + + if (!url.startsWith('data:')) { + Logger.log(`Opening ${url}`, 2); + shell.openExternal(url); + return; + } + + // only download data URLs that contain a CSV file + if (!url.startsWith('data:text/csv;base64,')) { + Logger.log(`Preventing request ${url}`, 2); + return; + } + + dialog.showSaveDialog({ + title: 'Save CSV File', + filters: [{ + name: 'CSV files', + extensions: ['csv'] + }] + }, (filename) => { + if (!filename) { + // silence error thrown when user cancels the save dialog + return; + } + + fs.writeFileSync( + filename, + Buffer.from(url.slice(1 + url.indexOf(',')), 'base64').toString() + ); + }); }); mainWindow.on('closed', () => { diff --git a/backend/plugins/authorization.js b/backend/plugins/authorization.js index 1d6fcc82c..69eaf1761 100644 --- a/backend/plugins/authorization.js +++ b/backend/plugins/authorization.js @@ -1,9 +1,13 @@ +import fetch from 'node-fetch'; import {contains} from 'ramda'; + +import {generateAndSaveAccessToken} from '../utils/authUtils.js'; +import { + getAccessTokenCookieOptions, + getUnsecuredCookieOptions +} from '../constants.js'; +import Logger from '../logger.js'; import {getSetting} from '../settings.js'; -import {getAccessTokenCookieOptions} from '../constants.js'; -import {generateAndSaveAccessToken} from '../utils/authUtils'; -import Logger from '../logger'; -import fetch from 'node-fetch'; /* * backend does not see `/external-data-connector` in on-prem (because it is proxied). @@ -29,7 +33,14 @@ export function PlotlyOAuth(electron) { return function isAuthorized(req, res, next) { const path = req.href(); - if (!getSetting('AUTH_ENABLED')) { + const clientId = process.env.PLOTLY_CONNECTOR_OAUTH2_CLIENT_ID || + 'isFcew9naom2f1khSiMeAtzuOvHXHuLwhPsM7oPt'; + res.setCookie('db-connector-oauth2-client-id', clientId, getUnsecuredCookieOptions()); + + const authEnabled = getSetting('AUTH_ENABLED'); + res.setCookie('db-connector-auth-enabled', authEnabled, getUnsecuredCookieOptions()); + + if (!authEnabled) { return next(); } diff --git a/backend/preload.js b/backend/preload.js new file mode 100644 index 000000000..2442de269 --- /dev/null +++ b/backend/preload.js @@ -0,0 +1,54 @@ +// this file is preloaded by electron in the renderer process +// (it has access to electron's API, even when nodeIntegration has been disabled) + +const {ipcRenderer, remote} = require('electron'); + +const propertyOptions = { + configurable: false, + enumerable: false, + writable: false +}; + +process.once('loaded', () => { + const $falcon = {}; + + Object.defineProperty(window, '$falcon', { + value: $falcon, + ...propertyOptions + }); + + Object.defineProperty($falcon, 'showOpenDialog', { + value: remote.dialog.showOpenDialog, + ...propertyOptions + }); + + let onUsername; + let receivedUsernameBeforeCallback; + let username; + function sendUsername(event, user) { + try { + if (onUsername) { + receivedUsernameBeforeCallback = false; + onUsername(event, user); + } else { + receivedUsernameBeforeCallback = true; + username = user; + } + } catch (error) { + console.error(error); // eslint-disable-line + } + } + + ipcRenderer.on('username', sendUsername); + + Object.defineProperty($falcon, 'setUsernameListener', { + value: (value) => { + onUsername = value; + + if (receivedUsernameBeforeCallback) { + sendUsername(null, username); + } + }, + ...propertyOptions + }); +}); diff --git a/backend/routes.js b/backend/routes.js index 296f3cff6..935a6d4c3 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -1,13 +1,24 @@ +const fetch = require('node-fetch'); +import {contains, keys, isEmpty, merge, pluck} from 'ramda'; const restify = require('restify'); const CookieParser = require('restify-cookies'); -const fetch = require('node-fetch'); import * as fs from 'fs'; import path from 'path'; -import * as Datastores from './persistent/datastores/Datastores.js'; import {PlotlyOAuth} from './plugins/authorization.js'; -import {getQueries, getQuery, deleteQuery} from './persistent/Queries'; +import {generateAndSaveAccessToken} from './utils/authUtils.js'; +import { + getAccessTokenCookieOptions, + getCookieOptions, + getUnsecuredCookieOptions +} from './constants.js'; +import {getCerts, timeoutFetchAndSaveCerts, setRenewalJob} from './certificates.js'; +import * as Datastores from './persistent/datastores/Datastores.js'; +import init from './init.js'; +import Logger from './logger.js'; +import {checkWritePermissions, newDatacache} from './persistent/plotly-api.js'; +import {getQueries, getQuery, deleteQuery} from './persistent/Queries.js'; import { deleteConnectionById, editConnectionById, @@ -21,13 +32,7 @@ import { } from './persistent/Connections.js'; import QueryScheduler from './persistent/QueryScheduler.js'; import {getSetting, saveSetting} from './settings.js'; -import {generateAndSaveAccessToken} from './utils/authUtils'; -import {getAccessTokenCookieOptions, getCookieOptions} from './constants'; -import {checkWritePermissions, newDatacache} from './persistent/plotly-api.js'; -import {contains, keys, isEmpty, merge, pluck} from 'ramda'; -import {getCerts, timeoutFetchAndSaveCerts, setRenewalJob} from './certificates'; -import Logger from './logger'; -import init from './init.js'; + export default class Servers { /* @@ -309,7 +314,7 @@ export default class Servers { res.setCookie('db-connector-auth-token', db_connector_access_token, getAccessTokenCookieOptions()); - res.setCookie('db-connector-user', username, getCookieOptions()); + res.setCookie('db-connector-user', username, getUnsecuredCookieOptions()); const existingUsers = getSetting('USERS'); const existingUsernames = pluck('username', existingUsers); @@ -613,13 +618,15 @@ export default class Servers { } else { const rand = Math.round(Math.random() * 1000).toString(); - const downloadPath = path.join(getSetting('STORAGE_PATH'), `data_export_${rand}.csv`); + const downloadPath = path.resolve( + path.join(getSetting('STORAGE_PATH'), `data_export_${rand}.csv`) + ); fs.writeFile(downloadPath, payload, (err) => { if (err) { res.json({type: 'error', message: err}); return next(); } - res.json({type: 'csv', url: 'file:///'.concat(downloadPath)}); + res.json({type: 'csv', url: 'file://'.concat(downloadPath)}); return next(); }); } diff --git a/package.json b/package.json index b3d18c06b..909e0972e 100644 --- a/package.json +++ b/package.json @@ -236,6 +236,7 @@ "dependencies": { "alasql": "^0.4.5", "csv-parse": "^2.0.0", + "dtrace-provider": "^0.8.6", "font-awesome": "^4.6.1", "ibm_db": "^2.3.0", "mysql": "^2.15.0", diff --git a/webpack.config.base.js b/webpack.config.base.js index f42560b58..5e19e5054 100644 --- a/webpack.config.base.js +++ b/webpack.config.base.js @@ -32,6 +32,7 @@ export default { { 'csv-parse': 'commonjs csv-parse', 'data-urls': 'commonjs data-urls', + 'dtrace-provider': 'commonjs dtrace-provider', 'font-awesome': 'font-awesome', 'ibm_db': 'commonjs ibm_db', 'mysql': 'mysql', diff --git a/webpack.config.web.js b/webpack.config.web.js index 6b2ddafb9..5f63e8523 100644 --- a/webpack.config.web.js +++ b/webpack.config.web.js @@ -2,13 +2,6 @@ import webpack from 'webpack'; import baseConfig from './webpack.config.base'; import path from 'path'; -const AUTH_ENABLED = process.env.PLOTLY_CONNECTOR_AUTH_ENABLED ? - JSON.parse(process.env.PLOTLY_CONNECTOR_AUTH_ENABLED) : true; - -const OAUTH2_CLIENT_ID = process.env.PLOTLY_CONNECTOR_OAUTH2_CLIENT_ID ? - JSON.stringify(process.env.PLOTLY_CONNECTOR_OAUTH2_CLIENT_ID) : - JSON.stringify('isFcew9naom2f1khSiMeAtzuOvHXHuLwhPsM7oPt'); - const config = { ...baseConfig, @@ -31,16 +24,6 @@ const config = { new webpack.DefinePlugin({ __DEV__: false, 'process.env.NODE_ENV': JSON.stringify('production') - }), - - // This is used to pass environment variables to frontend - // detailed discussions on this ticket: - // https://github.com/plotly/streambed/issues/10436 - new webpack.DefinePlugin({ - 'PLOTLY_ENV': { - 'AUTH_ENABLED': AUTH_ENABLED, - 'OAUTH2_CLIENT_ID': OAUTH2_CLIENT_ID - } }) ], diff --git a/yarn.lock b/yarn.lock index 66b223449..9ab787c7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3096,6 +3096,12 @@ dtrace-provider@^0.8.2, dtrace-provider@~0.8: dependencies: nan "^2.3.3" +dtrace-provider@^0.8.6: + version "0.8.7" + resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.7.tgz#dc939b4d3e0620cfe0c1cd803d0d2d7ed04ffd04" + dependencies: + nan "^2.10.0" + dtype@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/dtype/-/dtype-1.0.0.tgz#ae34ffa282673715203582d61bbdd0aad3cba3e7" @@ -6751,6 +6757,10 @@ mysql@^2.15.0: safe-buffer "5.1.1" sqlstring "2.3.0" +nan@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" + nan@^2.3.0, nan@^2.3.3, nan@~2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46"