diff --git a/.gitignore b/.gitignore index d30e961bd..70b6d482e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ client/coral-framework/graphql/introspection.json .idea/ *.swp *.DS_STORE +.prettierrc.json coverage/ test/e2e/reports/ @@ -52,3 +53,4 @@ plugins/* !plugins/talk-plugin-slack-notifications **/node_modules/* +yarn-error.log diff --git a/.nodemon.json b/.nodemon.json index 9077398bc..44b17e9ee 100644 --- a/.nodemon.json +++ b/.nodemon.json @@ -1,5 +1,11 @@ { + "exec": "npm-run-all --parallel generate-introspection start:development", "verbose": true, "ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"], - "ext": "js,json,graphql,yml" + "ext": "js,json,graphql,yml", + "watch": [ + ".", + "bin/cli", + "bin/cli-serve" + ] } diff --git a/app.js b/app.js index c180d2c04..c0962d3aa 100644 --- a/app.js +++ b/app.js @@ -10,6 +10,7 @@ const {HELMET_CONFIGURATION} = require('./config'); const {MOUNT_PATH} = require('./url'); const routes = require('./routes'); const debug = require('debug')('talk:app'); +const {ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT} = require('./config'); const app = express(); @@ -41,6 +42,22 @@ if (process.env.NODE_ENV !== 'test') { app.use(morgan('dev')); } +if (ENABLE_TRACING && APOLLO_ENGINE_KEY) { + const {Engine} = require('apollo-engine'); + + const engine = new Engine({ + engineConfig: { + apiKey: APOLLO_ENGINE_KEY + }, + graphqlPort: PORT, + endpoint: `${MOUNT_PATH}api/v1/graph/ql`, + }); + + engine.start(); + + app.use(engine.expressMiddleware()); +} + // Trust the first proxy in front of us, this will enable us to trust the fact // that SSL was terminated correctly. app.set('trust proxy', 1); diff --git a/bin/cli b/bin/cli index f618b24be..707f41032 100755 --- a/bin/cli +++ b/bin/cli @@ -4,7 +4,8 @@ * Module dependencies. */ -const program = require('./commander'); +require('./util'); +const program = require('commander'); const {head, map} = require('lodash'); const Matcher = require('did-you-mean'); @@ -18,40 +19,37 @@ program .command('users', 'work with the application auth') .command('migration', 'provides utilities for migrating the database') .command('verify', 'provides utilities for performing data verification') - .command( - 'plugins', - 'provides utilities for interacting with the plugin system' - ) + .command('plugins', 'provides utilities for interacting with the plugin system') .parse(process.argv); // If the command wasn't found, output help. -const cmds = map(program.commands, '_name'); -const cmd = head(program.args); -if (!cmds.includes(cmd)) { - const m = new Matcher(cmds); - const similarCMDs = m.list(cmd); +const commands = map(program.commands, '_name'); +const command = head(program.args); +if (!commands.includes(command)) { + const m = new Matcher(commands); + const similarCommands = m.list(command); - console.error(`cli '${cmd}' is not a talk cli command. See 'cli --help'.`); - if (similarCMDs.length > 0) { - const sc = similarCMDs.map(({value}) => `\t${value}\n`).join(''); + console.error(`cli '${command}' is not a talk cli command. See 'cli --help'.`); + if (similarCommands.length > 0) { + const sc = similarCommands.map(({value}) => `\t${value}\n`).join(''); console.error(`\nThe most similar commands are\n${sc}`); } process.exit(1); } -/** - * When this provess exists, check to see if we have a running command, if we do - * check to see if it is still running. If it is, then kill it with a SIGINT - * signal. This is for the use case where we want to kill the process that is - * labled with the PID written out by the parent process. - */ -process.once('exit', () => { - if ( +// /** +// * When this process exists, check to see if we have a running command, if we do +// * check to see if it is still running. If it is, then kill it with a SIGINT +// * signal. This is for the use case where we want to kill the process that is +// * labeled with the PID written out by the parent process. +// */ +// process.once('exit', () => { +// if ( - // program.runningCommand && - program.runningCommand.killed === false && - program.runningCommand.exitCode === null - ) { - program.runningCommand.kill('SIGINT'); - } -}); +// // program.runningCommand && +// program.runningCommand.killed === false && +// program.runningCommand.exitCode === null +// ) { +// program.runningCommand.kill('SIGINT'); +// } +// }); diff --git a/bin/cli-assets b/bin/cli-assets index 4805bead0..5c1780c5d 100755 --- a/bin/cli-assets +++ b/bin/cli-assets @@ -4,7 +4,8 @@ * Module dependencies. */ -const program = require('./commander'); +const util = require('./util'); +const program = require('commander'); const parseDuration = require('ms'); const Table = require('cli-table'); const AssetModel = require('../models/asset'); @@ -12,7 +13,6 @@ const CommentModel = require('../models/comment'); const AssetsService = require('../services/assets'); const mongoose = require('../services/mongoose'); const scraper = require('../services/scraper'); -const util = require('./util'); const inquirer = require('inquirer'); // Register the shutdown criteria. diff --git a/bin/cli-jobs b/bin/cli-jobs index 6048a69db..26eeab147 100755 --- a/bin/cli-jobs +++ b/bin/cli-jobs @@ -4,10 +4,10 @@ * Module dependencies. */ -const program = require('./commander'); +const util = require('./util'); +const program = require('commander'); const scraper = require('../services/scraper'); const mailer = require('../services/mailer'); -const util = require('./util'); const mongoose = require('../services/mongoose'); const kue = require('../services/kue'); diff --git a/bin/cli-migration b/bin/cli-migration index 036ac8b02..18a8c012a 100755 --- a/bin/cli-migration +++ b/bin/cli-migration @@ -4,8 +4,8 @@ * Module dependencies. */ -const program = require('./commander'); const util = require('./util'); +const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); const MigrationService = require('../services/migration'); diff --git a/bin/cli-plugins b/bin/cli-plugins index b7e301fce..4f293b0c3 100755 --- a/bin/cli-plugins +++ b/bin/cli-plugins @@ -7,7 +7,8 @@ // Interface heavily inspired by the yarn package manager: // https://yarnpkg.com/ -const program = require('./commander'); +require('./util'); +const program = require('commander'); const inquirer = require('inquirer'); // Make things colorful! diff --git a/bin/cli-serve b/bin/cli-serve index 723558651..3e356d829 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -1,7 +1,7 @@ #!/usr/bin/env node -const program = require('./commander'); const util = require('./util'); +const program = require('commander'); const serve = require('../serve'); //============================================================================== diff --git a/bin/cli-settings b/bin/cli-settings index f8768046c..6ffacbeaf 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -1,10 +1,10 @@ #!/usr/bin/env node -const program = require('./commander'); +const util = require('./util'); +const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); const SettingsService = require('../services/settings'); -const util = require('./util'); // Register the shutdown criteria. util.onshutdown([() => mongoose.disconnect()]); diff --git a/bin/cli-setup b/bin/cli-setup index c7a44c453..0f14add55 100755 --- a/bin/cli-setup +++ b/bin/cli-setup @@ -4,7 +4,8 @@ * Module dependencies. */ -const program = require('./commander'); +const util = require('./util'); +const program = require('commander'); const inquirer = require('inquirer'); const mongoose = require('../services/mongoose'); const SettingModel = require('../models/setting'); @@ -12,7 +13,6 @@ const MODERATION_OPTIONS = require('../models/enum/moderation_options'); const SettingsService = require('../services/settings'); const SetupService = require('../services/setup'); const UsersService = require('../services/users'); -const util = require('./util'); const errors = require('../errors'); // Register the shutdown criteria. diff --git a/bin/cli-token b/bin/cli-token index fa5d2e350..dc1b09e6f 100755 --- a/bin/cli-token +++ b/bin/cli-token @@ -4,10 +4,10 @@ * Module dependencies. */ -const program = require('./commander'); +const util = require('./util'); +const program = require('commander'); const mongoose = require('../services/mongoose'); const TokensService = require('../services/tokens'); -const util = require('./util'); const Table = require('cli-table'); // Register the shutdown criteria. diff --git a/bin/cli-users b/bin/cli-users index 89bf667a7..71c51e44d 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -4,133 +4,36 @@ * Module dependencies. */ -const program = require('./commander'); +const util = require('./util'); +const program = require('commander'); const inquirer = require('inquirer'); +const {graphql} = require('graphql'); +const {stripIndent} = require('common-tags'); +const Table = require('cli-table'); + +// Make things colorful! +require('colors'); + +// Register the autocomplete plugin. +inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt')); + +const schema = require('../graph/schema'); +const Context = require('../graph/context'); const UsersService = require('../services/users'); const UserModel = require('../models/user'); const CommentModel = require('../models/comment'); const ActionModel = require('../models/action'); const USER_ROLES = require('../models/enum/user_roles'); const mongoose = require('../services/mongoose'); -const util = require('./util'); -const Table = require('cli-table'); const databaseVerifications = require('./verifications/database'); -const validateRequired = (msg = 'Field is required', len = 1) => (input) => { - if (input && input.length >= len) { - return true; - } - - return msg; -}; - -// Regeister the shutdown criteria. +// Register 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, - username: options.name, - roles: [] - }; - - if (options.role && 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: 'username', - message: 'Username', - filter: (username) => { - return UsersService - .isValidUsername(username) - .catch((err) => { - throw err.message; - }); - } - }, - { - name: 'roles', - message: 'User Role', - type: 'checkbox', - choices: USER_ROLES - } - ]); -} - /** - * Prompts for input and registers a user based on those. - */ -async function createUser(options) { - try { - const answers = await getUserCreateAnswers(options); - if (answers.password !== answers.confirmPassword) { - throw new Error('Passwords do not match'); - } - - const user = await UsersService.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim()); - console.log(`Created user ${user.id}.`); - - 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}.`); - }); - })); - } - - util.shutdown(); - - } catch (err) { - console.error(err); - util.shutdown(1); - } -} - -/** - * Deletes a user. + * Deletes a user and cleans up their associated verifications. */ async function deleteUser(userID) { @@ -142,23 +45,50 @@ async function deleteUser(userID) { throw new Error(`user with id ${userID} not found`); } + printUserAsTable(user); + + console.warn(stripIndent` + + This will delete the above user. + + This might take a long time if there is a lot of data, please confirm that + you want to continue. + `); + const {confirm} = await inquirer.prompt({ + type: 'confirm', + name: 'confirm', + message: 'Continue', + default: false, + }); + if (!confirm) { + return util.shutdown(); + } + + console.warn('Removing user\'s actions'); + // Remove all the user's actions. await ActionModel .where({user_id: user.id}) .setOptions({multi: true}) .remove(); + console.warn('Removing user\'s comments'); + // Remove all the user's comments. await CommentModel .where({author_id: user.id}) .setOptions({multi: true}) .remove(); + console.warn('Updating the database indexes'); + // Update the counts that might have changed. for (const verification of databaseVerifications) { await verification({fix: true, limit: Infinity, batch: 1000}); } + console.warn('Removing the user'); + // Remove the user. await user.remove(); @@ -169,146 +99,90 @@ async function deleteUser(userID) { } } +function printUserAsTable(user) { + let table = new Table({}); + + table.push( + {'ID': user.id.gray}, + {'Username': user.username}, + {'Emails': user.profiles.filter(({provider}) => provider === 'local').map(({id})=> id) + .join(', ')}, + {'Tags': user.tags ? user.tags.map(({tag: {name}}) => name) : ''}, + {'Role': user.role}, + {'Verified': user.hasVerifiedEmail}, + {'Username': user.status.username.status}, + {'Banned': user.banned}, + {'Suspension': user.suspended ? `Until ${user.status.suspension.until}` : false}, + ); + + console.log(table.toString()); +} + /** - * Changes the password for a user. + * Searches for users based on their username and email address. */ -function passwd(userID) { - inquirer.prompt([ - { - name: 'password', - message: 'Password', - type: 'password', - validate: validateRequired('Password is required') - }, - { - name: 'confirmPassword', - message: 'Confirm Password', - type: 'password', - validate: validateRequired('Confirm Password is required') +async function searchUsers() { + const ctx = Context.forSystem(); + const searchQuery = ` + query SearchUsers($value: String) { + users(query: {value: $value}) { + nodes { + id + username + role + profiles { + id + provider + } + } + } } - ]) - .then((answers) => { - if (answers.password !== answers.confirmPassword) { - throw new Error('Password mismatch'); - } + `; - return UsersService.changePassword(userID, answers.password); - }) - .then(() => { - console.log('Password changed.'); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Updates the user from the options array. - */ -function updateUser(userID, options) { - const updates = []; - - if (options.email && typeof options.email === 'string' && options.email.length > 0) { - let q = UserModel.update({ - 'id': userID, - 'profiles.provider': 'local' - }, { - $set: { - 'profiles.$.id': options.email - } - }); - - updates.push(q); - } - - if (options.name && typeof options.name === 'string' && options.name.length > 0) { - let q = UserModel.update({ - 'id': userID - }, { - $set: { - username: options.name - } - }); - - updates.push(q); - } - - Promise - .all(updates.map((q) => q.exec())) - .then(() => { - console.log(`User ${userID} updated.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Lists all the users registered in the database. - */ -function listUsers() { - UsersService - .all() - .then((users) => { - let table = new Table({ - head: [ - 'ID', - 'Username', - 'Profiles', - 'Roles', - 'Status', - 'State' - ] - }); - - users.forEach((user) => { - let state = user.disabled ? 'Disabled' : 'Enabled'; - const profile = user.profiles.find(({provider}) => provider === 'local'); - if (profile && profile.metadata && profile.metadata.confirmed_at) { - state += ', Verified'; - } else { - state += ', Unverified'; + try { + const answers = await inquirer.prompt({ + type: 'autocomplete', + name: 'userID', + message: 'Search for a user', + source: async (answers, value) => { + if (value === null) { + value = ''; } - table.push([ - user.id, - user.username, - user.profiles.map((p) => p.provider).join(', '), - user.roles.join(', '), - user.status, - state - ]); - }); + const {data, errors} = await graphql(schema, searchQuery, {}, ctx, { + value, + }); + if (errors && errors.length > 0) { + throw errors[0]; + } - console.log(table.toString()); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} + if (data.users === null) { + return []; + } -/** - * Merges two users using the specified ID's. - * @param {String} dstUserID id of the user to which is the target of the merge - * @param {String} srcUserID id of the user to which is the source of the merge - */ -function mergeUsers(dstUserID, srcUserID) { - UsersService - .mergeUsers(dstUserID, srcUserID) - .then(() => { - console.log(`User ${srcUserID} was merged into user ${dstUserID}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); + return data.users.nodes.map((user) => { + const emails = user.profiles + .filter(({provider}) => provider === 'local') + .map(({id})=> id) + .join(', '); + + return { + name: `${user.username} (${emails}) ${user.id.gray} - ${user.role.gray}`, + value: user.id, + }; + }); + } }); + + const {userID} = answers; + const user = await UserModel.findOne({id: userID}); + + printUserAsTable(user); + util.shutdown(0); + } catch (err) { + console.error(err); + util.shutdown(1); + } } /** @@ -316,127 +190,75 @@ function mergeUsers(dstUserID, srcUserID) { * @param {String} userUD id of the user to add the role to * @param {String} role the role to add */ -function addRole(userID, role) { +async function setUserRole(userID) { + try { + const {role} = await inquirer.prompt([ + { + name: 'role', + message: 'User Role', + type: 'list', + choices: USER_ROLES + } + ]); - if (USER_ROLES.indexOf(role) === -1) { - console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`); + await UsersService.setRole(userID, role); + + console.log(`Set User ${userID} to the ${role} role.`); + util.shutdown(); + } catch (err) { + console.error(err); util.shutdown(1); - return; } - UsersService - .addRoleToUser(userID, role) - .then(() => { - console.log(`Added the ${role} role to User ${userID}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Removes a role from a user - * @param {String} userUD id of the user to remove the role from - * @param {String} role the role to remove - */ -function removeRole(userID, role) { - - if (USER_ROLES.indexOf(role) === -1) { - console.error(`Role '${role}' is not supported. Supported roles are ${USER_ROLES.join(', ')}.`); - util.shutdown(1); - return; - } - - UsersService - .removeRoleFromUser(userID, role) - .then(() => { - console.log(`Removed the ${role} role from User ${userID}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Ban a user - * @param {String} userID id of the user to ban - */ -function ban(userID) { - UsersService - .setStatus(userID, 'BANNED') - .then(() => { - console.log(`Banned the User ${userID}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Unban a user - * @param {String} userUD id of the user to remove the role from - */ -function unban(userID) { - UsersService - .setStatus(userID, 'ACTIVE') - .then(() => { - console.log(`Unban the User ${userID}.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Disable a given user. - * @param {String} userID the ID of a user to disable - */ -function disableUser(userID) { - UsersService - .disableUser(userID) - .then(() => { - console.log(`User ${userID} was disabled.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); -} - -/** - * Enabled a given user. - * @param {String} userID the ID of a user to enable - */ -function enableUser(userID) { - UsersService - .enableUser(userID) - .then(() => { - console.log(`User ${userID} was enabled.`); - util.shutdown(); - }) - .catch((err) => { - console.error(err); - util.shutdown(1); - }); } /** * Verifies an email address for a user. * * @param userID the user's id - * @param email the user's email address to be verified + * @param email the user's email address to be verified, otherwise verifies the + * first email if there is one, if there are multiple, you get a + * prompt. */ -async function verify(userID, email) { +async function verifyUserEmail(userID, email) { try { + + // Get the user. + const user = await UserModel.findOne({id: userID}); + if (!user) { + throw new Error(`user with ID ${userID} cannot be found`); + } + + // Get all the user's email addresses. + const emails = user.profiles.filter(({provider}) => provider === 'local').map(({id}) => id); + if (!emails || emails.length === 0) { + throw new Error('user did not have any email addresses'); + } + + if (!email && emails.length === 1) { + + // The email wasn't passed, and there is only one option. + email = emails[0]; + } else if (!emails.includes(email)){ + + // The email passed doesn't belong to this user. + throw new Error(`user does not have the email ${email}`); + } else if (emails.length > 1) { + + // The email wasn't passed, and there is more than one choice. + const answers = await inquirer.prompt([ + { + name: 'email', + message: 'Select Email to Verify', + type: 'list', + choices: emails + } + ]); + + email = answers.email; + } + + // Verify the email. await UsersService.confirmEmail(userID, email); console.log(`User ${userID} had their email ${email} verified.`); util.shutdown(); @@ -450,77 +272,25 @@ async function verify(userID, email) { // Setting up the program command line arguments. //============================================================================== -program - .command('create') - .option('--email [email]', 'Email to use') - .option('--password [password]', 'Password to use') - .option('--name [name]', 'Name to use') - .option('--role [role]', 'Role to add') - .option('-f, --flag_mode', 'Source from flags instead of prompting') - .description('create a new user') - .action(createUser); - program .command('delete ') .description('delete a user') .action(deleteUser); program - .command('passwd ') - .description('change a password for a user') - .action(passwd); + .command('search') + .description('searches for a user based on their stored username and email') + .action(searchUsers); program - .command('update ') - .option('--email [email]', 'Email to use') - .option('--name [name]', 'Name to use') - .description('update a user') - .action(updateUser); - -program - .command('list') - .description('list all the users in the database') - .action(listUsers); - -program - .command('merge ') - .description('merge srcUser into the dstUser') - .action(mergeUsers); - -program - .command('addrole ') - .description('adds a role to a given user') - .action(addRole); - -program - .command('removerole ') - .description('removes a role from a given user') - .action(removeRole); - -program - .command('ban ') - .description('ban a given user') - .action(ban); - -program - .command('uban ') - .description('unban a given user') - .action(unban); - -program - .command('disable ') - .description('disable a given user from logging in') - .action(disableUser); - -program - .command('enable ') - .description('enable a given user from logging in') - .action(enableUser); + .command('set-role ') + .description('sets the role on a user') + .action(setUserRole); program .command('verify ') .description('verifies the given user\'s email address') - .action(verify); + .action(verifyUserEmail); program.parse(process.argv); diff --git a/bin/cli-verify b/bin/cli-verify index 6e67e0016..5a55f2bd2 100755 --- a/bin/cli-verify +++ b/bin/cli-verify @@ -4,9 +4,9 @@ * Module dependencies. */ -const program = require('./commander'); -const mongoose = require('../services/mongoose'); const util = require('./util'); +const program = require('commander'); +const mongoose = require('../services/mongoose'); const databaseVerifications = require('./verifications/database'); // Register the shutdown criteria. diff --git a/bin/commander.js b/bin/commander.js deleted file mode 100644 index 328a25b29..000000000 --- a/bin/commander.js +++ /dev/null @@ -1,51 +0,0 @@ -const pkg = require('../package.json'); -const dotenv = require('dotenv'); -const fs = require('fs'); -const program = require('commander'); - -//============================================================================== -// 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 the config flag is present, then we have to load the configuration from - * the file specified. We will then load those values into the environment. - */ -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 present, then we have to create a pid file at the location - * specified. - */ -if (parseArgs.pid) { - const util = require('./util'); - - console.log('Wrote 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'); diff --git a/bin/util.js b/bin/util.js index ab9035fe5..546607c20 100644 --- a/bin/util.js +++ b/bin/util.js @@ -1,5 +1,8 @@ + +// Setup the environment. +require('../services/env'); + const debug = require('debug')('talk:util'); -const fs = require('fs'); const util = module.exports = {}; @@ -49,46 +52,16 @@ util.onshutdown = (jobs) => { util.toshutdown = util.toshutdown.concat(jobs); }; -/** - * Register a PID file to be maintained for the lifespan of the process. - * @param {String} path path to the PID file to create - */ -util.pid = (path) => { - if (!/\//.test(path)) { - if (!/\.pid/.test(path)) { - path += '.pid'; - } - path = `/tmp/${path}`; - } - - const pid = `${process.pid.toString()}\n`; - - fs.writeFile(path, pid, (err) => { - if (err) { - console.error(`Can't write PID file: ${err}`); - throw err; - } - - // Add the cleanup for the fs onto the shutdown. - util.onshutdown([ - () => new Promise((resolve, reject) => { - - // Remove the pid file. - fs.unlink(path, (err) => { - if (err) { - return reject(err); - } - - return resolve(); - }); - }) - ]); - }); -}; - // Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the // event that we have an external event. SIGUSR2 is called when the app is asked // to be 'killed', same procedure here. process.on('SIGTERM', () => util.shutdown(0, 'SIGTERM')); process.on('SIGINT', () => util.shutdown(0, 'SIGINT')); process.once('SIGUSR2', () => util.shutdown(0, 'SIGUSR2')); + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on('unhandledRejection', (err) => { + throw err; +}); diff --git a/bin/verifications/database/action_counts.js b/bin/verifications/database/action_counts.js new file mode 100644 index 000000000..2aa47a9a7 --- /dev/null +++ b/bin/verifications/database/action_counts.js @@ -0,0 +1,180 @@ +const UserModel = require('../../../models/user'); +const CommentModel = require('../../../models/comment'); +const ActionsService = require('../../../services/actions'); +const {arrayJoinBy} = require('../../../graph/loaders/util'); +const {get} = require('lodash'); +const debug = require('debug')('talk:cli:verify'); + +const MODELS = [ + UserModel, + CommentModel, +]; + +async function processBatch(Model, documents) { + + // Get an array of all the document id's. + const documentIDs = documents.map(({id}) => id); + + // Store all the operations on this batch in this array that we'll return + // later. + const operations = []; + + // Get the action summaries for this batch. + const totalActionSummaries = await ActionsService + .getActionSummaries(documentIDs) + .then(arrayJoinBy(documentIDs, 'item_id')); + + // Iterate over the documents. + for (let i = 0; i < documents.length; i++) { + const document = documents[i]; + const actionSummaries = totalActionSummaries[i]; + + let ops = []; + + for (const actionSummary of actionSummaries) { + if (actionSummary.group_id === null) { + continue; + } + + // And we generate the group id. + const ACTION_TYPE = actionSummary.action_type.toLowerCase(); + const GROUP_ID = actionSummary.group_id.toLowerCase(); + + if (GROUP_ID.length <= 0) { + continue; + } + + // And we add a new batch operation if the action summary is associated + // with a group. + const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`; + + // Check that the action summaries match the cached counts. + if (get(document, ['action_counts', ACTION_COUNT_FIELD]) !== actionSummary.count) { + + // Batch updates for those changes. + ops.push({ + [`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count, + }); + } + } + + // Group all the action summaries together from all the different group + // ids. + const groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => { + + // action_type is already snake cased (as it would have had to be when it + // was inserted in the database). + const ACTION_TYPE = actionSummary.action_type.toLowerCase(); + + if (!(ACTION_TYPE in acc)) { + acc[ACTION_TYPE] = 0; + } + + acc[ACTION_TYPE] += actionSummary.count; + + return acc; + }, {}); + + for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) { + const count = groupedActionSummaries[ACTION_COUNT_FIELD]; + + // Check that the action summaries match the cached counts. + if (get(document, ['action_counts', ACTION_COUNT_FIELD]) !== count) { + + // Batch updates for those changes. + ops.push({ + [`action_counts.${ACTION_COUNT_FIELD}`]: count, + }); + } + } + + // If this comment has action summaries that should be updated, then + // perform an update! + if (ops.length > 0) { + operations.push({ + updateOne: { + filter: { + id: document.id + }, + update: { + $set: Object.assign({}, ...ops), + }, + }, + }); + } + } + + return operations; +} + +module.exports = async ({fix, batch}) => { + for (const Model of MODELS) { + const cursor = Model + .collection + .find({}) + .project({ + id: 1, + action_counts: 1 + }) + .sort({created_at: 1}); + + let operations = []; + let documents = []; + + // While there are documents to process. + while (await cursor.hasNext()) { + + // Load the document. + const document = await cursor.next(); + + // Push the document into the documents array. + documents.push(document); + + // Check to see if the length of the documents array requires us to + // process it. + if (documents.length > batch) { + + // Process this batch. + let batchOperations = await processBatch(Model, documents); + + // Push the batch operations into the model operations. + operations.push(...batchOperations); + + // Clear this batch contents. + documents = []; + } + } + + // Check to see if there are any documents left over. + if (documents.length > 0) { + + // Process this batch. + let batchOperations = await processBatch(Model, documents); + + // Push the batch operations into the model operations. + operations.push(...batchOperations); + } + + const OPERATIONS_LENGTH = operations.length; + + console.log(`action_counts.js: ${OPERATIONS_LENGTH} ${Model.collection.name} need their action counts fixed.`); + + // If fix was enabled, execute the batch writes. + if (OPERATIONS_LENGTH > 0) { + if (fix) { + debug(`action_counts.js: fixing ${OPERATIONS_LENGTH} ${Model.collection.name}...`); + + while (operations.length) { + let result = await Model.collection.bulkWrite(operations.splice(0, batch)); + + debug(`action_counts.js: fixed batch of ${result.modifiedCount} ${Model.collection.name}.`); + } + + console.log(`action_counts.js: applied all ${OPERATIONS_LENGTH} fixes to ${Model.collection.name}.`); + } else { + console.warn('Skipping fixing, --fix was not enabled, pass --fix to fix these errors'); + } + } + } +}; + diff --git a/bin/verifications/database/comments.js b/bin/verifications/database/comment_replies.js similarity index 61% rename from bin/verifications/database/comments.js rename to bin/verifications/database/comment_replies.js index 231badabb..03a3a4ac6 100644 --- a/bin/verifications/database/comments.js +++ b/bin/verifications/database/comment_replies.js @@ -1,7 +1,5 @@ const CommentModel = require('../../../models/comment'); -const ActionsService = require('../../../services/actions'); -const {arrayJoinBy, singleJoinBy} = require('../../../graph/loaders/util'); -const sc = require('snake-case'); +const {singleJoinBy} = require('../../../graph/loaders/util'); const debug = require('debug')('talk:cli:verify'); const getBatch = async (limit, offset) => CommentModel @@ -55,15 +53,9 @@ module.exports = async ({fix, limit, batch}) => { .then(singleJoinBy(commentIDs, '_id')) .then((results) => results.map((result) => result ? result.count : 0)); - // Get their action summaries. - let allActionSummaries = await ActionsService - .getActionSummaries(commentIDs) - .then(arrayJoinBy(commentIDs, 'item_id')); - // Loop over the comments, with their action summaries. for (let i = 0; i < comments.length; i++) { let comment = comments[i]; - let actionSummaries = allActionSummaries[i]; let replyCount = allReplyCounts[i]; // And check to see if the action summaries we just computed match what is @@ -77,61 +69,6 @@ module.exports = async ({fix, limit, batch}) => { }); } - // First we process all the group id's. - for (let actionSummary of actionSummaries) { - if (actionSummary.group_id === null) { - continue; - } - - // And we generate the group id. - const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase()); - const GROUP_ID = sc(actionSummary.group_id.toLowerCase()); - - if (GROUP_ID.length <= 0) { - continue; - } - - // And we add a new batch operation if the action summary is associated - // with a group. - const ACTION_COUNT_FIELD = `${ACTION_TYPE}_${GROUP_ID}`; - - // Check that the action summaries match the cached counts. - if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== actionSummary.count) { - - // Batch updates for those changes. - commentOperations.push({ - [`action_counts.${ACTION_COUNT_FIELD}`]: actionSummary.count, - }); - } - } - - // Group all the action summaries together from all the different group - // ids. - let groupedActionSummaries = actionSummaries.reduce((acc, actionSummary) => { - const ACTION_TYPE = sc(actionSummary.action_type.toLowerCase()); - - if (!(ACTION_TYPE in acc)) { - acc[ACTION_TYPE] = 0; - } - - acc[ACTION_TYPE] += actionSummary.count; - - return acc; - }, {}); - - for (const ACTION_COUNT_FIELD of Object.keys(groupedActionSummaries)) { - const count = groupedActionSummaries[ACTION_COUNT_FIELD]; - - // Check that the action summaries match the cached counts. - if (!comment.action_counts || !(ACTION_COUNT_FIELD in comment.action_counts) || comment.action_counts[ACTION_COUNT_FIELD] !== count) { - - // Batch updates for those changes. - commentOperations.push({ - [`action_counts.${ACTION_COUNT_FIELD}`]: count, - }); - } - } - // If this comment has action summaries that should be updated, then // perform an update! if (commentOperations.length > 0) { diff --git a/bin/verifications/database/index.js b/bin/verifications/database/index.js index 3dafeb3bf..2a599cfac 100644 --- a/bin/verifications/database/index.js +++ b/bin/verifications/database/index.js @@ -6,7 +6,8 @@ // // async ({fix = false, batch = 1000}) => {} // -// where their options are derrived. +// where their options are derived. module.exports = [ - require('./comments'), + require('./comment_replies'), + require('./action_counts'), ]; diff --git a/circle.yml b/circle.yml index 9f35736c6..ebb746669 100644 --- a/circle.yml +++ b/circle.yml @@ -7,6 +7,8 @@ machine: environment: PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin" NODE_ENV: "test" + MOCHA_FILE: "${CIRCLE_TEST_REPORTS}/junit/test-results.xml" + MOCHA_REPORTER: "mocha-junit-reporter" pre: # TODO: use the following to add in support for MongoDB 3.4. # # Upgrade the database version to 3.4. @@ -47,10 +49,11 @@ database: test: override: # Run the tests using the junit reporter. - - MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test + - yarn test + # Run the end to end tests + - yarn e2e:ci # Check dependancies using nsp. - nsp check - - yarn e2e-ci deployment: release: diff --git a/client/coral-admin/src/actions/banUserDialog.js b/client/coral-admin/src/actions/banUserDialog.js index 8b068051c..8134318cb 100644 --- a/client/coral-admin/src/actions/banUserDialog.js +++ b/client/coral-admin/src/actions/banUserDialog.js @@ -4,4 +4,3 @@ export const showBanUserDialog = ({userId, username, commentId, commentStatus}) ({type: SHOW_BAN_USER_DIALOG, userId, username, commentId, commentStatus}); export const hideBanUserDialog = () => ({type: HIDE_BAN_USER_DIALOG}); - diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 6a091d214..669ff2f95 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -7,8 +7,6 @@ import { SORT_UPDATE, SET_PAGE, SET_SEARCH_VALUE, - SET_ROLE, - SET_COMMENTER_STATUS, SHOW_BANUSER_DIALOG, HIDE_BANUSER_DIALOG, SHOW_REJECT_USERNAME_DIALOG, @@ -56,20 +54,6 @@ export const setSearchValue = (value) => ({ value, }); -export const setRole = (id, role) => (dispatch, _, {rest}) => { - return rest(`/users/${id}/role`, {method: 'POST', body: {role}}) - .then(() => { - return dispatch({type: SET_ROLE, id, role}); - }); -}; - -export const setCommenterStatus = (id, status) => (dispatch, _, {rest}) => { - return rest(`/users/${id}/status`, {method: 'POST', body: {status}}) - .then(() => { - return dispatch({type: SET_COMMENTER_STATUS, id, status}); - }); -}; - // Ban User Dialog export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user}); export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG}); diff --git a/client/coral-admin/src/actions/userDetail.js b/client/coral-admin/src/actions/userDetail.js index 3a1de5745..1d0a20def 100644 --- a/client/coral-admin/src/actions/userDetail.js +++ b/client/coral-admin/src/actions/userDetail.js @@ -3,12 +3,12 @@ import * as actions from 'constants/userDetail'; export const viewUserDetail = (userId) => ({type: actions.VIEW_USER_DETAIL, userId}); export const hideUserDetail = () => ({type: actions.HIDE_USER_DETAIL}); -export const changeUserDetailStatuses = (tab) => { +export const changeTab = (tab) => { let statuses = null; if (tab === 'rejected') { statuses = ['REJECTED']; } - return {type: actions.CHANGE_USER_DETAIL_STATUSES, tab, statuses}; + return {type: actions.CHANGE_TAB_USER_DETAIL, tab, statuses}; }; export const clearUserDetailSelections = () => ({type: actions.CLEAR_USER_DETAIL_SELECTIONS}); diff --git a/client/coral-admin/src/components/AccountHistory.css b/client/coral-admin/src/components/AccountHistory.css new file mode 100644 index 000000000..09b324f08 --- /dev/null +++ b/client/coral-admin/src/components/AccountHistory.css @@ -0,0 +1,30 @@ +.table { + flex-direction: column; +} + +.table, .headerRow, .row { + display: flex; +} + +.headerRowItem, .item { + flex: 1; + padding: 20px; + + &:nth-child(2) { + flex: 2; + } +} + +.headerRowItem { + color: #595959; + font-weight: bold; +} + +.headerRow, .row { + border-bottom: 1px solid #e0e0e0; +} + +.action { + color: black; + font-weight: bold; +} diff --git a/client/coral-admin/src/components/AccountHistory.js b/client/coral-admin/src/components/AccountHistory.js new file mode 100644 index 000000000..fd1b034d2 --- /dev/null +++ b/client/coral-admin/src/components/AccountHistory.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {murmur3} from 'murmurhash-js'; +import styles from './AccountHistory.css'; +import cn from 'classnames'; +import flatten from 'lodash/flatten'; +import orderBy from 'lodash/orderBy'; +import moment from 'moment'; + +const buildUserHistory = (userState = {}) => { + return orderBy(flatten(Object.keys(userState.status) + .filter((k) => k !== '__typename') + .map((k) => userState.status[k].history)), 'created_at', 'desc'); +}; + +const buildActionResponse = (typename, status) => { + const actionResponses = { + 'UsernameStatusHistory' : `Username Status: ${status}`, + 'BannedStatusHistory': status ? 'User banned' : 'Ban removed', + 'SuspensionStatusHistory': status ? 'Account Suspended' : 'Suspension removed' + }; + + return actionResponses[typename]; +}; + +class AccountHistory extends React.Component { + render() { + const {userState} = this.props; + const userHistory = buildUserHistory(userState); + return ( +
+
+
+
Date
+
Action
+
Moderation
+
+ { + userHistory.map((h) => ( +
+
+ {moment(new Date(h.created_at)).format('MMM DD, YYYY')} +
+
+ {buildActionResponse(h.__typename, h.status)} +
+
+ {h.assigned_by ? h.assigned_by.username : 'SYSTEM'} +
+
+ )) + } +
+
+ ); + } +} + +AccountHistory.propTypes = { + history: PropTypes.array, + userState: PropTypes.object, +}; + +export default AccountHistory; diff --git a/client/coral-admin/src/components/ActionsMenu.css b/client/coral-admin/src/components/ActionsMenu.css index 1d2f91f74..c8638423f 100644 --- a/client/coral-admin/src/components/ActionsMenu.css +++ b/client/coral-admin/src/components/ActionsMenu.css @@ -8,9 +8,6 @@ color: black; > :global(.mdl-menu__container) { margin-left: 10px; - > :global(.mdl-menu__outline) { - box-shadow: none; - } } } @@ -18,12 +15,13 @@ box-shadow: none; color: white; background-color: #616161; + border-color: #616161; } .arrowIcon { margin-left: 6px; margin-right: 0; - vertical-align: middle; + vertical-align: middle; margin-right: 0; font-size: 14px; } @@ -33,8 +31,10 @@ } .menuItem { - background-color: #2a2a2a; - color: white; + color: #2a2a2a; + background-color: white; + font-size: 0.95em; + &:first-child { margin-bottom: 1px; border-radius: 2px 2px 0px 0px; @@ -43,7 +43,8 @@ border-radius: 0px 0px 2px 2px; } &:hover, &:active, &:focus { - background-color: #767676; + background-color: #e2e2e2; + border-color: #616161; } &[disabled], &[disabled]:hover, &[disabled]:focus, &[disabled]:active { background-color: #262626; diff --git a/client/coral-admin/src/components/ActionsMenu.js b/client/coral-admin/src/components/ActionsMenu.js index 6351afda7..ef4e46573 100644 --- a/client/coral-admin/src/components/ActionsMenu.js +++ b/client/coral-admin/src/components/ActionsMenu.js @@ -32,18 +32,18 @@ class ActionsMenu extends React.Component { }; render() { - const {className = ''} = this.props; + const {className = '', buttonClassNames = '', label = ''} = this.props; return (
- -
- -); +const initialState = {step: 0, message: ''}; + +class BanUserDialog extends React.Component { + + state = initialState; + + componentWillReceiveProps(next) { + if (this.props.open && !next.open) { + this.setState(initialState); + } + } + + handleMessageChange = (e) => { + const {value: message} = e; + this.setState({message}); + } + + goToStep1 = () => { + this.setState({ + step: 1, + message: t( + 'bandialog.email_message_ban', + this.props.username, + ), + }); + } + + renderStep0() { + const { + onCancel, + username, + info, + } = this.props; + + return ( +
+

+ {t('bandialog.ban_user')} +

+

+ {t('bandialog.are_you_sure', username)} +

+

+ {info} +

+
+ + +
+
+ ); + } + + renderStep1() { + const { + onCancel, + onPerform, + } = this.props; + const {message} = this.state; + + return ( +
+

+ {t('bandialog.notify_ban_headline')} +

+

+ {t('bandialog.notify_ban_description')} +

+
+ {t('bandialog.write_a_message')} +