diff --git a/.dockerignore b/.dockerignore index e413ac5ea..80bb20e2c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,10 @@ # excluded because we'll likely need to rebuild this. node_modules -# scripts are used during development and testing, not +# most scripts are used during development and testing, not # production. scripts +!scripts/generateIntrospectionResult.js # documentation should not be visable in production. docs diff --git a/.gitignore b/.gitignore index bd8edcd94..767066028 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ dist npm-debug.log* dump.rdb +client/coral-framework/graphql/introspection.json + .env *.cfg diff --git a/app.js b/app.js index 959aed0a3..a6b669b0e 100644 --- a/app.js +++ b/app.js @@ -6,6 +6,7 @@ const helmet = require('helmet'); const authentication = require('./middleware/authentication'); const {passport} = require('./services/passport'); const plugins = require('./services/plugins'); +const pubsub = require('./services/pubsub'); const i18n = require('./services/i18n'); const enabled = require('debug').enabled; const errors = require('./errors'); @@ -95,6 +96,17 @@ app.use(passport.initialize()); // (if present) the JWT on the request. app.use('/api', authentication); +// To handle dependancy injection safer, we inject the pubsub handle onto the +// request object. +app.use('/api', (req, res, next) => { + + // Attach the pubsub handle to the requests. + req.pubsub = pubsub.createClient(); + + // Forward on the request. + next(); +}); + //============================================================================== // GraphQL Router //============================================================================== @@ -159,7 +171,7 @@ app.use('/', (err, req, res, next) => { } i18n.init(req); - + if (err instanceof errors.APIError) { res.status(err.status); res.render('error', { diff --git a/bin/cli-serve b/bin/cli-serve index f2a6d3592..46fe51ff9 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -12,6 +12,7 @@ const SetupService = require('../services/setup'); const kue = require('../services/kue'); const mongoose = require('../services/mongoose'); const util = require('./util'); +const cache = require('../services/cache'); const {createSubscriptionManager} = require('../graph/subscriptions'); const { PORT @@ -80,7 +81,11 @@ function normalizePort(val) { * Event listener for HTTP server "listening" event. */ -function onListening() { +async function onListening() { + + // Start the cache instance. + await cache.init(); + let addr = server.address(); let bind = typeof addr === 'string' ? `pipe ${addr}` @@ -135,6 +140,11 @@ async function startApp(program) { /** * Listen on provided port, on all network interfaces. */ + server.on('error', onError); + server.on('listening', onListening); + server.on('listening', () => { + + }); server.listen(port, () => { // Mount the websocket server if requested. @@ -145,8 +155,6 @@ async function startApp(program) { createSubscriptionManager(server); } }); - server.on('error', onError); - server.on('listening', onListening); } //============================================================================== diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 29df3753c..14437c51b 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -2,8 +2,10 @@ import React from 'react'; import {render} from 'react-dom'; import {ApolloProvider} from 'react-apollo'; import smoothscroll from 'smoothscroll-polyfill'; +import EventEmitter from 'eventemitter2'; +import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider'; -import {getClient} from './services/client'; +import {getClient} from 'coral-framework/services/client'; import store from './services/store'; import App from './components/App'; @@ -12,13 +14,19 @@ import 'react-mdl/extra/material.js'; import './graphql'; import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins'; +const eventEmitter = new EventEmitter(); + +// TODO: pass redux actions through the emitter. + loadPluginsTranslations(); injectPluginsReducers(); smoothscroll.polyfill(); render( - - - + + + + + , document.querySelector('#root') ); diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js deleted file mode 100644 index 934de2c9e..000000000 --- a/client/coral-admin/src/services/client.js +++ /dev/null @@ -1,6 +0,0 @@ -import {getClient as getFrameworkClient} from 'coral-framework/services/client'; -import fragmentMatcher from './fragmentMatcher'; - -export function getClient() { - return getFrameworkClient({fragmentMatcher}); -} diff --git a/client/coral-admin/src/services/fragmentMatcher.js b/client/coral-admin/src/services/fragmentMatcher.js deleted file mode 100644 index 231c0fa2b..000000000 --- a/client/coral-admin/src/services/fragmentMatcher.js +++ /dev/null @@ -1,65 +0,0 @@ -import {IntrospectionFragmentMatcher} from 'apollo-client'; - -// TODO this is a short-term fix -// we need to set up something to query the server for the schema before ApolloClient initialization -// https://github.com/apollographql/apollo-client/issues/1555#issuecomment-295834774 -const fm = new IntrospectionFragmentMatcher({ - introspectionQueryResultData: { - __schema: { - types: [ - { - kind: 'INTERFACE', - name: 'UserError', - possibleTypes: [ - {name: 'GenericUserError'}, - {name: 'ValidationUserError'} - ] - }, - { - kind: 'INTERFACE', - name: 'Response', - possibleTypes: [ - {name: 'CreateCommentResponse'}, - {name: 'CreateFlagResponse'}, - {name: 'CreateDontAgreeResponse'}, - {name: 'DeleteActionResponse'}, - {name: 'SetUserStatusResponse'}, - {name: 'SuspendUserResponse'}, - {name: 'SetCommentStatusResponse'}, - {name: 'ModifyTagResponse'}, - {name: 'IgnoreUserResponse'}, - {name: 'StopIgnoringUserResponse'} - ] - }, - { - kind: 'INTERFACE', - name: 'Action', - possibleTypes: [ - {name: 'DefaultAction'}, - {name: 'FlagAction'}, - {name: 'DontAgreeAction'} - ] - }, - { - kind: 'INTERFACE', - name: 'ActionSummary', - possibleTypes: [ - {name: 'DefaultActionSummary'}, - {name: 'FlagActionSummary'}, - {name: 'DontAgreeActionSummary'} - ] - }, - { - kind: 'INTERFACE', - name: 'AssetActionSummary', - possibleTypes: [ - {name: 'DefaultAssetActionSummary'}, - {name: 'FlagAssetActionSummary'} - ] - } - ] - } - } -}); - -export default fm; diff --git a/client/coral-admin/src/services/store.js b/client/coral-admin/src/services/store.js index 06cb3c758..ca29c15c8 100644 --- a/client/coral-admin/src/services/store.js +++ b/client/coral-admin/src/services/store.js @@ -1,7 +1,7 @@ import {createStore, combineReducers, applyMiddleware, compose} from 'redux'; import thunk from 'redux-thunk'; import mainReducer from '../reducers'; -import {getClient} from './client'; +import {getClient} from 'coral-framework/services/client'; const middlewares = [ applyMiddleware(getClient().middleware()), diff --git a/client/coral-embed-stream/src/components/Stream.css b/client/coral-embed-stream/src/components/Stream.css index 8c5a4250b..23f620157 100644 --- a/client/coral-embed-stream/src/components/Stream.css +++ b/client/coral-embed-stream/src/components/Stream.css @@ -14,7 +14,7 @@ .filterWrapper { position: absolute; right: 0; - margin-top: 6px; + margin-top: 4px; } .highlightedContainer { @@ -26,4 +26,4 @@ margin-top: 28px; padding-bottom: 50px; min-height: 600px; -} +} \ No newline at end of file diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 2a1381862..e6e184dfa 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -81,7 +81,7 @@ class Stream extends React.Component { setActiveReplyBox, appendItemArray, commentClassNames, - root: {asset, asset: {comments, totalCommentCount}, comment, me}, + root: {asset, asset: {comment, comments, totalCommentCount}, me}, postComment, addNotification, editComment, diff --git a/client/coral-embed-stream/src/containers/Embed.js b/client/coral-embed-stream/src/containers/Embed.js index a31dbe911..06646a6c2 100644 --- a/client/coral-embed-stream/src/containers/Embed.js +++ b/client/coral-embed-stream/src/containers/Embed.js @@ -3,6 +3,7 @@ import {compose, gql} from 'react-apollo'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import isEqual from 'lodash/isEqual'; +import get from 'lodash/get'; import branch from 'recompose/branch'; import renderComponent from 'recompose/renderComponent'; @@ -91,7 +92,7 @@ class EmbedContainer extends React.Component { } componentDidUpdate(prevProps) { - if (!prevProps.root.comment && this.props.root.comment) { + if (!get(prevProps, 'root.asset.comment') && get(this.props, 'root.asset.comment')) { // Scroll to a permalinked comment if one is in the URL once the page is done rendering. setTimeout(() => pym.scrollParentToChildEl('talk-embed-stream-container'), 0); diff --git a/client/coral-embed-stream/src/containers/Stream.js b/client/coral-embed-stream/src/containers/Stream.js index ea0676e51..804f3d37e 100644 --- a/client/coral-embed-stream/src/containers/Stream.js +++ b/client/coral-embed-stream/src/containers/Stream.js @@ -237,16 +237,16 @@ const slots = [ const fragments = { root: gql` fragment CoralEmbedStream_Stream_root on RootQuery { - comment(id: $commentId) @include(if: $hasComment) { - ...CoralEmbedStream_Stream_comment - ${nest(` - parent { - ...CoralEmbedStream_Stream_comment - ...nest - } - `, THREADING_LEVEL)} - } asset(id: $assetId, url: $assetUrl) { + comment(id: $commentId) @include(if: $hasComment) { + ...CoralEmbedStream_Stream_comment + ${nest(` + parent { + ...CoralEmbedStream_Stream_comment + ...nest + } + `, THREADING_LEVEL)} + } id title url diff --git a/client/coral-embed-stream/src/graphql/utils.js b/client/coral-embed-stream/src/graphql/utils.js index 4f51b0ae2..1030a3547 100644 --- a/client/coral-embed-stream/src/graphql/utils.js +++ b/client/coral-embed-stream/src/graphql/utils.js @@ -12,8 +12,8 @@ function determineCommentDepth(comment) { } function applyToCommentsOrigin(root, callback) { - if (root.comment) { - let comment = root.comment; + if (root.asset.comment) { + let comment = root.asset.comment; for (let depth = 0; depth <= determineCommentDepth(comment); depth++) { let changes = {$apply: (node) => node ? callback(node) : node}; for (let i = 0; i < depth; i++) { @@ -24,7 +24,10 @@ function applyToCommentsOrigin(root, callback) { return { ...root, - comment, + asset: { + ...root.asset, + comment, + }, }; } return update(root, { @@ -135,8 +138,8 @@ export function findCommentInEmbedQuery(root, callbackOrId) { if (typeof callbackOrId === 'string') { callback = (node) => node.id === callbackOrId; } - if (root.comment) { - return findComment([getTopLevelParent(root.comment)], callback); + if (root.asset.comment) { + return findComment([getTopLevelParent(root.asset.comment)], callback); } if (!root.asset.comments) { return false; diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index 9020a8457..c40df74f1 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -5,15 +5,19 @@ import {ApolloProvider} from 'react-apollo'; import {checkLogin} from 'coral-framework/actions/auth'; import './graphql'; import {addExternalConfig} from 'coral-embed-stream/src/actions/config'; -import {getStore, injectReducers} from 'coral-framework/services/store'; +import {getStore, injectReducers, addListener} from 'coral-framework/services/store'; import {getClient} from 'coral-framework/services/client'; +import {createReduxEmitter} from 'coral-framework/services/events'; import pym from 'coral-framework/services/pym'; import AppRouter from './AppRouter'; import {loadPluginsTranslations, injectPluginsReducers} from 'coral-framework/helpers/plugins'; import reducers from './reducers'; +import EventEmitter from 'eventemitter2'; +import EventEmitterProvider from 'coral-framework/components/EventEmitterProvider'; const store = getStore(); const client = getClient(); +const eventEmitter = new EventEmitter({wildcard: true}); loadPluginsTranslations(); injectPluginsReducers(); @@ -42,11 +46,21 @@ if (!window.opener) { } else { init(); } + + // Pass any events through our parent. + eventEmitter.onAny((eventName, value) => { + pym.sendMessage('event', JSON.stringify({eventName, value})); + }); + + // Add a redux listener to pass through all actions to our event emitter. + addListener(createReduxEmitter(eventEmitter)); } render( - - - + + + + + , document.querySelector('#talk-embed-stream-container') ); diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index eb61f3b90..fda97c4b3 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -3,6 +3,9 @@ import URLSearchParams from 'url-search-params'; import {buildUrl} from 'coral-framework/utils'; import queryString from 'query-string'; +import EventEmitter from 'eventemitter2'; + +const eventEmitter = new EventEmitter({wildcard: true}); // TODO: Styles should live in a separate file const snackbarStyles = { @@ -57,6 +60,10 @@ function configurePymParent(pymParent, opts) { pymParent.sendMessage('config', JSON.stringify(config)); } + if (opts.events) { + opts.events(eventEmitter); + } + pymParent.onMessage('coral-auth-changed', function(message) { if (opts.onAuthChanged) { opts.onAuthChanged(message ? JSON.parse(message) : null); @@ -167,6 +174,12 @@ function configurePymParent(pymParent, opts) { window.open(url, '_blank').focus(); }); + // Pass events from iframe to the event emitter + pymParent.onMessage('event', (raw) => { + const {eventName, value} = JSON.parse(raw); + eventEmitter.emit(eventName, value); + }); + // get dimensions of viewport const viewport = () => { let e = window, a = 'inner'; diff --git a/client/coral-framework/components/EventEmitterProvider.js b/client/coral-framework/components/EventEmitterProvider.js new file mode 100644 index 000000000..36c0bccdb --- /dev/null +++ b/client/coral-framework/components/EventEmitterProvider.js @@ -0,0 +1,18 @@ +import React from 'react'; +const PropTypes = require('prop-types'); + +class EventEmitterProvider extends React.Component { + getChildContext() { + return {eventEmitter: this.props.eventEmitter}; + } + + render() { + return this.props.children; + } +} + +EventEmitterProvider.childContextTypes = { + eventEmitter: PropTypes.object, +}; + +export default EventEmitterProvider; diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 782f840bd..67b47482e 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -2,3 +2,6 @@ export {default as withFragments} from './withFragments'; export {default as withMutation} from './withMutation'; export {default as withQuery} from './withQuery'; export {default as withCopyToClipboard} from './withCopyToClipboard'; +export {default as withEmit} from './withEmit'; +export {default as excludeIf} from './excludeIf'; +export {default as connect} from './connect'; diff --git a/client/coral-framework/hocs/withEmit.js b/client/coral-framework/hocs/withEmit.js new file mode 100644 index 000000000..3a3216c8a --- /dev/null +++ b/client/coral-framework/hocs/withEmit.js @@ -0,0 +1,27 @@ +import React from 'react'; +const PropTypes = require('prop-types'); + +/** + * WithEmit provides a property `emit: (eventName, value)` + * to the wrapped component. + */ +export default (WrappedComponent) => { + class WithEmit extends React.Component { + static contextTypes = { + eventEmitter: PropTypes.object, + }; + + emit = (eventName, value) => { + this.context.eventEmitter.emit(eventName, value); + }; + + render() { + return ; + } + } + + return WithEmit; +}; diff --git a/client/coral-framework/hocs/withMutation.js b/client/coral-framework/hocs/withMutation.js index d3ba73ecd..4488b0e7f 100644 --- a/client/coral-framework/hocs/withMutation.js +++ b/client/coral-framework/hocs/withMutation.js @@ -5,8 +5,8 @@ import uniq from 'lodash/uniq'; import flatten from 'lodash/flatten'; import isEmpty from 'lodash/isEmpty'; import {getMutationOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; -import {getStore} from 'coral-framework/services/store'; import {getDefinitionName, getResponseErrors} from '../utils'; +import PropTypes from 'prop-types'; import t from 'coral-framework/services/i18n'; class ResponseErrors extends Error { @@ -36,98 +36,115 @@ export default (document, config = {}) => (WrappedComponent) => { options: config.options || {}, props: config.props || ((data) => ({mutate: data.mutate()})), }; - const wrappedProps = (data) => { - const name = getDefinitionName(document); - const callbacks = getMutationOptions(name); - const mutate = (base) => { - const variables = base.variables || config.options.variables; - const configs = callbacks.map((cb) => cb({variables, state: getStore().getState()})); - const optimisticResponse = merge( - base.optimisticResponse || config.options.optimisticResponse, - ...configs.map((cfg) => cfg.optimisticResponse), - ); - - const refetchQueries = flatten(uniq([ - base.refetchQueries || config.options.refetchQueries, - ...configs.map((cfg) => cfg.refetchQueries), - ].filter((i) => i))); - - const updateCallbacks = - [base.update || config.options.update] - .concat(...configs.map((cfg) => cfg.update)) - .filter((i) => i); - - const update = (proxy, result) => { - if (getResponseErrors(result)) { - - // Do not run updates when we have mutation errors. - return; - } - updateCallbacks.forEach((cb) => cb(proxy, result)); - }; - - const updateQueries = - [ - base.updateQueries || config.options.updateQueries, - ...configs.map((cfg) => cfg.updateQueries) - ] - .filter((i) => i) - .reduce((res, map) => { - Object.keys(map).forEach((key) => { - if (!(key in res)) { - res[key] = (prev, result) => { - if (getResponseErrors(result.mutationResult)) { - - // Do not run updates when we have mutation errors. - return prev; - } - return map[key](prev, result); - }; - } else { - const existing = res[key]; - res[key] = (prev, result) => { - const next = existing(prev, result); - return map[key](next, result); - }; - } - }); - return res; - }, {}); - - const wrappedConfig = { - variables, - optimisticResponse, - refetchQueries, - updateQueries, - update, - }; - if (isEmpty(wrappedConfig.optimisticResponse)) { - delete wrappedConfig.optimisticResponse; - } - return data.mutate(wrappedConfig) - .then((res) => { - const errors = getResponseErrors(res); - if (errors) { - throw new ResponseErrors(errors); - } - return Promise.resolve(res); - }); + return class WithMutation extends React.Component { + static contextTypes = { + eventEmitter: PropTypes.object, + store: PropTypes.object, }; - return config.props({...data, mutate}); - }; - // Lazily resolve fragments from graphRegistry to support circular dependencies. - let memoized = null; - const getWrapped = () => { - if (!memoized) { - memoized = graphql(resolveFragments(document), {...config, props: wrappedProps})(WrappedComponent); + // Lazily resolve fragments from graphRegistry to support circular dependencies. + memoized = null; + + wrappedProps = (data) => { + const name = getDefinitionName(document); + const callbacks = getMutationOptions(name); + const mutate = (base) => { + const variables = base.variables || config.options.variables; + const configs = callbacks.map((cb) => cb({variables, state: this.context.store.getState()})); + + const optimisticResponse = merge( + base.optimisticResponse || config.options.optimisticResponse, + ...configs.map((cfg) => cfg.optimisticResponse), + ); + + const refetchQueries = flatten(uniq([ + base.refetchQueries || config.options.refetchQueries, + ...configs.map((cfg) => cfg.refetchQueries), + ].filter((i) => i))); + + const updateCallbacks = + [base.update || config.options.update] + .concat(...configs.map((cfg) => cfg.update)) + .filter((i) => i); + + const update = (proxy, result) => { + if (getResponseErrors(result)) { + + // Do not run updates when we have mutation errors. + return; + } + updateCallbacks.forEach((cb) => cb(proxy, result)); + }; + + const updateQueries = + [ + base.updateQueries || config.options.updateQueries, + ...configs.map((cfg) => cfg.updateQueries) + ] + .filter((i) => i) + .reduce((res, map) => { + Object.keys(map).forEach((key) => { + if (!(key in res)) { + res[key] = (prev, result) => { + if (getResponseErrors(result.mutationResult)) { + + // Do not run updates when we have mutation errors. + return prev; + } + return map[key](prev, result); + }; + } else { + const existing = res[key]; + res[key] = (prev, result) => { + const next = existing(prev, result); + return map[key](next, result); + }; + } + }); + return res; + }, {}); + + const wrappedConfig = { + variables, + optimisticResponse, + refetchQueries, + updateQueries, + update, + }; + if (isEmpty(wrappedConfig.optimisticResponse)) { + delete wrappedConfig.optimisticResponse; + } + + this.context.eventEmitter.emit(`mutation.${name}.begin`, {variables}); + + return data.mutate(wrappedConfig) + .then((res) => { + const errors = getResponseErrors(res); + if (errors) { + throw new ResponseErrors(errors); + } + this.context.eventEmitter.emit(`mutation.${name}.success`, {variables, data: res.data}); + return Promise.resolve(res); + }) + .catch((error) => { + this.context.eventEmitter.emit(`mutation.${name}.error`, {variables, error}); + throw new error; + }); + }; + return config.props({...data, mutate}); + }; + + getWrapped = () => { + if (!this.memoized) { + this.memoized = graphql(resolveFragments(document), {...config, props: this.wrappedProps})(WrappedComponent); + } + return this.memoized; + }; + + render() { + const Wrapped = this.getWrapped(); + return ; } - return memoized; - }; - - return (props) => { - const Wrapped = getWrapped(); - return ; }; }; diff --git a/client/coral-framework/hocs/withQuery.js b/client/coral-framework/hocs/withQuery.js index ec190466c..adb67b7b3 100644 --- a/client/coral-framework/hocs/withQuery.js +++ b/client/coral-framework/hocs/withQuery.js @@ -2,6 +2,7 @@ import * as React from 'react'; import {graphql} from 'react-apollo'; import {getQueryOptions, resolveFragments} from 'coral-framework/services/graphqlRegistry'; import {getDefinitionName, separateDataAndRoot, getResponseErrors} from '../utils'; +import PropTypes from 'prop-types'; const withSkipOnErrors = (reducer) => (prev, action, ...rest) => { if (action.type === 'APOLLO_MUTATION_RESULT' && getResponseErrors(action.result)) { @@ -10,76 +11,146 @@ const withSkipOnErrors = (reducer) => (prev, action, ...rest) => { return reducer(prev, action, ...rest); }; +function networkStatusToString(networkStatus) { + switch(networkStatus) { + case 1: + return 'loading'; + case 2: + return 'setVariables'; + case 3: + return 'fetchMore'; + case 4: + return 'refetch'; + case 6: + return 'poll'; + case 7: + return 'ready'; + case 8: + return 'error'; + } + throw new Error(`Unknown network status ${networkStatus}`); +} + /** * Exports a HOC with the same signature as `graphql`, that will * apply query options registered in the graphRegistry. */ export default (document, config = {}) => (WrappedComponent) => { - const wrappedConfig = { - ...config, - options: config.options || {}, - props: (args) => { - const wrappedArgs = { - ...args, - data: { - ...args.data, - subscribeToMore(stmArgs) { + const name = getDefinitionName(document); - // Resolve document fragments before passing it to `apollo-client`. - return args.data.subscribeToMore({ - ...stmArgs, - document: resolveFragments(stmArgs.document), - }); - }, - fetchMore(lmArgs) { - - // Resolve document fragments before passing it to `apollo-client`. - return args.data.fetchMore({ - ...lmArgs, - query: resolveFragments(lmArgs.query), - }); - }, - }, - }; - return config.props - ? config.props(wrappedArgs) - : separateDataAndRoot(wrappedArgs.data); - }, - }; - - const wrappedOptions = (data) => { - const base = (typeof wrappedConfig.options === 'function') - ? wrappedConfig.options(data) - : wrappedConfig.options; - const name = getDefinitionName(document); - const configs = getQueryOptions(name); - const reducerCallbacks = - [base.reducer || ((i) => i)] - .concat(...configs.map((cfg) => cfg.reducer)) - .filter((i) => i); - - const reducer = withSkipOnErrors( - reducerCallbacks.reduce( - (a, b) => (prev, ...rest) => - b(a(prev, ...rest), ...rest), - )); - - return { - ...base, - reducer, + return class WithQuery extends React.Component { + static contextTypes = { + eventEmitter: PropTypes.object, }; - }; - let memoized = null; - const getWrapped = () => { - if (!memoized) { - memoized = graphql(resolveFragments(document), {...wrappedConfig, options: wrappedOptions})(WrappedComponent); + // Lazily resolve fragments from graphRegistry to support circular dependencies. + memoized = null; + lastNetworkStatus = null; + + emitWhenNeeded(data) { + const {variables, networkStatus} = data; + if (this.lastNetworkStatus === networkStatus) { + return; + } + this.lastNetworkStatus = networkStatus; + + const status = networkStatusToString(networkStatus); + + const {root} = separateDataAndRoot(data); + this.context.eventEmitter.emit(`query.${name}.${status}`, {variables, data: root}); } - return memoized; - }; - return (props) => { - const Wrapped = getWrapped(); - return ; + wrappedConfig = { + ...config, + options: config.options || {}, + props: (args) => { + this.emitWhenNeeded(args.data); + + const wrappedArgs = { + ...args, + data: { + ...args.data, + subscribeToMore: (stmArgs) => { + + // Resolve document fragments before passing it to `apollo-client`. + return args.data.subscribeToMore({ + ...stmArgs, + document: resolveFragments(stmArgs.document), + onError: (err) => { + if (stmArgs.onErr) { + return stmArgs.onErr(err); + } + throw err; + }, + }); + }, + fetchMore: (lmArgs) => { + const fetchName = getDefinitionName(lmArgs.query); + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.begin`, + {variables: lmArgs.variables}); + + // Resolve document fragments before passing it to `apollo-client`. + return args.data.fetchMore({ + ...lmArgs, + query: resolveFragments(lmArgs.query), + }) + .then((res) => { + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.success`, + {variables: lmArgs.variables, data: res.data}); + return Promise.resolve(res); + }) + .catch((err) => { + this.context.eventEmitter.emit( + `query.${name}.fetchMore.${fetchName}.error`, + {variables: lmArgs.variables, error: err}); + throw err; + }); + }, + }, + }; + return config.props + ? config.props(wrappedArgs) + : separateDataAndRoot(wrappedArgs.data); + }, + }; + + wrappedOptions = (data) => { + const base = (typeof this.wrappedConfig.options === 'function') + ? this.wrappedConfig.options(data) + : this.wrappedConfig.options; + const configs = getQueryOptions(name); + const reducerCallbacks = + [base.reducer || ((i) => i)] + .concat(...configs.map((cfg) => cfg.reducer)) + .filter((i) => i); + + const reducer = withSkipOnErrors( + reducerCallbacks.reduce( + (a, b) => (prev, ...rest) => + b(a(prev, ...rest), ...rest), + )); + + return { + ...base, + reducer, + }; + }; + + getWrapped = () => { + if (!this.memoized) { + this.memoized = graphql( + resolveFragments(document), + {...this.wrappedConfig, options: this.wrappedOptions}, + )(WrappedComponent); + } + return this.memoized; + }; + + render() { + const Wrapped = this.getWrapped(); + return ; + } }; }; diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index 307646db0..b266822fb 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,8 +1,9 @@ -import ApolloClient, {addTypename} from 'apollo-client'; +import ApolloClient, {addTypename, IntrospectionFragmentMatcher} from 'apollo-client'; import {networkInterface} from './transport'; import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; import MessageTypes from 'subscriptions-transport-ws/dist/message-types'; import {getAuthToken} from '../helpers/request'; +import introspectionQueryResultData from '../graphql/introspection.json'; let client, wsClient = null, wsClientToken = null; @@ -59,6 +60,7 @@ export function getClient(options = {}) { ...options, connectToDevTools: true, addTypename: true, + fragmentMatcher: new IntrospectionFragmentMatcher({introspectionQueryResultData}), queryTransformer: addTypename, dataIdFromObject: (result) => { if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle diff --git a/client/coral-framework/services/events.js b/client/coral-framework/services/events.js new file mode 100644 index 000000000..a1a2f4448 --- /dev/null +++ b/client/coral-framework/services/events.js @@ -0,0 +1,14 @@ +export function createReduxEmitter(eventEmitter) { + return (action) => { + + // Handle apollo actions. + if (action.type.startsWith('APOLLO_')) { + if (action.type === 'APOLLO_SUBSCRIPTION_RESULT') { + const {operationName, variables, result: {data}} = action; + eventEmitter.emit(`subscription.${operationName}.data`, {variables, data}); + } + return; + } + eventEmitter.emit(`action.${action.type}`, {action}); + }; +} diff --git a/client/coral-framework/services/store.js b/client/coral-framework/services/store.js index 4d6dafe20..f87a887a3 100644 --- a/client/coral-framework/services/store.js +++ b/client/coral-framework/services/store.js @@ -3,12 +3,22 @@ import thunk from 'redux-thunk'; import mainReducer from '../reducers'; import {getClient} from './client'; +let listeners = []; + export function injectReducers(reducers) { const store = getStore(); store.coralReducers = {...store.coralReducers, ...reducers}; store.replaceReducer(combineReducers(store.coralReducers)); } +/** + * Add a action listener to the redux store. + * The action is passed as the first argument to the callback. + */ +export function addListener(cb) { + listeners.push(cb); +} + export function getStore() { if (window.coralStore) { return window.coralStore; @@ -21,11 +31,17 @@ export function getStore() { return next(action); }; + const customListener = () => (next) => (action) => { + listeners.forEach((cb) => {cb(action);}); + return next(action); + }; + const middlewares = [ applyMiddleware( getClient().middleware(), thunk, apolloErrorReporter, + customListener, ), ]; diff --git a/client/coral-ui/components/Tab.css b/client/coral-ui/components/Tab.css index b3c026b3d..fc38d192d 100644 --- a/client/coral-ui/components/Tab.css +++ b/client/coral-ui/components/Tab.css @@ -68,3 +68,13 @@ pointer-events: none; } + +@custom-media --small-viewport (max-width: 320px); + +@media (--small-viewport) { + .buttonSub, .buttonSubActive, .buttonSubActive:hover, .buttonSubActive:focus { + font-size: 0.96em; + padding-right: 5px; + padding-right: 5px; + } +} diff --git a/config.js b/config.js index e3dd4578c..44747b188 100644 --- a/config.js +++ b/config.js @@ -13,6 +13,9 @@ require('env-rewrite').rewrite(); const CONFIG = { + // WEBPACK indicates when webpack is currently building. + WEBPACK: process.env.WEBPACK === 'true', + //------------------------------------------------------------------------------ // JWT based configuration //------------------------------------------------------------------------------ @@ -34,6 +37,9 @@ const CONFIG = { // JWT_EXPIRY is the time for which a given token is valid for. JWT_EXPIRY: process.env.TALK_JWT_EXPIRY || '1 day', + // JWT_ALG is the algorithm used for signing jwt tokens. + JWT_ALG: process.env.TALK_JWT_ALG || 'HS256', + //------------------------------------------------------------------------------ // Installation locks //------------------------------------------------------------------------------ diff --git a/graph/context.js b/graph/context.js index d2306cbc8..cffec0896 100644 --- a/graph/context.js +++ b/graph/context.js @@ -53,7 +53,7 @@ class Context { this.plugins = decorateContextPlugins(this, contextPlugins); // Bind the publish/subscribe to the context. - this.pubsub = pubsub; + this.pubsub = pubsub.createClient(); } } diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index b0475d3ef..16d2101f7 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -445,12 +445,12 @@ const genRecentComments = (_, ids) => { }; /** - * genComments returns the comments by the id's. Only admins can see non-public comments. + * getComments returns the comments by the id's. Only admins can see non-public comments. * @param {Object} context graph context * @param {Array} ids the comment id's to fetch * @return {Promise} resolves to the comments */ -const genComments = ({user}, ids) => { +const getComments = ({user}, ids) => { let comments; if (user && user.can(SEARCH_OTHERS_COMMENTS)) { comments = CommentModel.find({ @@ -478,7 +478,7 @@ const genComments = ({user}, ids) => { */ module.exports = (context) => ({ Comments: { - get: new DataLoader((ids) => genComments(context, ids)), + get: new DataLoader((ids) => getComments(context, ids)), getByQuery: (query) => getCommentsByQuery(context, query), getCountByQuery: (query) => getCommentCountByQuery(context, query), countByAssetID: new SharedCounterDataLoader('Comments.totalCommentCount', 3600, (ids) => getCountsByAssetID(context, ids)), diff --git a/graph/resolvers/asset.js b/graph/resolvers/asset.js index 86e102631..da9114c6a 100644 --- a/graph/resolvers/asset.js +++ b/graph/resolvers/asset.js @@ -4,6 +4,14 @@ const Asset = { recentComments({id}, _, {loaders: {Comments}}) { return Comments.genRecentComments.load(id); }, + async comment({id}, {id: commentId}, {loaders: {Comments}}) { + const comments = await Comments.getByQuery({ + asset_id: id, + ids: commentId + }); + + return comments.nodes[0]; + }, comments({id}, {sort, limit, deep, excludeIgnored, tags}, {loaders: {Comments}}) { return Comments.getByQuery({ asset_id: id, diff --git a/graph/subscriptions.js b/graph/subscriptions.js index ce1eb1a09..b8b4f2c0f 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -133,7 +133,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu const createSubscriptionManager = (server) => new SubscriptionServer({ subscriptionManager: new SubscriptionManager({ schema, - pubsub, + pubsub: pubsub.createClient(), setupFunctions, }), onConnect: ({token}, connection) => { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index a154f9f90..999aea51c 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -580,6 +580,9 @@ type Asset { deep: Boolean, ): CommentConnection! + # A Comment from the Asset by comment's ID + comment(id: ID!): Comment + # The count of top level comments on the asset. commentCount(excludeIgnored: Boolean, tags: [String!]): Int diff --git a/package.json b/package.json index e8a9159c2..65c802ba8 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,16 @@ { "name": "talk", - "version": "2.5.0", + "version": "3.0.0", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "scripts": { "postinstall": "./bin/cli plugins reconcile --skip-remote", "start": "./bin/cli serve -j -w", - "dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"./bin/cli -c .env serve -j -w\"", - "build": "NODE_ENV=production webpack -p --config webpack.config.js --bail", - "build-watch": "NODE_ENV=development webpack --progress --config webpack.config.js --watch", + "dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"yarn generate-introspection && ./bin/cli -c .env serve -j -w\"", + "prebuild": "yarn generate-introspection", + "build": "WEBPACK=true NODE_ENV=production webpack -p --config webpack.config.js --bail", + "prebuild-watch": "yarn generate-introspection", + "build-watch": "WEBPACK=true NODE_ENV=development webpack --progress --config webpack.config.js --watch", "lint": "eslint bin/* .", "lint-fix": "eslint bin/* . --fix", "test": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}", @@ -17,7 +19,8 @@ "e2e": "NODE_ENV=test nightwatch", "poste2e": "NODE_ENV=test scripts/poste2e.sh", "embed-start": "NODE_ENV=development yarn build && ./bin/cli serve --jobs", - "heroku-postbuild": "./bin/cli plugins reconcile && yarn build" + "heroku-postbuild": "./bin/cli plugins reconcile && yarn build", + "generate-introspection": "WEBPACK=true NODE_ENV=test ./scripts/generateIntrospectionResult.js" }, "talk": { "migration": { @@ -75,6 +78,7 @@ "dotenv": "^4.0.0", "ejs": "^2.5.6", "env-rewrite": "^1.0.2", + "eventemitter2": "^4.1.2", "express": "^4.15.2", "express-session": "^1.15.1", "file-loader": "^0.11.2", diff --git a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css index 81d373b95..c51d87f11 100644 --- a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css +++ b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.css @@ -5,6 +5,10 @@ composes: buttonReset from "coral-framework/styles/reset.css"; } +.button:hover { + cursor: pointer; +} + .list { background: white; position: absolute; @@ -29,3 +33,27 @@ font-size: 14px; vertical-align: middle; } + + +@custom-media --small-viewport (max-width: 425px); + +.filterText { + display: inline-block; +} + +.filterIcon { + vertical-align: middle; + display: none; +} + +@media (--small-viewport) { + .filterText { + display: none; + } + .filterIcon { + display: inline-block; + } +} + + + diff --git a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js index 87680b370..13b3094a1 100644 --- a/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js +++ b/plugins/coral-plugin-viewing-options/client/components/ViewingOptions.js @@ -1,8 +1,9 @@ import React from 'react'; import cn from 'classnames'; import styles from './ViewingOptions.css'; -import {Slot, ClickOutside} from 'plugin-api/beta/client/components'; +import {t} from 'plugin-api/beta/client/services'; import {Icon} from 'plugin-api/beta/client/components/ui'; +import {Slot, ClickOutside} from 'plugin-api/beta/client/components'; const ViewingOptions = (props) => { const toggleOpen = () => { @@ -23,7 +24,9 @@ const ViewingOptions = (props) => {
-
diff --git a/plugins/coral-plugin-viewing-options/client/index.js b/plugins/coral-plugin-viewing-options/client/index.js index 4e9001c1b..464a38d7d 100644 --- a/plugins/coral-plugin-viewing-options/client/index.js +++ b/plugins/coral-plugin-viewing-options/client/index.js @@ -1,9 +1,11 @@ import ViewingOptions from './containers/ViewingOptions'; import reducer from './reducer'; +import translations from './translations.yml'; export default { reducer, slots: { streamFilter: [ViewingOptions] - } + }, + translations }; diff --git a/plugins/coral-plugin-viewing-options/client/translations.yml b/plugins/coral-plugin-viewing-options/client/translations.yml new file mode 100644 index 000000000..6ab31fa1b --- /dev/null +++ b/plugins/coral-plugin-viewing-options/client/translations.yml @@ -0,0 +1,4 @@ +en: + viewing_options: "Viewing Options" +es: + viewing_options: "Opciones de visualización" \ No newline at end of file diff --git a/plugins/talk-plugin-featured-comments/client/components/Tag.css b/plugins/talk-plugin-featured-comments/client/components/Tag.css index 88a019b5d..7ade3d968 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Tag.css +++ b/plugins/talk-plugin-featured-comments/client/components/Tag.css @@ -24,6 +24,15 @@ cursor: pointer; } +.noSelect { + -ms-user-select:none; + -moz-user-select: none; + -webkit-user-select: none; + -webkit-touch-callout:none; + user-select: none; + -webkit-tap-highlight-color:rgba(0,0,0,0); +} + .tooltip { top: 36px; left: auto; diff --git a/plugins/talk-plugin-featured-comments/client/components/Tag.js b/plugins/talk-plugin-featured-comments/client/components/Tag.js index b7ee8cd6b..ee2d13b19 100644 --- a/plugins/talk-plugin-featured-comments/client/components/Tag.js +++ b/plugins/talk-plugin-featured-comments/client/components/Tag.js @@ -15,13 +15,15 @@ export default class Tag extends React.Component { } - showTooltip = () => { + showTooltip = e => { + e.preventDefault(); this.setState({ tooltip: true }); } - hideTooltip = () => { + hideTooltip = (e) => { + e.preventDefault(); this.setState({ tooltip: false }); @@ -30,10 +32,13 @@ export default class Tag extends React.Component { render() { const {tooltip} = this.state; return( -
+
{ isTagged(this.props.comment.tags, 'FEATURED') ? ( - + {t('talk-plugin-featured-comments.featured')} ) : null diff --git a/plugins/talk-plugin-featured-comments/client/containers/Comment.js b/plugins/talk-plugin-featured-comments/client/containers/Comment.js index 2b3de19b5..7080627e6 100644 --- a/plugins/talk-plugin-featured-comments/client/containers/Comment.js +++ b/plugins/talk-plugin-featured-comments/client/containers/Comment.js @@ -32,19 +32,6 @@ export default withFragments({ } } - ## - # TODO: Remove this when we have the IntrospectionFragmentMatcher. - # Currently without this loading more featured comments - # brings apollo into an inconsistent state. - action_summaries { - __typename - count - current_user { - id - } - } - ## - user { id username diff --git a/routes/api/account/index.js b/routes/api/account/index.js index 4c49c00a5..99cb9a468 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -19,7 +19,7 @@ router.get('/', authorization.needed(), (req, res, next) => { // POST /email/confirm takes the password confirmation token available as a // payload parameter and if it verifies, it updates the confirmed_at date on the // local profile. -router.post('/email/verify', (req, res, next) => { +router.post('/email/verify', async (req, res, next) => { const { token @@ -29,57 +29,47 @@ router.post('/email/verify', (req, res, next) => { return next(errors.ErrMissingToken); } - UsersService - .verifyEmailConfirmation(token) - .then(({referer}) => { - res.json({redirectUri: referer}); - }) - .catch((err) => { - next(err); - }); + try { + let {referer} = await UsersService.verifyEmailConfirmation(token); + res.json({redirectUri: referer}); + } catch (e) { + return next(e); + } }); /** * this endpoint takes an email (username) and checks if it belongs to a User account * if it does, create a JWT and send an email */ -router.post('/password/reset', (req, res, next) => { +router.post('/password/reset', async (req, res, next) => { const {email, loc} = req.body; if (!email) { - return next('you must submit an email when requesting a password.'); + return next(errors.ErrMissingEmail); } - UsersService - .createPasswordResetToken(email, loc) - .then((token) => { - - // Check to see if the token isn't defined. - if (!token) { - - // As it isn't, don't send any emails! - return; - } - - return mailer.sendSimple({ - template: 'password-reset', // needed to know which template to render! - locals: { // specifies the template locals. - token, - rootURL: ROOT_URL - }, - subject: 'Password Reset', - to: email - }); - }) - .then(() => { - - // we want to send a 204 regardless of the user being found in the db - // if we fail on missing emails, it would reveal if people are registered or not. + try { + let token = await UsersService.createPasswordResetToken(email, loc); + if (!token) { res.status(204).end(); - }) - .catch((err) => { - next(err); + return; + } + + // Send the password reset email. + await mailer.sendSimple({ + template: 'password-reset', // needed to know which template to render! + locals: { // specifies the template locals. + token, + rootURL: ROOT_URL + }, + subject: 'Password Reset', + to: email }); + + res.status(204).end(); + } catch (e) { + return next(e); + } }); /** @@ -87,7 +77,7 @@ router.post('/password/reset', (req, res, next) => { * 1) the token that was in the url of the email link {String} * 2) the new password {String} */ -router.put('/password/reset', (req, res, next) => { +router.put('/password/reset', async (req, res, next) => { const { token, @@ -102,28 +92,26 @@ router.put('/password/reset', (req, res, next) => { return next(errors.ErrPasswordTooShort); } - UsersService - .verifyPasswordResetToken(token) - .then(([user, loc]) => { - return Promise.all([UsersService.changePassword(user.id, password), loc]); - }) - .then(([ , loc]) => { - res.json({redirect: loc}); - }) - .catch(() => { - next(authorization.ErrNotAuthorized); - }); + try { + let [user, loc] = await UsersService.verifyPasswordResetToken(token); + + // Change the users' password. + await UsersService.changePassword(user.id, password); + + res.json({redirect: loc}); + } catch (e) { + console.error(e); + return next(errors.ErrNotAuthorized); + } }); -router.put('/username', authorization.needed(), (req, res, next) => { - UsersService - .editName(req.user.id, req.body.username) - .then(() => { - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); +router.put('/username', authorization.needed(), async (req, res, next) => { + try { + await UsersService.editName(req.user.id, req.body.username); + res.status(204).end(); + } catch (e) { + return next(e); + } }); module.exports = router; diff --git a/routes/api/assets/index.js b/routes/api/assets/index.js index f02b49f7e..a306b36b4 100644 --- a/routes/api/assets/index.js +++ b/routes/api/assets/index.js @@ -2,12 +2,39 @@ const express = require('express'); const router = express.Router(); const scraper = require('../../../services/scraper'); +const errors = require('../../../errors'); const AssetsService = require('../../../services/assets'); const AssetModel = require('../../../models/asset'); +const FilterOpenAssets = (query, filter) => { + switch(filter) { + case 'open': + return query.merge({ + $or: [ + { + closedAt: null + }, + { + closedAt: { + $gt: Date.now() + } + } + ] + }); + case 'closed': + return query.merge({ + closedAt: { + $lt: Date.now() + } + }); + default: + return query; + } +}; + // List assets. -router.get('/', (req, res, next) => { +router.get('/', async (req, res, next) => { const { limit = 20, @@ -18,138 +45,97 @@ router.get('/', (req, res, next) => { search = '' } = req.query; - const FilterOpenAssets = (query, filter) => { - switch(filter) { - case 'open': - return query.merge({ - $or: [ - { - closedAt: null - }, - { - closedAt: { - $gt: Date.now() - } - } - ] - }); - case 'closed': - return query.merge({ - closedAt: { - $lt: Date.now() - } - }); - default: - return query; - } - }; + try { - // Find all the assets. - Promise.all([ + // Find all the assets. + let [result, count] = await Promise.all([ - // Find the actuall assets. - FilterOpenAssets(AssetsService.search({value: search}), filter) - .sort({[field]: (sort === 'asc') ? 1 : -1}) - .skip(parseInt(skip)) - .limit(parseInt(limit)), + // Find the actuall assets. + FilterOpenAssets(AssetsService.search({value: search}), filter) + .sort({[field]: (sort === 'asc') ? 1 : -1}) + .skip(parseInt(skip)) + .limit(parseInt(limit)), - // Get the count of actual assets. - FilterOpenAssets(AssetsService.search({value: search}), filter) - .count() - ]) - .then(([result, count]) => { + // Get the count of actual assets. + FilterOpenAssets(AssetsService.search({value: search}), filter) + .count() + ]); // Send back the asset data. res.json({ result, count }); - }) - .catch((err) => { - next(err); - }); + } catch (e) { + return next(e); + } }); // Get an asset by id. -router.get('/:asset_id', (req, res, next) => { +router.get('/:asset_id', async (req, res, next) => { + try { - // Send back the asset. - AssetsService - .findById(req.params.asset_id) - .then((asset) => { - if (!asset) { - res.status(404).end(); - return; - } + // Send back the asset. + let asset = await AssetsService.findById(req.params.asset_id); + if (!asset) { + return next(errors.ErrNotFound); + } - res.json(asset); - }) - .catch((err) => { - next(err); - }); + return res.json(asset); + } catch (e) { + return next(e); + } }); // Adds the asset id to the queue to be scraped. -router.post('/:asset_id/scrape', (req, res, next) => { +router.post('/:asset_id/scrape', async (req, res, next) => { + try { - // Create a new asset scrape job. - AssetsService - .findById(req.params.asset_id) - .then((asset) => { - if (!asset) { - res.status(404).end(); - return; - } + // Send back the asset. + let asset = await AssetsService.findById(req.params.asset_id); + if (!asset) { + return next(errors.ErrNotFound); + } - return scraper - .create(asset) - .then((job) => { + let job = await scraper.create(asset); - // Send the job back for monitoring. - res.status(201).json(job); - }); - }) - .catch((err) => { - next(err); - }); + // Send the job back for monitoring. + res.status(201).json(job); + } catch (e) { + return next(e); + } }); -router.put('/:asset_id/settings', (req, res, next) => { - - // Override the settings for the asset. - AssetsService - .overrideSettings(req.params.asset_id, req.body) - .then(() => { - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); +router.put('/:asset_id/settings', async (req, res, next) => { + try { + await AssetsService.overrideSettings(req.params.asset_id, req.body); + res.status(204).end(); + } catch (e) { + return next(e); + } }); -router.put('/:asset_id/status', (req, res, next) => { - - const id = req.params.asset_id; - +router.put('/:asset_id/status', async (req, res, next) => { const { closedAt, closedMessage } = req.body; - AssetModel - .update({id}, { + try { + await AssetModel.update({ + id: req.params.asset_id + }, { $set: { closedAt, closedMessage } - }) - .then(() => { - res.status(204).json(); - }) - .catch((err) => { - next(err); }); + + res.status(204).json(); + } catch (e) { + return next(e); + } }); module.exports = router; diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index 392b04e9b..791573898 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -7,14 +7,11 @@ const router = express.Router(); * This returns the user if they are logged in. */ router.get('/', (req, res, next) => { - - if (req.user) { - return next(); + if (!req.user) { + res.status(204).end(); + return; } - res.status(204).end(); -}, (req, res) => { - // Send back the user object. res.json({user: req.user}); }); diff --git a/routes/api/index.js b/routes/api/index.js index 764612f78..a3bc552f6 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,6 +1,9 @@ const express = require('express'); const authorization = require('../../middleware/authorization'); const pkg = require('../../package.json'); +const { + WEBPACK +} = require('../../config'); const router = express.Router(); @@ -15,7 +18,12 @@ router.use('/users', require('./users')); router.use('/account', require('./account')); router.use('/setup', require('./setup')); -// Bind the kue handler to the /kue path. -router.use('/kue', authorization.needed('ADMIN'), require('../../services/kue').kue.app); +// Enable the kue app only if we aren't in webpack mode. +if (!WEBPACK) { + + // Bind the kue handler to the /kue path. + router.use('/kue', authorization.needed('ADMIN'), require('../../services/kue').kue.app); + +} module.exports = router; diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 5c4b03568..fb54494d1 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -3,25 +3,22 @@ const SettingsService = require('../../../services/settings'); const router = express.Router(); -router.get('/', (req, res, next) => { - SettingsService - .retrieve() - .then((settings) => { - res.json(settings); - }) - .catch((err) => { - next(err); - }); +router.get('/', async (req, res, next) => { + try { + let settings = await SettingsService.retrieve(); + res.json(settings); + } catch (e) { + return next(e); + } }); -router.put('/', (req, res, next) => { - SettingsService - .update(req.body).then(() => { - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); +router.put('/', async (req, res, next) => { + try { + await SettingsService.update(req.body); + res.status(204).end(); + } catch (e) { + return next(e); + } }); module.exports = router; diff --git a/routes/api/setup/index.js b/routes/api/setup/index.js index dfddc64d9..efb3c5d45 100644 --- a/routes/api/setup/index.js +++ b/routes/api/setup/index.js @@ -4,48 +4,33 @@ const SetupService = require('../../../services/setup'); const router = express.Router(); -router.get('/', (req, res, next) => { - SetupService - .isAvailable() - .then(() => { - res.json({installed: false}); - }) - .catch(() => { - res.json({installed: true}); - }); +router.get('/', async (req, res, next) => { + try { + await SetupService.isAvailable(); + res.json({installed: false}); + } catch (e) { + res.json({installed: true}); + } }); -router.post('/', (req, res, next) => { - - SetupService - .isAvailable() - .then(() => { - - // Allow the request to keep going here. - next(); - }) - .catch((err) => { - next(err); - }); - -}, (req, res, next) => { +router.post('/', async (req, res, next) => { + try { + await SetupService.isAvailable(); + } catch (e) { + return next(e); + } const { settings, user: {email, password, username} } = req.body; - SetupService - .setup({settings, user: {email, password, username}}) - .then(() => { - - // We're setup! - res.status(204).end(); - }) - .catch((err) => { - next(err); - }); - + try { + await SetupService.setup({settings, user: {email, password, username}}); + res.status(204).end(); + } catch (err) { + return next(err); + } }); module.exports = router; diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 47ff46853..60c568253 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -2,7 +2,6 @@ const express = require('express'); const router = express.Router(); const UsersService = require('../../../services/users'); const mailer = require('../../../services/mailer'); -const pubsub = require('../../../services/pubsub'); const errors = require('../../../errors'); const authorization = require('../../../middleware/authorization'); const i18n = require('../../../services/i18n'); @@ -11,7 +10,7 @@ const { } = require('../../../config'); // get a list of users. -router.get('/', authorization.needed('ADMIN'), (req, res, next) => { +router.get('/', authorization.needed('ADMIN'), async (req, res, next) => { const { value = '', field = 'created_at', @@ -20,15 +19,17 @@ router.get('/', authorization.needed('ADMIN'), (req, res, next) => { limit = 50 // Total Per Page } = req.query; - Promise.all([ - UsersService + try { + + let [result, count] = await Promise.all([ + UsersService .search(value) .sort({[field]: (asc === 'true') ? 1 : -1}) .skip((page - 1) * limit) .limit(limit), - UsersService.count() - ]) - .then(([result, count]) => { + UsersService.count() + ]); + res.json({ result, limit: Number(limit), @@ -36,72 +37,74 @@ router.get('/', authorization.needed('ADMIN'), (req, res, next) => { page: Number(page), totalPages: Math.ceil(count / (limit === 0 ? 1 : limit)) }); - }) - .catch(next); + + } catch (e) { + next(e); + } + }); -router.post('/:user_id/role', authorization.needed('ADMIN'), (req, res, next) => { - UsersService - .addRoleToUser(req.params.user_id, req.body.role) - .then(() => { - res.status(204).end(); - }) - .catch(next); +router.post('/:user_id/role', authorization.needed('ADMIN'), async (req, res, next) => { + try { + await UsersService.addRoleToUser(req.params.user_id, req.body.role); + res.status(204).end(); + } catch (e) { + next(e); + } }); // update the status of a user -router.post('/:user_id/status', authorization.needed('ADMIN'), (req, res, next) => { - UsersService - .setStatus(req.params.user_id, req.body.status) - .then((user) => { +router.post('/:user_id/status', authorization.needed('ADMIN'), async (req, res, next) => { + let {status} = req.body; - // TODO: current updating status behavior is weird. - if (user) { - if (user.status === 'BANNED') { - pubsub.publish('userBanned', user); - } - res.status(201).json(user.status); - } else { - res.status(500).json(); - } - }) - .catch(next); + try { + let user = await UsersService.setStatus(req.params.user_id, status); + if (!user) { + return next(errors.ErrNotFound); + } + + if (user.status === 'BANNED') { + req.pubsub.publish('userBanned', user); + } + + // TODO: investigate why this is returning a value? Also why is this a POST vs PUT? + res.status(201).json(user.status); + } catch (e) { + next(e); + } }); -router.post('/:user_id/username-enable', authorization.needed('ADMIN'), (req, res, next) => { - UsersService - .toggleNameEdit(req.params.user_id, true) - .then(() => { - res.status(204).end(); +router.post('/:user_id/username-enable', authorization.needed('ADMIN'), async (req, res, next) => { + try { + await UsersService.toggleNameEdit(req.params.user_id, true); + res.status(204).end(); + } catch (e) { + next(e); + } +}); + +router.post('/:user_id/email', authorization.needed('ADMIN'), async (req, res, next) => { + try { + let user = await UsersService.findById(req.params.user_id); + + let localProfile = user.profiles.find((profile) => profile.provider === 'local'); + if (!localProfile) { + return next(errors.ErrMissingEmail); + } + + await mailer.sendSimple({ + template: 'notification', // needed to know which template to render! + locals: { // specifies the template locals. + body: req.body.body + }, + subject: req.body.subject, + to: localProfile.id // This only works if the user has registered via e-mail. }); -}); -router.post('/:user_id/email', authorization.needed('ADMIN'), (req, res, next) => { - - UsersService.findById(req.params.user_id) - .then((user) => { - let localProfile = user.profiles.find((profile) => profile.provider === 'local'); - - if (localProfile) { - const options = - { - template: 'notification', // needed to know which template to render! - locals: { // specifies the template locals. - body: req.body.body - }, - subject: req.body.subject, - to: localProfile.id // This only works if the user has registered via e-mail. - // We may want a standard way to access a user's e-mail address in the future - }; - return mailer.sendSimple(options); - } else { - res.json({error: 'User does not have an e-mail address.'}); - } - }) - .then(() => { - res.status(204).end(); - }) - .catch(next); + res.status(204).end(); + } catch (e) { + next(e); + } }); /** @@ -110,72 +113,61 @@ router.post('/:user_id/email', authorization.needed('ADMIN'), (req, res, next) = * @param {String} userID the id for the user to send the email to * @param {String} email the email for the user to send the email to */ -const SendEmailConfirmation = (app, userID, email, referer) => UsersService - .createEmailConfirmToken(userID, email, referer) - .then((token) => { - return mailer.sendSimple({ - template: 'email-confirm', // needed to know which template to render! - locals: { // specifies the template locals. - token, - rootURL: ROOT_URL, - email - }, - subject: i18n.t('email.confirm.subject'), - to: email - }); +const SendEmailConfirmation = async (app, userID, email, referer) => { + let token = await UsersService.createEmailConfirmToken(userID, email, referer); + + return mailer.sendSimple({ + template: 'email-confirm', // needed to know which template to render! + locals: { // specifies the template locals. + token, + rootURL: ROOT_URL, + email + }, + subject: i18n.t('email.confirm.subject'), + to: email }); +}; // create a local user. -router.post('/', (req, res, next) => { +router.post('/', async (req, res, next) => { const {email, password, username} = req.body; const redirectUri = req.header('X-Pym-Url') || req.header('Referer'); - UsersService - .createLocalUser(email, password, username) - .then((user) => { + try { + let user = await UsersService.createLocalUser(email, password, username); // Send an email confirmation. The Front end will know about the // requireEmailConfirmation as it's included in the settings get endpoint. - return SendEmailConfirmation(req.app, user.id, email, redirectUri) - .then(() => { + await SendEmailConfirmation(req.app, user.id, email, redirectUri); - // Then send back the user. - res.status(201).json(user); - }); - }) - .catch((err) => { - next(err); - }); + res.status(201).json(user); + } catch (e) { + return next(e); + } }); -router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { +router.post('/:user_id/actions', authorization.needed(), async (req, res, next) => { const { action_type, metadata } = req.body; - UsersService - .addAction(req.params.user_id, req.user.id, action_type, metadata) - .then((action) => { + try { + let action = await UsersService.addAction(req.params.user_id, req.user.id, action_type, metadata); - // Set the user status to "pending" for review by moderators - if (action_type === 'FLAG') { - return UsersService.setStatus(req.params.user_id, 'PENDING') - .then(() => action); - } else { - return action; - } - }) - .then((action) => { - res.status(201).json(action); - }) - .catch((err) => { - next(err); - }); + // Set the user status to "pending" for review by moderators + if (action_type === 'FLAG') { + await UsersService.setStatus(req.params.user_id, 'PENDING'); + } + + res.status(201).json(action); + } catch (e) { + return next(e); + } }); // trigger an email confirmation re-send by a new user -router.post('/resend-verify', (req, res, next) => { +router.post('/resend-verify', async (req, res, next) => { const {email} = req.body; const redirectUri = req.header('X-Pym-Url') || req.header('Referer'); @@ -183,48 +175,45 @@ router.post('/resend-verify', (req, res, next) => { return next(errors.ErrMissingEmail); } - // find user by email. - // if the local profile is verified, return an error code? - // send a 204 after the email is re-sent - SendEmailConfirmation(req.app, null, email, redirectUri) - .then(() => { - res.status(204).end(); - }) - .catch(next); + try { + + // find user by email. + // if the local profile is verified, return an error code? + // send a 204 after the email is re-sent + await SendEmailConfirmation(req.app, null, email, redirectUri); + + res.status(204).end(); + } catch (e) { + return next(e); + } }); // trigger an email confirmation re-send from the admin panel -router.post('/:user_id/email/confirm', authorization.needed('ADMIN'), (req, res, next) => { +router.post('/:user_id/email/confirm', authorization.needed('ADMIN'), async (req, res, next) => { const { user_id } = req.params; - UsersService - .findById(user_id) - .then((user) => { - if (!user) { - res.status(404).end(); - return; - } + try { - // Find the first local profile. - let localProfile = user.profiles.find((profile) => profile.provider === 'local'); + let user = await UsersService.findById(user_id); + if (!user) { + return next(errors.ErrNotFound); + } - // If there was no local profile for the user, error out. - if (!localProfile) { - res.status(404).end(); - return; - } + // Find the first local profile. + let localProfile = user.profiles.find((profile) => profile.provider === 'local'); + if (!localProfile) { + return next(errors.ErrMissingEmail); + } - // Send the email to the first local profile that was found. - return SendEmailConfirmation(req.app, user.id, localProfile.id) - .then(() => { - res.status(204).end(); - }); - }) - .catch((err) => { - next(err); - }); + // Send the email to the first local profile that was found. + await SendEmailConfirmation(req.app, user.id, localProfile.id); + + res.status(204).end(); + } catch (e) { + return next(e); + } }); module.exports = router; diff --git a/scripts/generateIntrospectionResult.js b/scripts/generateIntrospectionResult.js new file mode 100755 index 000000000..a42dcd955 --- /dev/null +++ b/scripts/generateIntrospectionResult.js @@ -0,0 +1,24 @@ +#! /usr/bin/env node + +const path = require('path'); +const introspectionFilename = path.resolve(__dirname, '..', 'client', 'coral-framework', 'graphql', 'introspection.json'); + +const fs = require('fs'); +const {graphql, introspectionQuery} = require('graphql'); +const schema = require('../graph/schema'); + +graphql(schema, introspectionQuery) + .then(({data}) => { + + // Serialize the introspection result as JSON. + const introspectionResult = JSON.stringify(data, null, 2); + + // Write the introspection result to the filesystem. + fs.writeFileSync(introspectionFilename, introspectionResult, 'utf8'); + + console.log(`Outputted result of introspectionQuery to ${introspectionFilename}`); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/services/cache.js b/services/cache.js index 25642e1d0..526178a99 100644 --- a/services/cache.js +++ b/services/cache.js @@ -2,9 +2,7 @@ const redis = require('./redis'); const debug = require('debug')('talk:services:cache'); const crypto = require('crypto'); -const cache = module.exports = { - client: redis.createClient() -}; +const cache = module.exports = {}; /** * This collects a key that may either be an array or a string and creates a @@ -70,9 +68,6 @@ if redis.call('GET', KEYS[1]) ~= false then end `; -// Stores the SHA1 hash of INCR_SCRIPT, used for executing via EVALSHA. -let INCR_SCRIPT_HASH; - // This is designed to decrement a key and add an expiry iff the key already // exists. const DECR_SCRIPT = ` @@ -82,9 +77,6 @@ if redis.call('GET', KEYS[1]) ~= false then end `; -// Stores the SHA1 hash of DECR_SCRIPT, used for executing via EVALSHA. -let DECR_SCRIPT_HASH; - // Load the script into redis and track the script hash that we will use to exec // increments on. const loadScript = (name, script) => new Promise((resolve, reject) => { @@ -121,18 +113,24 @@ const loadScript = (name, script) => new Promise((resolve, reject) => { }); }); -// Load the INCR_SCRIPT and DECR_SCRIPT into Redis. -Promise.all([ - loadScript('INCR_SCRIPT', INCR_SCRIPT), - loadScript('DECR_SCRIPT', DECR_SCRIPT) -]) -.then(([incrScriptHash, decrScriptHash]) => { - INCR_SCRIPT_HASH = incrScriptHash; - DECR_SCRIPT_HASH = decrScriptHash; -}) -.catch((err) => { - throw err; -}); +/** + * Init sets up the scripts used in Redis with the incr/decr commands. + */ +cache.init = async () => { + + // Create the redis instance. + cache.client = redis.createClient(); + + // Load the INCR_SCRIPT and DECR_SCRIPT into Redis. + let [incrScriptHash, decrScriptHash] = await Promise.all([ + loadScript('INCR_SCRIPT', INCR_SCRIPT), + loadScript('DECR_SCRIPT', DECR_SCRIPT) + ]); + + // Set the globally scoped cache hashes. + cache.INCR_SCRIPT_HASH = incrScriptHash; + cache.DECR_SCRIPT_HASH = decrScriptHash; +}; /** * This will increment a key in redis and update the expiry iff it already @@ -140,7 +138,7 @@ Promise.all([ */ cache.incr = (key, expiry, kf = keyfunc) => new Promise((resolve, reject) => { cache.client - .evalsha(INCR_SCRIPT_HASH, 1, kf(key), expiry, (err) => { + .evalsha(cache.INCR_SCRIPT_HASH, 1, kf(key), expiry, (err) => { if (err) { return reject(err); } @@ -155,7 +153,7 @@ cache.incr = (key, expiry, kf = keyfunc) => new Promise((resolve, reject) => { */ cache.decr = (key, expiry, kf = keyfunc) => new Promise((resolve, reject) => { cache.client - .evalsha(DECR_SCRIPT_HASH, 1, kf(key), expiry, (err) => { + .evalsha(cache.DECR_SCRIPT_HASH, 1, kf(key), expiry, (err) => { if (err) { return reject(err); } @@ -174,7 +172,7 @@ cache.incrMany = (keys, expiry, kf = keyfunc) => { keys.forEach((key) => { // Queue up the evalsha command. - multi.evalsha(INCR_SCRIPT_HASH, 1, kf(key), expiry); + multi.evalsha(cache.INCR_SCRIPT_HASH, 1, kf(key), expiry); }); return new Promise((resolve, reject) => { @@ -198,7 +196,7 @@ cache.decrMany = (keys, expiry, kf = keyfunc) => { keys.forEach((key) => { // Queue up the evalsha command. - multi.evalsha(DECR_SCRIPT_HASH, 1, kf(key), expiry); + multi.evalsha(cache.DECR_SCRIPT_HASH, 1, kf(key), expiry); }); return new Promise((resolve, reject) => { diff --git a/services/kue.js b/services/kue.js index ac299a3d5..1b3fb605a 100644 --- a/services/kue.js +++ b/services/kue.js @@ -8,18 +8,24 @@ const kue = module.exports.kue = require('kue'); // Note that unlike what the name createQueue suggests, it currently returns a // singleton Queue instance. So you can configure and use only a single Queue // object within your node.js process. -const Queue = module.exports.queue = kue.createQueue({ - redis: { - createClientFactory: () => redis.createClient() - } -}); +let Queue = module.exports.queue = null; class Task { constructor({name, attempts = 3, delay = 1000}) { + debug(`Created new Task[${name}]`); + this.name = name; this.attempts = attempts; this.delay = delay; + + if (!Queue) { + module.exports.queue = Queue = kue.createQueue({ + redis: { + createClientFactory: redis.createClientFactory() + } + }); + } } /** @@ -132,3 +138,19 @@ if (process.env.NODE_ENV === 'test') { } else { module.exports.Task = Task; } + +module.exports.createTaskFactory = () => { + let taskInstance = null; + + return (options) => { + if (taskInstance) { + return taskInstance; + } + + options = Object.assign({}, options); + + taskInstance = new module.exports.Task(options); + + return taskInstance; + }; +}; diff --git a/services/mailer.js b/services/mailer.js index 27672a35c..7212979df 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -1,6 +1,7 @@ const debug = require('debug')('talk:services:mailer'); const nodemailer = require('nodemailer'); const kue = require('./kue'); +const taskFactory = kue.createTaskFactory(); const path = require('path'); const fs = require('fs'); const _ = require('lodash'); @@ -75,9 +76,11 @@ const mailer = module.exports = { /** * Create the new Task kue. */ - task: new kue.Task({ - name: 'mailer' - }), + get task() { + return taskFactory({ + name: 'mailer' + }); + }, sendSimple({template, locals, to, subject}) { diff --git a/services/mongoose.js b/services/mongoose.js index c697e38ef..2b05aaeaa 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -4,7 +4,8 @@ const enabled = require('debug').enabled; const queryDebuger = require('debug')('talk:db:query'); const { - MONGO_URL + MONGO_URL, + WEBPACK } = require('../config'); // Loading the formatter from Mongoose: @@ -40,14 +41,27 @@ if (enabled('talk:db')) { mongoose.set('debug', debugQuery); } -// Connect to the Mongo instance. -mongoose.connect(MONGO_URL, (err) => { - if (err) { - throw err; - } +if (WEBPACK) { - debug('connection established'); -}); + console.warn('Not connecting to mongodb during webpack build'); + + // @wyattjoh: We didn't call connect, but because we include mongoose, it will hold the socket ready, + // preventing node from exiting. Calling disconnect here just ensures that the application + // can quit correctly. + mongoose.disconnect(); + +} else { + + // Connect to the Mongo instance. + mongoose.connect(MONGO_URL, (err) => { + if (err) { + throw err; + } + + debug('connection established'); + }); + +} module.exports = mongoose; diff --git a/services/passport.js b/services/passport.js index 8396cc581..4ff6198f0 100644 --- a/services/passport.js +++ b/services/passport.js @@ -9,18 +9,19 @@ const LocalStrategy = require('passport-local').Strategy; const errors = require('../errors'); const uuid = require('uuid'); const debug = require('debug')('talk:services:passport'); -const {createClient} = require('./redis'); const bowser = require('bowser'); const ms = require('ms'); // Create a redis client to use for authentication. -const client = createClient(); +const {createClientFactory} = require('./redis'); +const client = createClientFactory(); const { JWT_SECRET, JWT_ISSUER, JWT_EXPIRY, JWT_AUDIENCE, + JWT_ALG, RECAPTCHA_SECRET, RECAPTCHA_ENABLED } = require('../config'); @@ -148,7 +149,7 @@ const HandleLogout = (req, res, next) => { const now = new Date(); const expiry = (jwt.exp - now.getTime() / 1000).toFixed(0); - client.set(`jtir[${jwt.jti}]`, now.toISOString(), 'EX', expiry, (err) => { + client().set(`jtir[${jwt.jti}]`, now.toISOString(), 'EX', expiry, (err) => { if (err) { return next(err); } @@ -159,7 +160,7 @@ const HandleLogout = (req, res, next) => { }; const checkGeneralTokenBlacklist = (jwt) => new Promise((resolve, reject) => { - client.get(`jtir[${jwt.jti}]`, (err, expiry) => { + client().get(`jtir[${jwt.jti}]`, (err, expiry) => { if (err) { return reject(err); } @@ -219,6 +220,7 @@ passport.use(new JwtStrategy({ // Prepare the extractor from the header. jwtFromRequest: ExtractJwt.fromExtractors([ cookieExtractor, + ExtractJwt.fromUrlQueryParameter('access_token'), ExtractJwt.fromAuthHeaderWithScheme('Bearer') ]), @@ -233,7 +235,7 @@ passport.use(new JwtStrategy({ audience: JWT_AUDIENCE, // Enable only the HS256 algorithm. - algorithms: ['HS256'], + algorithms: [JWT_ALG], // Pass the request object back to the callback so we can attach the JWT to // it. diff --git a/services/pubsub.js b/services/pubsub.js index a42ffb314..56e0f3e4d 100644 --- a/services/pubsub.js +++ b/services/pubsub.js @@ -2,4 +2,15 @@ const {RedisPubSub} = require('graphql-redis-subscriptions'); const {connectionOptions} = require('./redis'); -module.exports = new RedisPubSub({connection: connectionOptions}); +let pubsubInstance = null; +module.exports = { + createClient: () => { + if (pubsubInstance) { + return pubsubInstance; + } + + pubsubInstance = new RedisPubSub({connection: connectionOptions}); + + return pubsubInstance; + } +}; diff --git a/services/redis.js b/services/redis.js index c6506eb3c..1983249f1 100644 --- a/services/redis.js +++ b/services/redis.js @@ -29,21 +29,36 @@ const connectionOptions = { } }; +const createClient = () => { + let client = redis.createClient(connectionOptions); + + client.ping((err) => { + if (err) { + console.error('Can\'t ping the redis server!'); + + throw err; + } + + debug('connection established'); + }); + + return client; +}; + module.exports = { connectionOptions, - createClient() { - let client = redis.createClient(connectionOptions); + createClient, + createClientFactory: () => { + let client = null; - client.ping((err) => { - if (err) { - console.error('Can\'t ping the redis server!'); - - throw err; + return () => { + if (client) { + return client; } - debug('connection established'); - }); + client = createClient(); - return client; + return client; + }; } }; diff --git a/services/scraper.js b/services/scraper.js index aa56bf61b..40aac29ad 100644 --- a/services/scraper.js +++ b/services/scraper.js @@ -1,4 +1,5 @@ const kue = require('./kue'); +const taskFactory = kue.createTaskFactory(); const debug = require('debug')('talk:services:scraper'); const AssetModel = require('../models/asset'); const AssetsService = require('./assets'); @@ -12,11 +13,13 @@ const metascraper = require('metascraper'); const scraper = { /** - * Create the new Task kue. + * Create the new Task kue singleton. */ - task: new kue.Task({ - name: 'scraper' - }), + get task() { + return taskFactory({ + name: 'scraper' + }); + }, /** * Creates a new scraper job and scrapes the url when it gets processed. diff --git a/services/users.js b/services/users.js index 3bdc5148b..12da5ecce 100644 --- a/services/users.js +++ b/services/users.js @@ -11,9 +11,6 @@ const { } = require('../config'); const debug = require('debug')('talk:services:users'); -const redis = require('./redis'); -const redisClient = redis.createClient(); - const UserModel = require('../models/user'); const USER_STATUS = require('../models/enum/user_status'); const USER_ROLES = require('../models/enum/user_roles'); @@ -32,6 +29,11 @@ const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; // through during the salting process. const SALT_ROUNDS = 10; +// Create a redis client to use for authentication. +const {createClientFactory} = require('./redis'); + +const client = createClientFactory(); + // UsersService is the interface for the application to interact with the // UserModel through. module.exports = class UsersService { @@ -67,7 +69,7 @@ module.exports = class UsersService { const rdskey = `la[${email.toLowerCase().trim()}]`; return new Promise((resolve, reject) => { - redisClient + client() .multi() .incr(rdskey) .expire(rdskey, RECAPTCHA_WINDOW_SECONDS) @@ -80,7 +82,7 @@ module.exports = class UsersService { if (replies[0] === 1 || replies[1] === -1) { // then expire it after the timeout - redisClient.expire(rdskey, RECAPTCHA_WINDOW_SECONDS); + client().expire(rdskey, RECAPTCHA_WINDOW_SECONDS); } if (replies[0] >= RECAPTCHA_INCORRECT_TRIGGER) { @@ -102,7 +104,7 @@ module.exports = class UsersService { const rdskey = `la[${email.toLowerCase().trim()}]`; return new Promise((resolve, reject) => { - redisClient + client() .get(rdskey, (err, reply) => { if (err) { return reject(err); diff --git a/test/server/redis.js b/test/server/redis.js index 529548918..454a2afd8 100644 --- a/test/server/redis.js +++ b/test/server/redis.js @@ -1,3 +1,4 @@ const redis = require('../helpers/redis'); +const cache = require('../../services/cache'); -beforeEach(() => redis.clearDB()); +beforeEach(() => Promise.all([redis.clearDB(), cache.init()])); diff --git a/views/article.ejs b/views/article.ejs index d803b6295..f1a7ba5d9 100644 --- a/views/article.ejs +++ b/views/article.ejs @@ -30,6 +30,17 @@ asset_url: '<%= asset_url ? asset_url : '' %>', asset_id: '<%= asset_id ? asset_id : '' %>', auth_token: '', + /** + * You can listen to events using the example below. + * The argument passed is the event emitter from + * https://github.com/asyncly/EventEmitter2 + * + * events: function(events) { + * events.onAny(function(eventName, data) { + * console.log(eventName, data); + * }); + * }, + */ plugin_config: { /** * You can disable rendering slot components of a plugin by doing: diff --git a/yarn.lock b/yarn.lock index d2177f384..255dc7415 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2957,6 +2957,10 @@ event-stream@~3.3.0: stream-combiner "~0.0.4" through "~2.3.1" +eventemitter2@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-4.1.2.tgz#0e1a8477af821a6ef3995b311bf74c23a5247f15" + eventemitter3@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba"