Merge branch 'master' of github.com:coralproject/talk into install-admin

This commit is contained in:
Belen Curcio
2017-02-01 09:57:02 -03:00
37 changed files with 906 additions and 414 deletions
+2 -34
View File
@@ -4,45 +4,13 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const dotenv = require('dotenv');
const util = require('../util');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.option('--pid [path]', 'Specify a path to output the current PID to')
.parse(process.argv);
if (program.config) {
let r = dotenv.config({
path: program.config
});
if (r.error) {
throw r.error;
}
}
// If the `--pid` flag is used, put the current pid there.
if (program.pid) {
util.pid(program.pid);
}
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
// entrypoint for the entire application.
require('env-rewrite').rewrite();
const program = require('./commander');
program
.command('serve', 'serve the application')
.command('assets', 'interact with assets')
.command('settings', 'work with the application settings')
.command('setup', 'setup the application')
.command('jobs', 'work with the job queues')
.command('users', 'work with the application auth')
.parse(process.argv);
+1 -5
View File
@@ -4,8 +4,7 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const program = require('./commander');
const parseDuration = require('parse-duration');
const Table = require('cli-table');
const AssetModel = require('../models/asset');
@@ -86,9 +85,6 @@ function refreshAssets(ageString) {
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version);
program
.command('list')
.description('list all the assets in the database')
+1 -1
View File
@@ -4,7 +4,7 @@
* Module dependencies.
*/
const program = require('commander');
const program = require('./commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const util = require('../util');
+7 -19
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
const app = require('../app');
const program = require('./commander');
const debug = require('debug')('talk:server');
const http = require('http');
const init = require('../init');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const kue = require('../services/kue');
@@ -86,27 +86,15 @@ function onListening() {
* Start the app.
*/
function startApp() {
init().then(() => {
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
}
/**
* Module dependencies.
*/
const program = require('commander');
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('commander');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('init')
.description('initilizes the talk settings')
.action(() => {
const defaults = {
moderation: 'POST',
wordlist: {
banned: [],
suspect: []
}
};
SettingsService
.init(defaults)
.then(() => {
console.log('Created settings object.');
util.shutdown();
})
.catch((err) => {
console.error(`failed to create the settings object ${JSON.stringify(err)}`);
util.shutdown(1);
});
});
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
Executable
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingModel = require('../models/setting');
const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.description('runs the setup wizard to setup the application')
.option('--defaults', 'apply defaults for config instead of prompting')
.parse(process.argv);
//==============================================================================
// Setup the application
//==============================================================================
SettingsService
.init()
.then((settings) => {
if (program.defaults) {
return settings.save();
}
console.log('We\'ll ask you some questions in order to setup your installation of Talk.\n');
return inquirer.prompt([
{
type: 'input',
name: 'organizationName',
message: 'Organization Name',
default: settings.organizationName,
validate: (input) => {
if (input && input.length > 0) {
return true;
}
return 'Organization Name is required.';
}
},
{
type: 'list',
choices: SettingModel.MODERATION_OPTIONS,
name: 'moderation',
default: settings.moderation,
message: 'Select a moderation mode'
},
{
type: 'confirm',
name: 'requireEmailConfirmation',
default: settings.requireEmailConfirmation,
message: 'Should emails always be confirmed'
}
])
.then((answers) => {
// Update the settings that were changed.
Object.keys(answers).forEach((key) => {
if (answers[key] !== undefined) {
settings[key] = answers[key];
}
});
return settings.save();
});
})
.then(() => {
console.log('Talk is now installed!');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
+137 -109
View File
@@ -4,105 +4,132 @@
* Module dependencies.
*/
const program = require('commander');
const pkg = require('../package.json');
const prompt = require('prompt');
const program = require('./commander');
const inquirer = require('inquirer');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const mongoose = require('../services/mongoose');
const util = require('../util');
const Table = require('cli-table');
const validateRequired = (msg = 'Field is required', len = 1) => (input) => {
if (input && input.length >= len) {
return true;
}
return msg;
};
// Regeister the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
function getUserCreateAnswers(options) {
if (options.flag_mode) {
let user = {
email: options.email,
password: options.password,
confirmPassword: options.password,
displayName: options.name,
roles: []
};
if (options.role && UserModel.USER_ROLES.indexOf(options.role) > -1) {
user.roles = [options.role];
}
return Promise.resolve(user);
}
return inquirer.prompt([
{
name: 'email',
message: 'Email',
format: 'email',
validate: validateRequired('Email is required')
},
{
name: 'password',
message: 'Password',
type: 'password',
filter: (password) => {
return UsersService
.isValidPassword(password)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'confirmPassword',
message: 'Confirm Password',
type: 'password',
filter: (confirmPassword) => {
return UsersService
.isValidPassword(confirmPassword)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'displayName',
message: 'Display Name',
filter: (displayName) => {
return UsersService
.isValidDisplayName(displayName)
.catch((err) => {
throw err.message;
});
}
},
{
name: 'roles',
message: 'User Role',
type: 'checkbox',
choices: UserModel.USER_ROLES
}
]);
}
/**
* Prompts for input and registers a user based on those.
*/
function createUser(options) {
return new Promise((resolve, reject) => {
if (options.flag_mode) {
return resolve({
email: options.email,
password: options.password,
displayName: options.name,
role: options.role
});
}
prompt.start();
prompt.get([
{
name: 'email',
description: 'Email',
format: 'email',
required: true
},
{
name: 'password',
description: 'Password',
hidden: true,
required: true
},
{
name: 'confirmPassword',
description: 'Confirm Password',
hidden: true,
required: true
},
{
name: 'displayName',
description: 'Display Name',
required: true
},
{
name: 'role',
description: 'User Role',
required: false
}
], (err, result) => {
if (err) {
return reject(err);
getUserCreateAnswers(options)
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
return Promise.reject(new Error('Passwords do not match'));
}
if (result.password !== result.confirmPassword) {
return reject(new Error('Passwords do not match'));
}
return answers;
})
.then((answers) => {
return UsersService
.createLocalUser(answers.email.trim(), answers.password.trim(), answers.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
resolve(result);
});
})
.then((result) => {
return UsersService.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
let role = result.role ? result.role.trim() : null;
if (role && role.length > 0) {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
} else {
util.shutdown();
}
if (answers.roles.length > 0) {
return Promise.all(answers.roles.map((role) => {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the role ${role} to User ${user.id}.`);
});
}));
}
});
})
.then(() => {
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
});
}
/**
@@ -127,44 +154,34 @@ function deleteUser(userID) {
* Changes the password for a user.
*/
function passwd(userID) {
prompt.start();
prompt.get([
inquirer.prompt([
{
name: 'password',
description: 'Password',
hidden: true,
required: true
message: 'Password',
type: 'password',
validate: validateRequired('Password is required')
},
{
name: 'confirmPassword',
description: 'Confirm Password',
hidden: true,
required: true
message: 'Confirm Password',
type: 'password',
validate: validateRequired('Confirm Password is required')
}
], (err, result) => {
if (err) {
console.error(err);
util.shutdown();
return;
])
.then((answers) => {
if (answers.password !== answers.confirmPassword) {
return Promise.reject(new Error('Password mismatch'));
}
if (result.password !== result.confirmPassword) {
console.error(new Error('Password mismatch'));
util.shutdown(1);
return;
}
UsersService
.changePassword(userID, result.password)
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
return UsersService.changePassword(userID, answers.password);
})
.then(() => {
console.log('Password changed.');
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
@@ -273,6 +290,13 @@ function mergeUsers(dstUserID, srcUserID) {
* @param {String} role the role to add
*/
function addRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.addRoleToUser(userID, role)
.then(() => {
@@ -291,6 +315,13 @@ function addRole(userID, role) {
* @param {String} role the role to remove
*/
function removeRole(userID, role) {
if (UserModel.USER_ROLES.indexOf(role) === -1) {
console.error(`Role '${role}' is not supported. Supported roles are ${UserModel.USER_ROLES.join(', ')}.`);
util.shutdown(1);
return;
}
UsersService
.removeRoleFromUser(userID, role)
.then(() => {
@@ -375,9 +406,6 @@ function enableUser(userID) {
// Setting up the program command line arguments.
//==============================================================================
program
.version(pkg.version);
program
.command('create')
.option('--email [email]', 'Email to use')
+46
View File
@@ -0,0 +1,46 @@
const pkg = require('../package.json');
const dotenv = require('dotenv');
const fs = require('fs');
const program = require('commander');
const util = require('../util');
// Perform rewrites to the runtime environment variables based on the contents
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
// entrypoint for the entire application.
require('env-rewrite').rewrite();
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
const parseArgs = require('minimist')(process.argv.slice(2), {
alias: {
'c': 'config'
},
string: [
'config',
'pid'
],
default: {
'config': null,
'pid': null
}
});
if (parseArgs.config) {
let envConfig = dotenv.parse(fs.readFileSync(parseArgs.config, {encoding: 'utf8'}));
Object.keys(envConfig).forEach((k) => {
process.env[k] = envConfig[k];
});
}
// If the `--pid` flag is used, put the current pid there.
if (parseArgs.pid) {
util.pid(parseArgs.pid);
}
module.exports = program
.version(pkg.version)
.option('-c, --config [path]', 'Specify the configuration file to load')
.option('--pid [path]', 'Specify a path to output the current PID to');
+1 -1
View File
@@ -14,7 +14,7 @@ database:
post:
# Initialize the settings in the database, this will create indicies for the
# database.
- ./bin/cli settings init
- ./bin/cli setup --defaults
- sleep 2
test:
+1 -1
View File
@@ -105,7 +105,7 @@ class Embed extends Component {
loading ? <Spinner/>
: <div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={asset.comments.length}/></Tab>
<Tab><Count count={asset.commentCount}/></Tab>
<Tab>Settings</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
@@ -17,6 +17,7 @@ query AssetQuery($asset_url: String!) {
charCount
requireEmailConfirmation
}
commentCount
comments {
...commentView
replies {
+1
View File
@@ -73,6 +73,7 @@ query AssetQuery($asset_id: ID!) {
id
title
url
commentCount
comments {
...commentView
replies {
+5
View File
@@ -110,10 +110,15 @@ const ErrNotAuthorized = new APIError('not authorized', {
status: 401
});
// ErrSettingsNotInit is returned when the settings are required but not
// initialized.
const ErrSettingsNotInit = new Error('settings not initialized, run `./bin/cli setup` to setup the application first');
module.exports = {
ExtendableError,
APIError,
ErrPasswordTooShort,
ErrSettingsNotInit,
ErrMissingEmail,
ErrMissingPassword,
ErrMissingToken,
+14 -1
View File
@@ -3,6 +3,7 @@ const DataLoader = require('dataloader');
const util = require('./util');
const ActionsService = require('../../services/actions');
const ActionModel = require('../../models/action');
/**
* Looks up actions based on the requested id's all bounded by the user.
@@ -16,6 +17,17 @@ const genActionSummariessByItemID = ({user = {}}, item_ids) => {
.then(util.arrayJoinBy(item_ids, 'item_id'));
};
/**
* Search for actions based on their action_type and item_type and ensures that
* the actions returned have unique item id's.
* @param {String} action_type the action to search by
* @param {String} item_type the item id to search by
* @return {Promise} resolves to distinct items actions
*/
const getItemIdsByActionTypeAndItemType = (_, action_type, item_type) => {
return ActionModel.distinct('item_id', {action_type, item_type});
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -23,6 +35,7 @@ const genActionSummariessByItemID = ({user = {}}, item_ids) => {
*/
module.exports = (context) => ({
Actions: {
getByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
getSummariesByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
getByTypes: ({action_type, item_type}) => getItemIdsByActionTypeAndItemType(context, action_type, item_type)
}
});
+110 -63
View File
@@ -1,85 +1,134 @@
const DataLoader = require('dataloader');
const util = require('./util');
const ActionModel = require('../../models/action');
const CommentModel = require('../../models/comment');
const CommentsService = require('../../services/comments');
/**
* Retrieves comments by an array of asset id's, results are returned in reverse
* chronological order.
* @param {Array} ids array of ids to lookup
* Returns the comment count for all comments that are public based on their
* asset ids.
* @param {Object} context graph context
* @param {Array<String>} asset_ids the ids of assets for which there are
* comments that we want to get
*/
const genCommentsByAssetID = (context, ids) => {
return CommentModel.find({
asset_id: {
$in: ids
const getCountsByAssetID = (context, asset_ids) => {
return CommentModel.aggregate([
{
$match: {
asset_id: {
$in: asset_ids
},
status: {
$in: [null, 'ACCEPTED']
},
parent_id: null
}
},
parent_id: null,
status: {
$in: [null, 'ACCEPTED']
{
$group: {
_id: '$asset_id',
count: {
$sum: 1
}
}
}
})
.sort({created_at: -1})
.then(util.arrayJoinBy(ids, 'asset_id'));
])
.then(util.singleJoinBy(asset_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Retrieves comments by an array of parent ids, results are returned in
* chronological order.
* @param {Array} ids array of ids to lookup
* Returns the comment count for all comments that are public based on their
* parent ids.
* @param {Object} context graph context
* @param {Array<String>} parent_ids the ids of parents for which there are
* comments that we want to get
*/
const genCommentsByParentID = (context, ids) => {
return CommentModel.find({
parent_id: {
$in: ids
const getCountsByParentID = (context, parent_ids) => {
return CommentModel.aggregate([
{
$match: {
parent_id: {
$in: parent_ids
},
status: {
$in: [null, 'ACCEPTED']
}
}
},
status: {
$in: [null, 'ACCEPTED']
{
$group: {
_id: '$parent_id',
count: {
$sum: 1
}
}
}
})
.sort({created_at: 1})
.then(util.arrayJoinBy(ids, 'parent_id'));
])
.then(util.singleJoinBy(parent_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
const getCommentsByStatusAndAssetID = (context, {status = null, asset_id = null}) => {
/**
* Retrieves comments based on the passed in query that is filtered by the
* current used passed in via the context.
* @param {Object} context graph context
* @param {Object} query query terms to apply to the comments query
*/
const getCommentsByQuery = ({user}, {ids, statuses, asset_id, parent_id, limit, cursor, sort}) => {
let comments = CommentModel.find();
// TODO: remove when we move the enum over to the uppercase.
if (status) {
status = status.toLowerCase();
// Only administrators can search for comments with statuses that are not
// `null`, or `'ACCEPTED'`.
if (user != null && user.hasRoles('ADMIN') && statuses) {
comments = comments.where({
status: {
$in: statuses
}
});
} else {
comments = comments.where({
status: {
$in: [null, 'ACCEPTED']
}
});
}
return CommentsService.moderationQueue(status, asset_id);
};
const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = null}) => {
return ActionModel.find({
action_type,
item_type: 'COMMENTS'
}).then((actions) => {
let comments = CommentModel.find({
if (ids) {
comments = comments.find({
id: {
$in: actions.map((action) => action.item_id)
$in: ids
}
}).sort({created_at: 1});
});
}
if (asset_id) {
comments = comments.where({asset_id});
if (asset_id) {
comments = comments.where({asset_id});
}
// We perform the undefined check because, null, is a valid state for the
// search to be with, which indicates that it is at depth 0.
if (parent_id !== undefined) {
comments = comments.where({parent_id});
}
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
comments = comments.where({
created_at: {
$lt: cursor
}
});
} else {
comments = comments.where({
created_at: {
$gt: cursor
}
});
}
}
return comments;
});
};
const genCommentsByAuthorID = (context, authorIDs) => {
return CommentModel.find({
author_id: {
$in: authorIDs
}
})
.sort({created_at: -1})
.then(util.arrayJoinBy(authorIDs, 'author_id'));
return comments
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
.limit(limit);
};
/**
@@ -89,10 +138,8 @@ const genCommentsByAuthorID = (context, authorIDs) => {
*/
module.exports = (context) => ({
Comments: {
getByParentID: new DataLoader((ids) => genCommentsByParentID(context, ids)),
getByAssetID: new DataLoader((ids) => genCommentsByAssetID(context, ids)),
getByStatusAndAssetID: (query) => getCommentsByStatusAndAssetID(context, query),
getByActionTypeAndAssetID: (query) => getCommentsByActionTypeAndAssetID(context, query),
getByAuthorID: new DataLoader((authorIDs) => genCommentsByAuthorID(context, authorIDs))
getByQuery: (query) => getCommentsByQuery(context, query),
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids))
}
});
+72 -1
View File
@@ -1,4 +1,6 @@
const _ = require('lodash');
const DataLoader = require('dataloader');
const cache = require('../../services/cache');
/**
* SingletonResolver is a cached loader for a single result.
@@ -34,6 +36,7 @@ class SingletonResolver {
*/
const arrayJoinBy = (ids, key) => (items) => {
const itemsByKey = _.groupBy(items, key);
return ids.map((id) => {
if (id in itemsByKey) {
return itemsByKey[id];
@@ -61,8 +64,76 @@ const singleJoinBy = (ids, key) => (items) => {
});
};
/**
* SharedCacheDataLoader provides a version of the DataLoader that wraps up a
* redis backed cache with the dataloader's request cache.
*/
class SharedCacheDataLoader extends DataLoader {
constructor(prefix, expiry, batchLoadFn, options) {
super(SharedCacheDataLoader.batchLoadFn(prefix, expiry, batchLoadFn), options);
this._prefix = prefix;
this._expiry = expiry;
this._keyFunc = SharedCacheDataLoader.keyFunc(this._prefix);
}
/**
* clear the key from the shared cache and the request cache
*/
clear(key) {
return cache
.invalidate(key, this._keyFunc)
.then(() => super.clear(key));
}
/**
* prime the shared cache and the request cache
*/
prime(key, value) {
return cache
.set(key, value, this._expiry, this._keyFunc)
.then(() => super.prime(key, value));
}
/**
* prime many values in the shared cache and the request cache
*/
primeMany(keys, values) {
return cache
.setMany(keys, values, this._expiry, this._keyFunc)
.then(() => keys.map((key, i) => super.prime(key, values[i])));
}
/**
* wraps up the prefix needed for the redis backed shared cache driver
*/
static keyFunc(prefix) {
return (key) => `cache.sbl[${prefix}][${key}]`;
}
/**
* wraps the dataloader batchLoadFn with the shared cache's wrapper
*/
static batchLoadFn(prefix, expiry, batchLoadFn) {
return (ids) => cache.wrapMany(ids, expiry, (workKeys) => {
return batchLoadFn(workKeys);
}, SharedCacheDataLoader.keyFunc(prefix));
}
}
/**
* Maps an object's paths to a string that can be used as a cache key.
* @param {Array} paths paths on the object to be used to generate the cache
* key
*/
const objectCacheKeyFn = (...paths) => (obj) => {
return paths.map((path) => obj[path]).join(':');
};
module.exports = {
singleJoinBy,
arrayJoinBy,
SingletonResolver
objectCacheKeyFn,
SingletonResolver,
SharedCacheDataLoader
};
+9 -4
View File
@@ -18,10 +18,15 @@ const createAction = ({user = {}}, {item_id, item_type, action_type, metadata =
user_id: user.id,
action_type,
metadata
}).then((result) =>
item_type === 'USERS' && action_type === 'FLAG' ?
UsersService.setStatus(item_id, 'PENDING').then(() => result)
: result);
}).then((action) => {
if (item_type === 'USERS' && action_type === 'FLAG') {
return UsersService
.setStatus(item_id, 'PENDING')
.then(() => action);
}
return action;
});
};
/**
+20 -3
View File
@@ -14,13 +14,29 @@ const Wordlist = require('../../services/wordlist');
* @param {String} [status=null] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => {
const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id = null}, status = null) => {
return CommentsService.publicCreate({
body,
asset_id,
parent_id,
status,
author_id: user.id
})
.then((comment) => {
// TODO: explore using an `INCR` operation to update the counts here
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated.
if (Comments && Comments.countByAssetID && Comments.countByParentID) {
if (parent_id != null) {
Comments.countByParentID.clear(parent_id);
} else {
Comments.countByAssetID.clear(asset_id);
}
}
return comment;
});
};
@@ -121,7 +137,7 @@ const createPublicComment = (context, commentInput) => {
if (wordlist != null) {
// TODO: this is kind of fragile, we should refactor this to resolve
// all these const's that we're using like 'comments', 'flag' to be
// all these const's that we're using like 'COMMENTS', 'FLAG' to be
// defined in a checkable schema.
return context.mutators.Action.createAction(null, {
item_id: comment.id,
@@ -131,7 +147,8 @@ const createPublicComment = (context, commentInput) => {
field: 'body',
details: 'Matched suspect word filters.'
}
}).then(() => comment);
})
.then(() => comment);
}
// Finally, we return the comment.
+4 -4
View File
@@ -1,10 +1,10 @@
const Action = {
// This will load the user for the specific action. We'll limit this to the
// admin users only.
user({user_id}, _, {loaders, user}) {
if (user.hasRole('ADMIN')) {
return loaders.Users.getByID.load(user_id);
// admin users only or the current logged in user.
user({user_id}, _, {loaders: {Users}, user}) {
if (user && (user.hasRole('ADMIN') || user_id === user.id)) {
return Users.getByID.load(user_id);
}
}
};
+12 -4
View File
@@ -1,9 +1,17 @@
const Asset = {
comments({id}, _, {loaders}) {
return loaders.Comments.getByAssetID.load(id);
comments({id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id: id,
sort,
limit,
parent_id: null
});
},
settings({settings = null}, _, {loaders}) {
return loaders.Settings.load()
commentCount({id}, _, {loaders: {Comments}}) {
return Comments.countByAssetID.load(id);
},
settings({settings = null}, _, {loaders: {Settings}}) {
return Settings.load()
.then((globalSettings) => {
if (settings) {
settings = Object.assign({}, globalSettings.toObject(), settings);
+16 -8
View File
@@ -1,15 +1,23 @@
const Comment = {
user({author_id}, _, {loaders}) {
return loaders.Users.getByID.load(author_id);
user({author_id}, _, {loaders: {Users}}) {
return Users.getByID.load(author_id);
},
replies({id}, _, {loaders}) {
return loaders.Comments.getByParentID.load(id);
replies({id, asset_id}, {sort, limit}, {loaders: {Comments}}) {
return Comments.getByQuery({
asset_id,
parent_id: id,
sort,
limit
});
},
actions({id}, _, {loaders}) {
return loaders.Actions.getByItemID.load(id);
replyCount({id}, _, {loaders: {Comments}}) {
return Comments.countByParentID.load(id);
},
asset({asset_id}, _, {loaders}) {
return loaders.Assets.getByID.load(asset_id);
actions({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
asset({asset_id}, _, {loaders: {Assets}}) {
return Assets.getByID.load(asset_id);
}
};
+27
View File
@@ -0,0 +1,27 @@
const GraphQLScalarType = require('graphql').GraphQLScalarType;
const Kind = require('graphql/language').Kind;
module.exports = new GraphQLScalarType({
name: 'Date',
description: 'Date represented as an ISO8601 string',
serialize(value) {
return value.toISOString();
},
parseValue(value) {
return new Date(value);
},
parseLiteral(ast) {
switch (ast.kind) {
case Kind.STRING:
// This handles an empty string.
if (ast.value && ast.value.length === 0) {
return null;
}
return new Date(ast.value);
default:
return null;
}
}
});
+2
View File
@@ -2,6 +2,7 @@ const Action = require('./action');
const ActionSummary = require('./action_summary');
const Asset = require('./asset');
const Comment = require('./comment');
const Date = require('./date');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
@@ -12,6 +13,7 @@ module.exports = {
ActionSummary,
Asset,
Comment,
Date,
RootMutation,
RootQuery,
Settings,
+8 -8
View File
@@ -1,15 +1,15 @@
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators}) {
return mutators.Comment.create({asset_id, parent_id, body});
createComment(_, {asset_id, parent_id, body}, {mutators: {Comment}}) {
return Comment.create({asset_id, parent_id, body});
},
createAction(_, {action}, {mutators}) {
return mutators.Action.create(action);
createAction(_, {action}, {mutators: {Action}}) {
return Action.create(action);
},
deleteAction(_, {id}, {mutators}) {
return mutators.Action.delete({id});
deleteAction(_, {id}, {mutators: {Action}}) {
return Action.delete({id});
},
updateUserSettings(_, {settings}, {mutators}) {
return mutators.User.updateSettings(settings);
updateUserSettings(_, {settings}, {mutators: {User}}) {
return User.updateSettings(settings);
}
};
+23 -15
View File
@@ -1,39 +1,47 @@
const RootQuery = {
assets(_, args, {loaders, user}) {
assets(_, args, {loaders: {Assets}, user}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
return loaders.Assets.getAll.load();
return Assets.getAll.load();
},
asset(_, query, {loaders}) {
asset(_, query, {loaders: {Assets}}) {
if (query.id) {
// TODO: we may not always have a comment stream here, therefore, when we
// load it, we may also need to create with the url. This may also have to
// move the logic over to the mutators function as an upsert operation
// possibly.
return loaders.Assets.getByID.load(query.id);
return Assets.getByID.load(query.id);
}
return loaders.Assets.getByURL(query.url);
return Assets.getByURL(query.url);
},
settings(_, args, {loaders}) {
return loaders.Settings.load();
settings(_, args, {loaders: {Settings}}) {
return Settings.load();
},
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
comments(_, {query}, {loaders, user}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort}}, {user, loaders: {Comments, Actions}}) {
let query = {statuses, asset_id, parent_id, limit, cursor, sort};
if (user != null && user.hasRoles('ADMIN') && action_type) {
return Actions.getByTypes({action_type, item_type: 'COMMENTS'})
.then((actions) => {
// Map the actions from the items referenced byt this query. The actions
// returned by this query are explicitly going to be distinct by their
// `item_id`'s.
let ids = actions.map((action) => action.item_id);
// Perform the query using the available resolver.
return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort});
});
}
if (query.action_type) {
return loaders.Comments.getByActionTypeAndAssetID(query);
} else {
return loaders.Comments.getByStatusAndAssetID(query);
}
return Comments.getByQuery(query);
},
// This returns the current user, ensure that if we aren't logged in, we
+15 -6
View File
@@ -1,16 +1,25 @@
const User = {
actions({id}, _, {loaders}) {
return loaders.Actions.getByID.load(id);
actions({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
},
comments({id}, _, {loaders, user}) {
comments({id}, _, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
// the comments.
if (!user.hasRoles('ADMIN') || user.id !== id) {
return null;
if (user && (user.hasRoles('ADMIN') || user.id === id)) {
return Comments.getByAuthorID.load(id);
}
return loaders.Comments.getByAuthorID.load(id);
return null;
},
roles({id, roles}, _, {user}) {
// If the user is not an admin, only return the current user's roles.
if (user && (user.hasRoles('ADMIN') || user.id === id)) {
return roles;
}
return null;
}
};
+68 -15
View File
@@ -1,21 +1,50 @@
interface ActionableItem {
id: ID!
# Establishes the ordering of the content by their created_at time stamp.
enum SORT_ORDER {
# newest to oldest order.
REVERSE_CHRONOLOGICAL
# oldest to newer order.
CHRONOLOGICAL
}
# Date represented as an ISO8601 string.
scalar Date
type UserSettings {
# bio of the user.
bio: String
}
input CommentsInput {
input CommentsQuery {
# current status of a comment.
status: COMMENT_STATUS
statuses: [COMMENT_STATUS]
# asset that a comment is on.
asset_id: ID
# action type to find comments that have an action with.
# the parent of the comment that we want to retrive.
parent_id: ID
# comments returned will only be ones which have at least one action of this
# type.
action_type: ACTION_TYPE
# limit the number of results to be returned.
limit: Int = 10
# skip results from the last created_at timestamp.
cursor: Date
# sort the results by created_at.
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
}
enum USER_ROLES {
# an administrator of the site
ADMIN
# a moderator of the site
MODERATOR
}
# Any person who can author comments, create actions, and view comments on a
@@ -29,11 +58,14 @@ type User {
# actions against a specific user.
actions: [ActionSummary]
# the current roles of the user.
roles: [USER_ROLES]
# settings for a user.
settings: UserSettings
# returns all comments based on a query.
comments(query: CommentsInput): [Comment]
comments(query: CommentsQuery): [Comment]
}
type Comment {
@@ -46,7 +78,10 @@ type Comment {
user: User
# the replies that were made to the comment.
replies(limit: Int = 3): [Comment]
replies(sort: SORT_ORDER = CHRONOLOGICAL, limit: Int = 3): [Comment]
# the count of replies on a comment
replyCount: Int
# the actions made against a comment.
actions: [ActionSummary]
@@ -58,7 +93,7 @@ type Comment {
status: COMMENT_STATUS
# the time when the comment was created
created_at: String!
created_at: Date!
}
enum ITEM_TYPE {
@@ -78,11 +113,10 @@ type Action {
item_id: ID!
item_type: ITEM_TYPE!
item: ActionableItem
user: User!
updated_at: String
created_at: String
updated_at: Date
created_at: Date
}
type ActionSummary {
@@ -109,13 +143,31 @@ type Settings {
}
type Asset {
# The current ID of the asset.
id: ID!
# The scraped title of the asset.
title: String
# The URL that the asset is locaed on.
url: String
comments: [Comment]
# The top level comments that are attached to the asset.
comments(sort: SORT_ORDER = REVERSE_CHRONOLOGICAL, limit: Int = 10): [Comment]
# The count of top level comments on the asset.
commentCount: Int
# The settings (rectified with the global settings) that should be applied to
# this asset.
settings: Settings!
closedAt: String
created_at: String
# The date that the asset was closed at.
closedAt: Date
# The date that the asset was created.
created_at: Date
}
enum COMMENT_STATUS {
@@ -125,6 +177,7 @@ enum COMMENT_STATUS {
}
type RootQuery {
# retrieves site wide settings and defaults.
settings: Settings
@@ -135,7 +188,7 @@ type RootQuery {
asset(id: ID, url: String): Asset
# retrieves comments based on the input query.
comments(query: CommentsInput): [Comment]
comments(query: CommentsQuery!): [Comment]
# retrieves the current logged in user.
me: User
-13
View File
@@ -1,13 +0,0 @@
const SettingsService = require('./services/settings');
module.exports = () => Promise.all([
// Upsert the settings object.
SettingsService.init({
moderation: 'POST',
wordlist: {
banned: [],
suspect: []
}
})
]);
+19 -11
View File
@@ -1,12 +1,10 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const WordlistSchema = new Schema({
banned: [String],
suspect: [String]
}, {
_id: false
});
const MODERATION_OPTIONS = [
'PRE',
'POST'
];
/**
* SettingSchema manages application settings that get used on front and backend.
@@ -19,10 +17,7 @@ const SettingSchema = new Schema({
},
moderation: {
type: String,
enum: [
'PRE',
'POST'
],
enum: MODERATION_OPTIONS,
default: 'POST'
},
infoBoxEnable: {
@@ -33,6 +28,9 @@ const SettingSchema = new Schema({
type: String,
default: ''
},
organizationName: {
type: String
},
closedTimeout: {
type: Number,
@@ -43,7 +41,16 @@ const SettingSchema = new Schema({
type: String,
default: 'Expired'
},
wordlist: WordlistSchema,
wordlist: {
banned: {
type: Array,
default: []
},
suspect: {
type: Array,
default: []
}
},
charCount: {
type: Number,
default: 5000
@@ -87,3 +94,4 @@ SettingSchema.method('merge', function(src) {
const Setting = mongoose.model('Setting', SettingSchema);
module.exports = Setting;
module.exports.MODERATION_OPTIONS = MODERATION_OPTIONS;
+2 -1
View File
@@ -68,10 +68,12 @@
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.9.0",
"helmet": "^3.1.0",
"inquirer": "^3.0.1",
"jsonwebtoken": "^7.1.9",
"kue": "^0.11.5",
"lodash": "^4.16.6",
"metascraper": "^1.0.6",
"minimist": "^1.2.0",
"mongoose": "^4.6.5",
"morgan": "^1.7.0",
"natural": "^0.4.0",
@@ -80,7 +82,6 @@
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"prompt": "^1.0.0",
"react-apollo": "^0.8.1",
"redis": "^2.6.3",
"uuid": "^2.0.3"
+2 -2
View File
@@ -3,8 +3,8 @@
# install selenium
selenium-standalone install --config=./selenium.config.js
#Init settings
./bin/cli settings init
# Init application
./bin/cli setup --defaults
# Creating Admin Test User
./bin/cli users create --flag_mode --email "admin@test.com" --password "testtest" --name "AdminTestUser" --role "ADMIN"
+153 -13
View File
@@ -1,4 +1,5 @@
const redis = require('./redis');
const debug = require('debug')('talk:cache');
const cache = module.exports = {
client: redis.createClient()
@@ -29,31 +30,103 @@ const keyfunc = (key) => {
* resolved as the value to cache.
* @return {Promise} Resolves to the value either retrieved from cache
*/
cache.wrap = (key, expiry, work) => {
cache.wrap = (key, expiry, work, kf = keyfunc) => {
return cache
.get(key)
.get(key, kf)
.then((value) => {
if (value !== null) {
debug('wrap: hit', kf(key));
return value;
}
debug('wrap: miss', kf(key));
return work()
.then((value) => {
return cache
.set(key, value, expiry)
.then(() => value);
process.nextTick(() => {
cache
.set(key, value, expiry, kf)
.then(() => {
debug('wrap: set complete');
})
.catch((err) => {
console.error(err);
});
});
return value;
});
});
};
/**
* [wrapMany description]
* @param {Array<String>} keys Either an array of objects represening
* this work
* @param {Integer} expiry Time in seconds for the cache entry to live for
* @param {Function} work A function that returns a promise that can be
* resolved as the value to cache.
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
* @return {Promise} resovles to the values for the keys
*/
cache.wrapMany = (keys, expiry, work, kf = keyfunc) => {
return cache
.getMany(keys, kf)
.then((values) => {
// find any of the null valued items by collecting the work
let workRefs = values
.map((value, index) => ({value, index, key: keys[index]}))
.filter(({value}) => value === null);
let workKeys = workRefs.map(({key}) => key);
debug(`wrapMany: hit ratio: ${keys.length - workKeys.length}/${keys.length}`);
if (workKeys.length > 0) {
return work(workKeys)
.then((workedValues) => {
// Set the items in the cache that we needed to retrive after the
// next process tick.
process.nextTick(() => {
cache
.setMany(workKeys, workedValues, expiry, kf)
.then(() => {
debug('wrapMany: setMany complete');
})
.catch((err) => {
console.error(err);
});
});
return workedValues;
})
.then((workedValues) => {
// Walk over the worked keys to merge them with the existing values.
for (let i = 0; i < workRefs.length; i++) {
values[workRefs[i].index] = workedValues[i];
}
return values;
});
} else {
return values;
}
});
};
/**
* This returns a promise that returns a promise that resolves with the value
* from the cache or null if it does not exist in the cache.
* @param {Mixed} key Either an array of items composing a key or a string
* @return {Promise}
*/
cache.get = (key) => new Promise((resolve, reject) => {
cache.client.get(keyfunc(key), (err, reply) => {
cache.get = (key, kf = keyfunc) => new Promise((resolve, reject) => {
cache.client.get(kf(key), (err, reply) => {
if (err) {
return reject(err);
}
@@ -76,13 +149,80 @@ cache.get = (key) => new Promise((resolve, reject) => {
});
});
/**
* Returns many replies.
* @param {Array<String>} keys Either an array of objects represening
* this work
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
*/
cache.getMany = (keys, kf = keyfunc) => new Promise((resolve, reject) => {
cache.client.mget(keys.map(kf), (err, replies) => {
if (err) {
return reject(err);
}
// Parse the replies.
for (let i = 0; i < replies.length; i++) {
let value = null;
if (replies[i] != null) {
try {
// Parse the stored cache value from JSON.
value = JSON.parse(replies[i]);
} catch (e) {
return reject(e);
}
}
replies[i] = value;
}
return resolve(replies);
});
});
/**
* Sets many entries in the cache.
* @param {Array<String>} keys array of keys
* @param {Array} values array of values to set
* @param {Function} [kf=keyfunc] optional key function to use to turn the
* provided key into a string for the cache.
*/
cache.setMany = (keys, values, expiry, kf = keyfunc) => {
let multi = cache.client.multi();
keys.forEach((key, index) => {
// Serialize the value as JSON.
let reply = JSON.stringify(values[index]);
// Queue up the set command.
multi.set(kf(key), reply, 'EX', expiry);
});
return new Promise((resolve, reject) => {
multi.exec((err) => {
if (err) {
return reject(err);
}
resolve();
});
});
};
/**
* This invalidates a cached entry in the cache.
* @param {Mixed} key Either an array of items composing a key or a string
* @return {Promise}
*/
cache.invalidate = (key) => new Promise((resolve, reject) => {
cache.client.del(keyfunc(key), (err) => {
cache.invalidate = (key, kf = keyfunc) => new Promise((resolve, reject) => {
debug(`invalidate: ${kf(key)}`);
cache.client.del(kf(key), (err) => {
if (err) {
return reject(err);
}
@@ -94,17 +234,17 @@ cache.invalidate = (key) => new Promise((resolve, reject) => {
/**
* This sets a value on the key with the expiry and then resolves once it is
* done.
* @param {Mixed} key Either an array of items composing a key or a string
* @param {Mixed} value Object to be serialized and set to the cache
* @param {Mixed} key Either an array of items composing a key or a string
* @param {Mixed} value Object to be serialized and set to the cache
* @param {Integer} expiry Time in seconds for the cache entry to live for
* @return {Promise}
*/
cache.set = (key, value, expiry) => new Promise((resolve, reject) => {
cache.set = (key, value, expiry, kf = keyfunc) => new Promise((resolve, reject) => {
// Serialize the value as JSON.
let reply = JSON.stringify(value);
cache.client.set(keyfunc(key), reply, 'EX', expiry, (err) => {
cache.client.set(kf(key), reply, 'EX', expiry, (err) => {
if (err) {
return reject(err);
}
+17 -9
View File
@@ -1,4 +1,5 @@
const SettingModel = require('../models/setting');
const errors = require('../errors');
/**
* The selector used to uniquely identify the settings document.
@@ -15,7 +16,15 @@ module.exports = class SettingsService {
* @return {Promise} settings the whole settings record
*/
static retrieve() {
return SettingModel.findOne(selector);
return SettingModel
.findOne(selector)
.then((settings) => {
if (!settings) {
return Promise.reject(errors.ErrSettingsNotInit);
}
return settings;
});
}
/**
@@ -35,15 +44,14 @@ module.exports = class SettingsService {
/**
* This is run once when the app starts to ensure settings are populated.
* @return {Promise} null initialize the global settings object
*/
static init(defaults) {
static init(defaults = {}) {
return SettingsService
.retrieve()
.catch(() => {
let settings = new SettingModel(defaults);
// Inject the defaults on top of the passed in defaults to ensure that the new
// settings conform to the required selector.
defaults = Object.assign({}, defaults, selector);
// Actually update the settings collection.
return SettingsService.update(defaults);
return settings.save();
});
}
};
+16 -9
View File
@@ -189,6 +189,18 @@ module.exports = class UsersService {
return Wordlist.displayNameCheck(displayName);
}
static isValidPassword(password) {
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
}
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return Promise.resolve(password);
}
/**
* Creates the local user with a given email, password, and name.
* @param {String} email email of the new user
@@ -205,15 +217,10 @@ module.exports = class UsersService {
email = email.toLowerCase().trim();
displayName = displayName.toLowerCase().trim();
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
}
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return UsersService.isValidDisplayName(displayName)
return Promise.all([
UsersService.isValidDisplayName(displayName),
UsersService.isValidPassword(password)
])
.then(() => { // displayName is valid
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
+1
View File
@@ -203,6 +203,7 @@ class Wordlist {
*/
static displayNameCheck(displayName) {
const wl = new Wordlist();
return wl.load()
.then(() => {
displayName = displayName.replace(/_/g, '');
+2 -2
View File
@@ -66,7 +66,7 @@ describe('/api/v1/auth/local', () => {
describe('email confirmation enabled', () => {
beforeEach(() => SettingsService.init({requireEmailConfirmation: true}));
beforeEach(() => SettingsService.update({requireEmailConfirmation: true}));
describe('#post', () => {
it('should not allow a login from a user that is not confirmed', () => {
@@ -74,7 +74,7 @@ describe('/api/v1/auth/local', () => {
.post('/api/v1/auth/local')
.send({email: 'maria@gmail.com', password: 'password!'})
.catch((err) => {
err.response.should.have.status(401);
expect(err).to.have.status(401);
return UsersService.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id);
})
+3 -1
View File
@@ -20,6 +20,8 @@ util.shutdown = (defaultCode = 0, signal = null) => {
debug(`Reached ${signal} signal`);
}
debug(`${util.toshutdown.length} jobs now being called`);
Promise
.all(util.toshutdown.map((func) => func ? func(signal) : null).filter((func) => func))
.then(() => {
@@ -41,7 +43,7 @@ util.shutdown = (defaultCode = 0, signal = null) => {
*/
util.onshutdown = (jobs) => {
debug(`${jobs.length} jobs registered`);
debug(`${jobs.length} jobs registered to be called during shutdown`);
// Add the new jobs to shutdown to the object reference.
util.toshutdown = util.toshutdown.concat(jobs);