mirror of
https://github.com/wassname/talk.git
synced 2026-07-15 11:26:58 +08:00
Merge branch 'master' of github.com:coralproject/talk into install-admin
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
//==============================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -73,6 +73,7 @@ query AssetQuery($asset_id: ID!) {
|
||||
id
|
||||
title
|
||||
url
|
||||
commentCount
|
||||
comments {
|
||||
...commentView
|
||||
replies {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -203,6 +203,7 @@ class Wordlist {
|
||||
*/
|
||||
static displayNameCheck(displayName) {
|
||||
const wl = new Wordlist();
|
||||
|
||||
return wl.load()
|
||||
.then(() => {
|
||||
displayName = displayName.replace(/_/g, '');
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user