Merge branch 'event-emitter' of ssh://github.com/coralproject/talk into event-emitter

This commit is contained in:
Chi Vinh Le
2017-07-26 00:06:20 +07:00
39 changed files with 554 additions and 545 deletions
+2
View File
@@ -5,6 +5,8 @@ dist
npm-debug.log*
dump.rdb
client/coral-framework/graphql/introspection.json
.env
*.cfg
+13 -1
View File
@@ -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', {
+3 -3
View File
@@ -274,7 +274,7 @@ async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote
}
async function createSeedPlugin() {
const pluginsDir = path.join(__dirname, 'plugins');
const pluginsDir = path.resolve(__dirname, '..', 'plugins');
function pluginNameExists(pluginName) {
const pluginNames = fs.readdirSync(pluginsDir);
@@ -321,7 +321,7 @@ async function createSeedPlugin() {
// Creating plugin seed
//==============================================================================
const seedPlugin = path.join(__dirname, 'bin/templates/plugin');
const seedPlugin = path.join(__dirname, 'templates/plugin');
const newPluginPath = path.join(pluginsDir, answers.pluginName);
if (fs.existsSync(seedPlugin)) {
@@ -355,7 +355,7 @@ async function createSeedPlugin() {
// Let's add this to the plugins.json
if (answers.addPluginsJson) {
const pluginsJson = path.join(dir, 'plugins.json');
const pluginsJson = path.resolve(__dirname, '..', 'plugins.json');
fs.readJson(pluginsJson)
.then((j) => {
+11 -3
View File
@@ -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);
}
//==============================================================================
+1 -1
View File
@@ -5,7 +5,7 @@ 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';
@@ -1,6 +0,0 @@
import {getClient as getFrameworkClient} from 'coral-framework/services/client';
import fragmentMatcher from './fragmentMatcher';
export function getClient() {
return getFrameworkClient({fragmentMatcher});
}
@@ -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;
+1 -1
View File
@@ -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()),
@@ -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,
@@ -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);
@@ -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
@@ -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;
+3 -1
View File
@@ -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
+6
View File
@@ -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
//------------------------------------------------------------------------------
+1 -1
View File
@@ -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();
}
}
+3 -3
View File
@@ -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<String>} 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)),
+8
View File
@@ -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,
+1 -1
View File
@@ -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) => {
+3
View File
@@ -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
+8 -5
View File
@@ -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": {
@@ -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
+48 -60
View File
@@ -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;
+84 -98
View File
@@ -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;
+3 -6
View File
@@ -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});
});
+10 -2
View File
@@ -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;
+14 -17
View File
@@ -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;
+19 -34
View File
@@ -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;
+129 -140
View File
@@ -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;
+24
View File
@@ -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);
});
+23 -25
View File
@@ -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) => {
+27 -5
View File
@@ -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;
};
};
+6 -3
View File
@@ -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}) {
+22 -8
View File
@@ -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;
+7 -5
View File
@@ -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.
+12 -1
View File
@@ -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;
}
};
+25 -10
View File
@@ -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;
};
}
};
+7 -4
View File
@@ -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.
+8 -6
View File
@@ -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);
+2 -1
View File
@@ -1,3 +1,4 @@
const redis = require('../helpers/redis');
const cache = require('../../services/cache');
beforeEach(() => redis.clearDB());
beforeEach(() => Promise.all([redis.clearDB(), cache.init()]));