diff --git a/.eslintignore b/.eslintignore index 50d3f7109..47a26f579 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,33 +2,4 @@ dist docs node_modules -plugins/* public - -!plugins/talk-plugin-akismet -!plugins/talk-plugin-auth -!plugins/talk-plugin-author-menu -!plugins/talk-plugin-comment-content -!plugins/talk-plugin-deep-reply-count -!plugins/talk-plugin-facebook-auth -!plugins/talk-plugin-featured-comments -!plugins/talk-plugin-flag-details -!plugins/talk-plugin-ignore-user -!plugins/talk-plugin-like -!plugins/talk-plugin-love -!plugins/talk-plugin-member-since -!plugins/talk-plugin-mod -!plugins/talk-plugin-moderation-actions -!plugins/talk-plugin-offtopic -!plugins/talk-plugin-permalink -!plugins/talk-plugin-remember-sort -!plugins/talk-plugin-respect -!plugins/talk-plugin-sort-most-liked -!plugins/talk-plugin-sort-most-loved -!plugins/talk-plugin-sort-most-replied -!plugins/talk-plugin-sort-most-respected -!plugins/talk-plugin-sort-newest -!plugins/talk-plugin-sort-oldest -!plugins/talk-plugin-subscriber -!plugins/talk-plugin-toxic-comments -!plugins/talk-plugin-viewing-options \ No newline at end of file diff --git a/.gitignore b/.gitignore index a27a08f15..11b6af551 100644 --- a/.gitignore +++ b/.gitignore @@ -23,35 +23,39 @@ browserstack.err plugins.json plugins/* + !plugins/talk-plugin-akismet -!plugins/talk-plugin-facebook-auth !plugins/talk-plugin-auth -!plugins/talk-plugin-respect -!plugins/talk-plugin-offtopic -!plugins/talk-plugin-like -!plugins/talk-plugin-mod -!plugins/talk-plugin-love -!plugins/talk-plugin-viewing-options +!plugins/talk-plugin-author-menu !plugins/talk-plugin-comment-content -!plugins/talk-plugin-permalink +!plugins/talk-plugin-deep-reply-count +!plugins/talk-plugin-facebook-auth !plugins/talk-plugin-featured-comments -!plugins/talk-plugin-toxic-comments -!plugins/talk-plugin-sort-newest -!plugins/talk-plugin-sort-oldest -!plugins/talk-plugin-sort-most-replied +!plugins/talk-plugin-flag-details +!plugins/talk-plugin-google-auth +!plugins/talk-plugin-ignore-user +!plugins/talk-plugin-like +!plugins/talk-plugin-love +!plugins/talk-plugin-member-since +!plugins/talk-plugin-mod +!plugins/talk-plugin-moderation-actions +!plugins/talk-plugin-notifications +!plugins/talk-plugin-notifications-category-reply +!plugins/talk-plugin-offtopic +!plugins/talk-plugin-permalink +!plugins/talk-plugin-profile-settings +!plugins/talk-plugin-remember-sort +!plugins/talk-plugin-respect +!plugins/talk-plugin-slack-notifications !plugins/talk-plugin-sort-most-liked !plugins/talk-plugin-sort-most-loved +!plugins/talk-plugin-sort-most-replied !plugins/talk-plugin-sort-most-respected -!plugins/talk-plugin-author-menu -!plugins/talk-plugin-member-since -!plugins/talk-plugin-ignore-user -!plugins/talk-plugin-moderation-actions -!plugins/talk-plugin-toxic-comments -!plugins/talk-plugin-remember-sort -!plugins/talk-plugin-deep-reply-count +!plugins/talk-plugin-sort-newest +!plugins/talk-plugin-sort-oldest !plugins/talk-plugin-subscriber -!plugins/talk-plugin-flag-details -!plugins/talk-plugin-slack-notifications +!plugins/talk-plugin-toxic-comments +!plugins/talk-plugin-viewing-options **/node_modules/* yarn-error.log diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 000000000..9c9500651 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,13 @@ +{ + "linters": { + "*.js": [ + "git-exec-and-restage eslint --fix --" + ], + "bin/cli*": [ + "git-exec-and-restage eslint --fix --" + ], + "*.yml": [ + "yamllint" + ] + } +} diff --git a/.nsprc b/.nsprc index 583560bdd..da6cb9865 100644 --- a/.nsprc +++ b/.nsprc @@ -1,6 +1,7 @@ { "exceptions": [ "https://nodesecurity.io/advisories/531", - "https://nodesecurity.io/advisories/532" + "https://nodesecurity.io/advisories/532", + "https://nodesecurity.io/advisories/566" ] } diff --git a/Dockerfile b/Dockerfile index 95aa74d8a..4d0fea440 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ ENV NODE_ENV production # Install app dependencies and build static assets. RUN yarn global add node-gyp && \ yarn install --frozen-lockfile && \ - cli plugins reconcile && \ yarn build && \ yarn cache clean diff --git a/app.js b/app.js index e962e41ff..c2bf9648d 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,6 @@ const express = require('express'); const morgan = require('morgan'); const path = require('path'); -const uuid = require('uuid'); const merge = require('lodash/merge'); const helmet = require('helmet'); const plugins = require('./services/plugins'); @@ -13,13 +12,6 @@ const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config'); const app = express(); -// Request Identity Middleware -app.use((req, res, next) => { - req.id = uuid.v4(); - - next(); -}); - //============================================================================== // PLUGIN PRE APPLICATION MIDDLEWARE //============================================================================== diff --git a/bin/cli-jobs b/bin/cli-jobs index 672a5a70d..a9599213d 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -6,8 +6,7 @@ const util = require('./util'); const program = require('commander'); -const scraper = require('../services/scraper'); -const mailer = require('../services/mailer'); +const jobs = require('../jobs'); const mongoose = require('../services/mongoose'); const kue = require('../services/kue'); @@ -21,11 +20,8 @@ function processJobs() { // started. util.onshutdown([() => kue.Task.shutdown()]); - // Start the scraper processor. - scraper.process(); - - // Start the mail processor. - mailer.process(); + // Start the jobs processor. + jobs.process(); } /** diff --git a/bin/cli-plugins b/bin/cli-plugins index 883682ede..ee331bff2 100755 --- a/bin/cli-plugins +++ b/bin/cli-plugins @@ -135,18 +135,11 @@ function reconcilePackages({ quiet = false, upgradeRemote = false }) { return { local, fetchable, upgradable }; } -async function reconcileRemotePlugins({ skipLocal, dryRun, upgradeRemote }) { - console.log( - `\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get( - 'mag' - )} Reconciling plugins...`.yellow - ); +async function reconcileRemotePlugins({ dryRun, upgradeRemote }) { + console.log(`\n['1/2'] ${emoji.get('mag')} Reconciling plugins...`.yellow); const { fetchable, upgradable } = reconcilePackages({ upgradeRemote }); - console.log( - `[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n` - .yellow - ); + console.log(`['2/2'] ${emoji.get('truck')} Fetching plugins...\n`.yellow); if (fetchable.length > 0) { console.log( @@ -206,98 +199,41 @@ async function reconcileRemotePlugins({ skipLocal, dryRun, upgradeRemote }) { return { upgradable, fetchable }; } -async function reconcileLocalPlugins({ skipRemote, dryRun }) { - console.log( - `\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get( - 'pick' - )} Installing local plugin dependencies...\n`.yellow - ); - const { local } = reconcilePackages({ quiet: true }); - - for (let i in local) { - let { name } = local[i]; - - if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) { - continue; - } - - let wd = path.join(dir, 'plugins', name); - - console.log(`$ cd ${wd.cyan} && yarn`); - - if (!dryRun) { - let args = []; - - let output = spawn.sync('yarn', args, { - stdio: ['ignore', 'pipe', 'inherit'], - cwd: wd, - }); - - if (output.status) { - throw new Error( - 'Could not install local plugin dependencies, errors occurred during install' - ); - } - - console.log(output.stdout.toString()); - } - } -} - // This traverses the local plugins and installs any dependencies listed there, // this only is really needed for plugins that are installed via docker because // core plugins will have their dependencies already included in core. -async function reconcilePluginDeps({ - skipLocal, - skipRemote, - dryRun, - upgradeRemote, -}) { +async function reconcilePluginDeps({ dryRun, upgradeRemote }) { try { let startTime = new Date(); - // We don't need to do anything if we skip everything.... - if (skipLocal && skipRemote) { - return; - } - - // Traverse local plugins and install dependencies if enabled. - if (!skipLocal) { - await reconcileLocalPlugins({ skipRemote, dryRun }); - } - // Locate any external plugins and install them. - if (!skipRemote) { - const results = await reconcileRemotePlugins({ - skipLocal, - skipRemote, - dryRun, - upgradeRemote, - }); + const results = await reconcileRemotePlugins({ + dryRun, + upgradeRemote, + }); - let status; - if (dryRun) { - status = '[dry-run] success'.green; - } else { - status = 'success'.green; - } - - let message; - if (results.upgradable.length === 0 && results.fetchable.length === 0) { - message = 'Already up-to-date.'; - } else if (results.upgradable.length === 0) { - message = `Fetched ${results.fetchable.length} new plugins.`; - } else if (results.fetchable.length === 0) { - message = `Upgraded ${results.upgradable.length} new plugins.`; - } else { - message = `Fetched ${results.fetchable.length} new plugins, upgraded ${ - results.upgradable.length - } plugins.`; - } - - console.log(`\n${status} ${message}`); + let status; + if (dryRun) { + status = '[dry-run] success'.green; + } else { + status = 'success'.green; } + let message; + if (results.upgradable.length === 0 && results.fetchable.length === 0) { + message = 'Already up-to-date.'; + } else if (results.upgradable.length === 0) { + message = `Fetched ${results.fetchable.length} new plugins.`; + } else if (results.fetchable.length === 0) { + message = `Upgraded ${results.upgradable.length} new plugins.`; + } else { + message = `Fetched ${results.fetchable.length} new plugins, upgraded ${ + results.upgradable.length + } plugins.`; + } + + console.log(`\n${status} ${message}`); + let endTime = new Date(); let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed( @@ -440,16 +376,12 @@ program program .command('reconcile') - .description( - 'reconciles local plugin dependencies and downloads external plugins' - ) + .description('reconciles dependencies by downloading external plugins') .option('-u, --upgrade-remote', 'upgrades remote dependencies') .option( '-d, --dry-run', 'does not actually change anything on the filesystem acts only as a simulation' ) - .option('--skip-local', 'skips the local dependancy reconciliation') - .option('--skip-remote', 'skips the remote plugin reconciliation') .action(reconcilePluginDeps); program.parse(process.argv); diff --git a/client/coral-admin/src/components/AccountHistory.css b/client/coral-admin/src/components/AccountHistory.css index 09b324f08..fbd1027d1 100644 --- a/client/coral-admin/src/components/AccountHistory.css +++ b/client/coral-admin/src/components/AccountHistory.css @@ -15,6 +15,10 @@ } } +.username { + word-break: break-all; +} + .headerRowItem { color: #595959; font-weight: bold; diff --git a/client/coral-admin/src/components/AccountHistory.js b/client/coral-admin/src/components/AccountHistory.js index 52f3af92d..a16b334ca 100644 --- a/client/coral-admin/src/components/AccountHistory.js +++ b/client/coral-admin/src/components/AccountHistory.js @@ -5,7 +5,10 @@ import styles from './AccountHistory.css'; import cn from 'classnames'; import flatten from 'lodash/flatten'; import orderBy from 'lodash/orderBy'; +import has from 'lodash/has'; import moment from 'moment'; +import t from 'coral-framework/services/i18n'; +import { Icon } from 'coral-ui'; const buildUserHistory = (userState = {}) => { return orderBy( @@ -29,31 +32,39 @@ const readableDuration = (startDate, endDate) => { const durAsHours = dur.asHours().toFixed(0); return durAsHours > 23 - ? `${durAsDays} ${durAsDays > 1 ? 'days' : 'day'}` - : `${durAsHours} ${durAsHours > 1 ? 'hours' : 'hour'}`; + ? durAsDays > 1 + ? t('suspenduser.days', durAsDays) + : t('suspenduser.day', durAsDays) + : durAsHours > 1 + ? t('suspenduser.hours', durAsHours) + : t('suspenduser.hour', durAsHours); }; const buildActionResponse = (typename, created_at, until, status) => { switch (typename) { case 'UsernameStatusHistory': - return `Username ${status}`; + return t('account_history.username_status', status); case 'BannedStatusHistory': - return status ? 'User banned' : 'Ban removed'; + return status + ? t('account_history.user_banned') + : t('account_history.ban_removed'); case 'SuspensionStatusHistory': return until - ? `Suspended, ${readableDuration(created_at, until)}` - : 'Suspension removed'; + ? t('account_history.suspended', readableDuration(created_at, until)) + : t('account_history.suspension_removed'); default: return '-'; } }; -const getModerationValue = (userId, assignedBy = {}) => { - if (assignedBy && userId !== assignedBy.id) { - return assignedBy.username; - } - return 'SYSTEM'; -}; +const getModerationValue = assignedBy => + has(assignedBy, 'username') ? ( + assignedBy.username + ) : ( + + {t('account_history.system')} + + ); class AccountHistory extends React.Component { render() { @@ -68,9 +79,15 @@ class AccountHistory extends React.Component { 'talk-admin-account-history-header-row' )} > -
Date
-
Action
-
Moderation
+
+ {t('account_history.date')} +
+
+ {t('account_history.action')} +
+
+ {t('account_history.taken_by')} +
{userHistory.map( ({ __typename, created_at, assigned_by, until, status }) => ( @@ -98,10 +115,11 @@ class AccountHistory extends React.Component {
- {getModerationValue(user.id, assigned_by)} + {getModerationValue(assigned_by)}
) diff --git a/client/coral-auth-callback/src/index.js b/client/coral-auth-callback/src/index.js index b7f3ed226..41a83dcd2 100644 --- a/client/coral-auth-callback/src/index.js +++ b/client/coral-auth-callback/src/index.js @@ -1,14 +1,34 @@ +import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth'; +import { getStaticConfiguration } from 'coral-framework/services/staticConfiguration'; +import { createPostMessage } from 'coral-framework/services/postMessage'; + document.addEventListener('DOMContentLoaded', () => { - // Get the auth element and parse it as JSON by decoding it. - const auth = document.getElementById('auth'); - const doc = document.implementation.createHTMLDocument(''); - doc.body.innerHTML = auth.innerText; + try { + const staticConfig = getStaticConfiguration(); + const { STATIC_ORIGIN: origin } = staticConfig; + const postMessage = createPostMessage(origin); - // Set the item in localStorage. - localStorage.setItem('auth', doc.body.textContent); + // Get the auth element and parse it as JSON by decoding it. + const auth = document.getElementById('auth'); + const doc = document.implementation.createHTMLDocument(''); + doc.body.innerHTML = auth.innerText; - // Close the window. - setTimeout(() => { - window.close(); - }, 50); + // Auth state is contained within the node. + const { err, data } = JSON.parse(doc.body.textContent); + if (err) { + // TODO: send back the error message. + console.error(err); + } else { + // The data will contain a user and a token. + const { user, token } = data; + + // Send the state back. + postMessage.post(HANDLE_SUCCESSFUL_LOGIN, { user, token }); + } + } finally { + // Always close the window. + setTimeout(() => { + window.close(); + }, 50); + } }); diff --git a/client/coral-embed-stream/src/actions/asset.js b/client/coral-embed-stream/src/actions/asset.js deleted file mode 100644 index 158e65594..000000000 --- a/client/coral-embed-stream/src/actions/asset.js +++ /dev/null @@ -1,75 +0,0 @@ -import * as actions from '../constants/asset'; -import { notify } from 'coral-framework/actions/notification'; - -import t from 'coral-framework/services/i18n'; - -export const fetchAssetRequest = () => ({ type: actions.FETCH_ASSET_REQUEST }); -export const fetchAssetSuccess = asset => ({ - type: actions.FETCH_ASSET_SUCCESS, - asset, -}); -export const fetchAssetFailure = error => ({ - type: actions.FETCH_ASSET_FAILURE, - error, -}); - -const updateAssetSettingsRequest = () => ({ - type: actions.UPDATE_ASSET_SETTINGS_REQUEST, -}); -const updateAssetSettingsSuccess = settings => ({ - type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, - settings, -}); -const updateAssetSettingsFailure = error => ({ - type: actions.UPDATE_ASSET_SETTINGS_FAILURE, - error, -}); - -export const updateConfiguration = newConfig => ( - dispatch, - getState, - { rest } -) => { - const assetId = getState().asset.id; - dispatch(updateAssetSettingsRequest()); - rest(`/assets/${assetId}/settings`, { method: 'PUT', body: newConfig }) - .then(() => { - dispatch(notify('success', t('framework.success_update_settings'))); - dispatch(updateAssetSettingsSuccess(newConfig)); - }) - .catch(error => { - console.error(error); - dispatch(updateAssetSettingsFailure(error)); - }); -}; - -export const updateOpenStream = closedBody => ( - dispatch, - getState, - { rest } -) => { - const assetId = getState().asset.id; - dispatch(fetchAssetRequest()); - rest(`/assets/${assetId}/status`, { method: 'PUT', body: closedBody }) - .then(() => { - dispatch(notify('success', t('framework.success_update_settings'))); - dispatch(fetchAssetSuccess(closedBody)); - }) - .catch(error => { - console.error(error); - dispatch(fetchAssetFailure(error)); - }); -}; - -const openStream = () => ({ type: actions.OPEN_COMMENTS }); -const closeStream = () => ({ type: actions.CLOSE_COMMENTS }); - -export const updateOpenStatus = status => dispatch => { - if (status === 'open') { - dispatch(openStream()); - dispatch(updateOpenStream({ closedAt: null })); - } else { - dispatch(closeStream()); - dispatch(updateOpenStream({ closedAt: new Date().getTime() })); - } -}; diff --git a/client/coral-embed-stream/src/actions/config.js b/client/coral-embed-stream/src/actions/config.js deleted file mode 100644 index e30dc24cf..000000000 --- a/client/coral-embed-stream/src/actions/config.js +++ /dev/null @@ -1,6 +0,0 @@ -import { ADD_EXTERNAL_CONFIG } from '../constants/config'; - -export const addExternalConfig = config => ({ - type: ADD_EXTERNAL_CONFIG, - config, -}); diff --git a/client/coral-embed-stream/src/actions/login.js b/client/coral-embed-stream/src/actions/login.js index c602bf7b6..fe1f76149 100644 --- a/client/coral-embed-stream/src/actions/login.js +++ b/client/coral-embed-stream/src/actions/login.js @@ -1,14 +1,10 @@ import * as actions from '../constants/login'; -import { checkLogin } from 'coral-framework/actions/auth'; export const showSignInDialog = () => ({ type: actions.SHOW_SIGNIN_DIALOG, }); -export const hideSignInDialog = () => dispatch => { - dispatch(checkLogin()); - dispatch({ type: actions.HIDE_SIGNIN_DIALOG }); -}; +export const hideSignInDialog = () => ({ type: actions.HIDE_SIGNIN_DIALOG }); export const focusSignInDialog = () => ({ type: actions.FOCUS_SIGNIN_DIALOG, diff --git a/client/coral-embed-stream/src/actions/profile.js b/client/coral-embed-stream/src/actions/profile.js new file mode 100644 index 000000000..4b0c8f2e2 --- /dev/null +++ b/client/coral-embed-stream/src/actions/profile.js @@ -0,0 +1,3 @@ +import * as actions from '../constants/profile'; + +export const setActiveTab = tab => ({ type: actions.SET_ACTIVE_TAB, tab }); diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index b7eb6d580..0e251e9a3 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -9,16 +9,12 @@ import AutomaticAssetClosure from '../containers/AutomaticAssetClosure'; import ExtendableTabPanel from '../containers/ExtendableTabPanel'; import { Tab, TabPane } from 'coral-ui'; -import ProfileContainer from '../tabs/profile/containers/ProfileContainer'; +import Profile from '../tabs/profile/containers/Profile'; import Popup from 'coral-framework/components/Popup'; import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty'; import cn from 'classnames'; export default class Embed extends React.Component { - changeTab = tab => { - this.props.setActiveTab(tab); - }; - getTabs() { const tabs = [ - + , bindActionCreators( { setActiveTab, - fetchAssetSuccess, notify, focusSignInDialog, blurSignInDialog, @@ -333,6 +323,7 @@ const mapDispatchToProps = dispatch => ); export default compose( + withPopupAuthHandler, connect(mapStateToProps, mapDispatchToProps), branch(props => !props.checkedInitialLogin, renderComponent(Spinner)), withEmbedQuery diff --git a/client/coral-embed-stream/src/reducers/asset.js b/client/coral-embed-stream/src/reducers/asset.js deleted file mode 100644 index b683eae30..000000000 --- a/client/coral-embed-stream/src/reducers/asset.js +++ /dev/null @@ -1,28 +0,0 @@ -import * as actions from '../constants/asset'; - -const initialState = { - closedAt: null, - settings: null, - title: null, - url: null, - features: {}, - status: 'open', - moderation: null, -}; - -export default function asset(state = initialState, action) { - switch (action.type) { - case actions.FETCH_ASSET_SUCCESS: - return { - ...state, - ...action.asset, - }; - case actions.UPDATE_ASSET_SETTINGS_SUCCESS: - return { - ...state, - settings: action.settings, - }; - default: - return state; - } -} diff --git a/client/coral-embed-stream/src/reducers/config.js b/client/coral-embed-stream/src/reducers/config.js deleted file mode 100644 index 09a89a743..000000000 --- a/client/coral-embed-stream/src/reducers/config.js +++ /dev/null @@ -1,15 +0,0 @@ -import { ADD_EXTERNAL_CONFIG } from '../constants/config'; - -const initialState = {}; - -export default function config(state = initialState, action) { - switch (action.type) { - case ADD_EXTERNAL_CONFIG: - return { - ...state, - ...action.config, - }; - default: - return state; - } -} diff --git a/client/coral-embed-stream/src/reducers/index.js b/client/coral-embed-stream/src/reducers/index.js index 039db7ec3..15056d414 100644 --- a/client/coral-embed-stream/src/reducers/index.js +++ b/client/coral-embed-stream/src/reducers/index.js @@ -1,13 +1,13 @@ import login from './login'; -import asset from './asset'; import embed from './embed'; import configure from './configure'; import stream from './stream'; +import profile from './profile'; export default { login, - asset, embed, configure, stream, + profile, }; diff --git a/client/coral-embed-stream/src/reducers/profile.js b/client/coral-embed-stream/src/reducers/profile.js new file mode 100644 index 000000000..1d550ef78 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/profile.js @@ -0,0 +1,19 @@ +import * as actions from '../constants/profile'; + +const initialState = { + activeTab: 'comments', + previousTab: '', +}; + +export default function stream(state = initialState, action) { + switch (action.type) { + case actions.SET_ACTIVE_TAB: + return { + ...state, + activeTab: action.tab, + previousTab: state.activeTab, + }; + default: + return state; + } +} diff --git a/client/coral-embed-stream/src/tabs/profile/components/Comment.js b/client/coral-embed-stream/src/tabs/profile/components/Comment.js index fec5863a2..69725a362 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/Comment.js +++ b/client/coral-embed-stream/src/tabs/profile/components/Comment.js @@ -11,8 +11,18 @@ import { getTotalReactionsCount } from 'coral-framework/utils'; import t from 'coral-framework/services/i18n'; class Comment extends React.Component { + goToStory = () => { + this.props.navigate(this.props.comment.asset.url); + }; + + goToConversation = () => { + this.props.navigate( + `${this.props.comment.asset.url}?commentId=${this.props.comment.id}` + ); + }; + render() { - const { comment, link, data, root } = this.props; + const { comment, data, root } = this.props; const reactionCount = getTotalReactionsCount(comment.action_summaries); const queryData = { root, comment, asset: comment.asset }; @@ -67,7 +77,7 @@ class Comment extends React.Component { {t('common.story')}:{' '} {comment.asset.title ? comment.asset.title : comment.asset.url} @@ -77,10 +87,7 @@ class Comment extends React.Component {
  • - + {t('view_conversation')} @@ -105,10 +112,10 @@ class Comment extends React.Component { } Comment.propTypes = { - comment: PropTypes.shape({ - id: PropTypes.string, - body: PropTypes.string, - }).isRequired, + comment: PropTypes.object.isRequired, + navigate: PropTypes.func.isRequired, + data: PropTypes.object.isRequired, + root: PropTypes.object.isRequired, }; export default Comment; diff --git a/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js b/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js index a17a347c5..39f3bfd04 100644 --- a/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js +++ b/client/coral-embed-stream/src/tabs/profile/components/CommentHistory.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Comment from './Comment'; +import Comment from '../containers/Comment'; import LoadMore from './LoadMore'; class CommentHistory extends React.Component { @@ -21,9 +21,9 @@ class CommentHistory extends React.Component { }; render() { - const { link, comments, data, root } = this.props; + const { navigate, comments, data, root } = this.props; return ( -
    +
    {comments.nodes.map((comment, i) => { return ( @@ -32,7 +32,7 @@ class CommentHistory extends React.Component { data={data} root={root} comment={comment} - link={link} + navigate={navigate} /> ); })} @@ -51,7 +51,7 @@ class CommentHistory extends React.Component { CommentHistory.propTypes = { comments: PropTypes.object.isRequired, loadMore: PropTypes.func, - link: PropTypes.func, + navigate: PropTypes.func, data: PropTypes.object, root: PropTypes.object, }; diff --git a/client/coral-embed-stream/src/tabs/profile/components/Profile.css b/client/coral-embed-stream/src/tabs/profile/components/Profile.css new file mode 100644 index 000000000..e9b74b7cb --- /dev/null +++ b/client/coral-embed-stream/src/tabs/profile/components/Profile.css @@ -0,0 +1,11 @@ +.userInfo { + margin-bottom: 20px; +} + +.email { + margin: 0; +} + +.username { + margin-bottom: 4px; +} diff --git a/client/coral-embed-stream/src/tabs/profile/components/Profile.js b/client/coral-embed-stream/src/tabs/profile/components/Profile.js new file mode 100644 index 000000000..3b707ebe7 --- /dev/null +++ b/client/coral-embed-stream/src/tabs/profile/components/Profile.js @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Slot from 'coral-framework/components/Slot'; +import CommentHistory from '../containers/CommentHistory'; +import ExtendableTabPanel from '../../../containers/ExtendableTabPanel'; +import { Tab, TabPane } from 'coral-ui'; +import styles from './Profile.css'; +import t from 'coral-framework/services/i18n'; + +const Profile = ({ + username, + emailAddress, + data, + root, + activeTab, + setActiveTab, +}) => ( +
    +
    +

    {username}

    + {emailAddress ?

    {emailAddress}

    : null} +
    + + + {t('framework.my_comments')} + , + ]} + tabPanes={[ + + + , + ]} + sub + /> +
    +); + +Profile.propTypes = { + username: PropTypes.string, + emailAddress: PropTypes.string, + data: PropTypes.object, + root: PropTypes.object, + activeTab: PropTypes.string.isRequired, + setActiveTab: PropTypes.func.isRequired, +}; + +export default Profile; diff --git a/client/coral-embed-stream/src/tabs/profile/containers/Comment.js b/client/coral-embed-stream/src/tabs/profile/containers/Comment.js new file mode 100644 index 000000000..b8fb7fad8 --- /dev/null +++ b/client/coral-embed-stream/src/tabs/profile/containers/Comment.js @@ -0,0 +1,32 @@ +import { gql, compose } from 'react-apollo'; +import Comment from '../components/Comment'; +import { withFragments } from 'coral-framework/hocs'; +import { getSlotFragmentSpreads } from 'coral-framework/utils'; + +const slots = ['commentContent', 'historyCommentTimestamp']; + +const withCommentFragments = withFragments({ + comment: gql` + fragment TalkEmbedStream_ProfileComment_comment on Comment { + id + body + replyCount + action_summaries { + count + __typename + } + asset { + id + title + url + ${getSlotFragmentSpreads(slots, 'asset')} + } + created_at + ${getSlotFragmentSpreads(slots, 'comment')} + } + `, +}); + +const enhance = compose(withCommentFragments); + +export default enhance(Comment); diff --git a/client/coral-embed-stream/src/tabs/profile/containers/CommentHistory.js b/client/coral-embed-stream/src/tabs/profile/containers/CommentHistory.js new file mode 100644 index 000000000..a02225939 --- /dev/null +++ b/client/coral-embed-stream/src/tabs/profile/containers/CommentHistory.js @@ -0,0 +1,103 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose, gql } from 'react-apollo'; +import CommentHistory from '../components/CommentHistory'; +import Comment from './Comment'; +import { withFragments } from 'coral-framework/hocs'; + +import { appendNewNodes } from 'plugin-api/beta/client/utils'; +import update from 'immutability-helper'; +import { getDefinitionName } from 'coral-framework/utils'; + +class CommentHistoryContainer extends Component { + navigate = url => { + this.context.pym.sendMessage('navigate', url); + }; + + loadMore = () => { + return this.props.data.fetchMore({ + query: LOAD_MORE_QUERY, + variables: { + limit: 5, + cursor: this.props.root.me.comments.endCursor, + }, + updateQuery: (previous, { fetchMoreResult: { me: { comments } } }) => { + const updated = update(previous, { + me: { + comments: { + nodes: { + $apply: nodes => appendNewNodes(nodes, comments.nodes), + }, + hasNextPage: { $set: comments.hasNextPage }, + endCursor: { $set: comments.endCursor }, + }, + }, + }); + return updated; + }, + }); + }; + + render() { + return ( + + ); + } +} + +CommentHistoryContainer.contextTypes = { + pym: PropTypes.object, +}; + +CommentHistoryContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, +}; + +const LOAD_MORE_QUERY = gql` + query TalkEmbedStream_CommentHistory_LoadMoreComments($limit: Int, $cursor: Cursor) { + me { + comments(query: { limit: $limit, cursor: $cursor }) { + nodes { + ...${getDefinitionName(Comment.fragments.comment)} + } + endCursor + hasNextPage + } + } + } + ${Comment.fragments.comment} +`; + +const withCommentHistoryFragments = withFragments({ + root: gql` + fragment TalkEmbedStream_CommentHistory on RootQuery { + me { + comments(query: {limit: 10}) { + nodes { + ...${getDefinitionName(Comment.fragments.comment)} + } + endCursor + hasNextPage + } + } + } + ${Comment.fragments.comment} + `, +}); + +const mapStateToProps = state => ({ + currentUser: state.auth.user, +}); + +export default compose( + connect(mapStateToProps, null), + withCommentHistoryFragments +)(CommentHistoryContainer); diff --git a/client/coral-embed-stream/src/tabs/profile/containers/Profile.js b/client/coral-embed-stream/src/tabs/profile/containers/Profile.js new file mode 100644 index 000000000..82a0a49eb --- /dev/null +++ b/client/coral-embed-stream/src/tabs/profile/containers/Profile.js @@ -0,0 +1,104 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose, gql } from 'react-apollo'; +import { bindActionCreators } from 'redux'; +import { withQuery } from 'coral-framework/hocs'; +import NotLoggedIn from '../components/NotLoggedIn'; +import { Spinner } from 'coral-ui'; +import Profile from '../components/Profile'; +import CommentHistory from './CommentHistory'; +import { getDefinitionName } from 'coral-framework/utils'; + +import { showSignInDialog } from 'coral-embed-stream/src/actions/login'; +import { setActiveTab } from '../../../actions/profile'; +import { getSlotFragmentSpreads } from 'coral-framework/utils'; + +class ProfileContainer extends Component { + componentWillReceiveProps(nextProps) { + if (!this.props.currentUser && nextProps.currentUser) { + // Refetch after login. + this.props.data.refetch(); + } + } + + render() { + const { currentUser, showSignInDialog, root, data } = this.props; + const { me } = this.props.root; + const loading = this.props.data.loading; + + if (this.props.data.error) { + return
    {this.props.data.error.message}
    ; + } + + if (!currentUser) { + return ; + } + + if (loading || !me) { + return ; + } + + const localProfile = currentUser.profiles.find(p => p.provider === 'local'); + const emailAddress = localProfile && localProfile.id; + + return ( + + ); + } +} + +ProfileContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + currentUser: PropTypes.object, + showSignInDialog: PropTypes.func, + activeTab: PropTypes.string.isRequired, + setActiveTab: PropTypes.func.isRequired, +}; + +const slots = [ + 'profileSections', + 'profileTabs', + 'profileTabsPrepend', + 'profileTabPanes', +]; + +const withProfileQuery = withQuery( + gql` + query CoralEmbedStream_Profile { + me { + id + username + } + ...${getDefinitionName(CommentHistory.fragments.root)} + ${getSlotFragmentSpreads(slots, 'root')} + } + ${CommentHistory.fragments.root} +`, + { + options: { + fetchPolicy: 'network-only', + }, + } +); + +const mapStateToProps = state => ({ + currentUser: state.auth.user, + activeTab: state.profile.activeTab, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators({ showSignInDialog, setActiveTab }, dispatch); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withProfileQuery +)(ProfileContainer); diff --git a/client/coral-embed-stream/src/tabs/profile/containers/ProfileContainer.js b/client/coral-embed-stream/src/tabs/profile/containers/ProfileContainer.js deleted file mode 100644 index 57aa2b0fe..000000000 --- a/client/coral-embed-stream/src/tabs/profile/containers/ProfileContainer.js +++ /dev/null @@ -1,178 +0,0 @@ -import { connect } from 'react-redux'; -import { compose, gql } from 'react-apollo'; -import React, { Component } from 'react'; -import { bindActionCreators } from 'redux'; -import { withQuery } from 'coral-framework/hocs'; -import Slot from 'coral-framework/components/Slot'; -import cn from 'classnames'; -import { link } from 'coral-framework/services/pym'; -import NotLoggedIn from '../components/NotLoggedIn'; -import { Spinner } from 'coral-ui'; -import CommentHistory from '../components/CommentHistory'; - -import { showSignInDialog } from 'coral-embed-stream/src/actions/login'; -import { appendNewNodes } from 'plugin-api/beta/client/utils'; -import update from 'immutability-helper'; -import { getSlotFragmentSpreads } from 'coral-framework/utils'; - -import t from 'coral-framework/services/i18n'; - -class ProfileContainer extends Component { - componentWillReceiveProps(nextProps) { - if (!this.props.currentUser && nextProps.currentUser) { - // Refetch after login. - this.props.data.refetch(); - } - } - - loadMore = () => { - return this.props.data.fetchMore({ - query: LOAD_MORE_QUERY, - variables: { - limit: 5, - cursor: this.props.root.me.comments.endCursor, - }, - updateQuery: (previous, { fetchMoreResult: { me: { comments } } }) => { - const updated = update(previous, { - me: { - comments: { - nodes: { - $apply: nodes => appendNewNodes(nodes, comments.nodes), - }, - hasNextPage: { $set: comments.hasNextPage }, - endCursor: { $set: comments.endCursor }, - }, - }, - }); - return updated; - }, - }); - }; - - render() { - const { currentUser, showSignInDialog, root, data } = this.props; - const { me } = this.props.root; - const loading = this.props.data.loading; - - if (this.props.data.error) { - return
    {this.props.data.error.message}
    ; - } - - if (!currentUser) { - return ; - } - - if (loading || !me) { - return ; - } - - const localProfile = currentUser.profiles.find(p => p.provider === 'local'); - const emailAddress = localProfile && localProfile.id; - - return ( -
    -

    {me.username}

    - {emailAddress ?

    {emailAddress}

    : null} - -
    -

    {t('framework.my_comments')}

    -
    - {me.comments.nodes.length ? ( - - ) : ( -

    - {t('user_no_comment')} -

    - )} -
    -
    - ); - } -} - -const slots = [ - 'profileSections', - - // TODO: These Slots should be included in `talk-plugin-history` instead. - 'commentContent', - 'historyCommentTimestamp', -]; - -const CommentFragment = gql` - fragment TalkSettings_CommentConnectionFragment on CommentConnection { - nodes { - id - body - replyCount - action_summaries { - count - __typename - } - asset { - id - title - url - ${getSlotFragmentSpreads(slots, 'asset')} - } - created_at - ${getSlotFragmentSpreads(slots, 'comment')} - } - endCursor - hasNextPage - } -`; - -const LOAD_MORE_QUERY = gql` - query TalkSettings_LoadMoreComments($limit: Int, $cursor: Cursor) { - me { - comments(query: { limit: $limit, cursor: $cursor }) { - ...TalkSettings_CommentConnectionFragment - } - } - } - ${CommentFragment} -`; - -const withProfileQuery = withQuery( - gql` - query CoralEmbedStream_Profile { - me { - id - username - comments(query: {limit: 10}) { - ...TalkSettings_CommentConnectionFragment - } - } - ${getSlotFragmentSpreads(slots, 'root')} - } - ${CommentFragment} -`, - { - options: { - fetchPolicy: 'network-only', - }, - } -); - -const mapStateToProps = state => ({ - currentUser: state.auth.user, -}); - -const mapDispatchToProps = dispatch => - bindActionCreators({ showSignInDialog }, dispatch); - -export default compose( - connect(mapStateToProps, mapDispatchToProps), - withProfileQuery -)(ProfileContainer); diff --git a/client/coral-embed-stream/src/tabs/stream/components/Comment.js b/client/coral-embed-stream/src/tabs/stream/components/Comment.js index 521b28742..f0dcc1017 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Comment.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Comment.js @@ -604,6 +604,7 @@ export default class Comment extends React.Component { defaultComponent={CommentContent} {...slotProps} queryData={queryData} + slotSize={1} />
    )} diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 43bd3202e..2f456c4e2 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -16,7 +16,7 @@ function parseAssetURL() { // Try to get the url from the canonical tag on the page. return document.querySelector('link[rel="canonical"]').href; } catch (e) { - console.warn( + window.console.warn( 'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.' ); diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 0c4780548..17b1c38f9 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -63,13 +63,25 @@ export const setAuthToken = token => (dispatch, _, { localStorage }) => { export const handleSuccessfulLogin = (user, token) => ( dispatch, _, - { client, localStorage } + { client, localStorage, postMessage } ) => { + const { exp } = jwtDecode(token); + if (localStorage) { - localStorage.setItem('exp', jwtDecode(token).exp); + localStorage.setItem('exp', exp); localStorage.setItem('token', token); } + // Send the message via the messages service to the window.opener if it + // exists. + if (window.opener) { + postMessage.post( + actions.HANDLE_SUCCESSFUL_LOGIN, + { user, token }, + window.opener + ); + } + client.resetWebsocket(); dispatch({ diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index fe517eafb..952844ed0 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -7,6 +7,7 @@ import PropTypes from 'prop-types'; import isEqual from 'lodash/isEqual'; import get from 'lodash/get'; import { getShallowChanges } from 'coral-framework/utils'; +import omit from 'lodash/omit'; const emptyConfig = {}; @@ -32,27 +33,29 @@ class Slot extends React.Component { } getSlotProps(props = this.props) { - const { - fill: _a, - inline: _b, - className: _c, - reduxState: _d, - defaultComponent_: _e, - queryData: _f, - childFactory: _g, - component: _h, - ...rest - } = props; - return rest; + return omit(props, [ + 'fill', + 'inline', + 'className', + 'reduxState', + 'slotSize', + 'defaultComponent', + 'queryData', + 'childFactory', + 'component', + ]); } getChildren(props = this.props) { + const { slotSize = 0 } = props; const { plugins } = this.context; + return plugins.getSlotElements( props.fill, props.reduxState, this.getSlotProps(props), - props.queryData + props.queryData, + { slotSize } ); } @@ -110,6 +113,11 @@ Slot.propTypes = { reduxState: PropTypes.object, defaultComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + /** + * Specifies the number of children that can fill the slot. + */ + slotSize: PropTypes.number, + /** * You may specify the component to use as the root wrapper. * Defaults to 'div'. diff --git a/client/coral-framework/components/TalkProvider.js b/client/coral-framework/components/TalkProvider.js index 3164d8e9d..343d559ea 100644 --- a/client/coral-framework/components/TalkProvider.js +++ b/client/coral-framework/components/TalkProvider.js @@ -17,6 +17,7 @@ class TalkProvider extends React.Component { store: this.props.store, pymLocalStorage: this.props.pymLocalStorage, pymSessionStorage: this.props.pymSessionStorage, + postMessage: this.props.postMessage, }; } @@ -39,6 +40,7 @@ TalkProvider.childContextTypes = { pymSessionStorage: PropTypes.object, history: PropTypes.object, store: PropTypes.object, + postMessage: PropTypes.object, }; TalkProvider.propTypes = TalkProvider.childContextTypes; diff --git a/client/coral-framework/graphql/mutations.js b/client/coral-framework/graphql/mutations.js index f34a2cc88..1b3b4c6fc 100644 --- a/client/coral-framework/graphql/mutations.js +++ b/client/coral-framework/graphql/mutations.js @@ -698,7 +698,7 @@ export const withCloseAsset = withMutation( const fragmentId = `Asset_${id}`; const data = { __typename: 'Asset', - closedAt: new Date(), + closedAt: new Date().toISOString(), isClosed: true, }; proxy.writeFragment({ fragment, id: fragmentId, data }); diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index eb481de99..5e2e3dd40 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -10,6 +10,7 @@ export { default as withSignIn } from './withSignIn'; export { default as withSignUp } from './withSignUp'; export { default as withForgotPassword } from './withForgotPassword'; export { default as withSetUsername } from './withSetUsername'; +export { default as withPopupAuthHandler } from './withPopupAuthHandler'; export { default as withResendEmailConfirmation, } from './withResendEmailConfirmation'; diff --git a/client/coral-framework/hocs/withPopupAuthHandler.js b/client/coral-framework/hocs/withPopupAuthHandler.js new file mode 100644 index 000000000..ab2530e66 --- /dev/null +++ b/client/coral-framework/hocs/withPopupAuthHandler.js @@ -0,0 +1,54 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; +import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth'; +import { + handleSuccessfulLogin, + setAuthToken, +} from 'coral-framework/actions/auth'; + +/** + * WithPopupAuthHandler listens to successful logins over + * the `postMessage` service. + */ +export default hoistStatics(WrappedComponent => { + class WithPopupAuthHandler extends React.Component { + static contextTypes = { + store: PropTypes.object, + postMessage: PropTypes.object, + }; + + constructor(props, context) { + super(props, context); + context.postMessage.subscribe(this.handleAuth); + } + + componentWillUnmount() { + this.context.postMessage.unsubscribe(this.handleAuth); + } + + handleAuth = ({ name, data }) => { + if (name !== HANDLE_SUCCESSFUL_LOGIN) { + return; + } + + const { store } = this.context; + // data will contain the user and token. + const { user, token } = data; + + if (user && token) { + store.dispatch(handleSuccessfulLogin(user, token)); + } else if (token) { + store.dispatch(setAuthToken(token)); + } else { + console.error('Invalid auth data supplied', data); + } + }; + + render() { + return ; + } + } + + return WithPopupAuthHandler; +}); diff --git a/client/coral-framework/lib/action.js b/client/coral-framework/lib/action.js new file mode 100644 index 000000000..6f22d01e3 --- /dev/null +++ b/client/coral-framework/lib/action.js @@ -0,0 +1,21 @@ +export default class Action { + listeners = []; + + listen = cb => { + this.listeners.push(cb); + }; + + unlisten = cb => { + this.listeners = this.listeners.filter(i => i !== cb); + }; + + event = { listen: this.listen, unlisten: this.unlisten }; + + call(...args) { + this.listeners.forEach(cb => cb(...args)); + } + + asEvent() { + return this.event; + } +} diff --git a/client/coral-framework/reducers/config.js b/client/coral-framework/reducers/config.js index a3a0409eb..f7aebd9e2 100644 --- a/client/coral-framework/reducers/config.js +++ b/client/coral-framework/reducers/config.js @@ -1,9 +1,15 @@ import { MERGE_CONFIG } from '../constants/config'; +import { LOGOUT } from '../constants/auth'; const initialState = {}; export default function config(state = initialState, action) { switch (action.type) { + case LOGOUT: + return { + ...state, + auth_token: null, + }; case MERGE_CONFIG: return { ...state, diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js index a7dd6df98..691404369 100644 --- a/client/coral-framework/services/bootstrap.js +++ b/client/coral-framework/services/bootstrap.js @@ -14,6 +14,7 @@ import { createPluginsService } from './plugins'; import { createNotificationService } from './notification'; import { createGraphQLRegistry } from './graphqlRegistry'; import { createGraphQLService } from './graphql'; +import { createPostMessage } from './postMessage'; import globalFragments from 'coral-framework/graphql/fragments'; import { createStorage, @@ -118,7 +119,7 @@ export async function createContext({ }); const staticConfig = getStaticConfiguration(); - let { LIVE_URI: liveUri } = staticConfig; + let { LIVE_URI: liveUri, STATIC_ORIGIN: origin } = staticConfig; if (liveUri == null) { // The protocol must match the origin protocol, secure/insecure. const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; @@ -128,6 +129,8 @@ export async function createContext({ liveUri = `${protocol}://${location.host}${BASE_PATH}api/v1/live`; } + const postMessage = createPostMessage(origin); + const client = createClient({ uri: `${BASE_PATH}api/v1/graph/ql`, liveUri, @@ -158,6 +161,7 @@ export async function createContext({ pymLocalStorage, pymSessionStorage, inIframe, + postMessage, }; // Load framework fragments. diff --git a/client/coral-framework/services/plugins.js b/client/coral-framework/services/plugins.js index 6f99a2291..222cc1b42 100644 --- a/client/coral-framework/services/plugins.js +++ b/client/coral-framework/services/plugins.js @@ -97,8 +97,9 @@ class PluginsService { /** * Returns React Elements for given slot. */ - getSlotElements(slot, reduxState, props = {}, queryData = {}) { + getSlotElements(slot, reduxState, props = {}, queryData = {}, options = {}) { const pluginConfig = get(reduxState, 'config.plugin_config') || emptyConfig; + const { slotSize = 0 } = options; const isDisabled = component => { if ( @@ -129,11 +130,21 @@ class PluginsService { return false; }; - return flatten( + const slots = flatten( this.plugins .filter(o => o.module.slots && o.module.slots[slot]) .map(o => o.module.slots[slot]) - ) + ); + + if (slotSize > 0 && slots.length > slotSize) { + console.warn( + `Slot[${slot}] supports a maximum of ${slotSize} plugins providing slots, got ${ + slots.length + }, will only use the first ${slotSize}` + ); + } + + return (slotSize > 0 ? slots.slice(0, slotSize) : slots) .map((component, i) => ({ component, disabled: isDisabled(component), diff --git a/client/coral-framework/services/postMessage.js b/client/coral-framework/services/postMessage.js new file mode 100644 index 000000000..ef9311e64 --- /dev/null +++ b/client/coral-framework/services/postMessage.js @@ -0,0 +1,87 @@ +import set from 'lodash/set'; +import has from 'lodash/has'; +import get from 'lodash/get'; +import remove from 'lodash/remove'; + +/** + * createPostMessage returns a service that deals with cross + * window communication using the postMessage API. + * @param {string} origin + * @param {string} scope + * @return {Object} postMessage service + */ +export function createPostMessage(origin, scope = 'client') { + // Store a reference to each listener added. + const listeners = {}; + + // withOriginCheck wraps a given handler for a messages event with a check to + // see if the origin matches. + const withOriginCheck = handler => { + return event => { + if (get(event, 'origin') !== origin) { + return; + } + + if (get(event, 'data.scope') !== scope) { + return; + } + + // Pass the handler the event details. + handler(event); + }; + }; + + // withParseData will parse the data. + const withParseData = handler => { + return event => { + const data = get(event, 'data.data'); + const name = get(event, 'data.name'); + if (!data || !name) { + return; + } + + handler({ data, name, event }); + }; + }; + + return { + post(name, data, target = window.opener) { + if (!target) { + return; + } + + // Serialize the message to be sent via postMessage. + const msg = { name, data, scope }; + + // Send the message. + target.postMessage(msg, origin); + }, + subscribe: (handler, target = window) => { + // If this handler is already attached to the target, detach it. + if (has(listeners, [target, handler])) { + this.unsubscribeFromMessages(handler, target); + } + + // Wrap the listener with a origin check. + const listener = withOriginCheck(withParseData(handler)); + + // Save a reference to the compiled listener. + set(listeners, [target, handler], listener); + + // Attach the listener to the target. + target.addEventListener('message', listener); + }, + unsubscribe: (handler, target = window) => { + if (!has(listeners, [target, handler])) { + return; + } + + const listener = get(listeners, [target, handler]); + + // Remove the listener from the target. + target.removeEventListener('message', listener); + + remove(listeners, [target, handler]); + }, + }; +} diff --git a/client/coral-framework/services/pym.js b/client/coral-framework/services/pym.js index be81fc518..b4eb193ee 100644 --- a/client/coral-framework/services/pym.js +++ b/client/coral-framework/services/pym.js @@ -1,9 +1,5 @@ import Pym from 'pym.js'; const pym = new Pym.Child({ polling: 100 }); -export default pym; -export const link = url => e => { - e.preventDefault(); - pym.sendMessage('navigate', url); -}; +export default pym; diff --git a/client/coral-framework/services/storage.js b/client/coral-framework/services/storage.js index de96fdf4c..66e2c96cd 100644 --- a/client/coral-framework/services/storage.js +++ b/client/coral-framework/services/storage.js @@ -1,9 +1,10 @@ import uuid from 'uuid/v4'; function getStorage(type) { - let storage = window[type], - x = '__storage_test__'; + let storage; try { + storage = window[type]; + const x = '__storage_test__'; storage.setItem(x, x); storage.removeItem(x); } catch (e) { @@ -11,6 +12,8 @@ function getStorage(type) { e instanceof DOMException && // everything except Firefox (e.code === 22 || + // SecurityError related to having 3rd party cookies disabled. + e.code === 18 || // Firefox e.code === 1014 || @@ -19,14 +22,18 @@ function getStorage(type) { // everything except Firefox e.name === 'QuotaExceededError' || // Firefox - e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && - // acknowledge QuotaExceededError only if there's something already stored - storage.length !== 0; + e.name === 'NS_ERROR_DOM_QUOTA_REACHED'); if (!ignore) { - console.warning(e); // eslint-disable-line - return null; + console.warn(e); + } + + // When third party cookies are disabled, session storage is readable/ + // writable, but localStorage is not. Try to get the sessionStorage to use. + if (type !== 'sessionStorage') { + return getStorage('sessionStorage'); } } + return storage; } diff --git a/docs/_docs/02-02-advanced-configuration.md b/docs/_docs/02-02-advanced-configuration.md index 50c430252..4fc60a2cc 100644 --- a/docs/_docs/02-02-advanced-configuration.md +++ b/docs/_docs/02-02-advanced-configuration.md @@ -77,6 +77,30 @@ or by visiting the guide. This is only required while the `talk-plugin-facebook-auth` plugin is enabled. +## TALK_GOOGLE_CLIENT_ID + +The Google OAuth2 client ID for your Google login web app. You can learn more +about getting a Google Client ID at the +[Google API Console](https://console.developers.google.com/apis/){:target="_blank"}. + +You will need to enable the Google+ API in the dashboard and create credentials +for a new OAuth client ID web application. The authorized JavaScript origin +should be set to the Talk domain, and the authorized redirect URI should be set +to http:///api/v1/auth/google/callback. This is only required while +the `talk-plugin-google-auth` plugin is enabled. + +## TALK_GOOGLE_CLIENT_SECRET + +The Google OAuth2 client ID for your Google login web app. You can learn more +about getting a Google Client ID at the +[Google API Console](https://console.developers.google.com/apis/){:target="_blank"}. + +You will need to enable the Google+ API in the dashboard and create credentials +for a new OAuth client ID web application. The authorized JavaScript origin +should be set to the Talk domain, and the authorized redirect URI should be set +to http:///api/v1/auth/google/callback. This is only required while +the `talk-plugin-google-auth` plugin is enabled. + ## TALK_HELMET_CONFIGURATION A JSON string representing the configuration passed to the @@ -538,4 +562,4 @@ This is a **Build Variable** and must be consumed during build. If using the [Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`. -Specifies the initial replies to load for a comment. (Default `3`) \ No newline at end of file +Specifies the initial replies to load for a comment. (Default `3`) diff --git a/docs/_docs/04-03-additional-plugins.md b/docs/_docs/04-03-additional-plugins.md index 062904200..4f1654a4f 100644 --- a/docs/_docs/04-03-additional-plugins.md +++ b/docs/_docs/04-03-additional-plugins.md @@ -118,3 +118,34 @@ Configuration: - `TALK_AKISMET_API_KEY` (**required**) - The Akismet API key located on your account page. - `TALK_AKISMET_SITE` (**required**) - The URL where you are embedding the comment stream on to provide context to Akismet. If you're hosting talk on https://talk.mynews.org/, and your news site is https://mynews.org/, then you should set this parameter to `https://mynews.org/` + +## talk-plugin-notifications + +Source: [plugins/talk-plugin-notifications](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications){:target="_blank"} + +Enables the Notification system for sending out enabled email notifications to +users when they interact with Talk. By itself, this plugin will not send +anything. You need to enable one of the `talk-plugin-notifications-category-*` plugins. + +**Note that all `talk-plugin-notifications-*` plugins must be registered +*before* this plugin in order to work. For example:** + +```js +{ + "server": [ + // ... + "talk-plugin-notifications-category-reply", + "talk-plugin-notifications", + // ... + ] +} +``` +{:.no-copy} + +### talk-plugin-notifications-category-reply +{:.param} + +Source: [plugins/talk-plugin-notifications-category-reply](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-reply){:target="_blank"} + +Replies made to each user will trigger an email to be sent with the notification +details if enabled. diff --git a/docs/_sass/talk.scss b/docs/_sass/talk.scss index dfacc549c..4b779962c 100644 --- a/docs/_sass/talk.scss +++ b/docs/_sass/talk.scss @@ -264,7 +264,7 @@ pre { .toc { a { @extend .coral-link; - border-bottom: none; + border-bottom: none !important; } } diff --git a/graph/connectors.js b/graph/connectors.js index 976f05b71..3ba71fbfb 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -1,9 +1,23 @@ const debug = require('debug')('talk:graph:connectors'); const merge = require('lodash/merge'); +// Config. +const config = require('../config'); + +// Secrets. +const secrets = require('../secrets'); + // Errors. const errors = require('../errors'); +// Graph. +const { getBroker } = require('./subscriptions/broker'); +const { getPubsub } = require('./subscriptions/pubsub'); +const resolvers = require('./resolvers'); +const mutators = require('./mutators'); +const loaders = require('./loaders'); +const schema = require('./schema'); + // Models. const Action = require('../models/action'); const Asset = require('../models/asset'); @@ -28,7 +42,6 @@ const Moderation = require('../services/moderation'); const Mongoose = require('../services/mongoose'); const Passport = require('../services/passport'); const Plugins = require('../services/plugins'); -const Pubsub = require('../services/pubsub'); const Redis = require('../services/redis'); const Regex = require('../services/regex'); const Scraper = require('../services/scraper'); @@ -40,9 +53,11 @@ const Tokens = require('../services/tokens'); const Users = require('../services/users'); const Wordlist = require('../services/wordlist'); -// Connector. -const connectors = { +// Connectors. +const defaultConnectors = { errors, + config, + secrets, models: { Action, Asset, @@ -67,7 +82,6 @@ const connectors = { Mongoose, Passport, Plugins, - Pubsub, Redis, Regex, Scraper, @@ -79,14 +93,23 @@ const connectors = { Users, Wordlist, }, + graph: { + subscriptions: { getBroker, getPubsub }, + resolvers, + mutators, + loaders, + schema, + }, }; -module.exports = Plugins.get('server', 'connectors').reduce( +const connectors = Plugins.get('server', 'connectors').reduce( (defaultConnectors, { plugin, connectors: pluginConnectors }) => { debug(`adding plugin '${plugin.name}'`); // Merge in the plugin connectors. return merge(defaultConnectors, pluginConnectors); }, - connectors + defaultConnectors ); + +module.exports = connectors; diff --git a/graph/context.js b/graph/context.js index 35d84dd82..adf5be06a 100644 --- a/graph/context.js +++ b/graph/context.js @@ -1,12 +1,13 @@ const loaders = require('./loaders'); const mutators = require('./mutators'); -const uuid = require('uuid'); -const merge = require('lodash/merge'); +const uuid = require('uuid/v4'); const connectors = require('./connectors'); - +const { get, merge } = require('lodash'); const plugins = require('../services/plugins'); -const pubsub = require('../services/pubsub'); +const { getBroker } = require('./subscriptions/broker'); const debug = require('debug')('talk:graph:context'); +const { createLogger } = require('../services/logging'); +const { graphql } = require('graphql'); /** * Contains the array of plugins that provide context to the server, these top @@ -45,15 +46,16 @@ const decorateContextPlugins = (context, contextPlugins) => { * Stores the request context. */ class Context { - constructor(parent) { + constructor(ctx) { // Generate a new context id for the request if the parent doesn't provide // one. - this.id = parent.id || uuid.v4(); + this.id = ctx.id || uuid.v4(); + + // Attach a logger or create one. + this.log = ctx.log || createLogger('context', this.id); // Load the current logged in user to `user`, otherwise this will be null. - if (parent.user) { - this.user = parent.user; - } + this.user = get(ctx, 'user'); // Attach the connectors. this.connectors = connectors; @@ -68,14 +70,48 @@ class Context { this.plugins = decorateContextPlugins(this, contextPlugins); // Bind the publish/subscribe to the context. - this.pubsub = pubsub.getClient(); + this.pubsub = getBroker(); // Bind the parent context. - this.parent = parent; + this.parent = ctx; } /** + * graphql will execute a graph request for the current context. * + * @param {String} requestString A GraphQL language formatted string + * representing the requested operation. + * @param {Object} variableValues A mapping of variable name to runtime value + * to use for all variables defined in the requestString. + * @param {Object} rootValue The value provided as the first argument to + * resolver functions on the top level type (e.g. the query object type). + * @param {String} operationName The name of the operation to use if + * requestString contains multiple possible operations. Can be omitted if + * requestString contains only one operation. + * @returns {Promise} + */ + async graphql( + requestString, + variableValues = {}, + rootValue = {}, + operationName = undefined + ) { + // Perform the graph request directly using the graphql client. + return graphql( + // Use the connected graph schema. + this.connectors.graph.schema, + requestString, + rootValue, + // Use this, the context as the context. + this, + variableValues, + operationName + ); + } + + /** + * forSystem returns a system context object that can be used for internal + * operations. */ static forSystem() { const { models: { User } } = connectors; @@ -87,4 +123,15 @@ class Context { } } +// Attach the Context to the connectors. +connectors.graph.Context = Context; + +// Connect the connect based plugins after the server has started. +plugins.defer('server', 'connect', ({ plugin, connect }) => { + debug(`connecting plugin to connectors '${plugin.name}'`); + + // Pass the connectors down to the connect plugin. + connect(connectors); +}); + module.exports = Context; diff --git a/graph/index.js b/graph/index.js index c05d878e6..97974d30c 100644 --- a/graph/index.js +++ b/graph/index.js @@ -2,6 +2,7 @@ const schema = require('./schema'); const Context = require('./context'); const { createSubscriptionManager } = require('./subscriptions'); const { ENABLE_TRACING } = require('../config'); +const connectors = require('./connectors'); module.exports = { createGraphOptions: req => ({ @@ -10,11 +11,12 @@ module.exports = { // Load in the new context here, this will create the loaders + mutators for // the lifespan of this request. - context: new Context(req), + context: new Context(req.context), // Tracing request options, needed for Apollo Engine. tracing: ENABLE_TRACING, cacheControl: ENABLE_TRACING, }), createSubscriptionManager, + connectors, }; diff --git a/graph/loaders/users.js b/graph/loaders/users.js index a3ec3c54e..2c1e9fbac 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -1,12 +1,7 @@ const DataLoader = require('dataloader'); - const util = require('./util'); - const { SEARCH_OTHER_USERS } = require('../../perms/constants'); - -const UsersService = require('../../services/users'); const { escapeRegExp } = require('../../services/regex'); -const UserModel = require('../../models/user'); const mergeState = (query, state) => { const { status } = state; @@ -51,17 +46,14 @@ const mergeState = (query, state) => { } }; -const genUserByIDs = async (context, ids) => { +const genUserByIDs = async (ctx, ids) => { if (!ids || ids.length === 0) { return []; } - if (ids.length === 1) { - const user = await UsersService.findById(ids[0]); - return [user]; - } + const { connectors: { models: { User } } } = ctx; - return UsersService.findByIdArray(ids).then(util.singleJoinBy(ids, 'id')); + return User.find({ id: { $in: ids } }).then(util.singleJoinBy(ids, 'id')); }; /** @@ -71,10 +63,10 @@ const genUserByIDs = async (context, ids) => { * @param {Object} query query terms to apply to the users query */ const getUsersByQuery = async ( - { user }, + { user, connectors: { models: { User } } }, { limit, cursor, value = '', state, action_type, sortOrder } ) => { - let query = UserModel.find(); + let query = User.find(); if (action_type || state || value.length > 0) { if (!user || !user.can(SEARCH_OTHER_USERS)) { @@ -182,8 +174,11 @@ const getUsersByQuery = async ( * @return {Promise} resolves to the counts of the users from the * query */ -const getCountByQuery = async ({ user }, { action_type, state }) => { - let query = UserModel.find(); +const getCountByQuery = async ( + { user, connectors: { models: { User } } }, + { action_type, state } +) => { + const query = User.find(); if (action_type || state) { if (!user || !user.can(SEARCH_OTHER_USERS)) { @@ -203,7 +198,7 @@ const getCountByQuery = async ({ user }, { action_type, state }) => { } } - return UserModel.find(query).count(); + return query.count(); }; /** diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js index 8a58cbbc0..ff827e481 100644 --- a/graph/resolvers/action.js +++ b/graph/resolvers/action.js @@ -1,4 +1,4 @@ -const { SEARCH_OTHER_USERS } = require('../../perms/constants'); +const { decorateUserField } = require('./util'); const Action = { __resolveType({ action_type }) { @@ -11,14 +11,8 @@ const Action = { return undefined; } }, - - // This will load the user for the specific action. We'll limit this to the - // admin users only or the current logged in user. - user({ user_id }, _, { loaders: { Users }, user }) { - if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) { - return Users.getByID.load(user_id); - } - }, }; +decorateUserField(Action, 'user', 'user_id'); + module.exports = Action; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 37594ce49..7005701ec 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,4 +1,6 @@ -const { decorateWithTags } = require('./util'); +const { property } = require('lodash'); +const { SEARCH_ACTIONS } = require('../../perms/constants'); +const { decorateWithTags, decorateWithPermissionCheck } = require('./util'); const Comment = { hasParent({ parent_id }) { @@ -29,15 +31,8 @@ const Comment = { return Comments.getByQuery(query); }, - replyCount({ reply_count }) { - // A simple remap from the underlying database model to the graph model. - return reply_count; - }, - actions({ id }, _, { user, loaders: { Actions } }) { - if (!user || !user.can('SEARCH_ACTIONS')) { - return null; - } - + replyCount: property('reply_count'), + actions({ id }, _, { loaders: { Actions } }) { return Actions.getByID.load(id); }, action_summaries(comment, _, { loaders: { Actions } }) { @@ -65,4 +60,9 @@ const Comment = { // Decorate the Comment type resolver with a tags field. decorateWithTags(Comment); +// Protect direct action access. +decorateWithPermissionCheck(Comment, { + actions: [SEARCH_ACTIONS], +}); + module.exports = Comment; diff --git a/graph/resolvers/flag_action.js b/graph/resolvers/flag_action.js index f03fb6afe..b48d566c6 100644 --- a/graph/resolvers/flag_action.js +++ b/graph/resolvers/flag_action.js @@ -1,18 +1,11 @@ -const FlagAction = { - // Stored in the metadata, extract and return. - message({ metadata: { message } }) { - return message; - }, - reason({ group_id }) { - return group_id; - }, - user({ user_id }, _, { loaders: { Users } }) { - if (!user_id) { - return null; - } +const { decorateUserField } = require('./util'); +const { property } = require('lodash'); - return Users.getByID.load(user_id); - }, +const FlagAction = { + message: property('metadata.message'), + reason: property('group_id'), }; +decorateUserField(FlagAction, 'user', 'user_id'); + module.exports = FlagAction; diff --git a/graph/resolvers/flag_action_summary.js b/graph/resolvers/flag_action_summary.js index 9572d9743..e4c332b23 100644 --- a/graph/resolvers/flag_action_summary.js +++ b/graph/resolvers/flag_action_summary.js @@ -1,7 +1,7 @@ +const { property } = require('lodash'); + const FlagActionSummary = { - reason({ group_id }) { - return group_id; - }, + reason: property('group_id'), }; module.exports = FlagActionSummary; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 8da8859e1..74b006ebb 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -1,4 +1,4 @@ -const _ = require('lodash'); +const { merge } = require('lodash'); const debug = require('debug')('talk:graph:resolvers'); const Action = require('./action'); @@ -70,7 +70,7 @@ resolvers = plugins .reduce((acc, { plugin, resolvers }) => { debug(`added plugin '${plugin.name}'`); - return _.merge(acc, resolvers); + return merge(acc, resolvers); }, resolvers); module.exports = resolvers; diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index bf221610b..23fdef288 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -1,3 +1,4 @@ +const { decorateWithPermissionCheck } = require('./util'); const { SEARCH_ASSETS, SEARCH_OTHERS_COMMENTS, @@ -5,11 +6,7 @@ const { } = require('../../perms/constants'); const RootQuery = { - assets(_, { query }, { loaders: { Assets }, user }) { - if (user == null || !user.can(SEARCH_ASSETS)) { - return null; - } - + assets(_, { query }, { loaders: { Assets } }) { return Assets.getByQuery(query); }, asset(_, query, { loaders: { Assets } }) { @@ -33,11 +30,7 @@ const RootQuery = { return Comments.get.load(id); }, - async commentCount(_, { query }, { user, loaders: { Comments, Assets } }) { - if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) { - return null; - } - + async commentCount(_, { query }, { loaders: { Comments, Assets } }) { const { asset_url, asset_id } = query; if ( (!asset_id || asset_id.length === 0) && @@ -53,11 +46,7 @@ const RootQuery = { return Comments.getCountByQuery(query); }, - async userCount(_, { query }, { user, loaders: { Users } }) { - if (user == null || !user.can(SEARCH_OTHER_USERS)) { - return null; - } - + async userCount(_, { query }, { loaders: { Users } }) { return Users.getCountByQuery(query); }, @@ -72,23 +61,40 @@ const RootQuery = { }, // this returns an arbitrary user - user(_, { id }, { user, loaders: { Users } }) { - if (user == null || !user.can(SEARCH_OTHER_USERS)) { - return null; - } - + user(_, { id }, { loaders: { Users } }) { return Users.getByID.load(id); }, // This endpoint is used for loading the user moderation queues (users whose username has been flagged), // so hide it in the event that we aren't an admin. - users(_, { query }, { user, loaders: { Users } }) { - if (user == null || !user.can(SEARCH_OTHER_USERS)) { - return null; - } - + users(_, { query }, { loaders: { Users } }) { return Users.getByQuery(query); }, }; +// Protect some query fields that are privileged. +decorateWithPermissionCheck(RootQuery, { + assets: [SEARCH_ASSETS], + users: [SEARCH_OTHER_USERS], + userCount: [SEARCH_OTHER_USERS], + commentCount: [SEARCH_OTHERS_COMMENTS], +}); + +// Protect the user field so only users who have permission to look up another +// user may do so as well as a user looking up themselves. +decorateWithPermissionCheck( + RootQuery, + { + user: [SEARCH_OTHER_USERS], + }, + (obj, { id }, { user }) => { + if (user && user.id === id) { + return true; + } + + // We don't return false because we want to fallthrough to the permission + // check if the custom check fails. + } +); + module.exports = RootQuery; diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index aa5d45565..ace5b1a0d 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -1,40 +1,23 @@ -const Subscription = { - commentAdded(comment) { - return comment; - }, - commentEdited(comment) { - return comment; - }, - commentAccepted(comment) { - return comment; - }, - commentRejected(comment) { - return comment; - }, - commentReset(comment) { - return comment; - }, - commentFlagged(comment) { - return comment; - }, - userBanned(user) { - return user; - }, - userSuspended(user) { - return user; - }, - usernameApproved(user) { - return user; - }, - usernameRejected(user) { - return user; - }, - usernameFlagged(user) { - return user; - }, - usernameChanged(payload) { - return payload; - }, -}; +const Subscription = {}; + +// All of the subscription endpoints need to have an object to serialize when +// pushing out via PubSub, this simply ensures that all these entries will +// return the root object from the subscription to the PubSub framework. +[ + 'commentAdded', + 'commentEdited', + 'commentAccepted', + 'commentRejected', + 'commentReset', + 'commentFlagged', + 'userBanned', + 'userSuspended', + 'usernameApproved', + 'usernameRejected', + 'usernameFlagged', + 'usernameChanged', +].forEach(field => { + Subscription[field] = obj => obj; +}); module.exports = Subscription; diff --git a/graph/resolvers/tag_link.js b/graph/resolvers/tag_link.js index 5df43d05b..b81d5c743 100644 --- a/graph/resolvers/tag_link.js +++ b/graph/resolvers/tag_link.js @@ -1,11 +1,7 @@ -const { SEARCH_OTHER_USERS } = require('../../perms/constants'); +const { decorateUserField } = require('./util'); -const TagLink = { - assigned_by({ assigned_by }, _, { user, loaders: { Users } }) { - if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) { - return Users.getByID.load(assigned_by); - } - }, -}; +const TagLink = {}; + +decorateUserField(TagLink, 'assigned_by'); module.exports = TagLink; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index c72a5923c..46fe0ac3c 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -1,4 +1,8 @@ -const { decorateWithTags } = require('./util'); +const { + decorateWithTags, + decorateWithPermissionCheck, + checkSelfField, +} = require('./util'); const KarmaService = require('../../services/karma'); const { SEARCH_ACTIONS, @@ -7,86 +11,65 @@ const { VIEW_USER_ROLE, LIST_OWN_TOKENS, VIEW_USER_STATUS, + VIEW_USER_EMAIL, } = require('../../perms/constants'); +const { property } = require('lodash'); const User = { action_summaries(user, _, { loaders: { Actions } }) { return Actions.getSummariesByItem.load(user); }, - actions({ id }, _, { user, loaders: { Actions } }) { - // Only return the actions if the user is not an admin. - if (user && user.can(SEARCH_ACTIONS)) { - return Actions.getByID.load(id); - } + actions({ id }, _, { loaders: { Actions } }) { + return Actions.getByID.load(id); }, - comments({ id }, { query }, { loaders: { Comments }, user }) { - // If there is no user, or there is a user, but they are requesting someone - // else's comments, and they aren't allowed, don't return then anything! - if (!user || (user.id !== id && !user.can(SEARCH_OTHERS_COMMENTS))) { - return null; - } - + comments({ id }, { query }, { loaders: { Comments } }) { // Set the author id on the query. query.author_id = id; return Comments.getByQuery(query); }, - profiles({ profiles }, _, { user }) { - // if the user is not an admin, do not return the profiles - if (user && user.can(SEARCH_OTHER_USERS)) { - return profiles; - } - - return null; - }, - tokens({ id, tokens }, args, { user }) { - if (!user || (user.id !== id && !user.can(LIST_OWN_TOKENS))) { - return null; - } - - return tokens; - }, - ignoredUsers({ id }, args, { user, loaders: { Users } }) { - // Only allow a logged in user that is either the current user or is a staff - // member to access the ignoredUsers of a given user. - if (!user || (user.id !== id && !user.can(SEARCH_OTHER_USERS))) { - return null; - } + ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) { // Return nothing if there is nothing to query for. if (!user.ignoresUsers || user.ignoresUsers.length <= 0) { return []; } - return Users.getByID.loadMany(user.ignoresUsers); - }, - role({ id, role }, _, { user }) { - // If the user is not an admin, only return the current user's roles. - if (user && (user.can(VIEW_USER_ROLE) || user.id === id)) { - return role; - } - - return null; + return Users.getByID.loadMany(ignoresUsers); }, // Extract the reliability from the user metadata if they have permission. - reliable(user, _, { user: requestingUser }) { - if (requestingUser && requestingUser.can(SEARCH_ACTIONS)) { - return KarmaService.model(user); - } - }, + reliable: user => KarmaService.model(user), - state(user, args, ctx) { - if ( - ctx.user && - (ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS)) - ) { - return user; - } - }, + // The state requires the whole user object to make decisions. + state: user => user, + + // Get the first email on the user. + email: property('firstEmail'), }; // Decorate the User type resolver with a tags field. decorateWithTags(User); +// decorate the fields on the User resolver with a permission check where the +// current user can also get their own properties. +decorateWithPermissionCheck( + User, + { + actions: [SEARCH_ACTIONS], + email: [VIEW_USER_EMAIL], + state: [VIEW_USER_STATUS], + role: [VIEW_USER_ROLE], + ignoredUsers: [SEARCH_OTHER_USERS], + tokens: [LIST_OWN_TOKENS], + profiles: [SEARCH_OTHER_USERS], + comments: [SEARCH_OTHERS_COMMENTS], + }, + checkSelfField('id') +); + +// Decorate the fields on the User resolver where the current user has no impact +// on the resolvability of a field. +decorateWithPermissionCheck(User, { reliable: [SEARCH_ACTIONS] }); + module.exports = User; diff --git a/graph/resolvers/user_state.js b/graph/resolvers/user_state.js index 0436101e0..8d1843a43 100644 --- a/graph/resolvers/user_state.js +++ b/graph/resolvers/user_state.js @@ -1,14 +1,12 @@ +const { decorateWithPermissionCheck, checkSelfField } = require('./util'); const { VIEW_USER_STATUS } = require('../../perms/constants'); -const UserState = { - status: (user, args, ctx) => { - if ( - ctx.user && - (ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS)) - ) { - return user.status; - } - }, -}; +const UserState = {}; + +decorateWithPermissionCheck( + UserState, + { status: [VIEW_USER_STATUS] }, + checkSelfField('id') +); module.exports = UserState; diff --git a/graph/resolvers/util.js b/graph/resolvers/util.js index 992148b4f..cec2a71dd 100644 --- a/graph/resolvers/util.js +++ b/graph/resolvers/util.js @@ -2,59 +2,167 @@ const { ADD_COMMENT_TAG, SEARCH_OTHER_USERS, } = require('../../perms/constants'); -const property = require('lodash/property'); +const { property, isBoolean } = require('lodash'); /** - * Decorates the typeResolver with the tags field. + * getResolver will get the resolver from the typeResolver or apply the default + * resolver. + * + * @param {Object} typeResolver the type resolver + * @param {String} field the field name of the resolver we're getting */ -const decorateWithTags = typeResolver => { - typeResolver.tags = ({ tags = [] }, _, { user }) => { - if (user && user.can(ADD_COMMENT_TAG)) { - return tags; +const getResolver = (typeResolver, field) => { + if (field in typeResolver) { + return typeResolver[field]; + } + + return property(field); +}; + +/** + * + * @param {Object} typeResolver the type resolver + * @param {String} field the name of the field being wrapped + * @param {Function} customCheck the function that can return a boolean + * indicating the resolution status + * @param {Function} skipFieldResolver the optional skip resolver that can be used + * skip out from the oldFieldResolver. + */ +const wrapCheck = ( + typeResolver, + field, + customCheck, + skipFieldResolver = getResolver(typeResolver, field) +) => { + // Cache the old field resolver. In the event that the check does not return + // with a boolean, we'll use this. + const oldFieldResolver = getResolver(typeResolver, field); + + // Override the field resolver on the type resolver with this wrapped + // function. + typeResolver[field] = (obj, args, ctx, info) => { + const decision = customCheck(obj, args, ctx, info); + if (isBoolean(decision)) { + if (decision) { + // The custom check returns a boolean true, so we should execute the + // underlying field resolver (which may just be the old resolver). + return skipFieldResolver(obj, args, ctx, info); + } + + // The custom check returns a boolean false, then we should return null, + // because the check explicity said that we weren't allowed to access that + // field. + return null; } - return tags.filter(t => t.tag.permissions.public); + // The custom check yielded no decision, so we should just fall back to the + // oldFieldResolver. + return oldFieldResolver(obj, args, ctx, info); }; }; +/** + * checkPermissions checks that the current user has all the required + * permissions. + * + * @param {Object} ctx graph context + * @param {Array} permissions permissions that the user must have + */ +const checkPermissions = (ctx, permissions) => + !ctx.user || !ctx.user.can(...permissions); + +/** + * wrapCheckPermissions will wrap a specific field with a permission check. + * + * @param {Object} typeResolver the type resolver + * @param {String} field the field name of the resolver we're wrapping + * @param {Array} permissions array of permissions to check against + * @param {Function} fieldResolver base resolver for the field + */ +const wrapCheckPermissions = ( + typeResolver, + field, + permissions, + skipFieldResolver = getResolver(typeResolver, field) +) => + wrapCheck( + typeResolver, + field, + (obj, args, ctx) => !checkPermissions(ctx, permissions), + skipFieldResolver + ); + /** * decorateWithPermissionCheck will decorate the field resolver with * permission checks. * * @param {Object} typeResolver the type resolver * @param {Object} protect the object with field -> Array of permissions + * @param {Function} customCheck a function that can return a boolean based on a + * custom check */ -const decorateWithPermissionCheck = (typeResolver, protect) => { +const decorateWithPermissionCheck = ( + typeResolver, + protect, + customCheck = null +) => { for (const [field, permissions] of Object.entries(protect)) { - let fieldResolver = property(field); - if (field in typeResolver) { - fieldResolver = typeResolver[field]; + const baseFieldResolver = getResolver(typeResolver, field); + wrapCheckPermissions(typeResolver, field, permissions, baseFieldResolver); + + if (customCheck !== null) { + wrapCheck(typeResolver, field, customCheck, baseFieldResolver); } - - typeResolver[field] = (obj, args, ctx, info) => { - if (!ctx.user || !ctx.user.can(...permissions)) { - return null; - } - - return fieldResolver(obj, args, ctx, info); - }; } }; /** - * decorateUserField will decorate the user field accesses with correct - * permission checks. + * checkSelf will check if the current object is the same as the current user. + * + * @param {String} referenceField the field for the user id to check. + */ +const checkSelfField = referenceField => (obj, args, ctx) => { + if ( + ctx.user && + obj[referenceField] !== null && + ctx.user.id === obj[referenceField] + ) { + return true; + } +}; + +/** + * wrapCheckSelf wraps a typeResolver with a check for self (if the type is + * referencing the current user). * * @param {Object} typeResolver the type resolver * @param {String} field the field to decorate + * @param {String} referenceField the field to pull the user id from. + * @param {Function} fieldResolver base resolver for the field */ -const decorateUserField = (typeResolver, field) => { +const wrapCheckSelf = ( + typeResolver, + field, + referenceField = field, + fieldResolver = getResolver(typeResolver, field) +) => + wrapCheck(typeResolver, field, checkSelfField(referenceField), fieldResolver); + +/** + * decorateUserField will decorate the user field accesses with correct + * permission checks and will load the user. + * + * @param {Object} typeResolver the type resolver + * @param {String} field the field to decorate + * @param {String} referenceField the field to pull the user id from. + */ +const decorateUserField = (typeResolver, field, referenceField = field) => { // The default resolver for the user decorator is loading the user by id. let fieldResolver = (obj, args, ctx) => { - if (!obj[field]) { + if (!obj[referenceField]) { return null; } - return ctx.loaders.Users.getByID.load(obj[field]); + return ctx.loaders.Users.getByID.load(obj[referenceField]); }; // The resolver can be overridden however. This decorator will simply wrap the @@ -63,16 +171,39 @@ const decorateUserField = (typeResolver, field) => { fieldResolver = typeResolver[field]; } - typeResolver[field] = (obj, args, ctx, info) => { - if ( - !ctx.user || - obj[field] === null || - (ctx.user.id !== obj[field] && !ctx.user.can(SEARCH_OTHER_USERS)) - ) { - return null; + // Wrap the current fieldResolver with the resolver which will return the + // user. + wrapCheckPermissions( + typeResolver, + field, + [SEARCH_OTHER_USERS], + fieldResolver + ); + + // Wrap the checked resolver with a check to see if the current user is the + // one being retrieved, in which case we should allow it. + wrapCheckSelf(typeResolver, field, referenceField, fieldResolver); +}; + +/** + * decorateWithTags decorates a typeResolver with a tags getter that will + * sanitize the tag output for users without permission to see non-public + * tags. + * + * @param {Object} typeResolver base type resolver + * @param {Function} fieldResolver the field resolver that gets the tags + */ +const decorateWithTags = ( + typeResolver, + fieldResolver = getResolver(typeResolver, 'tags') +) => { + typeResolver.tags = async (obj, args, ctx, info) => { + const tags = await fieldResolver(obj, args, ctx, info); + if (checkPermissions(ctx, [ADD_COMMENT_TAG])) { + return tags; } - return fieldResolver(obj, args, ctx, info); + return tags.filter(t => t.tag.permissions.public); }; }; @@ -80,4 +211,5 @@ module.exports = { decorateUserField, decorateWithTags, decorateWithPermissionCheck, + checkSelfField, }; diff --git a/graph/subscriptions/broker.js b/graph/subscriptions/broker.js new file mode 100644 index 000000000..93f57d219 --- /dev/null +++ b/graph/subscriptions/broker.js @@ -0,0 +1,48 @@ +const { EventEmitter2 } = require('eventemitter2'); +const debug = require('debug')('talk:graph:subscriptions:broker'); +const { getPubsub } = require('./pubsub'); + +/** + * Broker acts as a pubsub client adapter. Any calls to publish will push into + * the PubSub client and the local event emitter. + */ +class Broker extends EventEmitter2 { + constructor(pubsub) { + // Create the underlying event emitter. + super({ + wildcard: true, // Allow wildcard listeners. + maxListeners: 0, // Disable maximum for listeners. + }); + + this.pubsub = pubsub; + } + + /** + * Publishes the event out to the pubsub system and to the broker. + * + * @param {String} event the name of the event to publish + * @param {Any} args the + */ + publish(event, ...args) { + debug(`publish:${event}`); + this.pubsub.publish(event, ...args); + this.emit(event, ...args); + } +} + +let client = null; +const getBroker = () => { + if (client !== null) { + return client; + } + + // Create the new Broker to manage events being published out to + // the pubsub so that we may intercept. + client = new Broker(getPubsub()); + + debug('created'); + + return client; +}; + +module.exports.getBroker = getBroker; diff --git a/graph/subscriptions.js b/graph/subscriptions/index.js similarity index 87% rename from graph/subscriptions.js rename to graph/subscriptions/index.js index 948d10a8f..5ade76feb 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions/index.js @@ -2,18 +2,18 @@ const { SubscriptionManager } = require('graphql-subscriptions'); const { SubscriptionServer } = require('subscriptions-transport-ws'); const debug = require('debug')('talk:graph:subscriptions'); -const pubsub = require('../services/pubsub'); -const schema = require('./schema'); -const Context = require('./context'); -const plugins = require('../services/plugins'); +const { getPubsub } = require('./pubsub'); +const schema = require('../schema'); +const Context = require('../context'); +const plugins = require('../../services/plugins'); -const { deserializeUser } = require('../services/subscriptions'); +const { deserializeUser } = require('../../services/subscriptions'); const setupFunctions = require('./setupFunctions'); const ms = require('ms'); -const { KEEP_ALIVE } = require('../config'); +const { KEEP_ALIVE } = require('../../config'); -const { BASE_PATH } = require('../url'); +const { BASE_PATH } = require('../../url'); // Collect all the plugin hooks that should be executed onConnect and // onDisconnect. @@ -99,7 +99,7 @@ const createSubscriptionManager = server => { subscriptionManager: new SubscriptionManager({ schema, - pubsub: pubsub.getClient(), + pubsub: getPubsub(), setupFunctions, }), onConnect, diff --git a/graph/subscriptions/pubsub.js b/graph/subscriptions/pubsub.js new file mode 100644 index 000000000..54761bdfb --- /dev/null +++ b/graph/subscriptions/pubsub.js @@ -0,0 +1,29 @@ +const { RedisPubSub } = require('graphql-redis-subscriptions'); +const { createClient: createRedisClient } = require('../../services/redis'); +const debug = require('debug')('talk:graph:subscriptions:pubsub'); + +/** + * getPubsub returns the pubsub singleton for this instance. + */ +let pubsub = null; +const getPubsub = () => { + if (pubsub !== null) { + return pubsub; + } + + // Create the publisher and subscriber redis clients. + const publisher = createRedisClient(); + const subscriber = createRedisClient(); + + // Create the new PubSub client, we only need one per instance of Talk. + pubsub = new RedisPubSub({ + publisher, + subscriber, + }); + + debug('created'); + + return pubsub; +}; + +module.exports.getPubsub = getPubsub; diff --git a/graph/setupFunctions.js b/graph/subscriptions/setupFunctions.js similarity index 98% rename from graph/setupFunctions.js rename to graph/subscriptions/setupFunctions.js index 12d9d5685..467c4e98f 100644 --- a/graph/setupFunctions.js +++ b/graph/subscriptions/setupFunctions.js @@ -11,11 +11,11 @@ const { SUBSCRIBE_ALL_USERNAME_APPROVED, SUBSCRIBE_ALL_USERNAME_FLAGGED, SUBSCRIBE_ALL_USERNAME_CHANGED, -} = require('../perms/constants'); +} = require('../../perms/constants'); const merge = require('lodash/merge'); const debug = require('debug')('talk:graph:setupFunctions'); -const plugins = require('../services/plugins'); +const plugins = require('../../services/plugins'); const setupFunctions = { commentAdded: (options, args, comment, context) => { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index cec8e0015..f108a7ad4 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -63,7 +63,7 @@ type Token { } type UserProfile { - # the id is an identifier for the user profile (email, facebook id, etc) + # The id is an identifier for the user profile (email, facebook id, etc) id: String! # name of the provider attached to the authentication mode @@ -184,13 +184,17 @@ type User { # Actions completed on the parent. actions: [Action!] - # the current roles of the user. + # The current roles of the user. role: USER_ROLES - # the current profiles of the user. + # The current profiles of the user. profiles: [UserProfile] - # the tags on the user + # The primary email address of the user. Only accessible to Administrators or + # the current user. + email: String + + # The tags on the user. tags: [TagLink!] # ignored users. @@ -421,7 +425,7 @@ input CommentCountQuery { # The URL that the asset is located on. asset_url: String - # the parent of the comment that we want to retrieve. + # The parent of the comment that we want to retrieve. parent_id: ID # comments returned will only be ones which have at least one action of this @@ -479,13 +483,13 @@ type Comment { # The body history of the comment. body_history: [CommentBodyHistory!]! - # the tags on the comment + # The tags on the comment tags: [TagLink!] - # the user who authored the comment. + # The user who authored the comment. user: User - # the replies that were made to the comment. + # The replies that were made to the comment. replies(query: RepliesQuery = {}): CommentConnection! # replyCount is the number of replies with a depth of 1. Only direct replies @@ -765,7 +769,7 @@ type Settings { infoBoxContent: String # questionBoxEnable will enable the Question Box's content to be visible above - # the comment box. + # The comment box. questionBoxEnable: Boolean # questionBoxContent is the content of the Question Box. @@ -858,7 +862,7 @@ type Asset { # The date that the asset was created. created_at: Date - # the tags on the asset + # The tags on the asset tags: [TagLink!] # The author(s) of the asset. @@ -1091,7 +1095,7 @@ input AssetSettingsInput { moderation: MODERATION_MODE # questionBoxEnable will enable the Question Boxs' content to be visible above - # the comment box. + # The comment box. questionBoxEnable: Boolean # questionBoxContent is the content of the Question Box. @@ -1167,7 +1171,7 @@ input ModifyTagInput { item_type: TAGGABLE_ITEM_TYPE! # asset_id is used when the item_type is `COMMENTS`, the is needed to rectify - # the settings to get the asset specific tags/settings. + # The settings to get the asset specific tags/settings. asset_id: ID } @@ -1225,7 +1229,7 @@ input UpdateSettingsInput { infoBoxContent: String # questionBoxEnable will enable the Question Box's content to be visible above - # the comment box. + # The comment box. questionBoxEnable: Boolean # questionBoxContent is the content of the Question Box. diff --git a/jobs/index.js b/jobs/index.js new file mode 100644 index 000000000..b64e59362 --- /dev/null +++ b/jobs/index.js @@ -0,0 +1,5 @@ +const jobs = [require('./mailer')]; + +const process = () => jobs.forEach(job => job()); + +module.exports = { process }; diff --git a/jobs/mailer.js b/jobs/mailer.js new file mode 100644 index 000000000..6415f5241 --- /dev/null +++ b/jobs/mailer.js @@ -0,0 +1,147 @@ +const { task } = require('../services/mailer'); +const nodemailer = require('nodemailer'); +const debug = require('debug')('talk:jobs:mailer'); +const Context = require('../graph/context'); +const { get } = require('lodash'); + +const { + SMTP_HOST, + SMTP_USERNAME, + SMTP_PORT, + SMTP_PASSWORD, + SMTP_FROM_ADDRESS, +} = require('../config'); + +// parseSMTPPort will return the port for SMTP. +const parseSMTPPort = () => { + if (!SMTP_PORT) { + return 25; + } + + try { + return parseInt(SMTP_PORT); + } catch (e) { + throw new Error('TALK_SMTP_PORT is not an integer'); + } +}; + +// createTransport will create a new transport. +const createTransport = () => { + const options = { + host: SMTP_HOST, + }; + + if (SMTP_USERNAME && SMTP_PASSWORD) { + options.auth = { + user: SMTP_USERNAME, + pass: SMTP_PASSWORD, + }; + } + + // Get the SMTP port. + options.port = parseSMTPPort(); + + return nodemailer.createTransport(options); +}; + +// sharedTransport is the transport singleton. +let sharedTransport; + +// getTransport will retrieve the mailer transport singleton. +const getTransport = () => { + if (sharedTransport) { + return sharedTransport; + } + + // enabled is true when the required configuration is available. When testing + // is enabled, we will be simulating that emails are being sent, because in a + // production system, emails should and would be sent. + + // If the transport details aren't available, we will return null as the + // transport. + if (!SMTP_HOST || !SMTP_FROM_ADDRESS) { + return null; + } + + // Create the transport. + sharedTransport = createTransport(); + + return sharedTransport; +}; + +// getEmailAddress will retrieve the email address to send the message to from +// the job data. +const getEmailAddress = async ({ email, user }) => { + // If the message has a specific email already to sent it to, just assign + // that to the message. If the email does not have an email, and instead has + // a user id, then we should lookup the user with the graph and get their + // email. + if (email) { + return email; + } else { + // Get the user to send the message to. + const ctx = Context.forSystem(); + + const { data, errors } = await ctx.graphql( + ` + query GetUserEmail($user: ID!) { + user(id: $user) { + email + } + } + `, + { user } + ); + if (errors) { + throw errors; + } + + const email = get(data, 'user.email'); + if (!email) { + throw errors.ErrMissingEmail; + } + + return email; + } +}; + +// processJob will handle new jobs sent via this queue. +const processJob = transport => async ({ id, data }, done) => { + const { message } = data; + + // Get the email address from the job data. + message.to = await getEmailAddress(data); + + debug(`Starting to send mail for Job[${id}]`); + + // Actually send the email. + transport.sendMail(message, err => { + if (err) { + debug(`Failed to send mail for Job[${id}]:`, err); + return done(err); + } + + debug(`Finished sending mail for Job[${id}]`); + return done(); + }); +}; + +/** + * Start the queue processor for the mailer job. + */ +module.exports = () => { + // Get a transport. + const transport = getTransport(); + if (transport === null) { + console.warn( + new Error( + 'sending email is not enabled because required configuration is not available' + ) + ); + return; + } + + debug(`Now processing ${task.name} jobs`); + + return task.process(processJob(transport)); +}; diff --git a/jobs/scraper.js b/jobs/scraper.js new file mode 100644 index 000000000..424304ba8 --- /dev/null +++ b/jobs/scraper.js @@ -0,0 +1,76 @@ +const Asset = require('../models/asset'); +const scraper = require('../services/scraper'); +const Assets = require('../services/assets'); +const debug = require('debug')('talk:jobs:scraper'); +const metascraper = require('metascraper'); + +/** + * Scrapes the given asset for metadata. + */ +async function scrape(asset) { + return metascraper.scrapeUrl( + asset.url, + Object.assign({}, metascraper.RULES, { + section: $ => $('meta[property="article:section"]').attr('content'), + modified: $ => $('meta[property="article:modified"]').attr('content'), + }) + ); +} + +/** + * Updates an Asset based on scraped asset metadata. + */ +function update(id, meta) { + return Asset.update( + { id }, + { + $set: { + title: meta.title || '', + description: meta.description || '', + image: meta.image ? meta.image : '', + author: meta.author || '', + publication_date: meta.date || '', + modified_date: meta.modified || '', + section: meta.section || '', + scraped: new Date(), + }, + } + ); +} + +module.exports = () => { + debug(`Now processing ${scraper.task.name} jobs`); + + scraper.task.process(async (job, done) => { + debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`); + + try { + // Find the asset, or complain that it doesn't exist. + const asset = await Assets.findById(job.data.asset_id); + if (!asset) { + return done(new Error('asset not found')); + } + + // Scrape the metadata from the asset. + const meta = await scrape(asset); + + debug( + `Scraped ${JSON.stringify(meta)} on Job[${job.id}] for Asset[${ + job.data.asset_id + }]` + ); + + // Assign the metadata retrieved for the asset to the db. + await update(job.data.asset_id, meta); + } catch (err) { + debug( + `Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, + err + ); + return done(err); + } + + debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`); + done(); + }); +}; diff --git a/locales/da.yml b/locales/da.yml index a1d1fab73..c68185406 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -432,6 +432,16 @@ da: all: "All" rejected: "Rejected" account_history: "Account History" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "Denne bruger efterligner" user_no_comment: "Du har aldrig efterladt en kommentar. Deltag i samtalen" username_offensive: "Dette brugernavn er stødende" diff --git a/locales/de.yml b/locales/de.yml index 069c8ddb3..b5ced1096 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -431,6 +431,16 @@ de: all: "Alle" rejected: "Abgelehnte" account_history: "Konto-Verlauf" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "Gibt sich für jemand anderen aus" user_no_comment: "Sie haben noch keinen Kommentar abgegeben. Teilen Sie Ihre Meinung mit uns!" username_offensive: "Dieser Nutzername ist unangemessen" diff --git a/locales/en.yml b/locales/en.yml index 8f7a3853b..2bbe23254 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -396,6 +396,8 @@ en: one_hour: "1 hour" hours: "{0} hours" days: "{0} days" + hour: "{0} hours" + day: "{0} days" cancel: "Cancel" suspend_user: "Suspend User" email_message_suspend: "Dear {0},\n\nIn accordance with {1}’s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation {2}." @@ -434,6 +436,16 @@ en: all: "All" rejected: "Rejected" account_history: "Account History" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "This user is impersonating" user_no_comment: "You've never left a comment. Join the conversation!" username_offensive: "This username is offensive" @@ -462,4 +474,4 @@ en: close: "Close this Installer" admin_sidebar: view_options: "View Options" - sort_comments: "Sort Comments" + sort_comments: "Sort Comments" \ No newline at end of file diff --git a/locales/es.yml b/locales/es.yml index 28e12ac3f..8edd1f8fc 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -433,6 +433,16 @@ es: all: "All" rejected: "Rejected" account_history: "Account History" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "Este usuario suplanta a alguien" user_no_comment: "No has dejado aún ningún comentario. ¡Únete a la conversación!" username_offensive: "Este nombre de usuario es ofensivo" diff --git a/locales/fr.yml b/locales/fr.yml index a922d931d..fb2b3be99 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -433,6 +433,16 @@ fr: all: "All" rejected: "Rejected" account_history: "Account History" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre" user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation !" username_offensive: "Ce nom d'utilisateur est offensant" diff --git a/locales/nl_NL.yml b/locales/nl_NL.yml index f6be0ef6c..a60268e42 100644 --- a/locales/nl_NL.yml +++ b/locales/nl_NL.yml @@ -432,6 +432,16 @@ nl_NL: all: "Alle" rejected: "Afgewezen" account_history: "Accountgeschiedenis" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "Deze gebruiker imiteert" user_no_comment: "Je hebt nog niet eerder gereageerd. Laat je mening horen!" username_offensive: "Dit is een aanstootgevende gebruikersnaam." diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index c64c17824..b19a3789b 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -431,6 +431,16 @@ pt_BR: all: "All" rejected: "Rejected" account_history: "Account History" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "Este usuário está representando" user_no_comment: "Você nunca deixou um comentário. Participe da conversa!" username_offensive: "Esse nome de usuário é ofensivo" diff --git a/locales/zh_CN.yml b/locales/zh_CN.yml index 3108addf6..31d6623a3 100644 --- a/locales/zh_CN.yml +++ b/locales/zh_CN.yml @@ -433,6 +433,16 @@ zh_CN: all: "All" rejected: "Rejected" account_history: "Account History" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "冒名用户" user_no_comment: "您未曾发表评论。现在就来加入对话吧!" username_offensive: "用户名有冒犯性" diff --git a/locales/zh_TW.yml b/locales/zh_TW.yml index c6698fec4..8f2f54dfd 100644 --- a/locales/zh_TW.yml +++ b/locales/zh_TW.yml @@ -433,6 +433,16 @@ zh_TW: all: "All" rejected: "Rejected" account_history: "Account History" + account_history: + user_banned: "User banned" + ban_removed: "Ban removed" + username_status: "Username {0}" + suspended: "Suspended, {0}" + suspension_removed: "Suspension removed" + system: "System" + date: "Date" + action: "Action" + taken_by: "Taken By" user_impersonating: "此用戶正在冒充" user_no_comment: "您尚未評論過。加入對話吧!" username_offensive: "這個用戶名有冒犯性" diff --git a/middleware/context.js b/middleware/context.js new file mode 100644 index 000000000..57620f827 --- /dev/null +++ b/middleware/context.js @@ -0,0 +1,8 @@ +const Context = require('../graph/context'); + +// Attach a new context to the request. +module.exports = (req, res, next) => { + req.context = new Context(req); + + next(); +}; diff --git a/middleware/staticTemplate.js b/middleware/staticTemplate.js index 6848e59e1..5a7c34b60 100644 --- a/middleware/staticTemplate.js +++ b/middleware/staticTemplate.js @@ -1,6 +1,12 @@ const SettingsService = require('../services/settings'); -const { BASE_URL, BASE_PATH, MOUNT_PATH, STATIC_URL } = require('../url'); +const { + BASE_URL, + BASE_PATH, + MOUNT_PATH, + STATIC_URL, + STATIC_ORIGIN, +} = require('../url'); const { RECAPTCHA_PUBLIC, WEBSOCKET_LIVE_URI } = require('../config'); @@ -15,6 +21,7 @@ const TEMPLATE_LOCALS = { TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC, LIVE_URI: WEBSOCKET_LIVE_URI, STATIC_URL, + STATIC_ORIGIN, }, }; diff --git a/models/setting.js b/models/setting.js index c4b20a4ed..1cca9c989 100644 --- a/models/setting.js +++ b/models/setting.js @@ -99,6 +99,12 @@ const SettingSchema = new Schema( default: 30 * 1000, }, tags: [TagSchema], + + // Additional metadata to let plugins write settings. + metadata: { + default: {}, + type: Object, + }, }, { timestamps: { diff --git a/package.json b/package.json index c61a5db45..48e0b1001 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "main": "app.js", "private": true, "scripts": { - "postinstall": "./bin/cli plugins reconcile --skip-remote", "generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js", "clean": "rm -rf dist client/coral-framework/graphql/introspection.json", "watch": "npm-run-all clean generate-introspection --parallel watch:*", @@ -16,7 +15,7 @@ "prebuild": "npm-run-all clean generate-introspection", "build": "NODE_ENV=production parallel-webpack --bail", "lint:yaml": "yamllint locales/*.yml", - "lint:js": "eslint bin/* .", + "lint:js": "eslint bin/cli* .", "lint": "npm-run-all lint:*", "plugins:reconcile": "./bin/cli plugins reconcile", "test": "npm-run-all test:client test:server", @@ -25,13 +24,18 @@ "test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch", "e2e": "./scripts/e2e.js", "e2e:ci": "./scripts/e2e-ci.sh", - "heroku-postbuild": "npm-run-all plugins:reconcile build" + "heroku-postbuild": "npm-run-all plugins:reconcile build", + "precommit": "npm-run-all lint-staged", + "lint-staged": "lint-staged" }, "talk": { "migration": { "minVersion": 1516920160 } }, + "workspaces": [ + "plugins/*" + ], "repository": { "type": "git", "url": "git+https://github.com/coralproject/talk.git" @@ -74,6 +78,7 @@ "bcryptjs": "^2.4.3", "bowser": "^1.7.2", "brotli-webpack-plugin": "^0.5.0", + "bunyan": "^1.8.12", "cli-table": "^0.3.1", "clipboard": "^1.7.1", "colors": "^1.1.2", @@ -112,13 +117,14 @@ "hammerjs": "^2.0.8", "helmet": "3.8.2", "history": "^3.0.0", + "hjson": "^3.1.1", + "hjson-loader": "^1.0.0", "immutability-helper": "^2.2.0", "imports-loader": "^0.7.1", "inquirer": "^3.2.2", "inquirer-autocomplete-prompt": "^0.12.1", "ioredis": "3.1.4", "joi": "^13.0.0", - "json-loader": "^0.5.7", "jsonwebtoken": "^8.0.0", "jwt-decode": "^2.2.0", "keymaster": "^1.6.2", @@ -132,7 +138,7 @@ "minimist": "^1.2.0", "moment": "^2.18.1", "mongoose": "^4.12.3", - "morgan": "1.9.0", + "morgan": "^1.9.0", "ms": "^2.0.0", "murmurhash-js": "^1.0.0", "node-emoji": "^1.8.1", @@ -192,6 +198,7 @@ }, "devDependencies": { "@coralproject/eslint-config-talk": "^0.1.0", + "@easyops/git-exec-and-restage": "^1.0.4", "babel-jest": "^21.2.0", "babel-plugin-dynamic-import-node": "^1.1.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", @@ -204,14 +211,15 @@ "enzyme-adapter-react-15": "^1.0.0", "eslint": "^4.5.0", "eslint-plugin-mocha": "^4.11.0", + "husky": "^0.14.3", "identity-obj-proxy": "^3.0.0", "ip": "^1.1.5", "jest": "^21.2.1", + "lint-staged": "^7.0.0", "mocha": "^3.1.2", "mocha-junit-reporter": "^1.12.1", "nightwatch": "^0.9.16", "nodemon": "^1.11.0", - "pre-commit": "^1.2.2", "selenium-standalone": "^6.11.0", "sinon": "^3.2.1", "sinon-chai": "^2.13.0", @@ -219,13 +227,5 @@ }, "engines": { "node": ">=8" - }, - "pre-commit": { - "silent": false, - "run": [ - "lint", - "test:client", - "test:server" - ] } } diff --git a/perms/constants/query.js b/perms/constants/query.js index 4cc07109c..b846b15ce 100644 --- a/perms/constants/query.js +++ b/perms/constants/query.js @@ -9,4 +9,5 @@ module.exports = { VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS', LIST_OWN_TOKENS: 'LIST_OWN_TOKENS', VIEW_USER_ROLE: 'VIEW_USER_ROLE', + VIEW_USER_EMAIL: 'VIEW_USER_EMAIL', }; diff --git a/perms/reducers/query.js b/perms/reducers/query.js index 4fc5ba79e..0852d8e2b 100644 --- a/perms/reducers/query.js +++ b/perms/reducers/query.js @@ -8,11 +8,11 @@ module.exports = (user, perm) => { case types.SEARCH_ACTIONS: case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: case types.SEARCH_OTHERS_COMMENTS: - return check(user, ['ADMIN', 'MODERATOR']); case types.SEARCH_COMMENT_STATUS_HISTORY: case types.VIEW_USER_STATUS: case types.VIEW_PROTECTED_SETTINGS: case types.VIEW_USER_ROLE: + case types.VIEW_USER_EMAIL: return check(user, ['ADMIN', 'MODERATOR']); case types.LIST_OWN_TOKENS: return check(user, ['ADMIN']); diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 397d8b94d..8b91c83de 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -6,6 +6,7 @@ export { withEmit, excludeIf, withFragments, + withMutation, withForgotPassword, withSignIn, withSignUp, diff --git a/plugin-api/client/lib/index.js b/plugin-api/client/lib/index.js new file mode 100644 index 000000000..20e084806 --- /dev/null +++ b/plugin-api/client/lib/index.js @@ -0,0 +1 @@ +export { default as Action } from 'coral-framework/lib/action'; diff --git a/plugins.default.json b/plugins.default.json index 066c94478..b54c6a54c 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -21,6 +21,7 @@ "talk-plugin-sort-most-respected", "talk-plugin-sort-newest", "talk-plugin-sort-oldest", - "talk-plugin-viewing-options" + "talk-plugin-viewing-options", + "talk-plugin-profile-settings" ] } diff --git a/plugins.js b/plugins.js index 8ffe89e3a..05d245c8a 100644 --- a/plugins.js +++ b/plugins.js @@ -4,6 +4,7 @@ const resolve = require('resolve'); const debug = require('debug')('talk:plugins'); const Joi = require('joi'); const amp = require('app-module-path'); +const hjson = require('hjson'); const pkg = require('./package.json'); const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON; @@ -33,7 +34,9 @@ try { pluginsPath = defaultPlugins; } - plugins = require(pluginsPath); + // Load/parse the plugin content using hjson. + const pluginContent = fs.readFileSync(pluginsPath, 'utf8'); + plugins = hjson.parse(pluginContent); } catch (err) { if (err.code === 'ENOENT') { console.error( @@ -73,6 +76,7 @@ const hookSchemas = { onConnect: Joi.func(), onDisconnect: Joi.func(), }), + connect: Joi.func().maxArity(1), }; /** @@ -293,14 +297,42 @@ class PluginManager { plugins[section] ); } + + this.deferredHooks = []; + this.ranDeferredHooks = false; } /** * Utility function which combines the Plugins.section and PluginSection.hook * calls. */ - get(section, hook) { - return this.section(section).hook(hook); + get(sectionName, hookName) { + return this.section(sectionName).hook(hookName); + } + + /** + * Utility function which combines the Plugins.section and PluginSection.hook + * calls and runs them when the `runDeferred` is called. + */ + defer(sectionName, hookName, callback) { + const plugins = this.section(sectionName).hook(hookName); + + // If we've already ran the callbacks, then we should run it immediately. + if (this.ranDeferredHooks) { + plugins.forEach(callback); + } else { + this.deferredHooks.push({ plugins, callback }); + } + } + + /** + * Calls all deferred hooks. + */ + runDeferred() { + this.deferredHooks.forEach(({ plugins, callback }) => + plugins.forEach(callback) + ); + this.ranDeferredHooks = true; } /** diff --git a/plugins/talk-plugin-akismet/yarn.lock b/plugins/talk-plugin-akismet/yarn.lock deleted file mode 100644 index ac97e590f..000000000 --- a/plugins/talk-plugin-akismet/yarn.lock +++ /dev/null @@ -1,141 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -akismet-api@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/akismet-api/-/akismet-api-4.0.1.tgz#1c771442f09316847132aa16171bb4fb708b6519" - dependencies: - bluebird "^3.1.1" - superagent "^3.8.0" - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - -bluebird@^3.1.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" - -combined-stream@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" - dependencies: - delayed-stream "~1.0.0" - -component-emitter@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" - -cookiejar@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.1.tgz#41ad57b1b555951ec171412a81942b1e8200d34a" - -core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - -debug@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - dependencies: - ms "2.0.0" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - -extend@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" - -form-data@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf" - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.5" - mime-types "^2.1.12" - -formidable@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.1.1.tgz#96b8886f7c3c3508b932d6bd70c4d3a88f35f1a9" - -inherits@~2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - -methods@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - -mime-db@~1.30.0: - version "1.30.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" - -mime-types@^2.1.12: - version "2.1.17" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a" - dependencies: - mime-db "~1.30.0" - -mime@^1.4.1: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - -process-nextick-args@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" - -qs@^6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" - -readable-stream@^2.0.5: - version "2.3.3" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~1.0.6" - safe-buffer "~5.1.1" - string_decoder "~1.0.3" - util-deprecate "~1.0.1" - -safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - -string_decoder@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" - dependencies: - safe-buffer "~5.1.0" - -superagent@^3.8.0: - version "3.8.2" - resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.2.tgz#e4a11b9d047f7d3efeb3bbe536d9ec0021d16403" - dependencies: - component-emitter "^1.2.0" - cookiejar "^2.1.0" - debug "^3.1.0" - extend "^3.0.0" - form-data "^2.3.1" - formidable "^1.1.1" - methods "^1.1.1" - mime "^1.4.1" - qs "^6.5.1" - readable-stream "^2.0.5" - -util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" diff --git a/plugins/talk-plugin-auth/client/login/containers/Main.js b/plugins/talk-plugin-auth/client/login/containers/Main.js index 05778133a..235bd6cde 100644 --- a/plugins/talk-plugin-auth/client/login/containers/Main.js +++ b/plugins/talk-plugin-auth/client/login/containers/Main.js @@ -4,10 +4,6 @@ import Main from '../components/Main'; import { connect } from 'plugin-api/beta/client/hocs'; import { bindActionCreators } from 'redux'; import { setView } from '../actions'; -import { - setAuthToken, - handleSuccessfulLogin, -} from 'plugin-api/beta/client/actions/auth'; import * as views from '../enums/views'; class MainContainer extends React.Component { @@ -24,7 +20,6 @@ class MainContainer extends React.Component { componentDidMount() { this.resizeHeight(); - this.listenToStorageChanges(); } componentDidUpdate(prevProps) { @@ -33,40 +28,6 @@ class MainContainer extends React.Component { } } - componentWillUnmount() { - this.unlisten(); - } - - listenToStorageChanges() { - window.addEventListener('storage', this.handleAuth); - } - - unlisten() { - window.removeEventListener('storage', this.handleAuth); - } - - // External logins store auth data into `auth`, we use it to detect - // a successful sign in. - handleAuth = e => { - if (e.key === 'auth') { - const { err, data } = JSON.parse(e.newValue); - if (err) { - console.error(err); - } else if (data && data.token) { - if (data.user) { - this.props.handleSuccessfulLogin(data.user, data.token); - } else { - this.props.setAuthToken(data.token); - } - this.unlisten(); - localStorage.removeItem('auth'); - window.close(); - } else { - console.error('auth was set, but did not contain a token'); - } - } - }; - render() { return
    ; } @@ -75,8 +36,6 @@ class MainContainer extends React.Component { MainContainer.propTypes = { view: PropTypes.string.isRequired, setView: PropTypes.func.isRequired, - handleSuccessfulLogin: PropTypes.func.isRequired, - setAuthToken: PropTypes.func.isRequired, }; const mapStateToProps = ({ talkPluginAuth: state }) => ({ @@ -87,8 +46,6 @@ const mapDispatchToProps = dispatch => bindActionCreators( { setView, - handleSuccessfulLogin, - setAuthToken, }, dispatch ); diff --git a/plugins/talk-plugin-comment-content/yarn.lock b/plugins/talk-plugin-comment-content/yarn.lock deleted file mode 100644 index 9f312c11d..000000000 --- a/plugins/talk-plugin-comment-content/yarn.lock +++ /dev/null @@ -1,112 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -asap@~2.0.3: - version "2.0.5" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f" - -core-js@^1.0.0: - version "1.2.7" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" - -encoding@^0.1.11: - version "0.1.12" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" - dependencies: - iconv-lite "~0.4.13" - -fbjs@^0.8.9: - version "0.8.12" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.12.tgz#10b5d92f76d45575fd63a217d4ea02bea2f8ed04" - dependencies: - core-js "^1.0.0" - isomorphic-fetch "^2.1.1" - loose-envify "^1.0.0" - object-assign "^4.1.0" - promise "^7.1.1" - setimmediate "^1.0.5" - ua-parser-js "^0.7.9" - -iconv-lite@~0.4.13: - version "0.4.18" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2" - -is-stream@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - -isomorphic-fetch@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" - dependencies: - node-fetch "^1.0.1" - whatwg-fetch ">=0.10.0" - -js-tokens@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7" - -linkify-it@^1.2.0: - version "1.2.4" - resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-1.2.4.tgz#0773526c317c8fd13bd534ee1d180ff88abf881a" - dependencies: - uc.micro "^1.0.1" - -loose-envify@^1.0.0, loose-envify@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" - dependencies: - js-tokens "^3.0.0" - -node-fetch@^1.0.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.1.tgz#899cb3d0a3c92f952c47f1b876f4c8aeabd400d5" - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - -object-assign@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - -promise@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf" - dependencies: - asap "~2.0.3" - -prop-types@^15.5.8: - version "15.5.10" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" - dependencies: - fbjs "^0.8.9" - loose-envify "^1.3.1" - -react-linkify@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/react-linkify/-/react-linkify-0.2.1.tgz#b28d3f9544539a622fec8d42b4800eb9d23bf981" - dependencies: - linkify-it "^1.2.0" - prop-types "^15.5.8" - tlds "^1.57.0" - -setimmediate@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - -tlds@^1.57.0: - version "1.189.0" - resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.189.0.tgz#b8cb46ea76dc2f4a01d45b8d907bf19a66e9f729" - -ua-parser-js@^0.7.9: - version "0.7.12" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.12.tgz#04c81a99bdd5dc52263ea29d24c6bf8d4818a4bb" - -uc.micro@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192" - -whatwg-fetch@>=0.10.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" diff --git a/plugins/talk-plugin-facebook-auth/client/actions.js b/plugins/talk-plugin-facebook-auth/client/actions.js index 5a9ebc66f..79f2d2a6a 100644 --- a/plugins/talk-plugin-facebook-auth/client/actions.js +++ b/plugins/talk-plugin-facebook-auth/client/actions.js @@ -1,7 +1,3 @@ export const loginWithFacebook = () => (dispatch, _, { rest }) => { - window.open( - `${rest.uri}/auth/facebook`, - 'Continue with Facebook', - 'menubar=0,resizable=0,width=500,height=500,top=200,left=500' - ); + window.location = `${rest.uri}/auth/facebook`; }; diff --git a/plugins/talk-plugin-facebook-auth/server/router.js b/plugins/talk-plugin-facebook-auth/server/router.js index 53b6c7e56..10d2469da 100644 --- a/plugins/talk-plugin-facebook-auth/server/router.js +++ b/plugins/talk-plugin-facebook-auth/server/router.js @@ -1,24 +1,31 @@ module.exports = router => { - const { passport, HandleAuthPopupCallback } = require('services/passport'); - /** - * Facebook auth endpoint, this will redirect the user immediatly to facebook + * Facebook auth endpoint, this will redirect the user immediately to Facebook * for authorization. */ - router.get( - '/api/v1/auth/facebook', - passport.authenticate('facebook', { + router.get('/api/v1/auth/facebook', (req, res, next) => { + const { + connectors: { services: { Passport: { passport } } }, + } = req.context; + + return passport.authenticate('facebook', { display: 'popup', authType: 'rerequest', scope: ['public_profile'], - }) - ); + })(req, res, next); + }); /** - * Facebook callback endpoint, this will send the user a html page designed to - * send back the user credentials upon sucesfull login. + * Facebook callback endpoint, this will send the user a HTML page designed to + * send back the user credentials upon successful login. */ router.get('/api/v1/auth/facebook/callback', (req, res, next) => { + const { + connectors: { + services: { Passport: { passport, HandleAuthPopupCallback } }, + }, + } = req.context; + // Perform the facebook login flow and pass the data back through the opener. passport.authenticate( 'facebook', diff --git a/plugins/talk-plugin-facebook-auth/yarn.lock b/plugins/talk-plugin-facebook-auth/yarn.lock deleted file mode 100644 index a2886dfbe..000000000 --- a/plugins/talk-plugin-facebook-auth/yarn.lock +++ /dev/null @@ -1,34 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -oauth@0.9.x: - version "0.9.15" - resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" - -passport-facebook@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/passport-facebook/-/passport-facebook-2.1.1.tgz#c39d0b52ae4d59163245a4e21a7b9b6321303311" - dependencies: - passport-oauth2 "1.x.x" - -passport-oauth2@1.x.x: - version "1.4.0" - resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad" - dependencies: - oauth "0.9.x" - passport-strategy "1.x.x" - uid2 "0.0.x" - utils-merge "1.x.x" - -passport-strategy@1.x.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" - -uid2@0.0.x: - version "0.0.3" - resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" - -utils-merge@1.x.x: - version "1.0.0" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8" diff --git a/plugins/talk-plugin-featured-comments/yarn.lock b/plugins/talk-plugin-featured-comments/yarn.lock deleted file mode 100644 index fb57ccd13..000000000 --- a/plugins/talk-plugin-featured-comments/yarn.lock +++ /dev/null @@ -1,4 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - diff --git a/plugins/talk-plugin-google-auth/.eslintrc.json b/plugins/talk-plugin-google-auth/.eslintrc.json new file mode 100644 index 000000000..78f7c2397 --- /dev/null +++ b/plugins/talk-plugin-google-auth/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk" +} diff --git a/plugins/talk-plugin-google-auth/client/.eslintrc.json b/plugins/talk-plugin-google-auth/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-google-auth/client/actions.js b/plugins/talk-plugin-google-auth/client/actions.js new file mode 100644 index 000000000..8b49bf39e --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/actions.js @@ -0,0 +1,3 @@ +export const loginWithGoogle = () => (dispatch, _, { rest }) => { + window.location = `${rest.uri}/auth/google`; +}; diff --git a/plugins/talk-plugin-google-auth/client/components/GoogleButton.css b/plugins/talk-plugin-google-auth/client/components/GoogleButton.css new file mode 100644 index 000000000..fb5e8abf3 --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/components/GoogleButton.css @@ -0,0 +1,13 @@ +.button { + background-color: #db3236; + border-color: #db3236; + color: rgb(255, 255, 255); + width: 100%; + box-sizing: border-box; + padding: 10px 20px; +} + +.button:hover { + background-color: #c71e22; + border-color: #c71e22; +} diff --git a/plugins/talk-plugin-google-auth/client/components/GoogleButton.js b/plugins/talk-plugin-google-auth/client/components/GoogleButton.js new file mode 100644 index 000000000..8451595f7 --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/components/GoogleButton.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { BareButton } from 'plugin-api/beta/client/components/ui'; +import styles from './GoogleButton.css'; + +export default ({ onClick, children }) => { + return ( + + {children} + + ); +}; diff --git a/plugins/talk-plugin-google-auth/client/components/SignIn.js b/plugins/talk-plugin-google-auth/client/components/SignIn.js new file mode 100644 index 000000000..a361cfe70 --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/components/SignIn.js @@ -0,0 +1,7 @@ +import React from 'react'; +import GoogleButton from '../containers/GoogleButton'; +import { t } from 'plugin-api/beta/client/services'; + +export default () => { + return {t('talk-plugin-google-auth.sign_in')}; +}; diff --git a/plugins/talk-plugin-google-auth/client/components/SignUp.js b/plugins/talk-plugin-google-auth/client/components/SignUp.js new file mode 100644 index 000000000..3193b02fb --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/components/SignUp.js @@ -0,0 +1,7 @@ +import React from 'react'; +import GoogleButton from '../containers/GoogleButton'; +import { t } from 'plugin-api/beta/client/services'; + +export default () => { + return {t('talk-plugin-google-auth.sign_up')}; +}; diff --git a/plugins/talk-plugin-google-auth/client/containers/GoogleButton.js b/plugins/talk-plugin-google-auth/client/containers/GoogleButton.js new file mode 100644 index 000000000..d1982e351 --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/containers/GoogleButton.js @@ -0,0 +1,9 @@ +import { connect } from 'plugin-api/beta/client/hocs'; +import { bindActionCreators } from 'redux'; +import { loginWithGoogle } from '../actions'; +import GoogleButton from '../components/GoogleButton'; + +const mapDispatchToProps = dispatch => + bindActionCreators({ onClick: loginWithGoogle }, dispatch); + +export default connect(null, mapDispatchToProps)(GoogleButton); diff --git a/plugins/talk-plugin-google-auth/client/index.js b/plugins/talk-plugin-google-auth/client/index.js new file mode 100644 index 000000000..cb8a8f059 --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/index.js @@ -0,0 +1,11 @@ +import SignIn from './components/SignIn'; +import SignUp from './components/SignUp'; +import translations from './translations.yml'; + +export default { + translations, + slots: { + authExternalSignIn: [SignIn], + authExternalSignUp: [SignUp], + }, +}; diff --git a/plugins/talk-plugin-google-auth/client/translations.yml b/plugins/talk-plugin-google-auth/client/translations.yml new file mode 100644 index 000000000..fd926f3ee --- /dev/null +++ b/plugins/talk-plugin-google-auth/client/translations.yml @@ -0,0 +1,20 @@ +en: + talk-plugin-google-auth: + sign_in: "Sign in with Google" + sign_up: "Sign up with Google" +es: + talk-plugin-google-auth: + google_sign_in: "Entrar con Google" + google_sign_up: "Registrarse con Google" +fr: + talk-plugin-google-auth: + google_sign_in: "Connectez-vous avec Google" + google_sign_up: "Inscrivez-vous avec Google" +zh_CN: + talk-plugin-google-auth: + google_sign_in: "使用 Google 帐号" + google_sign_up: "使用 Google 帐号" +zh_TW: + talk-plugin-google-auth: + google_sign_in: "使用 Google 帳號" + google_sign_up: "使用 Google 帳號" diff --git a/plugins/talk-plugin-google-auth/index.js b/plugins/talk-plugin-google-auth/index.js new file mode 100644 index 000000000..b9694613e --- /dev/null +++ b/plugins/talk-plugin-google-auth/index.js @@ -0,0 +1,7 @@ +const passport = require('./server/passport'); +const router = require('./server/router'); + +module.exports = { + passport, + router, +}; diff --git a/plugins/talk-plugin-google-auth/package.json b/plugins/talk-plugin-google-auth/package.json new file mode 100644 index 000000000..385556fce --- /dev/null +++ b/plugins/talk-plugin-google-auth/package.json @@ -0,0 +1,9 @@ +{ + "name": "talk-plugin-google-auth", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "passport-google-oauth2": "^0.1.6" + } +} diff --git a/plugins/talk-plugin-google-auth/server/passport.js b/plugins/talk-plugin-google-auth/server/passport.js new file mode 100644 index 000000000..c3463557b --- /dev/null +++ b/plugins/talk-plugin-google-auth/server/passport.js @@ -0,0 +1,41 @@ +const GoogleStrategy = require('passport-google-oauth2').Strategy; +const UsersService = require('services/users'); +const { ValidateUserLogin } = require('services/passport'); +let { ROOT_URL } = require('config'); + +if (ROOT_URL[ROOT_URL.length - 1] !== '/') { + ROOT_URL += '/'; +} + +module.exports = passport => { + if ( + process.env.TALK_GOOGLE_CLIENT_ID && + process.env.TALK_GOOGLE_CLIENT_SECRET && + process.env.TALK_ROOT_URL + ) { + passport.use( + new GoogleStrategy( + { + clientID: process.env.TALK_GOOGLE_CLIENT_ID, + clientSecret: process.env.TALK_GOOGLE_CLIENT_SECRET, + callbackURL: `${ROOT_URL}api/v1/auth/google/callback`, + passReqToCallback: true, + }, + async (req, accessToken, refreshToken, profile, done) => { + let user; + try { + user = await UsersService.findOrCreateExternalUser(profile); + } catch (err) { + return done(err.toString()); + } + + return ValidateUserLogin(profile, user, done); + } + ) + ); + } else if (process.env.NODE_ENV !== 'test') { + throw new Error( + 'Google cannot be enabled, missing one of TALK_GOOGLE_CLIENT_ID, TALK_GOOGLE_CLIENT_SECRET, TALK_ROOT_URL' + ); + } +}; diff --git a/plugins/talk-plugin-google-auth/server/router.js b/plugins/talk-plugin-google-auth/server/router.js new file mode 100644 index 000000000..4253919ff --- /dev/null +++ b/plugins/talk-plugin-google-auth/server/router.js @@ -0,0 +1,29 @@ +module.exports = router => { + const { passport, HandleAuthPopupCallback } = require('services/passport'); + + /** + * Google auth endpoint, this will redirect the user immediately to Google + * for authorization. + */ + router.get( + '/api/v1/auth/google', + passport.authenticate('google', { + display: 'popup', + authType: 'rerequest', + scope: ['profile'], + }) + ); + + /** + * Google callback endpoint, this will send the user a html page designed to + * send back the user credentials upon successful login. + */ + router.get('/api/v1/auth/google/callback', (req, res, next) => { + // Perform the Google login flow and pass the data back through the opener. + passport.authenticate( + 'google', + { session: false }, + HandleAuthPopupCallback(req, res, next) + )(req, res, next); + }); +}; diff --git a/plugins/talk-plugin-ignore-user/client/index.js b/plugins/talk-plugin-ignore-user/client/index.js index 3284915db..a7cb6c886 100644 --- a/plugins/talk-plugin-ignore-user/client/index.js +++ b/plugins/talk-plugin-ignore-user/client/index.js @@ -8,7 +8,7 @@ export default { slots: { authorMenuActions: [IgnoreUserAction], ignoreUserConfirmation: [IgnoreUserConfirmation], - profileSections: [IgnoredUserSection], + profileSettings: [IgnoredUserSection], }, translations, mutations: { diff --git a/plugins/talk-plugin-notifications-category-reply/.eslintrc.json b/plugins/talk-plugin-notifications-category-reply/.eslintrc.json new file mode 100644 index 000000000..78f7c2397 --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk" +} diff --git a/plugins/talk-plugin-notifications-category-reply/client/.eslintrc.json b/plugins/talk-plugin-notifications-category-reply/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-notifications-category-reply/client/containers/Toggle.js b/plugins/talk-plugin-notifications-category-reply/client/containers/Toggle.js new file mode 100644 index 000000000..61600a29f --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/client/containers/Toggle.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, gql } from 'react-apollo'; +import Toggle from 'talk-plugin-notifications/client/components/Toggle'; +import { t } from 'plugin-api/beta/client/services'; +import { withFragments } from 'plugin-api/beta/client/hocs'; + +class ToggleContainer extends React.Component { + constructor(props) { + super(props); + props.setTurnOffInputFragment({ onReply: false }); + + if (this.getOnReplySetting()) { + props.indicateOn(); + } + } + + componentWillReceiveProps(nextProps) { + const prevSetting = this.getOnReplySetting(this.props); + const nextSetting = this.getOnReplySetting(nextProps); + if (prevSetting && !nextSetting) { + nextProps.indicateOff(); + } else if (!prevSetting && nextSetting) { + nextProps.indicateOn(); + } + } + + getOnReplySetting = (props = this.props) => + props.root.me.notificationSettings.onReply; + + toggle = () => { + this.props.updateNotificationSettings({ + onReply: !this.getOnReplySetting(), + }); + }; + + render() { + return ( + + {t('talk-plugin-notifications-category-reply.toggle_description')} + + ); + } +} + +ToggleContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + indicateOn: PropTypes.func.isRequired, + indicateOff: PropTypes.func.isRequired, + setTurnOffInputFragment: PropTypes.func.isRequired, + updateNotificationSettings: PropTypes.func.isRequired, +}; + +const enhance = compose( + withFragments({ + root: gql` + fragment TalkNotificationsCategoryReply_Toggle_root on RootQuery { + me { + notificationSettings { + onReply + } + } + } + `, + }) +); + +export default enhance(ToggleContainer); diff --git a/plugins/talk-plugin-notifications-category-reply/client/graphql.js b/plugins/talk-plugin-notifications-category-reply/client/graphql.js new file mode 100644 index 000000000..623e72366 --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/client/graphql.js @@ -0,0 +1,33 @@ +import { gql } from 'react-apollo'; + +export default { + mutations: { + UpdateNotificationSettings: ({ + variables: { input }, + state: { auth: { user: { id } } }, + }) => ({ + update: proxy => { + if (input.onReply === undefined) { + return; + } + + const fragment = gql` + fragment TalkNotificationsCategoryReply_User_Fragment on User { + notificationSettings { + onReply + } + } + `; + const fragmentId = `User_${id}`; + const data = { + __typename: 'User', + notificationSettings: { + __typename: 'NotificationSettings', + onReply: input.onReply, + }, + }; + proxy.writeFragment({ fragment, id: fragmentId, data }); + }, + }), + }, +}; diff --git a/plugins/talk-plugin-notifications-category-reply/client/index.js b/plugins/talk-plugin-notifications-category-reply/client/index.js new file mode 100644 index 000000000..1e22c3a93 --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/client/index.js @@ -0,0 +1,11 @@ +import Toggle from './containers/Toggle'; +import translations from './translations.yml'; +import graphql from './graphql'; + +export default { + slots: { + notificationSettings: [Toggle], + }, + translations, + ...graphql, +}; diff --git a/plugins/talk-plugin-notifications-category-reply/client/translations.yml b/plugins/talk-plugin-notifications-category-reply/client/translations.yml new file mode 100644 index 000000000..703891de5 --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/client/translations.yml @@ -0,0 +1,3 @@ +en: + talk-plugin-notifications-category-reply: + toggle_description: My comment receives a reply diff --git a/plugins/talk-plugin-notifications-category-reply/index.js b/plugins/talk-plugin-notifications-category-reply/index.js new file mode 100644 index 000000000..32d90e05d --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/index.js @@ -0,0 +1,124 @@ +const { graphql } = require('graphql'); +const { get } = require('lodash'); +const path = require('path'); + +const handle = async (ctx, comment) => { + const { connectors: { graph: { schema } } } = ctx; + + // Check to see if this is a reply to an existing comment. + const parentID = get(comment, 'parent_id', null); + if (parentID === null) { + ctx.log.debug('could not get parent comment id'); + return; + } + + // Execute the graph request. + const reply = await graphql( + schema, + ` + query GetAuthorUserMetadata($comment_id: ID!) { + comment(id: $comment_id) { + id + user { + id + notificationSettings { + onReply + } + } + } + } + `, + {}, + ctx, + { comment_id: parentID } + ); + if (reply.errors) { + ctx.log.error({ err: reply.errors }, 'could not query for author metadata'); + return; + } + + // Check if the user has notifications enabled. + const enabled = get( + reply, + 'data.comment.user.notificationSettings.onReply', + false + ); + if (!enabled) { + return; + } + + const userID = get(reply, 'data.comment.user.id', null); + if (!userID) { + ctx.log.debug('could not get parent comment user id'); + return; + } + + // Check to see if this is yourself replying to yourself, if that's the case + // don't send a notification. + if (userID === get(comment, 'author_id')) { + ctx.log.debug('user id of parent comment is the same as the new comment'); + return; + } + + // The user does have notifications for replied comments enabled, queue the + // notification to be sent. + return { userID, date: comment.created_at, context: comment.id }; +}; + +const hydrate = async (ctx, category, context) => { + const { connectors: { graph: { schema } } } = ctx; + + const reply = await graphql( + schema, + ` + query GetNotificationData($context: ID!) { + comment(id: $context) { + id + asset { + title + url + } + user { + username + } + } + } + `, + {}, + ctx, + { context } + ); + if (reply.errors) { + throw reply.errors; + } + + const comment = get(reply, 'data.comment'); + const headline = get(comment, 'asset.title', null); + const replier = get(comment, 'user.username', null); + const assetURL = get(comment, 'asset.url', null); + const permalink = `${assetURL}?commentId=${comment.id}`; + + return [headline, replier, permalink]; +}; + +const handler = { handle, category: 'reply', event: 'commentAdded', hydrate }; + +module.exports = { + typeDefs: ` + type NotificationSettings { + onReply: Boolean! + } + + input NotificationSettingsInput { + onReply: Boolean + } + `, + resolvers: { + NotificationSettings: { + // onReply returns false by default if not specified. + onReply: settings => get(settings, 'onReply', false), + }, + }, + translations: path.join(__dirname, 'translations.yml'), + notifications: [handler], +}; diff --git a/plugins/talk-plugin-notifications-category-reply/translations.yml b/plugins/talk-plugin-notifications-category-reply/translations.yml new file mode 100644 index 000000000..b3421a1a3 --- /dev/null +++ b/plugins/talk-plugin-notifications-category-reply/translations.yml @@ -0,0 +1,6 @@ +en: + talk-plugin-notifications: + categories: + reply: + subject: "Some has replied to your comment on [{0}]" + body: "{0}\n{1} replied to your comment: {2}" \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/.eslintrc.json b/plugins/talk-plugin-notifications/.eslintrc.json new file mode 100644 index 000000000..78f7c2397 --- /dev/null +++ b/plugins/talk-plugin-notifications/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk" +} diff --git a/plugins/talk-plugin-notifications/client/.eslintrc.json b/plugins/talk-plugin-notifications/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-notifications/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-notifications/client/components/Settings.css b/plugins/talk-plugin-notifications/client/components/Settings.css new file mode 100644 index 000000000..12a7233e1 --- /dev/null +++ b/plugins/talk-plugin-notifications/client/components/Settings.css @@ -0,0 +1,27 @@ +.root { + margin-bottom: 20px; +} + +.innerSettings { + padding-left: 12px; +} + +.subtitle { + margin: 0; + margin-bottom: 8px; +} + +.turnOffButton { + padding: 2px 0; + color: #2099d6; + border-bottom: 1px solid #2099d6; + + &:disabled { + color: #e5e5e5; + border-bottom: 1px solid #e5e5e5; + } +} + +.notifcationSettingsSlot { + margin-bottom: 3px; +} diff --git a/plugins/talk-plugin-notifications/client/components/Settings.js b/plugins/talk-plugin-notifications/client/components/Settings.js new file mode 100644 index 000000000..dad6b2bed --- /dev/null +++ b/plugins/talk-plugin-notifications/client/components/Settings.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IfSlotIsNotEmpty } from 'plugin-api/beta/client/components'; +import { Slot } from 'plugin-api/beta/client/components'; +import { t } from 'plugin-api/beta/client/services'; +import styles from './Settings.css'; +import { BareButton } from 'plugin-api/beta/client/components/ui'; + +class Settings extends React.Component { + childFactory = el => { + const pluginName = el.type.talkPluginName; + const props = { + indicateOn: () => this.props.indicateOn(pluginName), + indicateOff: () => this.props.indicateOff(pluginName), + }; + return React.cloneElement(el, props); + }; + + render() { + const { + root, + setTurnOffInputFragment, + updateNotificationSettings, + turnOffAll, + turnOffButtonDisabled, + } = this.props; + + return ( + +
    +

    {t('talk-plugin-notifications.settings_title')}

    +

    + {t('talk-plugin-notifications.settings_subtitle')} +

    +
    + + + {t('talk-plugin-notifications.turn_off_all')} + +
    +
    +
    + ); + } +} + +Settings.propTypes = { + root: PropTypes.object, + indicateOn: PropTypes.func.isRequired, + indicateOff: PropTypes.func.isRequired, + setTurnOffInputFragment: PropTypes.func.isRequired, + updateNotificationSettings: PropTypes.func.isRequired, + turnOffAll: PropTypes.func.isRequired, + turnOffButtonDisabled: PropTypes.bool.isRequired, +}; + +export default Settings; diff --git a/plugins/talk-plugin-notifications/client/components/Toggle.css b/plugins/talk-plugin-notifications/client/components/Toggle.css new file mode 100644 index 000000000..f888da616 --- /dev/null +++ b/plugins/talk-plugin-notifications/client/components/Toggle.css @@ -0,0 +1,6 @@ +.title { + display: inline-block; + width: 270px; + cursor: pointer; + user-select: none; +} diff --git a/plugins/talk-plugin-notifications/client/components/Toggle.js b/plugins/talk-plugin-notifications/client/components/Toggle.js new file mode 100644 index 000000000..dbabbea52 --- /dev/null +++ b/plugins/talk-plugin-notifications/client/components/Toggle.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Checkbox } from 'plugin-api/beta/client/components/ui'; +import styles from './Toggle.css'; +import uuid from 'uuid/v4'; + +class Toggle extends React.Component { + id = uuid(); + + render() { + const { checked, onChange, children } = this.props; + return ( +
    + + +
    + ); + } +} + +Toggle.propTypes = { + checked: PropTypes.bool, + onChange: PropTypes.func, + children: PropTypes.node, +}; + +export default Toggle; diff --git a/plugins/talk-plugin-notifications/client/containers/Settings.js b/plugins/talk-plugin-notifications/client/containers/Settings.js new file mode 100644 index 000000000..ff07c6433 --- /dev/null +++ b/plugins/talk-plugin-notifications/client/containers/Settings.js @@ -0,0 +1,68 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { compose, gql } from 'react-apollo'; +import Settings from '../components/Settings'; +import { withFragments } from 'plugin-api/beta/client/hocs'; +import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils'; +import { withUpdateNotificationSettings } from '../mutations'; + +const slots = ['notificationSettings']; + +class SettingsContainer extends React.Component { + state = { + hasNotifications: [], + turnOffInput: {}, + }; + + indicateOn = plugin => + this.setState({ + hasNotifications: this.state.hasNotifications.concat(plugin), + }); + indicateOff = plugin => + this.setState({ + hasNotifications: this.state.hasNotifications.filter(i => i !== plugin), + }); + setTurnOffInputFragment = fragment => + this.setState({ + turnOffInput: { ...this.state.turnOffInput, ...fragment }, + }); + + turnOffAll = () => { + this.props.updateNotificationSettings(this.state.turnOffInput); + }; + + render() { + return ( + + ); + } +} + +SettingsContainer.propTypes = { + data: PropTypes.object, + root: PropTypes.object, + updateNotificationSettings: PropTypes.func.isRequired, +}; + +const enhance = compose( + withFragments({ + root: gql` + fragment TalkNotifications_Settings_root on RootQuery { + __typename + ${getSlotFragmentSpreads(slots, 'root')} + } + `, + }), + withUpdateNotificationSettings +); + +export default enhance(SettingsContainer); diff --git a/plugins/talk-plugin-notifications/client/graphql.js b/plugins/talk-plugin-notifications/client/graphql.js new file mode 100644 index 000000000..b48b63668 --- /dev/null +++ b/plugins/talk-plugin-notifications/client/graphql.js @@ -0,0 +1,23 @@ +import { gql } from 'react-apollo'; + +export default { + fragments: { + UpdateNotificationSettingsResponse: gql` + fragment Talk_UpdateNotificationSettingsResponse on UpdateNotificationSettingsResponse { + errors { + translation_key + } + } + `, + }, + mutations: { + UpdateNotificationSettings: () => ({ + optimisticResponse: { + updateNotificationSettings: { + __typename: 'UpdateNotificationSettingsResponse', + errors: null, + }, + }, + }), + }, +}; diff --git a/plugins/talk-plugin-notifications/client/index.js b/plugins/talk-plugin-notifications/client/index.js new file mode 100644 index 000000000..8d3b9a08a --- /dev/null +++ b/plugins/talk-plugin-notifications/client/index.js @@ -0,0 +1,11 @@ +import Settings from './containers/Settings'; +import translations from './translations.yml'; +import graphql from './graphql'; + +export default { + slots: { + profileSettings: [Settings], + }, + translations, + ...graphql, +}; diff --git a/plugins/talk-plugin-notifications/client/mutations.js b/plugins/talk-plugin-notifications/client/mutations.js new file mode 100644 index 000000000..83d50fc1f --- /dev/null +++ b/plugins/talk-plugin-notifications/client/mutations.js @@ -0,0 +1,23 @@ +import { withMutation } from 'plugin-api/beta/client/hocs'; +import { gql } from 'react-apollo'; + +export const withUpdateNotificationSettings = withMutation( + gql` + mutation UpdateNotificationSettings($input: NotificationSettingsInput!) { + updateNotificationSettings(input: $input) { + ...UpdateNotificationSettingsResponse + } + } + `, + { + props: ({ mutate }) => ({ + updateNotificationSettings: input => { + return mutate({ + variables: { + input, + }, + }); + }, + }), + } +); diff --git a/plugins/talk-plugin-notifications/client/translations.yml b/plugins/talk-plugin-notifications/client/translations.yml new file mode 100644 index 000000000..9088e6493 --- /dev/null +++ b/plugins/talk-plugin-notifications/client/translations.yml @@ -0,0 +1,5 @@ +en: + talk-plugin-notifications: + settings_title: Notifications + settings_subtitle: Receive notifications when + turn_off_all: I do not want to receive notifications diff --git a/plugins/talk-plugin-notifications/index.js b/plugins/talk-plugin-notifications/index.js new file mode 100644 index 000000000..b84647777 --- /dev/null +++ b/plugins/talk-plugin-notifications/index.js @@ -0,0 +1 @@ +module.exports = require('./server'); diff --git a/plugins/talk-plugin-notifications/package.json b/plugins/talk-plugin-notifications/package.json new file mode 100644 index 000000000..fbf9657fb --- /dev/null +++ b/plugins/talk-plugin-notifications/package.json @@ -0,0 +1,11 @@ +{ + "name": "@coralproject/talk-plugin-notifications", + "version": "1.0.0", + "description": "Adds notification support for Talk", + "main": "index.js", + "license": "Apache-2.0", + "private": false, + "dependencies": { + "linkifyjs": "^2.1.5" + } +} diff --git a/plugins/talk-plugin-notifications/server/NotificationManager.js b/plugins/talk-plugin-notifications/server/NotificationManager.js new file mode 100644 index 000000000..13a829cb1 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/NotificationManager.js @@ -0,0 +1,158 @@ +const { groupBy, forEach } = require('lodash'); +const debug = require('debug')('talk-plugin-notifications'); +const uuid = require('uuid/v4'); +const { UNSUBSCRIBE_SUBJECT } = require('./config'); + +class NotificationManager { + constructor(context) { + this.context = context; + this.registry = []; + } + + /** + * register will include the notification handlers on the manager. + * + * @param {Array} handlers notification handlers to register + */ + register(...handlers) { + this.registry.push(...handlers); + } + + /** + * attach will setup the notifications by walking the registry and loading all + * the notification types onto the handler. + * + * @param {Object} broker the event emitter for the Talk events + */ + attach(broker) { + const events = groupBy(this.registry, 'event'); + + forEach(events, (handlers, event) => { + debug( + `will now notify the [${handlers + .map(({ category }) => category) + .join(', ')}] handlers when the '${event}' event is emitted` + ); + broker.on(event, this.handle(handlers)); + }); + } + + /** + * handle will wrap a notification handler and attach it to the notification + * stream system. + * + * @param {Object} handler a notification handler + */ + handle(handlers) { + return async (...args) => + Promise.all( + handlers.map(async handler => { + // Grab the handler reference. + const { handle } = handler; + + // Create a system context to send down. + const ctx = this.context.forSystem(); + + try { + // Attempt to create a notification out of it. + const notification = await handle(ctx, ...args); + if (!notification) { + return; + } + + // Extract the notification details. + const { userID, date, context } = notification; + + // Send the notification. + return this.send(ctx, userID, date, handler, context); + } catch (err) { + ctx.log.error({ err }, 'could not handle the event'); + return; + } + }) + ); + } + + async send(ctx, userID, date, handler, context) { + const { + connectors: { + secrets: { jwt }, + config: { JWT_ISSUER, JWT_AUDIENCE }, + services: { Mailer, I18n: { t } }, + }, + loaders: { Settings }, + } = ctx; + const { category } = handler; + + try { + // Get the settings. + const { organizationName = null } = await Settings.load( + 'organizationName' + ); + if (organizationName === null) { + ctx.log.debug( + 'could not send the notification, organization name not in settings' + ); + return; + } + + // unsubscribeToken is the token used to perform the one-click + // unsubscribe. + const unsubscribeToken = jwt.sign({ + jti: uuid(), + iss: JWT_ISSUER, + aud: JWT_AUDIENCE, + sub: UNSUBSCRIBE_SUBJECT, + user: userID, + }); + + // Compose the subject for the email. + const subject = t( + `talk-plugin-notifications.categories.${category}.subject`, + organizationName + ); + + // Load the content into the comment. + const body = await this.getBody(ctx, handler, context); + + // Send the notification to the user. + const task = await Mailer.send({ + template: 'notification', + locals: { body, organizationName, unsubscribeToken }, + subject, + user: userID, + }); + + ctx.log.debug(`Sent the notification for Job.ID[${task.id}]`); + } catch (err) { + ctx.log.error( + { err, message: err.message }, + 'could not send the notification, an error occurred' + ); + return; + } + } + + /** + * getBody will return the body for the notification payload. + * + * @param {Object} ctx the graph context + * @param {Object} handler the notification handler + * @param {Mixed} context the notification context + */ + async getBody(ctx, handler, context) { + const { connectors: { services: { I18n: { t } } } } = ctx; + const { category } = handler; + + // Get the body replacement variables for the translation key. + const replacements = await handler.hydrate(ctx, category, context); + + // Generate the body. + return t( + `talk-plugin-notifications.categories.${category}.body`, + ...replacements + ); + } +} + +module.exports = NotificationManager; diff --git a/plugins/talk-plugin-notifications/server/config.js b/plugins/talk-plugin-notifications/server/config.js new file mode 100644 index 000000000..17bd94bf9 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/config.js @@ -0,0 +1,3 @@ +module.exports = { + UNSUBSCRIBE_SUBJECT: 'nunsub', +}; diff --git a/plugins/talk-plugin-notifications/server/connect.js b/plugins/talk-plugin-notifications/server/connect.js new file mode 100644 index 000000000..497025a5e --- /dev/null +++ b/plugins/talk-plugin-notifications/server/connect.js @@ -0,0 +1,72 @@ +const debug = require('debug')('talk-plugin-notifications'); +const path = require('path'); +const linkify = require('linkifyjs/html'); +const NotificationManager = require('./NotificationManager'); + +module.exports = connectors => { + const { + graph: { subscriptions: { getBroker }, Context }, + services: { Mailer, Plugins }, + } = connectors; + + // Setup the mailer. Other plugins registered before this one can replace the + // notification template by passing the same name + format for the template + // registration. + Mailer.templates.register( + path.join(__dirname, 'emails', 'notification.html.ejs'), + 'notification', + 'html' + ); + Mailer.templates.register( + path.join(__dirname, 'emails', 'notification.txt.ejs'), + 'notification', + 'txt' + ); + + // Register the mail helpers. You can register your own helpers by calling + // this function in another plugin. + Mailer.registerHelpers({ linkify }); + + // Get the handle for the broker to attach to notifications. + const broker = getBroker(); + + // Create a NotificationManager to handle notifications. + const manager = new NotificationManager(Context); + + // Get all the notification handlers. Additional plugins registered before + // this one can expose a `notifications` hook, that contains an array of + // notification handlers. + // + // A notification handler has the following form: + // + // { + // event // the graph event to listen for + // handle // the function called when the event is fired. It is called with + // // the (ctx, arg1, arg2, ...) where arg1, arg2 are args from the + // // event. + // category // the name representing the notification type (like 'reply') + // hydrate // returns the replacement parameters (in order!) to be used + // // in the translation. + // } + // + const notificationHandlers = Plugins.get('server', 'notifications').reduce( + (handlers, { plugin, notifications }) => { + debug( + `registered the ${ + plugin.name + } plugin for notifications ${notifications.map( + ({ category }) => category + )}` + ); + handlers.push(...notifications); + return handlers; + }, + [] + ); + + // Attach all the notification handlers. + manager.register(...notificationHandlers); + + // Attach the broker to the manager so it can listen for the events. + manager.attach(broker); +}; diff --git a/plugins/talk-plugin-notifications/server/emails/notification.html.ejs b/plugins/talk-plugin-notifications/server/emails/notification.html.ejs new file mode 100644 index 000000000..acc67a849 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/emails/notification.html.ejs @@ -0,0 +1,3 @@ +

    <%= linkify(body, {nl2br: true}) %>

    +

    <%= t('talk-plugin-notifications.templates.footer', organizationName) %>

    +

    <%= t('talk-plugin-notifications.templates.links.unsubscribe') %>

    \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/emails/notification.txt.ejs b/plugins/talk-plugin-notifications/server/emails/notification.txt.ejs new file mode 100644 index 000000000..dd6dc0ba3 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/emails/notification.txt.ejs @@ -0,0 +1,7 @@ +<%= body %> + +<%= t('talk-plugin-notifications.templates.footer', organizationName) %> + +<%= t('talk-plugin-notifications.templates.links.unsubscribe') %> + + <%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %> \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/index.js b/plugins/talk-plugin-notifications/server/index.js new file mode 100644 index 000000000..0c7e993b9 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/index.js @@ -0,0 +1,16 @@ +const path = require('path'); +const connect = require('./connect'); +const typeDefs = require('./typeDefs'); +const resolvers = require('./resolvers'); +const router = require('./router'); +const mutators = require('./mutators'); +const translations = path.join(__dirname, 'translations.yml'); + +module.exports = { + translations, + typeDefs, + resolvers, + mutators, + connect, + router, +}; diff --git a/plugins/talk-plugin-notifications/server/mutators.js b/plugins/talk-plugin-notifications/server/mutators.js new file mode 100644 index 000000000..5c0db9c8d --- /dev/null +++ b/plugins/talk-plugin-notifications/server/mutators.js @@ -0,0 +1,46 @@ +const { reduce, isNull, isEmpty } = require('lodash'); + +/** + * Reduce the settings to dotize the settings. + */ +function reduceSettings(newSettings, newValue, key) { + if (!isNull(newValue)) { + newSettings[`metadata.notifications.settings.${key}`] = newValue; + } + + return newSettings; +} + +/** + * Update the user notification settings. + */ +async function updateNotificationSettings(ctx, settings) { + const { connectors: { models: { User } }, user } = ctx; + + // Generate the settings set object, and just exit if we haven't changed + // anything. + const $set = reduce(settings, reduceSettings, {}); + if (isEmpty($set)) { + return; + } + + // Update the user. + return User.updateOne({ id: user.id }, { $set }); +} + +module.exports = ctx => { + let mutators = { + User: { + updateNotificationSettings: () => + Promise.reject(ctx.connectors.errors.ErrNotAuthorized), + }, + }; + + if (ctx.user) { + // TODO: check to see if the user is verified? + mutators.User.updateNotificationSettings = settings => + updateNotificationSettings(ctx, settings); + } + + return mutators; +}; diff --git a/plugins/talk-plugin-notifications/server/resolvers.js b/plugins/talk-plugin-notifications/server/resolvers.js new file mode 100644 index 000000000..0254361de --- /dev/null +++ b/plugins/talk-plugin-notifications/server/resolvers.js @@ -0,0 +1,19 @@ +const { get } = require('lodash'); + +module.exports = { + User: { + notificationSettings(user, args, { user: currentUser }) { + if ( + currentUser && + (currentUser.id === user.id || currentUser.can('VIEW_USER_STATUS')) + ) { + return get(user, 'metadata.notifications.settings', {}); + } + }, + }, + RootMutation: { + async updateNotificationSettings(obj, { input }, { mutators: { User } }) { + await User.updateNotificationSettings(input); + }, + }, +}; diff --git a/plugins/talk-plugin-notifications/server/router.js b/plugins/talk-plugin-notifications/server/router.js new file mode 100644 index 000000000..68919a936 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/router.js @@ -0,0 +1,95 @@ +const path = require('path'); +const { UNSUBSCRIBE_SUBJECT } = require('./config'); +const { get, isEmpty, reduce } = require('lodash'); + +module.exports = router => { + router.get('/account/unsubscribe-notifications', (req, res) => { + res.render(path.join(__dirname, 'views/unsubscribe-notifications')); + }); + + /** + * Verifies that the token is valid. + */ + const verifyToken = (req, res, next) => { + const { + connectors: { secrets: { jwt }, config: { JWT_ISSUER, JWT_AUDIENCE } }, + } = req.context; + const { token: tokenString = '' } = req.body; + if (!tokenString) { + return res.status(400).end(); + } + + jwt.verify( + tokenString, + { + issuer: JWT_ISSUER, + subject: UNSUBSCRIBE_SUBJECT, + audience: JWT_AUDIENCE, + }, + (err, token) => { + if (err) { + return res.status(400).end(); + } + + req.token = token; + next(); + } + ); + }; + + // Verifies that a token is valid. + router.post( + '/api/v1/account/unsubscribe-notifications/verify', + verifyToken, + (req, res) => { + res.status(204).end(); + } + ); + + router.post( + '/api/v1/account/unsubscribe-notifications', + verifyToken, + async (req, res, next) => { + const { connectors: { models: { User } } } = req.context; + const { user: userID } = req.token; + + try { + const user = await User.findOne({ id: userID }); + if (!user) { + return res.status(400).end(); + } + + // Get the notification settings. + const settings = get(user, 'metadata.notifications.settings', {}); + + // If they have no notification settings set to true, then we're done. + if (isEmpty(settings)) { + return res.status(204).end(); + } + + const update = reduce( + settings, + (updates, value, key) => { + if (value) { + updates[`metadata.notifications.settings.${key}`] = false; + } + + return updates; + }, + {} + ); + + if (isEmpty(update)) { + return res.status(204).end(); + } + + // Save the user. + await User.update({ id: userID }, { $set: update }); + + res.status(204).end(); + } catch (err) { + res.status(400).end(); + } + } + ); +}; diff --git a/plugins/talk-plugin-notifications/server/translations.yml b/plugins/talk-plugin-notifications/server/translations.yml new file mode 100644 index 000000000..97c931a91 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/translations.yml @@ -0,0 +1,12 @@ +en: + talk-plugin-notifications: + templates: + footer: "You received this notification because you are a commenter on {0} and you opted in to receive notifications." + links: + unsubscribe: "Unsubscribe from comment notifications" + unsubscribe_page: + unsubscribe: "Unsubscribe from comment notifications" + click_to_confirm: "Click below to confirm that you would like to unsubscribe from all notifications" + confirm: "Confirm" + are_unsubscribed: "You are now unsubscribed from all notifications." + token_invalid: "Unsubscribe link is invalid, click the link from a more recent email or visit a comment stream and login to change your notification preferences" \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/typeDefs.graphql b/plugins/talk-plugin-notifications/server/typeDefs.graphql new file mode 100644 index 000000000..6b2a43b2e --- /dev/null +++ b/plugins/talk-plugin-notifications/server/typeDefs.graphql @@ -0,0 +1,21 @@ +# NotificationSettings stores all the preferences related to notifications. +type NotificationSettings { } + +type User { + notificationSettings: NotificationSettings +} + +type UpdateNotificationSettingsResponse implements Response { + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +input NotificationSettingsInput { + +} + +type RootMutation { + # updateNotificationSettings will update the current user's notification + # settings. + updateNotificationSettings(input: NotificationSettingsInput!): UpdateNotificationSettingsResponse +} \ No newline at end of file diff --git a/plugins/talk-plugin-notifications/server/typeDefs.js b/plugins/talk-plugin-notifications/server/typeDefs.js new file mode 100644 index 000000000..7ab1954e1 --- /dev/null +++ b/plugins/talk-plugin-notifications/server/typeDefs.js @@ -0,0 +1,7 @@ +const fs = require('fs'); +const path = require('path'); + +module.exports = fs.readFileSync( + path.join(__dirname, 'typeDefs.graphql'), + 'utf8' +); diff --git a/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs new file mode 100644 index 000000000..a34a45dbb --- /dev/null +++ b/plugins/talk-plugin-notifications/server/views/unsubscribe-notifications.ejs @@ -0,0 +1,64 @@ + + + + + <%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %> + + + <%- include(root + '/partials/head') %> + + +
    +
    <%= t('talk-plugin-notifications.unsubscribe_page.token_invalid') %>
    + +
    + <%= t('talk-plugin-notifications.unsubscribe_page.click_to_confirm') %> + +
    +
    + + + + \ No newline at end of file diff --git a/plugins/talk-plugin-permalink/yarn.lock b/plugins/talk-plugin-permalink/yarn.lock deleted file mode 100644 index fb57ccd13..000000000 --- a/plugins/talk-plugin-permalink/yarn.lock +++ /dev/null @@ -1,4 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - diff --git a/plugins/talk-plugin-profile-settings/.eslintrc.json b/plugins/talk-plugin-profile-settings/.eslintrc.json new file mode 100644 index 000000000..78f7c2397 --- /dev/null +++ b/plugins/talk-plugin-profile-settings/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk" +} diff --git a/plugins/talk-plugin-profile-settings/client/.eslintrc.json b/plugins/talk-plugin-profile-settings/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-profile-settings/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-profile-settings/client/components/Tab.js b/plugins/talk-plugin-profile-settings/client/components/Tab.js new file mode 100644 index 000000000..86df0e8d1 --- /dev/null +++ b/plugins/talk-plugin-profile-settings/client/components/Tab.js @@ -0,0 +1,8 @@ +import React from 'react'; +import { t } from 'plugin-api/beta/client/services'; + +const Tab = () => { + return {t('talk-plugin-profile-settings.tab')}; +}; + +export default Tab; diff --git a/plugins/talk-plugin-profile-settings/client/components/TabPane.js b/plugins/talk-plugin-profile-settings/client/components/TabPane.js new file mode 100644 index 000000000..c0347a95c --- /dev/null +++ b/plugins/talk-plugin-profile-settings/client/components/TabPane.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Slot } from 'plugin-api/beta/client/components'; + +class TabPane extends React.Component { + render() { + const { data, root } = this.props; + return ( +
    + +
    + ); + } +} + +TabPane.propTypes = { + data: PropTypes.object, + root: PropTypes.object, +}; + +export default TabPane; diff --git a/plugins/talk-plugin-profile-settings/client/containers/TabPane.js b/plugins/talk-plugin-profile-settings/client/containers/TabPane.js new file mode 100644 index 000000000..6384c7160 --- /dev/null +++ b/plugins/talk-plugin-profile-settings/client/containers/TabPane.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { compose, gql } from 'react-apollo'; +import TabPane from '../components/TabPane'; +import { withFragments } from 'plugin-api/beta/client/hocs'; +import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils'; + +const slots = ['profileSettings']; + +class TabPaneContainer extends React.Component { + render() { + return ; + } +} + +const enhance = compose( + withFragments({ + root: gql` + fragment TalkProfileSettings_TabPane_root on RootQuery { + __typename + ${getSlotFragmentSpreads(slots, 'root')} + } + `, + }) +); + +export default enhance(TabPaneContainer); diff --git a/plugins/talk-plugin-profile-settings/client/index.js b/plugins/talk-plugin-profile-settings/client/index.js new file mode 100644 index 000000000..00e37a0f1 --- /dev/null +++ b/plugins/talk-plugin-profile-settings/client/index.js @@ -0,0 +1,11 @@ +import Tab from './components/Tab'; +import TabPane from './containers/TabPane'; +import translations from './translations.yml'; + +export default { + slots: { + profileTabs: [Tab], + profileTabPanes: [TabPane], + }, + translations, +}; diff --git a/plugins/talk-plugin-profile-settings/client/translations.yml b/plugins/talk-plugin-profile-settings/client/translations.yml new file mode 100644 index 000000000..cd8edb415 --- /dev/null +++ b/plugins/talk-plugin-profile-settings/client/translations.yml @@ -0,0 +1,27 @@ +en: + talk-plugin-profile-settings: + tab: Settings +de: + talk-plugin-profile-settings: + tab: Einstellungen +es: + talk-plugin-profile-settings: + tab: Configuración +fr: + talk-plugin-profile-settings: + tab: Paramètres +nl_NL: + talk-plugin-profile-settings: + tab: Instellingen +da: + talk-plugin-profile-settings: + tab: Indstillinger +pt_PR: + talk-plugin-profile-settings: + tab: Configurações +zh_TW: + talk-plugin-profile-settings: + tab: 設置 +zh_CN: + talk-plugin-profile-settings: + tab: 设置 diff --git a/plugins/talk-plugin-profile-settings/index.js b/plugins/talk-plugin-profile-settings/index.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/plugins/talk-plugin-profile-settings/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/plugins/talk-plugin-slack-notifications/server/config.js b/plugins/talk-plugin-slack-notifications/server/config.js index 7571565b0..11f76f138 100644 --- a/plugins/talk-plugin-slack-notifications/server/config.js +++ b/plugins/talk-plugin-slack-notifications/server/config.js @@ -5,7 +5,9 @@ const config = { if (process.env.NODE_ENV !== 'test' && !config.SLACK_WEBHOOK_URL) { // TODO this error should point users to Talk's Slack app once that's in place - throw new Error('Please set the TALK_SLACK_WEBHOOK_URL environment variable to use the slack-notifications plugin.'); + throw new Error( + 'Please set the TALK_SLACK_WEBHOOK_URL environment variable to use the slack-notifications plugin.' + ); } module.exports = config; diff --git a/plugins/talk-plugin-slack-notifications/server/hooks.js b/plugins/talk-plugin-slack-notifications/server/hooks.js index bda9dc4f5..4133f795d 100644 --- a/plugins/talk-plugin-slack-notifications/server/hooks.js +++ b/plugins/talk-plugin-slack-notifications/server/hooks.js @@ -1,5 +1,5 @@ const fetch = require('node-fetch'); -const {SLACK_WEBHOOK_URL, SLACK_WEBHOOK_TIMEOUT} = require('./config'); +const { SLACK_WEBHOOK_URL, SLACK_WEBHOOK_TIMEOUT } = require('./config'); const debug = require('debug')('talk:plugin:slack-notifications'); // We don't add the hooks during _test_ as the Slack API is not available. @@ -10,14 +10,9 @@ if (process.env.NODE_ENV === 'test') { module.exports = { RootMutation: { createComment: { - async post(_, {input}, context, _info, result) { + async post(root, args, context, info, result) { debug(`Posting notification to Slack webhook: ${SLACK_WEBHOOK_URL}`); - const { - comment: { - body: text, - created_at: createdAt - } - } = result; + const { comment: { body: text, created_at: createdAt } } = result; const username = context.user.username; process.nextTick(async () => { const response = await fetch(SLACK_WEBHOOK_URL, { @@ -27,16 +22,22 @@ module.exports = { }, timeout: SLACK_WEBHOOK_TIMEOUT, body: JSON.stringify({ - attachments: [{ - text: text, - footer: `Comment by ${username}`, - ts: Math.floor(Date.parse(createdAt) / 1000), - }] + attachments: [ + { + text: text, + footer: `Comment by ${username}`, + ts: Math.floor(Date.parse(createdAt) / 1000), + }, + ], }), }); if (!response.ok) { const responseText = await response.text(); - console.trace(`Posting to Slack failed with HTTP code ${response.status} and body '${responseText}'`); + console.trace( + `Posting to Slack failed with HTTP code ${ + response.status + } and body '${responseText}'` + ); } }); return result; diff --git a/plugins/talk-plugin-subscriber/yarn.lock b/plugins/talk-plugin-subscriber/yarn.lock deleted file mode 100644 index 60fdd97df..000000000 --- a/plugins/talk-plugin-subscriber/yarn.lock +++ /dev/null @@ -1,11 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -moment@^2.18.1: - version "2.18.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f" - -momentjs@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/momentjs/-/momentjs-2.0.0.tgz#73df904b4fa418f6e3c605e831cef6ed5518ebd4" diff --git a/plugins/talk-plugin-toxic-comments/yarn.lock b/plugins/talk-plugin-toxic-comments/yarn.lock deleted file mode 100644 index f73f43463..000000000 --- a/plugins/talk-plugin-toxic-comments/yarn.lock +++ /dev/null @@ -1,7 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -ms@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" diff --git a/routes/index.js b/routes/index.js index 3727f76bb..143ceefd8 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,7 +1,6 @@ const SetupService = require('../services/setup'); const authentication = require('../middleware/authentication'); const cookieParser = require('cookie-parser'); -const debug = require('debug')('talk:routes'); const enabled = require('debug').enabled; const errors = require('../errors'); const express = require('express'); @@ -14,6 +13,7 @@ const staticMiddleware = require('express-static-gzip'); const { DISABLE_STATIC_SERVER } = require('../config'); const { passport } = require('../services/passport'); const { MOUNT_PATH } = require('../url'); +const context = require('../middleware/context'); const router = express.Router(); @@ -101,9 +101,12 @@ plugins.get('server', 'passport').forEach(plugin => { // Setup the PassportJS Middleware. router.use(passport.initialize()); +// Setup the Graph Context on the router. +router.use(authentication, context); + // Attach the authentication middleware, this will be responsible for decoding // (if present) the JWT on the request. -router.use('/api', authentication, require('./api')); +router.use('/api', require('./api')); //============================================================================== // DEVELOPMENT ROUTES @@ -136,17 +139,8 @@ if (process.env.NODE_ENV !== 'production') { }); } -//============================================================================== -// PLUGIN ROUTES -//============================================================================== - -// Inject server route plugins. -plugins.get('server', 'router').forEach(plugin => { - debug(`added plugin '${plugin.plugin.name}'`); - - // Pass the root router to the plugin to mount it's routes. - plugin.router(router); -}); +// Mount the plugin routes. +router.use(require('./plugins')); //============================================================================== // ERROR HANDLING diff --git a/routes/plugins.js b/routes/plugins.js new file mode 100644 index 000000000..647acdb75 --- /dev/null +++ b/routes/plugins.js @@ -0,0 +1,23 @@ +const express = require('express'); +const debug = require('debug')('talk:routes:plugins'); +const plugins = require('../services/plugins'); +const staticTemplate = require('../middleware/staticTemplate'); + +const router = express.Router(); + +// Routes mounted from plugins won't have access to our internal partials +// directory, so we should make that available. +router.use(staticTemplate, (req, res, next) => { + res.locals.root = res.app.get('views'); + next(); +}); + +// Inject server route plugins. +plugins.get('server', 'router').forEach(plugin => { + debug(`added plugin '${plugin.plugin.name}'`); + + // Pass the root router to the plugin to mount it's routes. + plugin.router(router); +}); + +module.exports = router; diff --git a/serve.js b/serve.js index 77de110cf..a96b12202 100644 --- a/serve.js +++ b/serve.js @@ -2,10 +2,10 @@ const app = require('./app'); const debug = require('debug')('talk:cli:serve'); const errors = require('./errors'); const { createServer } = require('http'); -const scraper = require('./services/scraper'); -const mailer = require('./services/mailer'); +const jobs = require('./jobs'); const MigrationService = require('./services/migration'); const SetupService = require('./services/setup'); +const PluginsService = require('./services/plugins'); const kue = require('./services/kue'); const mongoose = require('./services/mongoose'); const cache = require('./services/cache'); @@ -78,7 +78,10 @@ async function onListening() { /** * Start the app. */ -async function serve({ jobs = false, websockets = false } = {}) { +async function serve({ jobs: processJobs = false, websockets = false } = {}) { + // Run the deferred plugins. + PluginsService.runDeferred(); + // Start the cache instance. await cache.init(); @@ -132,19 +135,16 @@ async function serve({ jobs = false, websockets = false } = {}) { }); // Enable job processing on the thread if enabled. - if (jobs) { - // Start the scraper processor. - scraper.process(); - + if (processJobs) { // Start the mail processor. - mailer.process(); + jobs.process(); } // Define a safe shutdown function to call in the event we need to shutdown // because the node hooks are below which will interrupt the shutdown process. // Shutdown the mongoose connection, the app server, and the scraper. util.onshutdown([ - () => (jobs ? kue.Task.shutdown() : null), + () => (processJobs ? kue.Task.shutdown() : null), () => mongoose.disconnect(), () => server.close(), ]); diff --git a/services/actions.js b/services/actions.js index 055a812f8..1c95e5128 100644 --- a/services/actions.js +++ b/services/actions.js @@ -3,8 +3,34 @@ const CommentModel = require('../models/comment'); const UserModel = require('../models/user'); const _ = require('lodash'); const errors = require('../errors'); -const events = require('./events'); -const { ACTIONS_NEW, ACTIONS_DELETE } = require('./events/constants'); + +const incrActionCounts = async (action, value) => { + const ACTION_TYPE = action.action_type.toLowerCase(); + + const query = { id: action.item_id }; + const update = { + [`action_counts.${ACTION_TYPE}`]: value, + }; + + if (action.group_id && action.group_id.length > 0) { + const GROUP_ID = action.group_id.toLowerCase(); + + update[`action_counts.${ACTION_TYPE}_${GROUP_ID}`] = value; + } + + switch (action.item_type) { + case 'USERS': + return UserModel.update(query, { + $inc: update, + }); + case 'COMMENTS': + return CommentModel.update(query, { + $inc: update, + }); + default: + return; + } +}; /** * findOnlyOneAndUpdate will perform a fondOneAndUpdate on the mongo collection @@ -58,14 +84,12 @@ module.exports = class ActionsService { /** * Inserts an action. * - * @param {String} item_id identifier of the item (uuid) - * @param {String} user_id user id of the action (uuid) * @param {String} action the new action to the item * @return {Promise} */ static async create(action) { - // Actions are made unique by using a query that can be reproducable, i.e., - // not containing user inputable values. + // Actions are made unique by using a query that can be reproducible, i.e., + // not containing user modifiable values. let foundAction = await findOnlyOneAndUpdate( { action_type: action.action_type, @@ -80,8 +104,7 @@ module.exports = class ActionsService { } ); - // Emit that there was a new action created. - await events.emitAsync(ACTIONS_NEW, foundAction); + await incrActionCounts(action, 1); return foundAction; } @@ -119,19 +142,6 @@ module.exports = class ActionsService { }); } - /** - * Finds all comments for a specific action. - * - * @param {String} action_type type of action - * @param {String} item_type type of item the action is on - */ - static findByType(action_type, item_type) { - return ActionModel.find({ - action_type: action_type, - item_type: item_type, - }); - } - /** * delete will remove the record from the collection if it exists. Otherwise * it will do nothing. This will then return the deleted action. @@ -148,8 +158,7 @@ module.exports = class ActionsService { return; } - // Emit that the action was deleted. - await events.emitAsync(ACTIONS_DELETE, action); + await incrActionCounts(action, -1); return action; } @@ -170,68 +179,3 @@ module.exports = class ActionsService { ); } }; - -const incrActionCounts = async (action, value) => { - const ACTION_TYPE = action.action_type.toLowerCase(); - - const update = { - [`action_counts.${ACTION_TYPE}`]: value, - }; - - if (action.group_id && action.group_id.length > 0) { - const GROUP_ID = action.group_id.toLowerCase(); - - update[`action_counts.${ACTION_TYPE}_${GROUP_ID}`] = value; - } - - try { - switch (action.item_type) { - case 'USERS': - return UserModel.update( - { - id: action.item_id, - }, - { - $inc: update, - } - ); - case 'COMMENTS': - return CommentModel.update( - { - id: action.item_id, - }, - { - $inc: update, - } - ); - default: - throw new Error('Invalid item type for action summary monitoring'); - } - } catch (err) { - console.error(`Can't mutate the action_counts.${ACTION_TYPE}:`, err); - } -}; - -// When a new action is created, modify the comment. -events.on(ACTIONS_NEW, async action => { - if ( - !action || - (action.item_type !== 'COMMENTS' && action.item_type !== 'USERS') - ) { - return; - } - - return incrActionCounts(action, 1); -}); - -// When an action is deleted, remove the action count on the comment. -events.on(ACTIONS_DELETE, async action => { - if ( - !action || - (action.item_type !== 'COMMENTS' && action.item_type !== 'USERS') - ) { - return; - } - - return incrActionCounts(action, -1); -}); diff --git a/services/comments.js b/services/comments.js index 68f991324..fd6f2282d 100644 --- a/services/comments.js +++ b/services/comments.js @@ -1,22 +1,36 @@ const CommentModel = require('../models/comment'); const debug = require('debug')('talk:services:comments'); -const ActionsService = require('./actions'); const SettingsService = require('./settings'); const cloneDeep = require('lodash/cloneDeep'); const errors = require('../errors'); -const events = require('./events'); const merge = require('lodash/merge'); -const { COMMENTS_NEW, COMMENTS_EDIT } = require('./events/constants'); -module.exports = class CommentsService { +const incrReplyCount = async (comment, value) => { + try { + await CommentModel.update( + { + id: comment.parent_id, + }, + { + $inc: { + reply_count: value, + }, + } + ); + } catch (err) { + console.error("Can't mutate the reply count:", err); + } +}; + +module.exports = { /** * Creates a new Comment that came from a public source. * @param {Object} input either a single comment or an array of comments. * @return {Promise} */ - static async publicCreate(input) { + publicCreate: async input => { // Extract the parent_id from the comment, if there is one. const { status = 'NONE', parent_id = null } = input; const created_at = new Date(); @@ -54,27 +68,10 @@ module.exports = class CommentsService { ); // Emit that the comment was created! - await events.emitAsync(COMMENTS_NEW, comment); + await incrReplyCount(comment, 1); return comment; - } - - /** - * lastUnmoderatedStatus will retrieve the last status before this one. - * - * @param {Object} comment the comment to get the last status of - */ - static lastUnmoderatedStatus(comment) { - const UNMODERATED_STATUSES = ['NONE', 'PREMOD']; - - for (let i = comment.status_history.length - 1; i >= 0; i--) { - const { type } = comment.status_history[i]; - - if (UNMODERATED_STATUSES.includes(type)) { - return type; - } - } - } + }, /** * Edit a Comment. @@ -84,7 +81,7 @@ module.exports = class CommentsService { * @param {String} body the new Comment body * @param {String} status the new Comment status */ - static async edit({ id, author_id, body, status }) { + edit: async ({ id, author_id, body, status }) => { const EDITABLE_STATUSES = ['NONE', 'PREMOD', 'ACCEPTED']; const created_at = new Date(); @@ -125,7 +122,7 @@ module.exports = class CommentsService { if (originalComment == null) { // Try to get the comment. - const comment = await CommentsService.findById(id); + const comment = await CommentModel.findOne({ id }); if (comment == null) { debug('rejecting comment edit because comment was not found'); throw errors.ErrNotFound; @@ -169,86 +166,8 @@ module.exports = class CommentsService { created_at, }); - await events.emitAsync(COMMENTS_EDIT, originalComment, editedComment); - return editedComment; - } - - /** - * Finds a comment by the id. - * @param {String} id identifier of comment (uuid) - * @return {Promise} - */ - static findById(id) { - return CommentModel.findOne({ id }); - } - - /** - * Finds ALL the comments by the asset_id. - * @param {String} asset_id identifier of the asset which owns this comment (uuid) - * @return {Promise} - */ - static findByAssetId(asset_id) { - return CommentModel.find({ - asset_id, - }); - } - - /** - * findByAssetIdWithStatuses finds all the comments where the asset id matches - * what's provided and the status is one of the ones listed in the statuses - * array. - * @param {String} asset_id the asset id to search by - * @param {Array} [statuses=[]] the array of statuses to search by - * @return {Promise} resolves to an array of comments - */ - static findByAssetIdWithStatuses(asset_id, statuses = []) { - return CommentModel.find({ - asset_id, - status: { - $in: statuses, - }, - }); - } - - /** - * Find comments by an action that was performed on them. - * @param {String} action_type the type of action that was performed on the comment - * @return {Promise} - */ - static findByActionType(action_type) { - return ActionsService.findCommentsIdByActionType( - action_type, - 'COMMENTS' - ).then(actions => - CommentModel.find({ - id: { - $in: actions.map(a => a.item_id), - }, - }) - ); - } - - /** - * Find comment id's where the action type matches the argument. - * @param {String} action_type the type of action that was performed on the comment - * @return {Promise} - */ - static findIdsByActionType(action_type) { - return ActionsService.findCommentsIdByActionType( - action_type, - 'COMMENTS' - ).then(actions => actions.map(a => a.item_id)); - } - - /** - * Find comments by current status - * @param {String} status status of the comment to search for - * @return {Promise} resovles to comment array - */ - static findByStatus(status = 'NONE') { - return CommentModel.find({ status }); - } + }, /** * Pushes a new status in for the user. @@ -258,7 +177,7 @@ module.exports = class CommentsService { * moderation action * @return {Promise} */ - static async pushStatus(id, status, assigned_by = null) { + pushStatus: async (id, status, assigned_by = null) => { const created_at = new Date(); const originalComment = await CommentModel.findOneAndUpdate( { id }, @@ -286,83 +205,16 @@ module.exports = class CommentsService { }); editedComment.status = status; - // Emit that the comment was edited, and pass the original comment and the - // edited comment. - await events.emitAsync(COMMENTS_EDIT, originalComment, editedComment); + // If the comment was visible before, and now it isn't, decrement the count; + if (originalComment.visible && !editedComment.visible) { + await incrReplyCount(editedComment, -1); + } + + // If the comment was not visible before, and now it is, increment the count. + if (!originalComment.visible && editedComment.visible) { + await incrReplyCount(editedComment, 1); + } return editedComment; - } - - /** - * Add an action to the comment. - * @param {String} item_id identifier of the comment (uuid) - * @param {String} user_id user id of the action (uuid) - * @param {String} action the new action to the comment - * @return {Promise} - */ - static addAction(item_id, user_id, action_type, metadata = {}) { - return ActionsService.create({ - item_id, - item_type: 'COMMENTS', - user_id, - action_type, - metadata, - }); - } + }, }; - -//============================================================================== -// Event Hooks -//============================================================================== - -const incrReplyCount = async (comment, value) => { - try { - await CommentModel.update( - { - id: comment.parent_id, - }, - { - $inc: { - reply_count: value, - }, - } - ); - } catch (err) { - console.error("Can't mutate the reply count:", err); - } -}; - -// When a comment is created, if it is a reply, increment the reply count on the -// parent's document. -events.on(COMMENTS_NEW, async comment => { - if ( - !comment || // Check that the comment is defined. - (!comment.parent_id || comment.parent_id.length === 0) || // Check that the comment has a parent (is a reply). - !(comment.status === 'NONE' || comment.status === 'ACCEPTED') // Check that the comment is visible. - ) { - return; - } - - return incrReplyCount(comment, 1); -}); - -// When a comment is edited, if the visability changed publicly, then modify the -// comment. -events.on(COMMENTS_EDIT, async (originalComment, editedComment) => { - if ( - !editedComment || // Check that the comment is defined. - (!editedComment.parent_id || editedComment.parent_id.length === 0) // Check that the comment has a parent (is a reply). - ) { - return; - } - - // If the comment was visible before, and now it isn't, decrement the count; - if (originalComment.visible && !editedComment.visible) { - return incrReplyCount(editedComment, -1); - } - - // If the comment was not visible before, and now it is, increment the count. - if (!originalComment.visible && editedComment.visible) { - return incrReplyCount(editedComment, 1); - } -}); diff --git a/services/events/constants.js b/services/events/constants.js deleted file mode 100644 index f3aa84d62..000000000 --- a/services/events/constants.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - USERS_NEW: 'USERS_NEW', - ACTIONS_DELETE: 'ACTIONS_DELETE', - ACTIONS_NEW: 'ACTIONS_NEW', - COMMENTS_NEW: 'COMMENTS_NEW', - COMMENTS_EDIT: 'COMMENTS_EDIT', - USERS_SUSPENSION_CHANGE: 'USERS_SUSPENSION_CHANGE', - USERS_BAN_CHANGE: 'USERS_BAN_CHANGE', - USERS_USERNAME_STATUS_CHANGE: 'USERS_USERNAME_STATUS_CHANGE', -}; diff --git a/services/events/index.js b/services/events/index.js deleted file mode 100644 index 2698df653..000000000 --- a/services/events/index.js +++ /dev/null @@ -1,36 +0,0 @@ -const { EventEmitter2 } = require('eventemitter2'); -const constants = require('./constants'); -const debug = require('debug')('talk:services:events'); -const enabled = require('debug').enabled('talk:services:events'); - -const emitter = new EventEmitter2({ - wildcard: true, -}); - -// If event debugging is enabled, bind the debugger to all events being emitted -// and log a debug message. -if (enabled) { - emitter.onAny(function(event) { - debug( - `[${event}] ${arguments.length - 1} argument${ - arguments.length - 1 === 1 ? '' : 's' - }` - ); - }); -} - -// Allow any number of listeners to attach to this. -emitter.setMaxListeners(0); - -// The default error handler. -emitter.on('error', err => { - console.error('events error:', err); -}); - -emitter.on('newListener', event => { - if (!(event in constants)) { - throw new Error(`Event[${event}] not a valid event name`); - } -}); - -module.exports = emitter; diff --git a/services/jwt.js b/services/jwt.js index 356adbb62..c36ea4c97 100644 --- a/services/jwt.js +++ b/services/jwt.js @@ -101,9 +101,12 @@ class Secret { jwt.verify( token, this.verifiyingKey, - Object.assign({}, options, { - algorithms: [this.algorithm], - }), + omitBy( + merge({}, options, { + algorithms: [this.algorithm], + }), + isUndefined + ), callback ); } diff --git a/services/logging.js b/services/logging.js new file mode 100644 index 000000000..48367f834 --- /dev/null +++ b/services/logging.js @@ -0,0 +1,16 @@ +const { version } = require('../package.json'); +const Logger = require('bunyan'); +const uuid = require('uuid/v1'); + +// Create the logging instance that all logger's are branched from. +function createLogger(name, id = uuid()) { + return new Logger({ + src: true, + name, + id, + version, + serializers: { req: Logger.stdSerializers.req }, + }); +} + +module.exports = { createLogger }; diff --git a/services/mailer.js b/services/mailer.js deleted file mode 100644 index 67cb188be..000000000 --- a/services/mailer.js +++ /dev/null @@ -1,148 +0,0 @@ -const debug = require('debug')('talk:services:mailer'); -const nodemailer = require('nodemailer'); -const kue = require('./kue'); -const i18n = require('./i18n'); -const path = require('path'); -const fs = require('fs-extra'); -const _ = require('lodash'); -const { TEMPLATE_LOCALS } = require('../middleware/staticTemplate'); - -const { - SMTP_HOST, - SMTP_USERNAME, - SMTP_PORT, - SMTP_PASSWORD, - SMTP_FROM_ADDRESS, - EMAIL_SUBJECT_PREFIX, -} = require('../config'); - -// load all the templates as strings -const templates = { - data: {}, -}; - -// load the templates per request during development -templates.render = async (name, format = 'txt', context) => { - if (process.env.NODE_ENV === 'production') { - // If we are in production mode, check the view cache. - const view = _.get(templates.data, [name, format], null); - if (view !== null) { - return view(context); - } - } - - const filename = path.join( - __dirname, - 'email', - [name, format, 'ejs'].join('.') - ); - const file = await fs.readFile(filename, 'utf8'); - const view = _.template(file); - - if (process.env.NODE_ENV === 'production') { - // If we are in production mode, fill the view cache. - _.set(templates.data, [name, format], view); - } - - return view(context); -}; - -const mailer = {}; - -// enabled is true when the required configuration is available. When testing -// is enabled, we will be simulating that emails are being sent, because in a -// production system, emails should and would be sent. -mailer.enabled = - Boolean(SMTP_HOST && SMTP_USERNAME && SMTP_PASSWORD && SMTP_FROM_ADDRESS) || - process.env.NODE_ENV === 'test'; - -if (mailer.enabled) { - const options = { - host: SMTP_HOST, - auth: { - user: SMTP_USERNAME, - pass: SMTP_PASSWORD, - }, - }; - - if (SMTP_PORT) { - try { - options.port = parseInt(SMTP_PORT); - } catch (e) { - throw new Error('TALK_SMTP_PORT is not an integer'); - } - } else { - options.port = 25; - } - - mailer.transport = nodemailer.createTransport(options); -} - -/** - * Create the new Task kue. - */ -mailer.task = new kue.Task({ - name: 'mailer', -}); - -/** - * send will create a new message and send it. - */ -mailer.send = async options => { - if (!mailer.enabled) { - const err = new Error( - 'sending email is not enabled because required configuration is not available' - ); - console.warn(err); - return; - } - - // Create the new locals object and attach the static locals and the i18n - // framework. - const locals = _.merge({}, options.locals, TEMPLATE_LOCALS, { t: i18n.t }); - - // Render the templates. - const [html, text] = await Promise.all( - ['html', 'txt'].map(fmt => { - return templates.render(options.template, fmt, locals); - }) - ); - - // Create the job to send the email later. - return mailer.task.create({ - title: 'Mail', - message: { - to: options.to, - subject: `${EMAIL_SUBJECT_PREFIX} ${options.subject}`, - text, - html, - }, - }); -}; - -/** - * Start the queue processor for the mailer job. - */ -mailer.process = () => { - debug(`Now processing ${mailer.task.name} jobs`); - - return mailer.task.process(({ id, data }, done) => { - debug(`Starting to send mail for Job[${id}]`); - - // Set the `from` field. - data.message.from = SMTP_FROM_ADDRESS; - - // Actually send the email. - mailer.transport.sendMail(data.message, err => { - if (err) { - debug(`Failed to send mail for Job[${id}]:`, err); - return done(err); - } - - debug(`Finished sending mail for Job[${id}]`); - return done(); - }); - }); -}; - -module.exports = mailer; diff --git a/services/mailer/index.js b/services/mailer/index.js new file mode 100644 index 000000000..5f6adedef --- /dev/null +++ b/services/mailer/index.js @@ -0,0 +1,112 @@ +const kue = require('../kue'); +const i18n = require('../i18n'); +const { get, merge, isObject } = require('lodash'); +const { TEMPLATE_LOCALS } = require('../../middleware/staticTemplate'); +const debug = require('debug')('talk:services:mailer'); +const { SMTP_FROM_ADDRESS, EMAIL_SUBJECT_PREFIX } = require('../../config'); +const templates = require('./templates'); + +// createRecipient will extract the recipient details from the options. +const createRecipient = options => { + // Try to get the email if it was explicitly provided. + const email = get(options, 'email'); + if (email) { + return { email }; + } + + // Try to get the user if the email wasn't explicitly provided. + const user = isObject(options.user) ? get(options.user, 'id') : options.user; + if (user) { + return { user }; + } + + // If we don't have a user or a email, we can't send an email. + throw new Error('user/email not provided'); +}; + +// createMessage creates a message payload to send a email to a user. +const createMessage = async options => { + // Create the new locals object and attach the static locals and the i18n + // framework. + const locals = merge({}, mailer.helpers, options.locals, TEMPLATE_LOCALS, { + t: i18n.t, + }); + + // Render the templates. + const [html, text] = await Promise.all( + ['html', 'txt'].map(fmt => { + return mailer.templates.render(options.template, fmt, locals); + }) + ); + + const subject = EMAIL_SUBJECT_PREFIX + ? `${EMAIL_SUBJECT_PREFIX} ${options.subject}` + : options.subject; + + return { + html, + text, + subject, + from: SMTP_FROM_ADDRESS, + }; +}; + +const mailer = { templates, helpers: {} }; + +/** + * Create the new Task kue. + */ +mailer.task = new kue.Task({ + name: 'mailer', +}); + +/** + * registerHelpers will register the helpers on the mailer. + * + * @param {Object} helpers the helpers in object form that should be used by the + * mailer. + */ +mailer.registerHelpers = helpers => { + mailer.helpers = merge(mailer.helpers, helpers); +}; + +/** + * queue will add the message to the sending queue. + * + * @param {Object} message the message to be sent + * @param {Object} recipient the recipient to send it to + */ +mailer.queue = async (message, recipient) => { + debug('Creating Job'); + + // Create the job to send the email later. + const job = await mailer.task.create( + merge( + { + title: 'Mail', + message, + }, + recipient + ) + ); + + debug(`Created Job[${job.id}]`); + + return job; +}; + +/** + * send will prepare the message and queue the message to be sent. + */ +mailer.send = async options => { + // Create the recipient to sent the message to. + const recipient = createRecipient(options); + + // Create the message to send. + const message = await createMessage(options); + + // Create the job to send the message. + return mailer.queue(message, recipient); +}; + +module.exports = mailer; diff --git a/services/mailer/templates.js b/services/mailer/templates.js new file mode 100644 index 000000000..59bcdda5a --- /dev/null +++ b/services/mailer/templates.js @@ -0,0 +1,58 @@ +const path = require('path'); +const fs = require('fs-extra'); +const { get, set, template } = require('lodash'); + +// load all the templates as strings +const templates = { + cache: {}, + registered: {}, +}; + +// Registers a template with the given filename and format. +templates.register = async (filename, name, format) => { + // Check to see if this template was already registered. + if (get(templates.registered, [name, format], null) !== null) { + return; + } + + const file = await fs.readFile(filename, 'utf8'); + const view = template(file); + + set(templates.registered, [name, format], view); +}; + +// load the templates per request during development +templates.render = async (name, format = 'txt', context) => { + // Check to see if the template is a registered template (provided by a plugin + // ) and prefer that first. + let view = get(templates.registered, [name, format], null); + if (view !== null) { + return view(context); + } + + if (process.env.NODE_ENV === 'production') { + // If we are in production mode, check the view cache. + const view = get(templates.cache, [name, format], null); + if (view !== null) { + return view(context); + } + } + + // Template was not registered and was not cached. Let's try and find it! + const filename = path.join( + __dirname, + 'templates', + [name, format, 'ejs'].join('.') + ); + const file = await fs.readFile(filename, 'utf8'); + view = template(file); + + if (process.env.NODE_ENV === 'production') { + // If we are in production mode, fill the view cache. + set(templates.cache, [name, format], view); + } + + return view(context); +}; + +module.exports = templates; diff --git a/services/email/email-confirm.html.ejs b/services/mailer/templates/email-confirm.html.ejs similarity index 100% rename from services/email/email-confirm.html.ejs rename to services/mailer/templates/email-confirm.html.ejs diff --git a/services/email/email-confirm.txt.ejs b/services/mailer/templates/email-confirm.txt.ejs similarity index 100% rename from services/email/email-confirm.txt.ejs rename to services/mailer/templates/email-confirm.txt.ejs diff --git a/services/email/password-reset.html.ejs b/services/mailer/templates/password-reset.html.ejs similarity index 100% rename from services/email/password-reset.html.ejs rename to services/mailer/templates/password-reset.html.ejs diff --git a/services/email/password-reset.txt.ejs b/services/mailer/templates/password-reset.txt.ejs similarity index 100% rename from services/email/password-reset.txt.ejs rename to services/mailer/templates/password-reset.txt.ejs diff --git a/services/email/plain.html.ejs b/services/mailer/templates/plain.html.ejs similarity index 100% rename from services/email/plain.html.ejs rename to services/mailer/templates/plain.html.ejs diff --git a/services/email/plain.txt.ejs b/services/mailer/templates/plain.txt.ejs similarity index 100% rename from services/email/plain.txt.ejs rename to services/mailer/templates/plain.txt.ejs diff --git a/services/pubsub.js b/services/pubsub.js deleted file mode 100644 index 3bfb9aaa3..000000000 --- a/services/pubsub.js +++ /dev/null @@ -1,24 +0,0 @@ -const { RedisPubSub } = require('graphql-redis-subscriptions'); -const { createClient } = require('./redis'); - -/** - * getClient returns the pubsub singleton for this instance. - */ -let pubsub = null; -const getClient = () => { - if (pubsub !== null) { - return pubsub; - } - - // Create the new PubSub client, we only need one per instance of Talk. - pubsub = new RedisPubSub({ - publisher: createClient(), - subscriber: createClient(), - }); - - return pubsub; -}; - -module.exports = { - getClient, -}; diff --git a/services/scraper.js b/services/scraper.js index 36f935876..daba96a99 100644 --- a/services/scraper.js +++ b/services/scraper.js @@ -1,9 +1,5 @@ const kue = require('./kue'); const debug = require('debug')('talk:services:scraper'); -const AssetModel = require('../models/asset'); -const AssetsService = require('./assets'); - -const metascraper = require('metascraper'); /** * Exposes a service object to allow operations to execute against the scraper. @@ -20,93 +16,17 @@ const scraper = { /** * Creates a new scraper job and scrapes the url when it gets processed. */ - create(asset) { + async create(asset) { debug(`Creating job for Asset[${asset.id}]`); - return scraper.task - .create({ - title: `Scrape for asset ${asset.id}`, - asset_id: asset.id, - }) - .then(job => { - debug(`Created Job[${job.id}] for Asset[${asset.id}]`); - - return job; - }); - }, - - /** - * Scrapes the given asset for metadata. - */ - async scrape(asset) { - return metascraper.scrapeUrl( - asset.url, - Object.assign({}, metascraper.RULES, { - section: $ => $('meta[property="article:section"]').attr('content'), - modified: $ => $('meta[property="article:modified"]').attr('content'), - }) - ); - }, - - /** - * Updates an Asset based on scraped asset metadata. - */ - update(id, meta) { - return AssetModel.update( - { id }, - { - $set: { - title: meta.title || '', - description: meta.description || '', - image: meta.image ? meta.image : '', - author: meta.author || '', - publication_date: meta.date || '', - modified_date: meta.modified || '', - section: meta.section || '', - scraped: new Date(), - }, - } - ); - }, - - /** - * Start the queue processor for the scraper job. - */ - process() { - debug(`Now processing ${scraper.task.name} jobs`); - - scraper.task.process(async (job, done) => { - debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`); - - try { - // Find the asset, or complain that it doesn't exist. - const asset = await AssetsService.findById(job.data.asset_id); - if (!asset) { - return done(new Error('asset not found')); - } - - // Scrape the metadata from the asset. - const meta = await scraper.scrape(asset); - - debug( - `Scraped ${JSON.stringify(meta)} on Job[${job.id}] for Asset[${ - job.data.asset_id - }]` - ); - - // Assign the metadata retrieved for the asset to the db. - await scraper.update(job.data.asset_id, meta); - } catch (err) { - debug( - `Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, - err - ); - return done(err); - } - - debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`); - done(); + const job = await scraper.task.create({ + title: `Scrape for asset ${asset.id}`, + asset_id: asset.id, }); + + debug(`Created Job[${job.id}] for Asset[${asset.id}]`); + + return job; }, }; diff --git a/services/users.js b/services/users.js index a8a6b292b..f5319122c 100644 --- a/services/users.js +++ b/services/users.js @@ -1,28 +1,13 @@ const uuid = require('uuid'); const bcrypt = require('bcryptjs'); const errors = require('../errors'); -const some = require('lodash/some'); -const merge = require('lodash/merge'); - -const { - USERS_NEW, - USERS_SUSPENSION_CHANGE, - USERS_BAN_CHANGE, - USERS_USERNAME_STATUS_CHANGE, -} = require('./events/constants'); -const events = require('./events'); - +const { some, merge } = require('lodash'); const { ROOT_URL } = require('../config'); - const { jwt: JWT_SECRET } = require('../secrets'); - const debug = require('debug')('talk:services:users'); - const UserModel = require('../models/user'); - const RECAPTCHA_WINDOW = '10m'; // 10 minutes. const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 5 incorrect attempts, recaptcha will be required. - const ActionsService = require('./actions'); const mailer = require('./mailer'); const i18n = require('./i18n'); @@ -125,12 +110,16 @@ class UsersService { ); } - // Emit that the user username status was changed. - await events.emitAsync(USERS_SUSPENSION_CHANGE, user, { - until, - message, - assignedBy, - }); + // Check to see if the user was suspended now and is currently suspended. + if (user.suspended && message && message.length > 0) { + await UsersService.sendEmail(user, { + template: 'plain', + locals: { + body: message, + }, + subject: 'Your account has been suspended', + }); + } return user; } @@ -174,12 +163,16 @@ class UsersService { throw new Error('ban status change edit failed for an unknown reason'); } - // Emit that the user ban status was changed. - await events.emitAsync(USERS_BAN_CHANGE, user, { - status, - assignedBy, - message, - }); + // Check to see if the user was banned now and is currently banned. + if (user.banned && status && message && message.length > 0) { + await UsersService.sendEmail(user, { + template: 'plain', + locals: { + body: message, + }, + subject: 'Your account has been banned', + }); + } return user; } @@ -223,12 +216,6 @@ class UsersService { ); } - // Emit that the user username status was changed. - await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, { - status, - assignedBy, - }); - return user; } @@ -286,9 +273,6 @@ class UsersService { throw new Error('edit username failed for an unexpected reason'); } - // Emit that the user username status was changed. - await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, toStatus); - return user; } catch (err) { if (err.code === 11000) { @@ -404,16 +388,14 @@ class UsersService { // Save the user in the database. await user.save(); - // Emit that the user was created. - await events.emitAsync(USERS_NEW, user); - return user; } /** * sendEmailConfirmation sends a confirmation email to the user. + * * @param {String} user the user to send the email to - * @param {String} email the email for the user to send the email to + * @param {String} email the email for the user to send the email to */ static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) { let token = await UsersService.createEmailConfirmToken( @@ -430,21 +412,14 @@ class UsersService { email, }, subject: i18n.t('email.confirm.subject'), - to: email, + email, }); } static async sendEmail(user, options) { - const email = user.firstEmail; - if (!email) { - // Rather than throwing an error here, we'll - console.warn(new Error('user does not have an email')); - return; - } - return mailer.send( merge({}, options, { - to: email, + user: user.id, }) ); } @@ -578,9 +553,6 @@ class UsersService { throw err; } - // Emit that the user was created. - await events.emitAsync(USERS_NEW, user); - return user; } @@ -952,38 +924,6 @@ class UsersService { module.exports = UsersService; -events.on(USERS_BAN_CHANGE, async (user, { status, message }) => { - // Check to see if the user was banned now and is currently banned. - if (user.banned && status && message && message.length > 0) { - await UsersService.sendEmail(user, { - template: 'plain', - locals: { - body: message, - }, - subject: 'Your account has been banned', - }); - } -}); - -events.on(USERS_SUSPENSION_CHANGE, async (user, { until, message }) => { - // Check to see if the user was suspended now and is currently suspended. - if ( - user.suspended && - until !== null && - until > Date.now() && - message && - message.length > 0 - ) { - await UsersService.sendEmail(user, { - template: 'plain', - locals: { - body: message, - }, - subject: 'Your account has been suspended', - }); - } -}); - // Extract all the tokenUserNotFound plugins so we can integrate with other // providers. let tokenUserNotFoundHooks = null; diff --git a/test/server/graph/context.js b/test/server/graph/context.js index f1244c3e8..a788f509b 100644 --- a/test/server/graph/context.js +++ b/test/server/graph/context.js @@ -39,7 +39,7 @@ describe('graph.Context', () => { }); it('creates a context without a user', done => { - expect(c).to.not.have.property('user'); + expect(c.user).to.be.falsy; done(); }); diff --git a/test/server/graph/mutations/addTag.js b/test/server/graph/mutations/addTag.js index d93c7fa44..fe22c498f 100644 --- a/test/server/graph/mutations/addTag.js +++ b/test/server/graph/mutations/addTag.js @@ -3,6 +3,7 @@ const { graphql } = require('graphql'); const schema = require('../../../../graph/schema'); const Context = require('../../../../graph/context'); const AssetModel = require('../../../../models/asset'); +const CommentModel = require('../../../../models/comment'); const UserModel = require('../../../../models/user'); const SettingsService = require('../../../../services/settings'); const CommentsService = require('../../../../services/comments'); @@ -50,7 +51,7 @@ describe('graph.mutations.addTag', () => { expect(res.errors).to.be.empty; - let { tags } = await CommentsService.findById(comment.id); + let { tags } = await CommentModel.findOne({ id: comment.id }); expect(tags).to.have.length(1); }); diff --git a/test/server/graph/mutations/createComment.js b/test/server/graph/mutations/createComment.js index 058b19a41..fbbbf4d38 100644 --- a/test/server/graph/mutations/createComment.js +++ b/test/server/graph/mutations/createComment.js @@ -3,12 +3,12 @@ const { graphql } = require('graphql'); const schema = require('../../../../graph/schema'); const Context = require('../../../../graph/context'); -const UserModel = require('../../../../models/user'); -const AssetModel = require('../../../../models/asset'); const ActionModel = require('../../../../models/action'); +const AssetModel = require('../../../../models/asset'); +const CommentModel = require('../../../../models/comment'); +const UserModel = require('../../../../models/user'); const SettingsService = require('../../../../services/settings'); -const CommentsService = require('../../../../services/comments'); const { expect } = require('chai'); @@ -336,9 +336,9 @@ describe('graph.mutations.createComment', () => { expect(data.createComment).to.have.property('comment').not.null; expect(data.createComment).to.have.property('errors').null; - const { tags } = await CommentsService.findById( - data.createComment.comment.id - ); + const { tags } = await CommentModel.findOne({ + id: data.createComment.comment.id, + }); if (tag) { expect(tags).to.have.length(1); expect(tags[0].tag.name).to.have.equal(tag); diff --git a/test/server/graph/mutations/editComment.js b/test/server/graph/mutations/editComment.js index 67c81a5d5..476279ae8 100644 --- a/test/server/graph/mutations/editComment.js +++ b/test/server/graph/mutations/editComment.js @@ -1,12 +1,13 @@ const { graphql } = require('graphql'); const timekeeper = require('timekeeper'); -const schema = require('../../../../graph/schema'); -const Context = require('../../../../graph/context'); -const UsersService = require('../../../../services/users'); const AssetModel = require('../../../../models/asset'); -const SettingsService = require('../../../../services/settings'); +const CommentModel = require('../../../../models/comment'); const CommentsService = require('../../../../services/comments'); +const Context = require('../../../../graph/context'); +const schema = require('../../../../graph/schema'); +const SettingsService = require('../../../../services/settings'); +const UsersService = require('../../../../services/users'); const { expect } = require('chai'); @@ -70,7 +71,7 @@ describe('graph.mutations.editComment', () => { expect(response.data.editComment.errors).to.be.null; // assert body has changed - const commentAfterEdit = await CommentsService.findById(comment.id); + const commentAfterEdit = await CommentModel.findOne({ id: comment.id }); expect(commentAfterEdit.body).to.equal(newBody); expect(commentAfterEdit.body_history).to.be.instanceOf(Array); expect(commentAfterEdit.body_history.length).to.equal(2); @@ -110,7 +111,7 @@ describe('graph.mutations.editComment', () => { expect(response.data.editComment.errors[0].translation_key).to.equal( 'EDIT_WINDOW_ENDED' ); - const commentAfterEdit = await CommentsService.findById(comment.id); + const commentAfterEdit = await CommentModel.findOne({ id: comment.id }); // it *hasn't* changed from the original expect(commentAfterEdit.body).to.equal(comment.body); @@ -142,7 +143,7 @@ describe('graph.mutations.editComment', () => { expect(response.data.editComment.errors[0].translation_key).to.equal( 'NOT_AUTHORIZED' ); - const commentAfterEdit = await CommentsService.findById(comment.id); + const commentAfterEdit = await CommentModel.findOne({ id: comment.id }); // it *hasn't* changed from the original expect(commentAfterEdit.body).to.equal(comment.body); @@ -274,7 +275,7 @@ describe('graph.mutations.editComment', () => { console.error(response.data.editComment.errors); } expect(response.data.editComment.errors).to.be.null; - const commentAfterEdit = await CommentsService.findById(comment.id); + const commentAfterEdit = await CommentModel.findOne({ id: comment.id }); expect(commentAfterEdit.body).to.equal(newBody); expect(commentAfterEdit.status).to.equal(afterEdit.status); } diff --git a/test/server/graph/mutations/removeTag.js b/test/server/graph/mutations/removeTag.js index f1de90f5c..ccd5e95f5 100644 --- a/test/server/graph/mutations/removeTag.js +++ b/test/server/graph/mutations/removeTag.js @@ -6,8 +6,9 @@ const UserModel = require('../../../../models/user'); const SettingModel = require('../../../../models/setting'); const AssetModel = require('../../../../models/asset'); -const SettingsService = require('../../../../services/settings'); +const CommentModel = require('../../../../models/comment'); const CommentsService = require('../../../../services/comments'); +const SettingsService = require('../../../../services/settings'); const TagsService = require('../../../../services/tags'); const { expect } = require('chai'); @@ -59,7 +60,7 @@ describe('graph.mutations.removeTag', () => { expect(response.errors).to.be.empty; expect(response.data.removeTag).to.be.null; - let retrievedComment = await CommentsService.findById(comment.id); + let retrievedComment = await CommentModel.findOne({ id: comment.id }); expect(retrievedComment.tags).to.have.length(0); }); diff --git a/test/server/graph/queries/user.js b/test/server/graph/queries/user.js index c5727987e..3ec8abaf9 100644 --- a/test/server/graph/queries/user.js +++ b/test/server/graph/queries/user.js @@ -22,6 +22,53 @@ describe('graph.queries.user', () => { ); }); + describe('email', () => { + const query = ` + query User($user_id: ID!) { + user(id: $user_id) { + id + email + } + } + `; + + it('can query for your own email', async () => { + const ctx = new Context({ user }); + + const { data, errors } = await graphql(schema, query, {}, ctx, { + user_id: user.id, + }); + + expect(errors).to.be.undefined; + expect(data.user).to.not.be.null; + expect(data.user.email).to.be.equal(user.firstEmail); + }); + + [ + { role: 'COMMENTER', can: false }, + { role: 'STAFF', can: false }, + { role: 'MODERATOR', can: true }, + { role: 'ADMIN', can: true }, + ].forEach(({ role, can }) => { + it(`${can ? 'can' : 'can not'} query with role = ${role}`, async () => { + const actor = new UserModel({ role }); + const ctx = new Context({ user: actor }); + + const { data, errors } = await graphql(schema, query, {}, ctx, { + user_id: user.id, + }); + + expect(errors).to.be.undefined; + if (!can) { + expect(data.user).to.be.null; + } else { + expect(data.user).to.not.be.null; + expect(data.user.email).to.be.equal(user.firstEmail); + } + }); + }); + }); + describe('state', () => { const meQuery = ` query Me { diff --git a/test/server/services/actions.js b/test/server/services/actions.js index 96170338e..0615e55df 100644 --- a/test/server/services/actions.js +++ b/test/server/services/actions.js @@ -6,14 +6,6 @@ const chai = require('chai'); chai.use(require('chai-as-promised')); const expect = chai.expect; -const events = require('../../../services/events'); -const { - ACTIONS_NEW, - ACTIONS_DELETE, -} = require('../../../services/events/constants'); - -const sinon = require('sinon'); - describe('services.ActionsService', () => { let mockActions = []; let comment; @@ -78,30 +70,6 @@ describe('services.ActionsService', () => { expect(retrievedAction).has.property('id', createdAction.id); expect(retrievedAction).has.property('item_id', comment.id); }); - - it('fires the callback successfully', async () => { - const srcAction = { - action_type: 'LIKE', - item_type: 'COMMENTS', - item_id: comment.id, - }; - - const spy = sinon.spy(); - events.once(ACTIONS_NEW, spy); - - const createdAction = await ActionsService.create(srcAction); - - expect(createdAction).is.not.null; - expect(createdAction).has.property('id'); - expect(createdAction).has.property('item_id', comment.id); - - expect(spy).to.have.been.calledWith(createdAction); - - const retrievedComment = await CommentModel.findOne({ id: comment.id }); - - expect(retrievedComment).to.have.property('action_counts'); - expect(retrievedComment.action_counts).to.have.property('like', 1); - }); }); describe('#delete', () => { @@ -116,21 +84,6 @@ describe('services.ActionsService', () => { expect(retrievedAction).is.null; }); - - it('fires the callback successfully', async () => { - const spy = sinon.spy(); - events.once(ACTIONS_DELETE, spy); - - const deletedAction = await ActionsService.delete(mockActions[0]); - - expect(deletedAction).has.property('id', mockActions[0].id); - expect(spy).to.have.been.calledWith(deletedAction); - - const retrievedComment = await CommentModel.findOne({ id: comment.id }); - - expect(retrievedComment).to.have.property('action_counts'); - expect(retrievedComment.action_counts).to.have.property('flag', -1); - }); }); describe('#findById()', () => { diff --git a/test/server/services/comments.js b/test/server/services/comments.js index e0fc6da67..0c1a72ab7 100644 --- a/test/server/services/comments.js +++ b/test/server/services/comments.js @@ -1,8 +1,6 @@ const CommentModel = require('../../../models/comment'); const ActionModel = require('../../../models/action'); -const events = require('../../../services/events'); -const { COMMENTS_EDIT } = require('../../../services/events/constants'); const UsersService = require('../../../services/users'); const SettingsService = require('../../../services/settings'); const CommentsService = require('../../../services/comments'); @@ -17,8 +15,6 @@ const chai = require('chai'); chai.use(require('sinon-chai')); const expect = chai.expect; -const sinon = require('sinon'); - describe('services.CommentsService', () => { const comments = [ { @@ -214,7 +210,9 @@ describe('services.CommentsService', () => { await CommentsService.pushStatus(originalComment.id, 'ACCEPTED'); - let retrivedComment = await CommentsService.findById(originalComment.id); + let retrivedComment = await CommentModel.findOne({ + id: originalComment.id, + }); expect(retrivedComment).to.have.property('status', 'ACCEPTED'); expect(retrivedComment.status_history).to.have.length(2); @@ -237,7 +235,7 @@ describe('services.CommentsService', () => { 'PREMOD' ); - retrivedComment = await CommentsService.findById(originalComment.id); + retrivedComment = await CommentModel.findOne({ id: originalComment.id }); expect(retrivedComment).to.have.property('status', 'PREMOD'); expect(retrivedComment.status_history).to.have.length(3); @@ -248,41 +246,13 @@ describe('services.CommentsService', () => { }); }); - describe('#findById()', () => { - it('should find a comment by id', async () => { - const comment = await CommentsService.findById('1'); - expect(comment).to.not.be.null; - expect(comment).to.have.property('body', 'comment 10'); - }); - }); - - describe('#findByAssetId()', () => { - it('should find an array of all comments by asset id', async () => { - const comments = await CommentsService.findByAssetId('123'); - expect(comments).to.have.length(3); - comments.sort((a, b) => { - if (a.body < b.body) { - return -1; - } else { - return 1; - } - }); - expect(comments[0]).to.have.property('body', 'comment 10'); - expect(comments[1]).to.have.property('body', 'comment 20'); - expect(comments[2]).to.have.property('body', 'comment 40'); - }); - }); - describe('#changeStatus', () => { it('should change the status of a comment from no status', async () => { let comment_id = comments[0].id; - let c = await CommentsService.findById(comment_id); + let c = await CommentModel.findOne({ id: comment_id }); expect(c.status).to.be.equal('NONE'); - const spy = sinon.spy(); - events.once(COMMENTS_EDIT, spy); - let c2 = await CommentsService.pushStatus(comment_id, 'REJECTED', '123'); expect(c2).to.have.property('status'); expect(c2.status).to.equal('REJECTED'); @@ -290,9 +260,7 @@ describe('services.CommentsService', () => { expect(c2.status_history[0]).to.have.property('type', 'REJECTED'); expect(c2.status_history[0]).to.have.property('assigned_by', '123'); - expect(spy).to.have.been.called; - - let c3 = await CommentsService.findById(comment_id); + let c3 = await CommentModel.findOne({ id: comment_id }); expect(c3).to.have.property('status'); expect(c3.status).to.equal('REJECTED'); expect(c3.status_history).to.have.length(1); @@ -302,7 +270,7 @@ describe('services.CommentsService', () => { it('should change the status of a comment from accepted', async () => { await CommentsService.pushStatus(comments[1].id, 'REJECTED', '123'); - const c = await CommentsService.findById(comments[1].id); + const c = await CommentModel.findOne({ id: comments[1].id }); expect(c).to.have.property('status_history'); expect(c).to.have.property('status'); expect(c.status).to.equal('REJECTED'); diff --git a/test/server/services/tags.js b/test/server/services/tags.js index eb3144114..92e566f7c 100644 --- a/test/server/services/tags.js +++ b/test/server/services/tags.js @@ -1,4 +1,3 @@ -const CommentsService = require('../../../services/comments'); const TagsService = require('../../../services/tags'); const UsersService = require('../../../services/users'); const SettingsService = require('../../../services/settings'); @@ -40,7 +39,7 @@ describe('services.TagsService', () => { assigned_by, }); - const { tags } = await CommentsService.findById(id); + const { tags } = await CommentModel.findOne({ id }); expect(tags.length).to.equal(1); expect(tags[0].tag.name).to.equal(name); expect(tags[0].assigned_by).to.equal(assigned_by); @@ -59,7 +58,7 @@ describe('services.TagsService', () => { }); { - let { tags } = await CommentsService.findById(id); + let { tags } = await CommentModel.findOne({ id }); expect(tags.length).to.equal(1); } @@ -71,7 +70,7 @@ describe('services.TagsService', () => { }); { - let { tags } = await CommentsService.findById(id); + let { tags } = await CommentModel.findOne({ id }); expect(tags.length).to.equal(1); } }); @@ -91,7 +90,7 @@ describe('services.TagsService', () => { }); { - const { tags } = await CommentsService.findById(id); + const { tags } = await CommentModel.findOne({ id }); expect(tags.length).to.equal(1); } @@ -104,7 +103,7 @@ describe('services.TagsService', () => { }); { - const { tags } = await CommentsService.findById(id); + const { tags } = await CommentModel.findOne({ id }); expect(tags.length).to.equal(0); } }); @@ -128,7 +127,7 @@ describe('services.TagsService', () => { }); { - const { tags } = await CommentsService.findById(id); + const { tags } = await CommentModel.findOne({ id }); expect(tags.length).to.equal(2); } @@ -141,7 +140,7 @@ describe('services.TagsService', () => { }); { - const { tags } = await CommentsService.findById(id); + const { tags } = await CommentModel.findOne({ id }); expect(tags.length).to.equal(1); expect(tags[0].tag.name).to.equal('ANOTHER'); } diff --git a/url.js b/url.js index e855859de..294095d48 100644 --- a/url.js +++ b/url.js @@ -18,9 +18,12 @@ const MOUNT_PATH = ROOT_URL_MOUNT_PATH ? BASE_PATH : '/'; // The STATIC_URL is the url where static assets should be loaded from. const STATIC_URL = trailingSlash(STATIC_URI); +const STATIC_ORIGIN = new URL(STATIC_URI).origin; + module.exports = { BASE_URL, BASE_PATH, MOUNT_PATH, STATIC_URL, + STATIC_ORIGIN, }; diff --git a/views/auth-callback.ejs b/views/auth-callback.ejs index 25603bda4..424889065 100644 --- a/views/auth-callback.ejs +++ b/views/auth-callback.ejs @@ -1,5 +1,8 @@ + + <%- include partials/data %> + diff --git a/views/partials/data.ejs b/views/partials/data.ejs new file mode 100644 index 000000000..f0bbf5925 --- /dev/null +++ b/views/partials/data.ejs @@ -0,0 +1,3 @@ +<%_ if (data != null) { _%> + +<%_ } _%> \ No newline at end of file diff --git a/views/partials/head.ejs b/views/partials/head.ejs index bc8b949d3..555f5689e 100644 --- a/views/partials/head.ejs +++ b/views/partials/head.ejs @@ -18,7 +18,5 @@ <%_ if (locals.customCssUrl) { _%> <%_ } _%> -<%_ if (data != null) { _%> - -<%_ } _%> +<%- include data %> \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 193ebe65e..00daeedee 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -75,7 +75,7 @@ const config = { }, }, { - loader: 'json-loader', + loader: 'hjson-loader', test: /\.(json|yml)$/, exclude: /node_modules/, }, @@ -214,12 +214,14 @@ if (process.env.NODE_ENV === 'production') { toplevel: false, typeofs: false, unused: false, - // Switch off all types of compression except those needed to convince // react-devtools that we're using a production build conditionals: true, dead_code: true, evaluate: true, + // Remove warnings + discard any console.* functions + warnings: false, + drop_console: true, }, mangle: true, }, diff --git a/yarn.lock b/yarn.lock index bc59a3be3..7ba8aab3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,6 +10,12 @@ esutils "^2.0.2" js-tokens "^3.0.0" +"@babel/code-frame@^7.0.0-beta.35": + version "7.0.0-beta.40" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0-beta.40.tgz#37e2b0cf7c56026b4b21d3927cadf81adec32ac6" + dependencies: + "@babel/highlight" "7.0.0-beta.40" + "@babel/helper-function-name@7.0.0-beta.36": version "7.0.0-beta.36" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0-beta.36.tgz#366e3bc35147721b69009f803907c4d53212e88d" @@ -24,6 +30,14 @@ dependencies: "@babel/types" "7.0.0-beta.36" +"@babel/highlight@7.0.0-beta.40": + version "7.0.0-beta.40" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0-beta.40.tgz#b43d67d76bf46e1d10d227f68cddcd263786b255" + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^3.0.0" + "@babel/template@7.0.0-beta.36": version "7.0.0-beta.36" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0-beta.36.tgz#02e903de5d68bd7899bce3c5b5447e59529abb00" @@ -72,6 +86,14 @@ dependencies: graphql-ast-tools "^0.2.2" +"@easyops/git-exec-and-restage@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@easyops/git-exec-and-restage/-/git-exec-and-restage-1.0.4.tgz#51ee761def34502665dcfa1deb2bbd0e8a3f1ade" + dependencies: + babel-polyfill "^6.23.0" + core-js "^2.5.1" + cross-spawn-promise "^0.10.1" + "@kadira/storybook-deployer@^1.1.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@kadira/storybook-deployer/-/storybook-deployer-1.2.0.tgz#1708f5cb37fa08ab4173b1bd99df6f4717dfae12" @@ -101,7 +123,7 @@ dependencies: "@types/node" "*" -abab@^1.0.0, abab@^1.0.3: +abab@^1.0.0, abab@^1.0.3, abab@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" @@ -134,6 +156,12 @@ acorn-globals@^3.0.0, acorn-globals@^3.1.0: dependencies: acorn "^4.0.4" +acorn-globals@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.1.0.tgz#ab716025dbe17c54d3ef81d32ece2b2d99fe2538" + dependencies: + acorn "^5.0.0" + acorn-jsx@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b" @@ -160,6 +188,10 @@ acorn@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.2.1.tgz#317ac7821826c22c702d66189ab8359675f135d7" +acorn@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.4.1.tgz#fdc58d9d17f4a4e98d102ded826a9b9759125102" + addressparser@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746" @@ -200,6 +232,13 @@ ajv@^5.1.0, ajv@^5.1.5, ajv@^5.2.3, ajv@^5.3.0: fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.3.0" +akismet-api@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/akismet-api/-/akismet-api-4.0.1.tgz#1c771442f09316847132aa16171bb4fb708b6519" + dependencies: + bluebird "^3.1.1" + superagent "^3.8.0" + align-text@^0.1.1, align-text@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" @@ -222,6 +261,10 @@ ansi-align@^2.0.0: dependencies: string-width "^2.0.0" +ansi-escapes@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" + ansi-escapes@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-2.0.0.tgz#5bae52be424878dd9783e8910e3fc2922e83c81b" @@ -248,6 +291,10 @@ ansi-styles@^3.1.0, ansi-styles@^3.2.0: dependencies: color-convert "^1.9.0" +any-observable@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.2.0.tgz#c67870058003579009083f54ac0abafb5c33d242" + any-promise@^0.1.0, any-promise@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-0.1.0.tgz#830b680aa7e56f33451d4b049f3bd8044498ee27" @@ -347,6 +394,10 @@ app-module-path@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" +app-root-path@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.0.1.tgz#cd62dcf8e4fd5a417efc664d2e5b10653c651b46" + "apparatus@>= 0.0.9": version "0.0.9" resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.9.tgz#37dcd25834ad0b651076596291db823eeb1908bd" @@ -382,10 +433,18 @@ arr-diff@^2.0.0: dependencies: arr-flatten "^1.0.1" -arr-flatten@^1.0.1: +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + +arr-flatten@^1.0.1, arr-flatten@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + array-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" @@ -427,6 +486,10 @@ array-unique@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + arrify@^1.0.0, arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" @@ -469,6 +532,10 @@ assertion-error@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.0.2.tgz#13ca515d86206da0bac66e834dd397d87581094c" +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + ast-types@0.x.x: version "0.10.1" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.10.1.tgz#f52fca9715579a14f841d67d7f8d25432ab6a3dd" @@ -519,6 +586,10 @@ asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" +atob@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.0.3.tgz#19c7a760473774468f20b2d2d03372ad7d4cbf5d" + autoprefixer@^6.3.1, autoprefixer@^6.5.2: version "6.7.7" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014" @@ -1048,7 +1119,7 @@ babel-plugin-transform-strict-mode@^6.24.1: babel-runtime "^6.22.0" babel-types "^6.24.1" -babel-polyfill@^6.26.0: +babel-polyfill@^6.23.0, babel-polyfill@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.26.0.tgz#379937abc67d7895970adc621f284cd966cf2153" dependencies: @@ -1197,6 +1268,18 @@ base64url@2.0.0, base64url@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + basic-auth@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.0.tgz#015db3f353e02e56377755f962742e8981e7bbba" @@ -1237,7 +1320,7 @@ bluebird@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" -bluebird@^3.0.6, bluebird@^3.3.4, bluebird@^3.4.6, bluebird@^3.5.0: +bluebird@^3.0.6, bluebird@^3.1.1, bluebird@^3.3.4, bluebird@^3.4.6, bluebird@^3.5.0: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" @@ -1313,6 +1396,23 @@ braces@^1.8.2: preserve "^0.2.0" repeat-element "^1.1.2" +braces@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.1.tgz#7086c913b4e5a08dbe37ac0ee6a2500c4ba691bb" + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + define-property "^1.0.0" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + kind-of "^6.0.2" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + brorand@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" @@ -1333,6 +1433,10 @@ brotli@^1.3.1: dependencies: base64-js "^1.1.2" +browser-process-hrtime@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.2.tgz#425d68a58d3447f02a04aa894187fce8af8b7b8e" + browser-resolve@^1.11.2: version "1.11.2" resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.2.tgz#8ff09b0a2c421718a1051c260b32e48f442938ce" @@ -1480,6 +1584,15 @@ builtin-status-codes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" +bunyan@^1.8.12: + version "1.8.12" + resolved "https://registry.yarnpkg.com/bunyan/-/bunyan-1.8.12.tgz#f150f0f6748abdd72aeae84f04403be2ef113797" + optionalDependencies: + dtrace-provider "~0.8" + moment "^2.10.6" + mv "~2" + safe-json-stringify "~1" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -1502,6 +1615,20 @@ cacache@^10.0.1: unique-filename "^1.1.0" y18n "^3.2.1" +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" @@ -1627,6 +1754,14 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0: escape-string-regexp "^1.0.5" supports-color "^4.0.0" +chalk@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.1.tgz#523fe2678aec7b04e8041909292fe8b17059b796" + dependencies: + ansi-styles "^3.2.0" + escape-string-regexp "^1.0.5" + supports-color "^5.2.0" + change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" @@ -1722,6 +1857,15 @@ clap@^1.0.9: dependencies: chalk "^1.1.3" +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + classnames@^2.2.3, classnames@^2.2.5: version "2.2.5" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" @@ -1737,18 +1881,35 @@ cli-boxes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" +cli-cursor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987" + dependencies: + restore-cursor "^1.0.1" + cli-cursor@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" dependencies: restore-cursor "^2.0.0" +cli-spinners@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" + cli-table@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" dependencies: colors "1.0.3" +cli-truncate@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574" + dependencies: + slice-ansi "0.0.4" + string-width "^1.0.1" + cli-width@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" @@ -1813,6 +1974,13 @@ codemirror@*: version "5.30.0" resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.30.0.tgz#86e57dd5ea5535acbcf9c720797b4cefe05b5a70" +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + color-convert@^1.3.0: version "1.9.0" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" @@ -1891,6 +2059,10 @@ commander@^2.11.0, commander@^2.9.0: version "2.12.2" resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555" +commander@^2.14.1: + version "2.14.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.14.1.tgz#2235123e37af8ca3c65df45b026dbd357b01b9aa" + commander@~2.13.0: version "2.13.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" @@ -1905,7 +2077,7 @@ commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" -component-emitter@^1.2.0: +component-emitter@^1.2.0, component-emitter@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" @@ -2004,6 +2176,10 @@ content-type-parser@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.1.tgz#c3e56988c53c65127fb46d4032a3a900246fdc94" +content-type-parser@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.2.tgz#caabe80623e63638b2502fd4c7f12ff4ce2352e7" + content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -2031,6 +2207,10 @@ cookiejar@2.0.x, cookiejar@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.0.6.tgz#0abf356ad00d1c5a219d88d44518046dd026acfe" +cookiejar@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.1.tgz#41ad57b1b555951ec171412a81942b1e8200d34a" + copy-concurrently@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" @@ -2042,6 +2222,10 @@ copy-concurrently@^1.0.0: rimraf "^2.5.4" run-queue "^1.0.0" +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + copy-webpack-plugin@^4.0.0: version "4.3.0" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.3.0.tgz#cfdf4d131c78d66917a1bb863f86630497aacf42" @@ -2085,6 +2269,15 @@ cosmiconfig@^2.1.0, cosmiconfig@^2.1.1: parse-json "^2.2.0" require-from-string "^1.1.0" +cosmiconfig@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-4.0.0.tgz#760391549580bbd2df1e562bc177b13c290972dc" + dependencies: + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + require-from-string "^2.0.1" + create-ecdh@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" @@ -2126,6 +2319,12 @@ create-react-class@^15.5.1, create-react-class@^15.5.2, create-react-class@^15.6 loose-envify "^1.3.1" object-assign "^4.1.1" +cross-spawn-promise@^0.10.1: + version "0.10.1" + resolved "https://registry.yarnpkg.com/cross-spawn-promise/-/cross-spawn-promise-0.10.1.tgz#db9cb4c50c60b72a15be049b78122ce382d87b10" + dependencies: + cross-spawn "^5.1.0" + cross-spawn@^5.0.1, cross-spawn@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -2325,6 +2524,10 @@ dataloader@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-1.3.0.tgz#6fec5be4b30a712e4afd30b86b4334566b97673b" +date-fns@^1.27.2: + version "1.29.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6" + date-now@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" @@ -2335,7 +2538,7 @@ debug@*, debug@3.1.0, debug@^3.0.0, debug@^3.0.1, debug@^3.1.0: dependencies: ms "2.0.0" -debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.6.3, debug@^2.6.8: +debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.3, debug@^2.6.8: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" dependencies: @@ -2361,6 +2564,10 @@ decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" +dedent@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" + deep-eql@0.1.3, deep-eql@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-0.1.3.tgz#ef558acab8de25206cd713906d74e56930eb69f2" @@ -2388,6 +2595,25 @@ define-properties@^1.1.2: foreach "^2.0.5" object-keys "^1.0.8" +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + defined@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" @@ -2546,6 +2772,12 @@ domelementtype@~1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" +domexception@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + dependencies: + webidl-conversions "^4.0.2" + domhandler@2.3: version "2.3.0" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.3.0.tgz#2de59a0822d5027fabff6f032c2b25a2a8abe738" @@ -2590,6 +2822,12 @@ double-ended-queue@^2.1.0-0: version "2.1.0-0" resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c" +dtrace-provider@~0.8: + version "0.8.6" + resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.6.tgz#428a223afe03425d2cd6d6347fdf40c66903563d" + dependencies: + nan "^2.3.3" + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -2636,6 +2874,10 @@ electron-to-chromium@^1.2.7: version "1.3.26" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.26.tgz#996427294861a74d9c7c82b9260ea301e8c02d66" +elegant-spinner@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" + elliptic@^6.0.0: version "6.4.0" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" @@ -2852,7 +3094,7 @@ escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1 version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" -escodegen@1.x.x, escodegen@^1.6.1: +escodegen@1.x.x, escodegen@^1.6.1, escodegen@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.9.0.tgz#9811a2f265dc1cd3894420ee3717064b632b8852" dependencies: @@ -3065,12 +3307,40 @@ execa@^0.7.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +execa@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.9.0.tgz#adb7ce62cf985071f60580deb4a88b9e34712d01" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +exit-hook@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8" + expand-brackets@^0.1.4: version "0.1.5" resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" dependencies: is-posix-bracket "^0.1.0" +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + expand-range@^1.8.1: version "1.8.2" resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" @@ -3096,6 +3366,17 @@ expect@^21.2.1: jest-message-util "^21.2.1" jest-regex-util "^21.2.0" +expect@^22.4.0: + version "22.4.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-22.4.0.tgz#371edf1ae15b83b5bf5ec34b42f1584660a36c16" + dependencies: + ansi-styles "^3.2.0" + jest-diff "^22.4.0" + jest-get-type "^22.1.0" + jest-matcher-utils "^22.4.0" + jest-message-util "^22.4.0" + jest-regex-util "^22.1.0" + exports-loader@^0.6.4: version "0.6.4" resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.4.tgz#d70fc6121975b35fc12830cf52754be2740fc886" @@ -3144,6 +3425,19 @@ express@4.16.0, express@^4.12.2: utils-merge "1.0.1" vary "~1.1.2" +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + extend@3, extend@^3.0.0, extend@~3.0.0, extend@~3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" @@ -3166,6 +3460,19 @@ extglob@^0.3.1: dependencies: is-extglob "^1.0.0" +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + extsprintf@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" @@ -3222,6 +3529,13 @@ fd-slicer@~1.0.1: dependencies: pend "~1.2.0" +figures@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" + dependencies: + escape-string-regexp "^1.0.5" + object-assign "^4.1.0" + figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" @@ -3266,6 +3580,15 @@ fill-range@^2.1.0: repeat-element "^1.1.2" repeat-string "^1.5.2" +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + finalhandler@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.6.tgz#007aea33d1a4d3e42017f624848ad58d212f814f" @@ -3298,6 +3621,10 @@ find-cache-dir@^1.0.0: make-dir "^1.0.0" pkg-dir "^2.0.0" +find-parent-dir@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" + find-up@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" @@ -3335,7 +3662,7 @@ flush-write-stream@^1.0.0: inherits "^2.0.1" readable-stream "^2.0.4" -for-in@^1.0.1: +for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -3397,7 +3724,7 @@ formatio@1.2.0, formatio@^1.2.0: dependencies: samsam "1.x" -formidable@^1.0.17: +formidable@^1.0.17, formidable@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.1.1.tgz#96b8886f7c3c3508b932d6bd70c4d3a88f35f1a9" @@ -3405,6 +3732,12 @@ forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + dependencies: + map-cache "^0.2.2" + frameguard@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/frameguard/-/frameguard-3.0.0.tgz#7bcad469ee7b96e91d12ceb3959c78235a9272e9" @@ -3545,6 +3878,10 @@ get-caller-file@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" +get-own-enumerable-property-symbols@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-2.0.1.tgz#5c4ad87f2834c4b9b4e84549dc1e0650fb38c24b" + get-stdin@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398" @@ -3564,6 +3901,10 @@ get-uri@2: ftp "~0.3.10" readable-stream "2" +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + getpass@^0.1.1: version "0.1.7" resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" @@ -3643,6 +3984,16 @@ glob@^5.0.3: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^6.0.1: + version "6.0.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" @@ -3904,10 +4255,41 @@ has-flag@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + has-unicode@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + has@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" @@ -3997,6 +4379,16 @@ history@^3.0.0: query-string "^4.2.2" warning "^3.0.0" +hjson-loader@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hjson-loader/-/hjson-loader-1.0.0.tgz#d6547d77eebbfc8ab9df45c1db90c6c061cc88fc" + dependencies: + loader-utils "^0.2.12" + +hjson@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/hjson/-/hjson-3.1.1.tgz#eaab95eebc6c0c749442219a817d9b4ff0070dd2" + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -4058,6 +4450,12 @@ html-encoding-sniffer@^1.0.1: dependencies: whatwg-encoding "^1.0.1" +html-encoding-sniffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" + dependencies: + whatwg-encoding "^1.0.1" + htmlparser2@^3.9.1: version "3.9.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338" @@ -4135,6 +4533,14 @@ https-proxy-agent@1, https-proxy-agent@^1.0.0: debug "2" extend "3" +husky@^0.14.3: + version "0.14.3" + resolved "https://registry.yarnpkg.com/husky/-/husky-0.14.3.tgz#c69ed74e2d2779769a17ba8399b54ce0b63c12c3" + dependencies: + is-ci "^1.0.10" + normalize-path "^1.0.0" + strip-indent "^2.0.0" + iconv-lite@0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" @@ -4213,6 +4619,16 @@ imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" +indent-string@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" + dependencies: + repeating "^2.0.0" + +indent-string@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + indexes-of@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" @@ -4354,6 +4770,18 @@ is-absolute-url@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + dependencies: + kind-of "^6.0.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -4388,10 +4816,38 @@ is-ci@^1.0.10: dependencies: ci-info "^1.0.0" +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + dependencies: + kind-of "^6.0.0" + is-date-object@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + is-directory@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" @@ -4420,10 +4876,16 @@ is-expression@^3.0.0: acorn "~4.0.2" object-assign "^4.0.1" -is-extendable@^0.1.1: +is-extendable@^0.1.0, is-extendable@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + dependencies: + is-plain-object "^2.0.4" + is-extglob@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" @@ -4448,6 +4910,10 @@ is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" +is-generator-fn@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-1.0.0.tgz#969d49e1bb3329f6bb7f09089be26578b2ddd46a" + is-glob@^2.0.0, is-glob@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" @@ -4502,10 +4968,26 @@ is-number@^3.0.0: dependencies: kind-of "^3.0.2" -is-obj@^1.0.0: +is-number@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" + +is-obj@^1.0.0, is-obj@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" +is-observable@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-0.2.0.tgz#b361311d83c6e5d726cabf5e250b0237106f5ae2" + dependencies: + symbol-observable "^0.2.2" + +is-odd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-2.0.0.tgz#7646624671fd7ea558ccd9a2795182f2958f1b24" + dependencies: + is-number "^4.0.0" + is-path-cwd@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" @@ -4526,6 +5008,12 @@ is-plain-obj@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + dependencies: + isobject "^3.0.1" + is-posix-bracket@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" @@ -4552,6 +5040,10 @@ is-regex@^1.0.3, is-regex@^1.0.4: dependencies: has "^1.0.1" +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + is-resolvable@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.0.1.tgz#acca1cd36dbe44b974b924321555a70ba03b1cf4" @@ -4600,6 +5092,10 @@ is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + isarray@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" @@ -4628,6 +5124,10 @@ isobject@^2.0.0: dependencies: isarray "1.0.0" +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + isomorphic-fetch@^2.1.1: version "2.2.1" resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" @@ -4766,6 +5266,22 @@ jest-config@^21.2.1: jest-validate "^21.2.1" pretty-format "^21.2.1" +jest-config@^22.4.2: + version "22.4.2" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-22.4.2.tgz#580ba5819bf81a5e48f4fd470e8b81834f45c855" + dependencies: + chalk "^2.0.1" + glob "^7.1.1" + jest-environment-jsdom "^22.4.1" + jest-environment-node "^22.4.1" + jest-get-type "^22.1.0" + jest-jasmine2 "^22.4.2" + jest-regex-util "^22.1.0" + jest-resolve "^22.4.2" + jest-util "^22.4.1" + jest-validate "^22.4.2" + pretty-format "^22.4.0" + jest-diff@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-21.2.1.tgz#46cccb6cab2d02ce98bc314011764bb95b065b4f" @@ -4775,6 +5291,15 @@ jest-diff@^21.2.1: jest-get-type "^21.2.0" pretty-format "^21.2.1" +jest-diff@^22.4.0: + version "22.4.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-22.4.0.tgz#384c2b78519ca44ca126382df53f134289232525" + dependencies: + chalk "^2.0.1" + diff "^3.2.0" + jest-get-type "^22.1.0" + pretty-format "^22.4.0" + jest-docblock@^21.0.0, jest-docblock@^21.2.0: version "21.2.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-21.2.0.tgz#51529c3b30d5fd159da60c27ceedc195faf8d414" @@ -4787,6 +5312,14 @@ jest-environment-jsdom@^21.2.1: jest-util "^21.2.1" jsdom "^9.12.0" +jest-environment-jsdom@^22.4.1: + version "22.4.1" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-22.4.1.tgz#754f408872441740100d3917e5ec40c74de6447f" + dependencies: + jest-mock "^22.2.0" + jest-util "^22.4.1" + jsdom "^11.5.1" + jest-environment-node@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-21.2.1.tgz#98c67df5663c7fbe20f6e792ac2272c740d3b8c8" @@ -4794,10 +5327,21 @@ jest-environment-node@^21.2.1: jest-mock "^21.2.0" jest-util "^21.2.1" +jest-environment-node@^22.4.1: + version "22.4.1" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-22.4.1.tgz#418850eb654596b8d6e36c2021cbedbc23df8e16" + dependencies: + jest-mock "^22.2.0" + jest-util "^22.4.1" + jest-get-type@^21.2.0: version "21.2.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-21.2.0.tgz#f6376ab9db4b60d81e39f30749c6c466f40d4a23" +jest-get-type@^22.1.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.1.0.tgz#4e90af298ed6181edc85d2da500dbd2753e0d5a9" + jest-haste-map@^21.2.0: version "21.2.0" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-21.2.0.tgz#1363f0a8bb4338f24f001806571eff7a4b2ff3d8" @@ -4822,6 +5366,22 @@ jest-jasmine2@^21.2.1: jest-snapshot "^21.2.1" p-cancelable "^0.3.0" +jest-jasmine2@^22.4.2: + version "22.4.2" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-22.4.2.tgz#dfd3d259579ed6f52510d8f1ab692808f0d40691" + dependencies: + chalk "^2.0.1" + co "^4.6.0" + expect "^22.4.0" + graceful-fs "^4.1.11" + is-generator-fn "^1.0.0" + jest-diff "^22.4.0" + jest-matcher-utils "^22.4.0" + jest-message-util "^22.4.0" + jest-snapshot "^22.4.0" + jest-util "^22.4.1" + source-map-support "^0.5.0" + jest-matcher-utils@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-21.2.1.tgz#72c826eaba41a093ac2b4565f865eb8475de0f64" @@ -4830,6 +5390,14 @@ jest-matcher-utils@^21.2.1: jest-get-type "^21.2.0" pretty-format "^21.2.1" +jest-matcher-utils@^22.4.0: + version "22.4.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-22.4.0.tgz#d55f5faf2270462736bdf7c7485ee931c9d4b6a1" + dependencies: + chalk "^2.0.1" + jest-get-type "^22.1.0" + pretty-format "^22.4.0" + jest-message-util@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-21.2.1.tgz#bfe5d4692c84c827d1dcf41823795558f0a1acbe" @@ -4838,14 +5406,32 @@ jest-message-util@^21.2.1: micromatch "^2.3.11" slash "^1.0.0" +jest-message-util@^22.4.0: + version "22.4.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-22.4.0.tgz#e3d861df16d2fee60cb2bc8feac2188a42579642" + dependencies: + "@babel/code-frame" "^7.0.0-beta.35" + chalk "^2.0.1" + micromatch "^2.3.11" + slash "^1.0.0" + stack-utils "^1.0.1" + jest-mock@^21.2.0: version "21.2.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-21.2.0.tgz#7eb0770e7317968165f61ea2a7281131534b3c0f" +jest-mock@^22.2.0: + version "22.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-22.2.0.tgz#444b3f9488a7473adae09bc8a77294afded397a7" + jest-regex-util@^21.2.0: version "21.2.0" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-21.2.0.tgz#1b1e33e63143babc3e0f2e6c9b5ba1eb34b2d530" +jest-regex-util@^22.1.0: + version "22.1.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-22.1.0.tgz#5daf2fe270074b6da63e5d85f1c9acc866768f53" + jest-resolve-dependencies@^21.2.0: version "21.2.0" resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-21.2.0.tgz#9e231e371e1a736a1ad4e4b9a843bc72bfe03d09" @@ -4860,6 +5446,13 @@ jest-resolve@^21.2.0: chalk "^2.0.1" is-builtin-module "^1.0.0" +jest-resolve@^22.4.2: + version "22.4.2" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-22.4.2.tgz#25d88aa4147462c9c1c6a1ba16250d3794c24d00" + dependencies: + browser-resolve "^1.11.2" + chalk "^2.0.1" + jest-runner@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-21.2.1.tgz#194732e3e518bfb3d7cbfc0fd5871246c7e1a467" @@ -4908,6 +5501,17 @@ jest-snapshot@^21.2.1: natural-compare "^1.4.0" pretty-format "^21.2.1" +jest-snapshot@^22.4.0: + version "22.4.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-22.4.0.tgz#03d3ce63f8fa7352388afc6a3c8b5ccc3a180ed7" + dependencies: + chalk "^2.0.1" + jest-diff "^22.4.0" + jest-matcher-utils "^22.4.0" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + pretty-format "^22.4.0" + jest-util@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-21.2.1.tgz#a274b2f726b0897494d694a6c3d6a61ab819bb78" @@ -4920,6 +5524,18 @@ jest-util@^21.2.1: jest-validate "^21.2.1" mkdirp "^0.5.1" +jest-util@^22.4.1: + version "22.4.1" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-22.4.1.tgz#dd17c3bdb067f8e90591563ec0c42bf847dc249f" + dependencies: + callsites "^2.0.0" + chalk "^2.0.1" + graceful-fs "^4.1.11" + is-ci "^1.0.10" + jest-message-util "^22.4.0" + mkdirp "^0.5.1" + source-map "^0.6.0" + jest-validate@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-21.2.1.tgz#cc0cbca653cd54937ba4f2a111796774530dd3c7" @@ -4929,6 +5545,16 @@ jest-validate@^21.2.1: leven "^2.1.0" pretty-format "^21.2.1" +jest-validate@^22.4.0, jest-validate@^22.4.2: + version "22.4.2" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-22.4.2.tgz#e789a4e056173bf97fe797a2df2d52105c57d4f4" + dependencies: + chalk "^2.0.1" + jest-config "^22.4.2" + jest-get-type "^22.1.0" + leven "^2.1.0" + pretty-format "^22.4.0" + jest@^21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/jest/-/jest-21.2.1.tgz#c964e0b47383768a1438e3ccf3c3d470327604e1" @@ -4952,6 +5578,10 @@ joi@^6.10.1: moment "2.x.x" topo "1.x.x" +jquery@>=1.9.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" + js-base64@^2.1.9: version "2.3.2" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.3.2.tgz#a79a923666372b580f8e27f51845c6f7e8fbfbaf" @@ -4978,7 +5608,7 @@ js-yaml@0.3.x: version "0.3.7" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-0.3.7.tgz#d739d8ee86461e54b354d6a7d7d1f2ad9a167f62" -js-yaml@^3.4.3, js-yaml@^3.5.2, js-yaml@^3.7.0, js-yaml@^3.9.1: +js-yaml@^3.4.3, js-yaml@^3.5.2, js-yaml@^3.7.0, js-yaml@^3.9.0, js-yaml@^3.9.1: version "3.10.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc" dependencies: @@ -4996,6 +5626,37 @@ jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" +jsdom@^11.5.1: + version "11.6.2" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.6.2.tgz#25d1ef332d48adf77fc5221fe2619967923f16bb" + dependencies: + abab "^1.0.4" + acorn "^5.3.0" + acorn-globals "^4.1.0" + array-equal "^1.0.0" + browser-process-hrtime "^0.1.2" + content-type-parser "^1.0.2" + cssom ">= 0.3.2 < 0.4.0" + cssstyle ">= 0.2.37 < 0.3.0" + domexception "^1.0.0" + escodegen "^1.9.0" + html-encoding-sniffer "^1.0.2" + left-pad "^1.2.0" + nwmatcher "^1.4.3" + parse5 "4.0.0" + pn "^1.1.0" + request "^2.83.0" + request-promise-native "^1.0.5" + sax "^1.2.4" + symbol-tree "^3.2.2" + tough-cookie "^2.3.3" + w3c-hr-time "^1.0.1" + webidl-conversions "^4.0.2" + whatwg-encoding "^1.0.3" + whatwg-url "^6.4.0" + ws "^4.0.0" + xml-name-validator "^3.0.0" + jsdom@^7.0.2: version "7.2.2" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-7.2.2.tgz#40b402770c2bda23469096bee91ab675e3b1fc6e" @@ -5052,7 +5713,7 @@ jsmin@1.x: version "1.0.1" resolved "https://registry.yarnpkg.com/jsmin/-/jsmin-1.0.1.tgz#e7bd0dcd6496c3bf4863235bf461a3d98aa3b98c" -json-loader@^0.5.4, json-loader@^0.5.7: +json-loader@^0.5.4: version "0.5.7" resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d" @@ -5199,7 +5860,7 @@ keymaster@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/keymaster/-/keymaster-1.6.2.tgz#e1ae54d0ea9488f9f60b66b668f02e9a1946c6eb" -kind-of@^3.0.2: +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" dependencies: @@ -5211,6 +5872,14 @@ kind-of@^4.0.0: dependencies: is-buffer "^1.1.5" +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + kue@0.11.6: version "0.11.6" resolved "https://registry.yarnpkg.com/kue/-/kue-0.11.6.tgz#5b76916bcedd56636a107861471c63c94611860a" @@ -5237,12 +5906,22 @@ lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" +lazy-cache@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264" + dependencies: + set-getter "^0.1.0" + lcid@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" dependencies: invert-kv "^1.0.0" +left-pad@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.2.0.tgz#d30a73c6b8201d8f7d8e7956ba9616087a68e0ee" + leprechaun@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/leprechaun/-/leprechaun-0.0.2.tgz#8b96514a9e634c53fbe59a8094f3378c8fb2084d" @@ -5286,6 +5965,88 @@ linkify-it@^2.0.3: dependencies: uc.micro "^1.0.1" +linkifyjs@^2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-2.1.5.tgz#effc9f01e4aeafbbdbef21a45feab38b9516f93e" + optionalDependencies: + jquery ">=1.9.0" + react ">=0.14.0" + react-dom ">=0.14.0" + +lint-staged@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-7.0.0.tgz#57926c63201e7bd38ca0576d74391efa699b4a9d" + dependencies: + app-root-path "^2.0.1" + chalk "^2.3.1" + commander "^2.14.1" + cosmiconfig "^4.0.0" + debug "^3.1.0" + dedent "^0.7.0" + execa "^0.9.0" + find-parent-dir "^0.3.0" + is-glob "^4.0.0" + jest-validate "^22.4.0" + listr "^0.13.0" + lodash "^4.17.5" + log-symbols "^2.2.0" + micromatch "^3.1.8" + npm-which "^3.0.1" + p-map "^1.1.1" + path-is-inside "^1.0.2" + pify "^3.0.0" + please-upgrade-node "^3.0.1" + staged-git-files "1.1.0" + stringify-object "^3.2.2" + +listr-silent-renderer@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" + +listr-update-renderer@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.4.0.tgz#344d980da2ca2e8b145ba305908f32ae3f4cc8a7" + dependencies: + chalk "^1.1.3" + cli-truncate "^0.2.1" + elegant-spinner "^1.0.1" + figures "^1.7.0" + indent-string "^3.0.0" + log-symbols "^1.0.2" + log-update "^1.0.2" + strip-ansi "^3.0.1" + +listr-verbose-renderer@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#8206f4cf6d52ddc5827e5fd14989e0e965933a35" + dependencies: + chalk "^1.1.3" + cli-cursor "^1.0.2" + date-fns "^1.27.2" + figures "^1.7.0" + +listr@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/listr/-/listr-0.13.0.tgz#20bb0ba30bae660ee84cc0503df4be3d5623887d" + dependencies: + chalk "^1.1.3" + cli-truncate "^0.2.1" + figures "^1.7.0" + indent-string "^2.1.0" + is-observable "^0.2.0" + is-promise "^2.1.0" + is-stream "^1.1.0" + listr-silent-renderer "^1.1.1" + listr-update-renderer "^0.4.0" + listr-verbose-renderer "^0.4.0" + log-symbols "^1.0.2" + log-update "^1.0.2" + ora "^0.2.3" + p-map "^1.1.1" + rxjs "^5.4.2" + stream-to-observable "^0.2.0" + strip-ansi "^3.0.1" + load-json-file@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" @@ -5318,7 +6079,7 @@ loader-runner@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" -loader-utils@^0.2.15: +loader-utils@^0.2.12, loader-utils@^0.2.15: version "0.2.17" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348" dependencies: @@ -5587,6 +6348,10 @@ lodash.snakecase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + lodash.toarray@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561" @@ -5603,12 +6368,29 @@ lodash@^4.0.0, lodash@^4.1.0, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.16.6, lo version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" +lodash@^4.13.1, lodash@^4.17.5: + version "4.17.5" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511" + log-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" dependencies: chalk "^1.0.0" +log-symbols@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" + dependencies: + chalk "^2.0.1" + +log-update@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1" + dependencies: + ansi-escapes "^1.0.0" + cli-cursor "^1.0.2" + lolex@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31" @@ -5683,10 +6465,20 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + map-stream@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + dependencies: + object-visit "^1.0.0" + marked@*, marked@^0.3.5: version "0.3.6" resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.6.tgz#b2c6c618fccece4ef86c4fc6cb8a7cbf5aeda8d7" @@ -5780,6 +6572,24 @@ micromatch@^2.1.5, micromatch@^2.3.11: parse-glob "^3.0.4" regex-cache "^0.4.2" +micromatch@^3.1.8: + version "3.1.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.8.tgz#5c8caa008de588eebb395e8c0ad12c128f25fff1" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + miller-rabin@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" @@ -5862,6 +6672,13 @@ mississippi@^1.3.0: stream-each "^1.1.0" through2 "^2.0.0" +mixin-deep@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" @@ -5922,7 +6739,7 @@ moment@^2.10.3: version "2.19.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167" -moment@^2.18.1: +moment@^2.10.6, moment@^2.18.1: version "2.20.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" @@ -5963,7 +6780,7 @@ moo-server@*, moo-server@1.3.x: version "1.3.0" resolved "https://registry.yarnpkg.com/moo-server/-/moo-server-1.3.0.tgz#5dc79569565a10d6efed5439491e69d2392e58f1" -morgan@1.9.0: +morgan@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.9.0.tgz#d01fa6c65859b76fcf31b3cb53a3821a311d8051" dependencies: @@ -6025,10 +6842,35 @@ mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" -nan@^2.3.0, nan@^2.6.2: +mv@~2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2" + dependencies: + mkdirp "~0.5.1" + ncp "~2.0.0" + rimraf "~2.4.0" + +nan@^2.3.0, nan@^2.3.3, nan@^2.6.2: version "2.8.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.8.0.tgz#ed715f3fe9de02b57a5e6252d90a96675e1f085a" +nanomatch@^1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.9.tgz#879f7150cb2dab7a471259066c104eee6e0fa7c2" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-odd "^2.0.0" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + native-promise-only@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11" @@ -6054,6 +6896,10 @@ nconf@^0.8.4: secure-keys "^1.0.0" yargs "^3.19.0" +ncp@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" + nearley@^2.7.10: version "2.11.0" resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.11.0.tgz#5e626c79a6cd2f6ab9e7e5d5805e7668967757ae" @@ -6328,6 +7174,10 @@ normalize-package-data@^2.3.2: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-path@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-1.0.0.tgz#32d0e472f91ff345701c15a8311018d3b0a90379" + normalize-path@^2.0.0, normalize-path@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" @@ -6347,6 +7197,12 @@ normalize-url@^1.4.0: query-string "^4.1.0" sort-keys "^1.0.0" +npm-path@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.4.tgz#c641347a5ff9d6a09e4d9bce5580c4f505278e64" + dependencies: + which "^1.2.10" + npm-run-all@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.2.tgz#90d62d078792d20669139e718621186656cea056" @@ -6367,6 +7223,14 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" +npm-which@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-3.0.1.tgz#9225f26ec3a285c209cae67c3b11a6b4ab7140aa" + dependencies: + commander "^2.9.0" + npm-path "^2.0.2" + which "^1.2.10" + "npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.1, npmlog@^4.0.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" @@ -6390,7 +7254,7 @@ number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" -"nwmatcher@>= 1.3.7 < 2.0.0", "nwmatcher@>= 1.3.9 < 2.0.0": +"nwmatcher@>= 1.3.7 < 2.0.0", "nwmatcher@>= 1.3.9 < 2.0.0", nwmatcher@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/nwmatcher/-/nwmatcher-1.4.3.tgz#64348e3b3d80f035b40ac11563d278f8b72db89c" @@ -6398,10 +7262,22 @@ oauth-sign@~0.8.1, oauth-sign@~0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" +oauth@0.9.x: + version "0.9.15" + resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" + object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + object-is@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.1.tgz#0aa60ec9989a0b3ed795cf4d06f62cf1ad6539b6" @@ -6410,6 +7286,12 @@ object-keys@^1.0.10, object-keys@^1.0.8: version "1.0.11" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + dependencies: + isobject "^3.0.0" + object.assign@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.0.4.tgz#b1c9cc044ef1b9fe63606fc141abbb32e14730cc" @@ -6434,6 +7316,12 @@ object.omit@^2.0.0: for-own "^0.1.4" is-extendable "^0.1.1" +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + dependencies: + isobject "^3.0.1" + object.values@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.0.4.tgz#e524da09b4f66ff05df457546ec72ac99f13069a" @@ -6459,6 +7347,10 @@ once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: dependencies: wrappy "1" +onetime@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" + onetime@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" @@ -6483,6 +7375,15 @@ optionator@^0.8.1, optionator@^0.8.2: type-check "~0.3.2" wordwrap "~1.0.0" +ora@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4" + dependencies: + chalk "^1.1.1" + cli-cursor "^1.0.2" + cli-spinners "^0.1.2" + object-assign "^4.0.1" + os-browserify@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" @@ -6505,10 +7406,6 @@ os-locale@^2.0.0: lcid "^1.0.0" mem "^1.1.0" -os-shim@^0.1.2: - version "0.1.3" - resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917" - os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" @@ -6546,6 +7443,10 @@ p-locate@^2.0.0: dependencies: p-limit "^1.1.0" +p-map@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" + pac-proxy-agent@1: version "1.1.0" resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-1.1.0.tgz#34a385dfdf61d2f0ecace08858c745d3e791fd4d" @@ -6647,6 +7548,10 @@ parse-url@^1.3.0: is-ssh "^1.3.0" protocols "^1.4.0" +parse5@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" + parse5@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94" @@ -6665,6 +7570,22 @@ parseurl@~1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3" +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + +passport-facebook@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/passport-facebook/-/passport-facebook-2.1.1.tgz#c39d0b52ae4d59163245a4e21a7b9b6321303311" + dependencies: + passport-oauth2 "1.x.x" + +passport-google-oauth2@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/passport-google-oauth2/-/passport-google-oauth2-0.1.6.tgz#dfd7016ac7449fe27cfeb252ae974afc23257a0d" + dependencies: + passport-oauth2 "^1.1.2" + passport-jwt@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-3.0.1.tgz#e4f7276dad8bd251d43c6fc38883130b963272f6" @@ -6678,6 +7599,15 @@ passport-local@^1.0.0: dependencies: passport-strategy "1.x.x" +passport-oauth2@1.x.x, passport-oauth2@^1.1.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad" + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + utils-merge "1.x.x" + passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" @@ -6819,6 +7749,10 @@ platform@1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.4.tgz#6f0fb17edaaa48f21442b3a975c063130f1c3ebd" +please-upgrade-node@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.0.1.tgz#0a681f2c18915e5433a5ca2cd94e0b8206a782db" + pluralize@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45" @@ -6827,6 +7761,10 @@ pluralize@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" +pn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" + popsicle@^6.2.0: version "6.2.2" resolved "https://registry.yarnpkg.com/popsicle/-/popsicle-6.2.2.tgz#e273c8bd48181f73a59b199e2a5e25b692b3e237" @@ -6840,6 +7778,10 @@ popsicle@^6.2.0: tough-cookie "^2.0.0" xtend "^4.0.0" +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + postcss-advanced-variables@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/postcss-advanced-variables/-/postcss-advanced-variables-1.2.2.tgz#90a6213262e66a050a368b4a9c5d4778d72dbd74" @@ -7250,14 +8192,6 @@ postcss@^6.0.1: source-map "^0.6.1" supports-color "^4.4.0" -pre-commit@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/pre-commit/-/pre-commit-1.2.2.tgz#dbcee0ee9de7235e57f79c56d7ce94641a69eec6" - dependencies: - cross-spawn "^5.0.1" - spawn-sync "^1.0.15" - which "1.2.x" - prebuild-install@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-2.3.0.tgz#19481247df728b854ab57b187ce234211311b485" @@ -7321,6 +8255,13 @@ pretty-format@^21.2.1: ansi-regex "^3.0.0" ansi-styles "^3.2.0" +pretty-format@^22.4.0: + version "22.4.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-22.4.0.tgz#237b1f7e1c50ed03bc65c03ccc29d7c8bb7beb94" + dependencies: + ansi-regex "^3.0.0" + ansi-styles "^3.2.0" + private@^0.1.6, private@^0.1.7: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -7539,7 +8480,7 @@ punycode@1.4.1, punycode@^1.2.4, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" -punycode@2.x.x: +punycode@2.x.x, punycode@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.0.tgz#5f863edc89b96db09074bad7947bf09056ca4e7d" @@ -7555,7 +8496,7 @@ q@^1.1.2: version "1.5.0" resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" -qs@6.5.1, qs@^6.1.0, qs@^6.2.0, qs@~6.5.1: +qs@6.5.1, qs@^6.1.0, qs@^6.2.0, qs@^6.5.1, qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" @@ -7683,6 +8624,15 @@ react-apollo@^1.4.12: object-assign "^4.0.1" prop-types "^15.5.8" +react-dom@>=0.14.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + react-dom@^15.3.1, react-dom@^15.4.2: version "15.6.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.6.2.tgz#41cfadf693b757faf2708443a1d1fd5a02bef730" @@ -7699,6 +8649,14 @@ react-input-autosize@^1.1.4: create-react-class "^15.5.2" prop-types "^15.5.8" +react-linkify@^0.2.1: + version "0.2.2" + resolved "https://registry.yarnpkg.com/react-linkify/-/react-linkify-0.2.2.tgz#55b99b1cc7244446a0f9bdebbe13b2c30f789e65" + dependencies: + linkify-it "^2.0.3" + prop-types "^15.5.8" + tlds "^1.57.0" + react-mdl-selectfield@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/react-mdl-selectfield/-/react-mdl-selectfield-0.2.0.tgz#36e1a97233036c057ab2bdb31ec09ad8d9988411" @@ -7804,6 +8762,15 @@ react-virtualized@9.13.0: loose-envify "^1.3.0" prop-types "^15.5.4" +react@>=0.14.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.0" + react@^15.3.1, react@^15.4.2: version "15.6.2" resolved "https://registry.yarnpkg.com/react/-/react-15.6.2.tgz#dba0434ab439cfe82f108f0f511663908179aa72" @@ -8008,6 +8975,13 @@ regex-cache@^0.4.2: dependencies: is-equal-shallow "^0.1.3" +regex-not@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + regexp-clone@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/regexp-clone/-/regexp-clone-0.0.1.tgz#a7c2e09891fdbf38fbb10d376fb73003e68ac589" @@ -8059,7 +9033,7 @@ repeat-element@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.2.tgz#ef089a178d1483baae4d93eb98b4f9e4e11d990a" -repeat-string@^1.5.2: +repeat-string@^1.5.2, repeat-string@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" @@ -8069,7 +9043,21 @@ repeating@^2.0.0: dependencies: is-finite "^1.0.0" -request@2, request@^2.55.0, request@^2.74.0, request@^2.79.0, request@^2.81.0: +request-promise-core@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.1.tgz#3eee00b2c5aa83239cfb04c5700da36f81cd08b6" + dependencies: + lodash "^4.13.1" + +request-promise-native@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.5.tgz#5281770f68e0c9719e5163fd3fab482215f4fda5" + dependencies: + request-promise-core "1.1.1" + stealthy-require "^1.1.0" + tough-cookie ">=2.3.3" + +request@2, request@^2.55.0, request@^2.74.0, request@^2.79.0, request@^2.81.0, request@^2.83.0: version "2.83.0" resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356" dependencies: @@ -8156,6 +9144,10 @@ require-from-string@^1.1.0: version "1.2.1" resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-1.2.1.tgz#529c9ccef27380adfec9a2f965b649bbee636418" +require-from-string@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.1.tgz#c545233e9d7da6616e9d59adfb39fc9f588676ff" + require-main-filename@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" @@ -8182,6 +9174,10 @@ resolve-from@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57" +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" @@ -8198,6 +9194,13 @@ resolve@^1.1.7: dependencies: path-parse "^1.0.5" +restore-cursor@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541" + dependencies: + exit-hook "^1.0.0" + onetime "^1.0.0" + restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -8231,6 +9234,12 @@ rimraf@^2.2.8, rimraf@~2.5.2: dependencies: glob "^7.0.5" +rimraf@~2.4.0: + version "2.4.5" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" + dependencies: + glob "^6.0.1" + ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7" @@ -8267,10 +9276,26 @@ rx-lite@*, rx-lite@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" +rxjs@^5.4.2: + version "5.5.6" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.6.tgz#e31fb96d6fd2ff1fd84bcea8ae9c02d007179c02" + dependencies: + symbol-observable "1.0.1" + safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" +safe-json-stringify@~1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.1.0.tgz#bd2b6dad1ebafab3c24672a395527f01804b7e19" + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + dependencies: + ret "~0.1.10" + samsam@1.1.2, samsam@~1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" @@ -8297,7 +9322,7 @@ sax@0.5.x: version "0.5.8" resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" -sax@^1.1.4, sax@^1.2.1, sax@~1.2.1: +sax@^1.1.4, sax@^1.2.1, sax@^1.2.4, sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -8424,10 +9449,34 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" +set-getter@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376" + dependencies: + to-object-path "^0.3.0" + set-immediate-shim@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" +set-value@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.1" + to-object-path "^0.3.0" + +set-value@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -8531,6 +9580,10 @@ slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" +slice-ansi@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" + slice-ansi@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d" @@ -8566,6 +9619,33 @@ snake-case@2.1.0: dependencies: no-case "^2.2.0" +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.1.tgz#e12b5487faded3e3dea0ac91e9400bf75b401370" + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^2.0.0" + sntp@1.x.x: version "1.0.9" resolved "https://registry.yarnpkg.com/sntp/-/sntp-1.0.9.tgz#6541184cc90aeea6c6e7b35e2659082443c66198" @@ -8610,6 +9690,16 @@ source-list-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" +source-map-resolve@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.1.tgz#7ad0f593f2281598e854df80f19aae4b92d7a11a" + dependencies: + atob "^2.0.0" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + source-map-support@^0.4.15: version "0.4.18" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" @@ -8622,6 +9712,10 @@ source-map-support@^0.5.0: dependencies: source-map "^0.6.0" +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + source-map@0.1.x: version "0.1.43" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" @@ -8642,13 +9736,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" -spawn-sync@^1.0.15: - version "1.0.15" - resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-1.0.15.tgz#b00799557eb7fb0c8376c29d44e8a1ea67e57476" - dependencies: - concat-stream "^1.4.7" - os-shim "^0.1.2" - spdx-correct@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-1.0.2.tgz#4b3073d933ff51f3912f03ac5519498a4150db40" @@ -8663,6 +9750,12 @@ spdx-license-ids@^1.0.2: version "1.2.2" resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz#c9df7a3424594ade6bd11900d596696dc06bac57" +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + dependencies: + extend-shallow "^3.0.0" + split@0.3: version "0.3.3" resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" @@ -8697,6 +9790,21 @@ stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" +stack-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.1.tgz#d4f33ab54e8e38778b0ca5cfd3b3afb12db68620" + +staged-git-files@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/staged-git-files/-/staged-git-files-1.1.0.tgz#1a9bb131c1885601023c7aaddd3d54c22142c526" + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + "statuses@>= 1.3.1 < 2": version "1.4.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" @@ -8705,6 +9813,10 @@ statuses@~1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" +stealthy-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + stream-browserify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" @@ -8745,6 +9857,12 @@ stream-shift@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" +stream-to-observable@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/stream-to-observable/-/stream-to-observable-0.2.0.tgz#59d6ea393d87c2c0ddac10aa0d561bc6ba6f0e10" + dependencies: + any-observable "^0.2.0" + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" @@ -8793,6 +9911,14 @@ string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" +stringify-object@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.2.2.tgz#9853052e5a88fb605a44cd27445aa257ad7ffbcd" + dependencies: + get-own-enumerable-property-symbols "^2.0.1" + is-obj "^1.0.1" + is-regexp "^1.0.0" + stringstream@~0.0.4, stringstream@~0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -8823,6 +9949,10 @@ strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" +strip-indent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" + strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" @@ -8880,6 +10010,21 @@ superagent@^2.0.0: qs "^6.1.0" readable-stream "^2.0.5" +superagent@^3.8.0: + version "3.8.2" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.2.tgz#e4a11b9d047f7d3efeb3bbe536d9ec0021d16403" + dependencies: + component-emitter "^1.2.0" + cookiejar "^2.1.0" + debug "^3.1.0" + extend "^3.0.0" + form-data "^2.3.1" + formidable "^1.1.1" + methods "^1.1.1" + mime "^1.4.1" + qs "^6.5.1" + readable-stream "^2.0.5" + supports-color@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.1.2.tgz#72a262894d9d408b956ca05ff37b2ed8a6e2a2d5" @@ -8902,6 +10047,12 @@ supports-color@^4, supports-color@^4.0.0, supports-color@^4.2.1, supports-color@ dependencies: has-flag "^2.0.0" +supports-color@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.2.0.tgz#b0d5333b1184dd3666cbe5aa0b45c5ac7ac17a4a" + dependencies: + has-flag "^3.0.0" + svgo@^0.7.0: version "0.7.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5" @@ -8918,11 +10069,19 @@ svgo@^0.7.0: version "0.0.21" resolved "https://registry.yarnpkg.com/sylvester/-/sylvester-0.0.21.tgz#2987b1ce2bd2f38b0dce2a34388884bfa4400ea7" +symbol-observable@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" + +symbol-observable@^0.2.2: + version "0.2.4" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-0.2.4.tgz#95a83db26186d6af7e7a18dbd9760a2f86d08f40" + symbol-observable@^1.0.2, symbol-observable@^1.0.3, symbol-observable@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.4.tgz#29bf615d4aa7121bdd898b22d4b3f9bc4e2aa03d" -"symbol-tree@>= 3.1.0 < 4.0.0", symbol-tree@^3.2.1: +"symbol-tree@>= 3.1.0 < 4.0.0", symbol-tree@^3.2.1, symbol-tree@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" @@ -9069,7 +10228,7 @@ title-case-minors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/title-case-minors/-/title-case-minors-1.0.0.tgz#51f17037c294747a1d1cda424b5004c86d8eb115" -tlds@^1.196.0: +tlds@^1.196.0, tlds@^1.57.0: version "1.199.0" resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.199.0.tgz#a4fc8c3058216488a80aaaebb427925007e55217" @@ -9105,6 +10264,27 @@ to-no-case@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.1.tgz#15358bee4a2c83bd76377ba1dc049d0f18837aae" + dependencies: + define-property "^0.2.5" + extend-shallow "^2.0.1" + regex-not "^1.0.0" + to-sentence-case@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/to-sentence-case/-/to-sentence-case-1.0.0.tgz#c483bf3647737e5c738ef7006fe360d5f99c572e" @@ -9148,12 +10328,18 @@ touch@^3.1.0: dependencies: nopt "~1.0.10" -tough-cookie@^2.0.0, tough-cookie@^2.2.0, tough-cookie@^2.3.2, tough-cookie@~2.3.0, tough-cookie@~2.3.3: +tough-cookie@>=2.3.3, tough-cookie@^2.0.0, tough-cookie@^2.2.0, tough-cookie@^2.3.2, tough-cookie@^2.3.3, tough-cookie@~2.3.0, tough-cookie@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561" dependencies: punycode "^1.4.1" +tr46@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + dependencies: + punycode "^2.1.0" + tr46@~0.0.1, tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -9270,6 +10456,10 @@ uid-number@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" +uid2@0.0.x: + version "0.0.3" + resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" + ultron@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864" @@ -9290,6 +10480,15 @@ underscore@~1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.7.0.tgz#6bbaf0877500d36be34ecaa584e0db9fef035209" +union-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^0.4.3" + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" @@ -9330,6 +10529,13 @@ unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + unzip-response@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe" @@ -9356,6 +10562,10 @@ urijs@^1.18.4: version "1.19.0" resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.0.tgz#d8aa284d0e7469703a6988ad045c4cbfdf08ada0" +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + url-join@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/url-join/-/url-join-2.0.2.tgz#c072756967ad24b8b59e5741551caac78f50b8b7" @@ -9385,6 +10595,14 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/use/-/use-2.0.2.tgz#ae28a0d72f93bf22422a18a2e379993112dec8e8" + dependencies: + define-property "^0.2.5" + isobject "^3.0.0" + lazy-cache "^2.0.2" + user-home@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" @@ -9399,7 +10617,7 @@ util@0.10.3, "util@>=0.10.3 <1", util@^0.10.3: dependencies: inherits "2.0.1" -utils-merge@1.0.1: +utils-merge@1.0.1, utils-merge@1.x.x: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -9454,6 +10672,12 @@ void-elements@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" +w3c-hr-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" + dependencies: + browser-process-hrtime "^0.1.2" + walker@1.x, walker@~1.0.5: version "1.0.7" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" @@ -9489,7 +10713,7 @@ webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" -webidl-conversions@^4.0.0: +webidl-conversions@^4.0.0, webidl-conversions@^4.0.1, webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -9533,6 +10757,12 @@ whatwg-encoding@^1.0.1: dependencies: iconv-lite "0.4.13" +whatwg-encoding@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.3.tgz#57c235bc8657e914d24e1a397d3c82daee0a6ba3" + dependencies: + iconv-lite "0.4.19" + whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" @@ -9550,6 +10780,14 @@ whatwg-url@^4.3.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" +whatwg-url@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.4.0.tgz#08fdf2b9e872783a7a1f6216260a1d66cc722e08" + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.0" + webidl-conversions "^4.0.1" + whet.extend@~0.9.9: version "0.9.9" resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" @@ -9562,18 +10800,12 @@ which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" -which@1, which@^1.2.12, which@^1.2.9: +which@1, which@^1.2.10, which@^1.2.12, which@^1.2.9: version "1.3.0" resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a" dependencies: isexe "^2.0.0" -which@1.2.x: - version "1.2.14" - resolved "https://registry.yarnpkg.com/which/-/which-1.2.14.tgz#9a87c4378f03e827cecaf1acdf56c736c01c14e5" - dependencies: - isexe "^2.0.0" - wide-align@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710" @@ -9679,6 +10911,13 @@ ws@^3.0.0: safe-buffer "~5.1.0" ultron "~1.1.0" +ws@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-4.1.0.tgz#a979b5d7d4da68bf54efe0408967c324869a7289" + dependencies: + async-limiter "~1.0.0" + safe-buffer "~5.1.0" + x-xss-protection@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/x-xss-protection/-/x-xss-protection-1.0.0.tgz#898afb93869b24661cf9c52f9ee8db8ed0764dd9" @@ -9691,6 +10930,10 @@ xdg-basedir@^3.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-2.0.1.tgz#4d8b8f1eccd3419aa362061becef515e1e559635" +xml-name-validator@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" + xml@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5"