Merge branch 'user-status-refactor' of github.com:coralproject/talk into mod-improv

This commit is contained in:
Belen Curcio
2017-11-14 13:54:21 -03:00
83 changed files with 2758 additions and 1637 deletions
+1
View File
@@ -52,3 +52,4 @@ plugins/*
!plugins/talk-plugin-slack-notifications
**/node_modules/*
yarn-error.log
+7 -1
View File
@@ -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"
"ext": "js,json,graphql",
"watch": [
".",
"bin/cli",
"bin/cli-serve"
]
}
+9 -100
View File
@@ -106,20 +106,17 @@ async function createUser(options) {
}
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}.`);
});
}));
for (const role of answers.roles) {
await UsersService.addRoleToUser(user.id, role);
}
}
util.shutdown();
await UsersService.sendEmailConfirmation(user, answers.email.trim());
console.log(`Created User ${user.id}.`);
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown();
@@ -241,12 +238,12 @@ function listUsers() {
});
users.forEach((user) => {
let state = user.disabled ? 'Disabled' : 'Enabled';
const profile = user.profiles.find(({provider}) => provider === 'local');
let state;
if (profile && profile.metadata && profile.metadata.confirmed_at) {
state += ', Verified';
state = 'Verified';
} else {
state += ', Unverified';
state = 'Unverified';
}
table.push([
@@ -336,74 +333,6 @@ function removeRole(userID, role) {
});
}
/**
* 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.
*
@@ -472,26 +401,6 @@ program
.description('removes a role from a given user')
.action(removeRole);
program
.command('ban <userID>')
.description('ban a given user')
.action(ban);
program
.command('uban <userID>')
.description('unban a given user')
.action(unban);
program
.command('disable <userID>')
.description('disable a given user from logging in')
.action(disableUser);
program
.command('enable <userID>')
.description('enable a given user from logging in')
.action(enableUser);
program
.command('verify <userID> <email>')
.description('verifies the given user\'s email address')
+185
View File
@@ -0,0 +1,185 @@
const UserModel = require('../../../models/user');
const CommentModel = require('../../../models/comment');
const ActionsService = require('../../../services/actions');
const {arrayJoinBy} = require('../../../graph/loaders/util');
const sc = require('snake-case');
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 = 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 (
!document.action_counts ||
!(ACTION_COUNT_FIELD in document.action_counts) ||
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.
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 (
!document.action_counts ||
!(ACTION_COUNT_FIELD in document.action_counts) ||
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');
}
}
}
};
@@ -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) {
+3 -2
View File
@@ -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'),
];
+5 -2
View File
@@ -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:
+1 -1
View File
@@ -14,7 +14,7 @@ export default withQuery(gql`
})
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
statuses: [SET, CHANGED]
})
}
`, {
@@ -22,7 +22,7 @@ const withData = withQuery(gql`
query TalkAdmin_Community {
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
statuses: [SET, CHANGED]
})
...${getDefinitionName(FlaggedAccounts.fragments.root)}
...${getDefinitionName(FlaggedUser.fragments.root)}
@@ -26,4 +26,4 @@ CloseCommentsInfo.propTypes = {
onClick: PropTypes.func,
};
export default CloseCommentsInfo;
export default CloseCommentsInfo;
-21
View File
@@ -1,21 +0,0 @@
.bio textarea {
width: 100%;
box-sizing: border-box;
border-radius: 2px;
min-height: 100px;
margin: 10px 0;
border: solid 1px #d8d8d8;
}
.bio h1 {
font-size: 16px;
margin: 3px 0;
}
.bio p {
margin: 3px 0;
}
.actions {
text-align: right;
}
-18
View File
@@ -1,18 +0,0 @@
import React from 'react';
import styles from './Bio.css';
import {Button} from '../../coral-ui';
export default ({bio, handleSave, handleInput, handleCancel}) => (
<div className={styles.bio}>
<h1>Bio</h1>
<p>Tell the community about yourself</p>
<form>
<textarea value={bio} onChange={handleInput} />
<div className={styles.actions}>
<Button cStyle='cancel' type="button" onClick={handleCancel} raised>Cancel</Button>
<Button cStyle='success' type="submit" onClick={handleSave}>Save Changes</Button>
</div>
</form>
</div>
);
@@ -1,45 +0,0 @@
import React, {Component} from 'react';
import Bio from '../components/Bio';
export default class BioContainer extends Component {
constructor (props) {
super(props);
this.state = {
bio: props.bio
};
this.handleSave = this.handleSave.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleInput(e) {
this.setState({
bio: e.target.value
});
}
handleSave (e) {
e.preventDefault();
const {userData, saveBio} = this.props;
const {bio} = this.state;
saveBio(userData.id, {bio});
}
handleCancel () {
this.setState({
bio: this.props.bio
});
}
render () {
return <Bio
bio={this.state.bio}
userData={this.props.userData}
handleSave={this.handleSave}
handleInput={this.handleInput}
handleCancel={this.handleCancel}
/>;
}
}
@@ -7,6 +7,7 @@ import {PopupMenu, Button} from 'coral-ui';
import ClickOutside from 'coral-framework/components/ClickOutside';
import cn from 'classnames';
import styles from './styles.css';
import * as REASONS from '../helpers/flagReasons';
import {getErrorMessages} from 'coral-framework/utils';
@@ -90,10 +91,9 @@ export default class FlagButton extends Component {
let action = {
item_id,
item_type: itemType,
reason: null,
message
};
if (reason === 'COMMENT_NOAGREE') {
if (reason === REASONS.comment.noagree) {
postDontAgree(action)
.then(({data}) => {
if (itemType === 'COMMENTS') {
@@ -122,7 +122,7 @@ export default class FlagButton extends Component {
onPopupOptionClick = (sets) => (e) => {
// If flagging a user, indicate that this is referencing the username rather than the bio
if(sets === 'itemType' && e.target.value === 'users') {
if (sets === 'itemType' && e.target.value === 'users') {
this.setState({field: 'username'});
}
+1 -1
View File
@@ -244,4 +244,4 @@ module.exports = {
ErrSpecialChars,
ErrUsernameTaken,
ExtendableError,
};
};
+16 -2
View File
@@ -1,12 +1,26 @@
const DataLoader = require('dataloader');
const TagsService = require('../../services/tags');
const plugins = require('../../services/plugins');
const debug = require('debug')('talk:graph:loaders:tags');
const PLUGIN_TAGS = plugins.get('server', 'tags').reduce((acc, {plugin, tags}) => {
debug(`added plugin '${plugin.name}'`);
acc = acc.concat(tags);
return acc;
}, []);
/**
* Get all the tags for the context for the dataloader.
*/
const genAll = (context, queries) => {
return Promise.all(queries.map(({id, item_type, asset_id}) => {
return TagsService.getAll({id, item_type, asset_id});
return Promise.all(queries.map(async ({id, item_type, asset_id}) => {
let tags = await TagsService.getAll({id, item_type, asset_id});
// Merge in the global plugin tags as well.
tags = tags.concat(PLUGIN_TAGS);
return tags;
}));
};
+67 -25
View File
@@ -1,7 +1,7 @@
const DataLoader = require('dataloader');
const util = require('./util');
const union = require('lodash/union');
const sc = require('snake-case');
const {
SEARCH_OTHER_USERS,
@@ -10,6 +10,47 @@ const {
const UsersService = require('../../services/users');
const UserModel = require('../../models/user');
const mergeState = (query, state) => {
const {status} = state;
if (status) {
const {username, banned, suspended} = status;
if (typeof username !== 'undefined' && username && username.length > 0) {
query.merge({
'status.username.status': {
$in: username
}
});
}
if (typeof banned !== 'undefined' && banned !== null) {
query.merge({
'status.banned.status': banned
});
}
if (typeof suspended !== 'undefined' && suspended !== null) {
if (suspended) {
query.merge({
'status.suspension.until': {
$gte: Date.now()
}
});
} else {
query.merge({
$or: [
{'status.suspension.until': null},
{'status.suspension.until': {
$lt: Date.now()
}}
]
});
}
}
}
};
const genUserByIDs = async (context, ids) => {
if (!ids || ids.length === 0) {
return [];
@@ -31,23 +72,24 @@ const genUserByIDs = async (context, ids) => {
* @param {Object} context graph context
* @param {Object} query query terms to apply to the users query
*/
const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor, statuses, action_type, sortOrder}) => {
const getUsersByQuery = async ({user}, {ids, limit, cursor, state, action_type, sortOrder}) => {
let query = UserModel.find();
if (action_type || statuses) {
if (action_type || state) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
if (statuses) {
query = query.where({
status: {
$in: statuses
if (state) {
mergeState(query, state);
}
if (action_type) {
query.merge({
[`action_counts.${sc(action_type.toLowerCase())}`]: {
$gt: 0
}
});
} else {
const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'});
ids = ids ? union(ids, userIds) : userIds;
}
}
@@ -115,25 +157,25 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor,
* @return {Promise} resolves to the counts of the users from the
* query
*/
const getCountByQuery = async ({loaders: {Actions}}, {action_type, statuses}) => {
const getCountByQuery = async ({user}, {action_type, state}) => {
let query = UserModel.find();
if (action_type) {
const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'});
if (action_type || state) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
query = query.find({
id: {
$in: userIds
}
});
}
if (state) {
mergeState(query, state);
}
if (statuses) {
query = query.where({
status: {
$in: statuses
}
});
if (action_type) {
query.merge({
[`action_counts.${sc(action_type.toLowerCase())}`]: {
$gt: 0
}
});
}
}
return UserModel
+60 -36
View File
@@ -1,27 +1,57 @@
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
const errors = require('../../errors');
const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
/**
* getActionItem will return the item that is associated with the given action.
* If it does not exist, it will throw an error.
*
* @param {Object} ctx the graphql context for the request
* @param {Object} action the action being performed
* @return {Promise} resolves to the referenced item
*/
const getActionItem = async ({loaders: {Comments, Users}}, {item_id, item_type}) => {
if (item_type === 'COMMENTS') {
const comment = await Comments.get.load(item_id);
if (!comment) {
throw errors.ErrNotFound;
}
return comment;
} else if (item_type === 'USERS') {
const user = await Users.getByID.load(item_id);
if (!user) {
throw errors.ErrNotFound;
}
return user;
}
};
/**
* Creates an action on a item. If the item is a user flag, sets the user's status to
* pending.
* @param {Object} user the user performing the request
* @param {String} item_id id of the item to add the action to
* @param {String} item_type type of the item
* @param {String} action_type type of the action
* @return {Promise} resolves to the action created
*
* @param {Object} ctx the graphql context for the request
* @param {Object} action the action being created
* @return {Promise} resolves to the action created
*/
const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id, item_type, action_type, group_id, metadata = {}}) => {
const createAction = async (ctx, {item_id, item_type, action_type, group_id, metadata = {}}) => {
const {user = {}, pubsub} = ctx;
let comment;
if (item_type === 'COMMENTS') {
comment = await Comments.get.load(item_id);
if (!comment) {
throw new Error('Comment not found');
// Gets the item referenced by the action.
const item = await getActionItem(ctx, {item_id, item_type});
if (action_type === 'FLAG' && item_type === 'USERS') {
// The item is a user, and this is a flag. Check to see if they are staff,
// if they are, don't permit the flag.
if (item.isStaff()) {
throw errors.ErrNotAuthorized;
}
}
// Create the action itself.
let action = await ActionsService.create({
item_id,
item_type,
@@ -31,17 +61,11 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id,
metadata
});
if (item_type === 'USERS' && action_type === 'FLAG') {
if (action_type === 'FLAG' && item_type === 'COMMENTS') {
// Set the user as pending if it was a user flag and user has no Admin, Staff or Moderation roles
let user = await UsersService.findById(item_id);
if(!user.isStaff()){
await UsersService.setStatus(item_id, 'PENDING');
}
}
if (comment) {
pubsub.publish('commentFlagged', comment);
// The item is a comment, and this is a flag. Push that the comment was
// flagged, don't wait for it to finish.
pubsub.publish('commentFlagged', item);
}
return action;
@@ -49,28 +73,28 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id,
/**
* Deletes an action based on the user id if the user owns that action.
*
* @param {Object} user the user performing the request
* @param {String} id the id of the action to delete
* @return {Promise} resolves to the deleted action, or null if not found.
*/
const deleteAction = ({user}, {id}) => {
return ActionsService.delete({id, user_id: user.id});
};
const deleteAction = ({user}, {id}) => ActionsService.delete({id, user_id: user.id});
module.exports = (context) => {
if (context.user && context.user.can(CREATE_ACTION, DELETE_ACTION)) {
return {
Action: {
create: (action) => createAction(context, action),
delete: (action) => deleteAction(context, action)
}
};
}
return {
module.exports = (ctx) => {
const mutators = {
Action: {
create: () => Promise.reject(errors.ErrNotAuthorized),
delete: () => Promise.reject(errors.ErrNotAuthorized)
}
};
if (ctx.user && ctx.user.can(CREATE_ACTION)) {
mutators.Action.create = (action) => createAction(ctx, action);
}
if (ctx.user && ctx.user.can(DELETE_ACTION)) {
mutators.Action.delete = (action) => deleteAction(ctx, action);
}
return mutators;
};
+7 -25
View File
@@ -1,15 +1,12 @@
const errors = require('../../errors');
const ActionModel = require('../../models/action');
const AssetsService = require('../../services/assets');
const ActionsService = require('../../services/actions');
const TagsService = require('../../services/tags');
const CommentsService = require('../../services/comments');
const KarmaService = require('../../services/karma');
const tlds = require('tlds');
const merge = require('lodash/merge');
const linkify = require('linkify-it')()
.tlds(tlds);
const linkify = require('linkify-it')().tlds(require('tlds'));
const Wordlist = require('../../services/wordlist');
const {
CREATE_COMMENT,
@@ -17,21 +14,8 @@ const {
ADD_COMMENT_TAG,
EDIT_COMMENT
} = require('../../perms/constants');
const {
DISABLE_AUTOFLAG_SUSPECT_WORDS
} = require('../../config');
const debug = require('debug')('talk:graph:mutators:tags');
const plugins = require('../../services/plugins');
const pluginTags = plugins.get('server', 'tags').reduce((acc, {plugin, tags}) => {
debug(`added plugin '${plugin.name}'`);
acc = acc.concat(tags);
return acc;
}, []);
const debug = require('debug')('talk:graph:mutators:comment');
const {DISABLE_AUTOFLAG_SUSPECT_WORDS} = require('../../config');
const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags = []}) => {
const item_type = 'COMMENTS';
@@ -48,8 +32,6 @@ const resolveTagsForComment = async ({user, loaders: {Tags}}, {asset_id, tags =
globalTags = [];
}
globalTags = globalTags.concat(pluginTags);
// Merge in the tags for the given comment.
tags = tags.map((name) => {
@@ -285,14 +267,14 @@ const moderationPhases = [
actions: [{
action_type: 'FLAG',
user_id: null,
group_id: 'Matched suspect word filter',
group_id: 'SUSPECT_WORD',
metadata: {}
}],
};
}
},
// This phase checks to see if the comment's length exeeds maximum.
// This phase checks to see if the comment's length exceeds maximum.
(context, comment, {assetSettings: {charCountEnable, charCount}}) => {
// Reject if the comment is too long
@@ -320,7 +302,7 @@ const moderationPhases = [
// Add the flag related to Trust to the comment.
return {
status:'SYSTEM_WITHHELD',
status: 'SYSTEM_WITHHELD',
actions: [{
action_type: 'FLAG',
user_id: null,
@@ -362,7 +344,7 @@ const moderationPhases = [
}
},
// This phase checks to see if the comment was already perscribed a status.
// This phase checks to see if the comment was already prescribed a status.
(context, comment) => {
// If the status was already defined, don't redefine it. It's only defined
+1 -1
View File
@@ -11,7 +11,7 @@ const modify = async ({user, loaders: {Tags}}, operation, {name, id, item_type,
const tags = await Tags.getAll.load({id, item_type, asset_id});
// Resolve the TagLink that should be used to insert to the user. This will
// addtionally return with an ownership property that can be used to determine
// additionally return with an ownership property that can be used to determine
// that the user who adds this tag must also be the owner of the resource.
let {tagLink, ownership} = TagsService.resolveLink(user, tags, {name, item_type});
+58 -30
View File
@@ -1,29 +1,34 @@
const errors = require('../../errors');
const UsersService = require('../../services/users');
const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants');
const {
CHANGE_USERNAME,
SET_USERNAME,
SET_USER_USERNAME_STATUS,
SET_USER_BAN_STATUS,
SET_USER_SUSPENSION_STATUS,
} = require('../../perms/constants');
const setUserStatus = async ({pubsub}, {id, status}) => {
const result = await UsersService.setStatus(id, status);
if (result && result.status === 'BANNED') {
pubsub.publish('userBanned', result);
const setUserUsernameStatus = async (ctx, id, status) => {
const user = await UsersService.setUsernameStatus(id, status, ctx.user.id);
if (status === 'REJECTED') {
ctx.pubsub.publish('usernameRejected', user);
} else if (status === 'APPROVED') {
ctx.pubsub.publish('usernameApproved', user);
}
return result;
};
const suspendUser = async ({pubsub}, {id, message, until}) => {
const result = await UsersService.suspendUser(id, message, until);
if (result) {
pubsub.publish('userSuspended', result);
const setUserBanStatus = async (ctx, id, status) => {
const user = await UsersService.setBanStatus(id, status, ctx.user.id);
if (user.banned) {
ctx.pubsub.publish('userBanned', user);
}
return result;
};
const rejectUsername = async ({pubsub}, {id, message}) => {
const result = await UsersService.rejectUsername(id, message);
if (result) {
pubsub.publish('usernameRejected', result);
const setUserSuspensionStatus = async (ctx, id, until) => {
const user = await UsersService.setSuspensionStatus(id, until, ctx.user.id);
if (user.suspended) {
ctx.pubsub.publish('userSuspended', user);
}
return result;
};
const ignoreUser = ({user}, userToIgnore) => {
@@ -34,27 +39,50 @@ const stopIgnoringUser = ({user}, userToStopIgnoring) => {
return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
};
module.exports = (context) => {
const changeUsername = async (ctx, id, username) => {
return UsersService.changeUsername(id, username);
};
const setUsername = async (ctx, id, username) => {
return UsersService.setUsername(id, username);
};
module.exports = (ctx) => {
let mutators = {
User: {
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
suspendUser: () => Promise.reject(errors.ErrNotAuthorized),
rejectUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: (action) => ignoreUser(context, action),
stopIgnoringUser: (action) => stopIgnoringUser(context, action),
ignoreUser: () => Promise.reject(errors.ErrNotAuthorized),
changeUsername: () => Promise.reject(errors.ErrNotAuthorized),
setUsername: () => Promise.reject(errors.ErrNotAuthorized),
stopIgnoringUser: () => Promise.reject(errors.ErrNotAuthorized),
setUserUsernameStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserBanStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserSuspensionStatus: () => Promise.reject(errors.ErrNotAuthorized),
}
};
if (context.user && context.user.can(SET_USER_STATUS)) {
mutators.User.setUserStatus = (action) => setUserStatus(context, action);
}
if (ctx.user) {
mutators.User.ignoreUser = (action) => ignoreUser(ctx, action);
mutators.User.stopIgnoringUser = (action) => stopIgnoringUser(ctx, action);
if (context.user && context.user.can(SUSPEND_USER)) {
mutators.User.suspendUser = (action) => suspendUser(context, action);
}
if (ctx.user.can(CHANGE_USERNAME)) {
mutators.User.changeUsername = (id, username) => changeUsername(ctx, id, username);
}
if (context.user && context.user.can(REJECT_USERNAME)) {
mutators.User.rejectUsername = (action) => rejectUsername(context, action);
if (ctx.user.can(SET_USERNAME)) {
mutators.User.setUsername = (id, username) => setUsername(ctx, id, username);
}
if (ctx.user.can(SET_USER_USERNAME_STATUS)) {
mutators.User.setUserUsernameStatus = (id, status) => setUserUsernameStatus(ctx, id, status);
}
if (ctx.user.can(SET_USER_BAN_STATUS)) {
mutators.User.setUserBanStatus = (id, status) => setUserBanStatus(ctx, id, status);
}
if (ctx.user.can(SET_USER_SUSPENSION_STATUS)) {
mutators.User.setUserSuspensionStatus = (id, until) => setUserSuspensionStatus(ctx, id, until);
}
}
return mutators;
+1 -7
View File
@@ -1,9 +1,3 @@
const DontAgreeAction = {
// Stored in the metadata, extract and return.
reason({metadata: {reason}}) {
return reason;
}
};
const DontAgreeAction = {};
module.exports = DontAgreeAction;
+1 -5
View File
@@ -1,7 +1,3 @@
const DontAgreeActionSummary = {
reason({group_id}) {
return group_id;
}
};
const DontAgreeActionSummary = {};
module.exports = DontAgreeActionSummary;
+17 -8
View File
@@ -13,20 +13,29 @@ const RootMutation = {
createFlag: async (_, {flag: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({
flag: Action.create({item_id, item_type, action_type: 'FLAG', group_id: reason, metadata: {message}}),
}),
createDontAgree: async (_, {dontagree: {item_id, item_type, reason, message}}, {mutators: {Action}}) => ({
dontagree: await Action.create({item_id, item_type, action_type: 'DONTAGREE', group_id: reason, metadata: {message}}),
createDontAgree: async (_, {dontagree: {item_id, item_type, message}}, {mutators: {Action}}) => ({
dontagree: await Action.create({item_id, item_type, action_type: 'DONTAGREE', metadata: {message}}),
}),
deleteAction: async (_, {id}, {mutators: {Action}}) => {
await Action.delete({id});
},
setUserStatus: async (_, {id, status}, {mutators: {User}}) => {
await User.setUserStatus({id, status});
approveUsername: async (_, {id}, {mutators: {User}}) => {
await User.setUserUsernameStatus(id, 'APPROVED');
},
suspendUser: async (_, {input: {id, message, until}}, {mutators: {User}}) => {
await User.suspendUser({id, message, until});
rejectUsername: async (_, {id}, {mutators: {User}}) => {
await User.setUserUsernameStatus(id, 'REJECTED');
},
rejectUsername: async (_, {input: {id, message}}, {mutators: {User}}) => {
await User.rejectUsername({id, message});
changeUsername: async (_, {id, username}, {mutators: {User}}) => {
await User.changeUsername(id, username);
},
setUsername: async (_, {id, username}, {mutators: {User}}) => {
await User.setUsername(id, username);
},
setUserSuspensionStatus: async (_, {input: {id, until}}, {mutators: {User}}) => {
await User.setUserSuspensionStatus(id, until);
},
setUserBanStatus: async (_, {input: {id, status}}, {mutators: {User}}) => {
await User.setUserBanStatus(id, status);
},
ignoreUser: async (_, {id}, {mutators: {User}}) => {
await User.ignoreUser({id});
+6 -7
View File
@@ -6,7 +6,6 @@ const {
SEARCH_OTHERS_COMMENTS,
UPDATE_USER_ROLES,
SEARCH_COMMENT_METRICS,
VIEW_SUSPENSION_INFO,
LIST_OWN_TOKENS
} = require('../../perms/constants');
@@ -84,12 +83,12 @@ const User = {
}
},
suspension({id, suspension}, _, {user}) {
if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) {
return null;
}
return suspension;
}
// suspension({id, suspension}, _, {user}) {
// if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) {
// return null;
// }
// return suspension;
// }
};
// Decorate the User type resolver with a tags field.
+106 -114
View File
@@ -7,131 +7,123 @@ const {
SUBSCRIBE_ALL_USER_SUSPENDED,
SUBSCRIBE_ALL_USER_BANNED,
SUBSCRIBE_ALL_USERNAME_REJECTED,
SUBSCRIBE_ALL_USERNAME_APPROVED,
} = require('../perms/constants');
const merge = require('lodash/merge');
const debug = require('debug')('talk:graph:setupFunctions');
const plugins = require('../services/plugins');
const setupFunctions = {
commentAdded: (options, args, comment, context) => {
// Only privileged users can subscribe to all assets.
if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))) {
return false;
}
// If user subscribes for statuses other than NONE and/or ACCEPTED statuses, it needs
// special privileges.
if (
(!args.statuses || args.statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))
) {
return false;
}
if (args.asset_id && comment.asset_id !== args.asset_id) {
return false;
}
if (args.statuses && !args.statuses.includes(comment.status)) {
return false;
}
return true;
},
commentEdited: (options, args, comment, context) => {
if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_EDITED))) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
},
commentFlagged: (options, args, comment, context) => {
if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGGED)) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
},
commentAccepted: (options, args, comment, context) => {
if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_ACCEPTED)) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
},
commentRejected: (options, args) => (comment, context) => {
if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_REJECTED)) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
},
userSuspended: (options, args, user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_SUSPENDED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
},
userBanned: (options, args, user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_BANNED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
},
usernameRejected: (options, args, user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USERNAME_REJECTED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
},
usernameApproved: (options, args, user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USERNAME_APPROVED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
},
};
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
* as provide new ones.
* plugin based ones. This allows plugins to extend existing setupFunctions as
* well as provide new ones. We'll remap our internal representation of the
* setupFunctions into the format needed by Apollo.
*/
const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plugin, setupFunctions}) => {
module.exports = plugins.get('server', 'setupFunctions').reduce((acc, {plugin, setupFunctions}) => {
debug(`added plugin '${plugin.name}'`);
return merge(acc, setupFunctions);
}, {
commentAdded: (options, args) => ({
commentAdded: {
filter: (comment, context) => {
}, Object.keys(setupFunctions).map((key) => {
const filter = setupFunctions[key];
// Only priviledged users can subscribe to all assets.
if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))) {
return false;
}
// If user scubsscribes for statuses other than NONE and/or ACCEPTED statuses, it needs
// special priviledges.
if (
(!args.statuses || args.statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))
) {
return false;
}
if (args.asset_id && comment.asset_id !== args.asset_id) {
return false;
}
if (args.statuses && !args.statuses.includes(comment.status)) {
return false;
}
return true;
return {
[key]: (options, args) => ({
[key]: {
filter: (user, ctx) => filter(options, args, user, ctx)
}
},
}),
commentEdited: (options, args) => ({
commentEdited: {
filter: (comment, context) => {
if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_EDITED))) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
}
},
}),
commentFlagged: (options, args) => ({
commentFlagged: {
filter: (comment, context) => {
if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGGED)) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
}
},
}),
commentAccepted: (options, args) => ({
commentAccepted: {
filter: (comment, context) => {
if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_ACCEPTED)) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
}
},
}),
commentRejected: (options, args) => ({
commentRejected: {
filter: (comment, context) => {
if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_REJECTED)) {
return false;
}
return !args.asset_id || comment.asset_id === args.asset_id;
}
},
}),
userSuspended: (options, args) => ({
userSuspended: {
filter: (user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_SUSPENDED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
}
},
}),
userBanned: (options, args) => ({
userBanned: {
filter: (user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USER_BANNED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
}
},
}),
usernameRejected: (options, args) => ({
usernameRejected: {
filter: (user, context) => {
if (
!context.user
|| args.user_id !== user.id && !context.user.can(SUBSCRIBE_ALL_USERNAME_REJECTED)
) {
return false;
}
return !args.user_id || user.id === args.user_id;
}
},
}),
});
module.exports = setupFunctions;
})
};
})
.reduce((setupFunction, setupFunctions) => {
return merge(setupFunctions, setupFunction);
}, {}));
+195 -68
View File
@@ -64,8 +64,99 @@ type UserProfile {
provider: String!
}
type SuspensionInfo {
# USER_STATUS_USERNAME is the different states that a username can be in.
enum USER_STATUS_USERNAME {
# UNSET is used when the username can be changed, and does not necessarily
# require moderator action to become active. This can be used when the user
# signs up with a social login and has the option of setting their own
# username.
UNSET
# SET is used when the username has been set for the first time, but cannot
# change without the username being rejected by a moderator and that moderator
# agreeing that the username should be allowed to change.
SET
# APPROVED is used when the username was changed, and subsequently approved by
# said moderator.
APPROVED
# REJECTED is used when the username was changed, and subsequently rejected by
# said moderator.
REJECTED
# CHANGED is used after a user has changed their username after it was
# rejected.
CHANGED
}
# UserStatusInput describes the queryable components of the UserStatus.
input UserStatusInput {
# username will restrict the returned users to only those with the given
# username status's. If not provided, no filtering will be performed.
username: [USER_STATUS_USERNAME!]
# banned will restrict the returned users to only those that are, or are not
# banned. If not provided, no filtering will be performed.
banned: Boolean
# suspended will restrict the returned users to only those that are, or are not
# suspended. If not provided, no filtering will be performed.
suspended: Boolean
}
type UsernameStatusHistory {
status: USER_STATUS_USERNAME!
assigned_by: User
created_at: Date!
}
type UsernameStatus {
status: USER_STATUS_USERNAME!
history: [UsernameStatusHistory!]
}
type BannedStatusHistory {
status: Boolean!
assigned_by: User
created_at: Date!
}
type BannedStatus {
status: Boolean!
history: [BannedStatusHistory!]
}
type SuspensionStatusHistory {
until: Date
assigned_by: User
created_at: Date!
}
type SuspensionStatus {
until: Date
history: [SuspensionStatusHistory!]
}
type UserStatus {
# username is the status of the username.
username: UsernameStatus!
# banned is the bool that determines if the user is banned or not.
banned: BannedStatus!
# suspension is the date that the user is suspended until.
suspension: SuspensionStatus!
}
input UserStateInput {
status: UserStatusInput
}
# UserState describes the different permission based details for a user.
type UserState {
# status describes the statuses of different aspects of the user's details.
status: UserStatus
}
# Any person who can author comments, create actions, and view comments on a
@@ -96,9 +187,6 @@ type User {
# the tags on the user
tags: [TagLink!]
# determines whether the user can edit their username
canEditName: Boolean
# ignored users.
ignoredUsers: [User!]
@@ -114,11 +202,7 @@ type User {
reliable: Reliability
# returns user status
status: USER_STATUS
# returns suspension info. Only available to Admins and Moderators
# or on own logged in User.
suspension: SuspensionInfo
state: UserState
}
# UserConnection represents a paginable subset of a user list.
@@ -143,8 +227,7 @@ input UsersQuery {
# Users returned will only be ones which have at least one action of this.
action_type: ACTION_TYPE
# Current status of a user..
statuses: [USER_STATUS!]
state: UserStateInput
# Limit the number of results to be returned.
limit: Int = 10
@@ -261,7 +344,7 @@ enum ACTION_TYPE {
# CommentsQuery allows the ability to query comments by a specific methods.
input CommentsQuery {
# Author of the commente
# Author of the comments.
author_id: ID
# Current status of a comment.
@@ -351,8 +434,8 @@ input UserCountQuery {
# type.
action_type: ACTION_TYPE
# Current status of a user.
statuses: [USER_STATUS]
# state queries for a specific subset of users with the given state query.
state: UserStateInput
}
type EditInfo {
@@ -518,6 +601,36 @@ type FlagAssetActionSummary implements AssetActionSummary {
actionableItemCount: Int
}
enum FLAG_REASON {
# The current user thinks that the flagged username is offensive.
USERNAME_OFFENSIVE
# The current user does not like the flagged username.
USERNAME_NOLIKE
# The current user thinks that the flagged username is being used to
# impersonate another user.
USERNAME_IMPERSONATING
# The current user thinks that the flagged username is spam.
USERNAME_SPAM
# The current user thinks that the flagged username is wrong for another
# reason.
USERNAME_OTHER
# The current user thinks that the flagged comment is offensive.
COMMENT_OFFENSIVE
# The current user thinks that the flagged comment is spam.
COMMENT_SPAM
# The current user thinks that the flagged comment is wrong for another
# reason.
COMMENT_OTHER
}
# A FLAG action that contains flag metadata.
type FlagAction implements Action {
@@ -546,9 +659,6 @@ type DontAgreeAction implements Action {
# The ID of the DontAgree Action.
id: ID!
# The reason for which the DontAgree Action was created.
reason: String
# An optional message sent with the flagging action by the user.
message: String
@@ -581,9 +691,6 @@ type DontAgreeActionSummary implements ActionSummary {
# The total count of flags with this reason.
count: Int!
# The reason for which the Flag Action was created.
reason: String
# The don't agree action by the current user against the parent entity with this reason.
current_user: DontAgreeAction
}
@@ -810,14 +917,6 @@ enum SORT_COMMENTS_BY {
REPLIES
}
# All queries that can be executed.
enum USER_STATUS {
ACTIVE
BANNED
PENDING
APPROVED
}
# Metrics for the assets.
enum ASSET_METRICS_SORT {
@@ -948,7 +1047,7 @@ input CreateFlagInput {
item_type: ACTION_ITEM_TYPE!
# The reason for flagging the item.
reason: String!
reason: FLAG_REASON
# An optional message sent with the flagging action by the user.
message: String
@@ -987,34 +1086,18 @@ input CreateDontAgreeInput {
# The type of the item for which we are to create the don't agree.
item_type: ACTION_ITEM_TYPE!
# The reason for not agreeing with the item.
reason: String
# An optional message sent with the don't agree action by the user.
message: String
}
# Input for suspendUser mutation.
input SuspendUserInput {
input SetUserSuspensionStatusInput {
# id of target user.
id: ID!
# message to be sent to the user.
message: String!
# target user will be suspended until this date.
until: Date!
}
# Input for rejectUsername mutation.
input RejectUsernameInput {
# id of target user.
id: ID!
# message to be sent to the user.
message: String!
until: Date
}
# Configurable settings that can be overridden for the Asset. You must specify
@@ -1027,7 +1110,7 @@ input AssetSettingsInput {
# moderation is the moderation mode for the asset.
moderation: MODERATION_MODE
# questionBoxEnable will enable the Question Boxs' content to be visable above
# questionBoxEnable will enable the Question Boxs' content to be visible above
# the comment box.
questionBoxEnable: Boolean
@@ -1075,17 +1158,9 @@ type DeleteActionResponse implements Response {
errors: [UserError!]
}
# SetUserStatusResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type SetUserStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# SuspendUserResponse is the response returned with possibly some errors
# relating to the suspend action attempt.
type SuspendUserResponse implements Response {
# SetUserSuspensionStatusResponse is the response returned with possibly some
# errors relating to the suspend action attempt.
type SetUserSuspensionStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
@@ -1218,7 +1293,7 @@ input UpdateSettingsInput {
# comment is posted that it can still be edited by the author.
editCommentWindowLength: Int
# wordlist allows chaninging the available wordlists.
# wordlist allows changing the available wordlists.
wordlist: UpdateWordlistInput
# domains allows changing the available lists of domains.
@@ -1281,6 +1356,41 @@ type RevokeTokenResponse implements Response {
errors: [UserError!]
}
# SetUserBanStatusInput contains the input to change the ban status of a given
# user.
input SetUserBanStatusInput {
# id is the user to set the ban status on.
id: ID!
# status is the ban status to set on the target user.
status: Boolean!
}
type SetUserBanStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type SetUsernameStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type ChangeUsernameResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type SetUsernameResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -1299,17 +1409,30 @@ type RootMutation {
# Edit a comment
editComment(id: ID!, asset_id: ID!, edit: EditCommentInput): EditCommentResponse!
# Sets User status. Requires the `ADMIN` role.
# Sets the suspension status on a given user. Requires the `MODERATOR` role.
# Mutation is restricted.
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
setUserSuspensionStatus(input: SetUserSuspensionStatusInput!): SetUserSuspensionStatusResponse
# Suspends a user. Requires the `ADMIN` role.
# Sets the ban status on a given user. Requires the `MODERATOR` role.
# Mutation is restricted.
suspendUser(input: SuspendUserInput!): SuspendUserResponse
setUserBanStatus(input: SetUserBanStatusInput!): SetUserBanStatusResponse
# Reject a username. Requires the `ADMIN` role.
# Mutation is restricted.
rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse
# Sets the username status on a given user to `APPROVED`. Requires the
# `MODERATOR` role. Mutation is restricted.
approveUsername(id: ID!): SetUsernameStatusResponse
# Sets the username status on a given user to `REJECTED`. Requires the
# `MODERATOR` role. Mutation is restricted.
rejectUsername(id: ID!): SetUsernameStatusResponse
# Changes the username to the desired username. Mutation is restricted to
# those users with permission do to so.
changeUsername(id: ID!, username: String!): ChangeUsernameResponse
# Sets the username to the desired username if the user has not had a chance
# to set their username. Mutation is restricted to those users with permission
# do to so that have not done so before.
setUsername(id: ID!, username: String!): SetUsernameResponse
# Sets Comment status. Requires the `ADMIN` role.
# Mutation is restricted.
@@ -1327,7 +1450,7 @@ type RootMutation {
# Updates the status of an asset allowing you to close/reopen an asset for
# commenting.
# Mutation is restricted.
# Mutation is restricted.
updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse
# updateSettings will update the global settings.
@@ -1390,6 +1513,10 @@ type Subscription {
# `user_id` must match id of current user except for
# users with the `ADMIN` or `MODERATOR` role.
usernameRejected(user_id: ID): User
# Gen an update whenever a username has been approved. `user_id` must match id
# of current user except for users with the `ADMIN` or `MODERATOR` role.
usernameApproved(user_id: ID): User
}
################################################################################
+18
View File
@@ -254,6 +254,24 @@ en:
loading_results: "Loading Results"
marketing: "This looks like an ad/marketing"
moderate_this_stream: "Moderate this stream"
flags:
reasons:
user:
username_offensive: "Offensive"
username_nolike: "Dislike"
username_impersonating: "Impersonation"
username_spam: "Spam"
username_other: "Other"
comment:
comment_offensive: "Offensive"
comment_spam: "Spam"
comment_noagree: "Disagree"
comment_other: "Other"
suspect_word: "Suspect Word"
banned_word: "Banned Word"
body_count: "Body exceeds max length"
trust: "Trust"
links: "Link"
modqueue:
account: "account flags"
actions: Actions
+13
View File
@@ -249,6 +249,19 @@ es:
loading_results: "Cargando Resultados"
marketing: "Esto parece una propaganda"
moderate_this_stream: "Moderar este hilo de comentarios"
flags:
reasons:
user:
username_offensive: "Es ofensivo"
username_nolike: "No le gusta"
username_impersonating: "Está suplantando identidad"
username_spam: "Contiene spam"
username_other: "Otra razón"
comment:
comment_offensive: "Es ofensivo"
comment_spam: "Contiene spam"
comment_noagree: "No está de acuerdo"
comment_other: "Otra razón"
modqueue:
account: "reportes de cuentas"
actions: Acciones
+1 -1
View File
@@ -17,7 +17,7 @@ module.exports = {
}}
]);
// If no comments were found, nothing needes to be done!
// If no comments were found, nothing needs to be done!
if (comments.length <= 0) {
return;
}
+50
View File
@@ -0,0 +1,50 @@
const ActionModel = require('../models/action');
const mapping = {
COMMENTS: {
'Comment contains toxic language': 'TOXIC_COMMENT',
'Matched suspect word filter': 'SUSPECT_WORD',
'other': 'COMMENT_OTHER',
'Other': 'COMMENT_OTHER',
'This looks like an ad/marketing': 'COMMENT_SPAM',
'This comment is offensive': 'COMMENT_OFFENSIVE',
},
};
module.exports = {
async up() {
// Setup the batch operation.
const batch = ActionModel.collection.initializeUnorderedBulkOp();
for (const item_type in mapping) {
const mappings = mapping[item_type];
for (const OLD_GROUP_ID in mappings) {
const NEW_GROUP_ID = mappings[OLD_GROUP_ID];
// OLD
// {
// group_id: <OLD_GROUP_ID>
// }
// NEW
// {
// group_id: <NEW_GROUP_ID>
// }
batch.find({
group_id: OLD_GROUP_ID,
item_type,
}).update({
$set: {
group_id: NEW_GROUP_ID,
},
});
}
}
// Execute the batch update operation.
await batch.execute();
}
};
+21
View File
@@ -0,0 +1,21 @@
const ActionModel = require('../models/action');
module.exports = {
async up() {
// This will update all the old flags that are 'COMMENT_NOAGREE' to change
// them to DONTAGREE actions instead.
return ActionModel.update({
action_type: 'FLAG',
group_id: 'COMMENT_NOAGREE',
}, {
$set: {
action_type: 'DONTAGREE',
group_id: null,
},
}, {
multi: true,
});
}
};
+211
View File
@@ -0,0 +1,211 @@
const UserModel = require('../models/user');
const merge = require('lodash/merge');
const getUserBatch = async () => {
let query = {
status: {
$in: [
'ACTIVE',
'BANNED',
'PENDING',
'APPROVED'
]
}
};
// Find all the users that need migrating.
return UserModel.collection.find(query);
};
module.exports = {
async up() {
const created_at = Date.now();
// Create a new batch operation.
let bulk = UserModel.collection.initializeUnorderedBulkOp();
// Get the first batch of users.
let cursor = await getUserBatch();
while (await cursor.hasNext()) {
const user = await cursor.next();
const {id, status, canEditName, suspension, disabled} = user;
let update = {
$unset: {
canEditName: '',
suspension: '',
disabled: '',
},
$set: {
status: {
// The username status is specific to each case.
username: {
history: []
},
// The user is not banned by default.
banned: {
status: false,
history: []
},
// The user is not suspended by default.
suspension: {
until: null,
history: []
},
},
updated_at: created_at
},
};
if (disabled) {
update = merge(update, {
$set: {
status: {
banned: {
status: true,
history: [{
status: true,
created_at
}]
},
}
}
});
}
// If the user has an "until" property of their suspension, then we need
// to reflect that in the new status object.
if (suspension && suspension.until !== null) {
update = merge(update, {
$set: {
status: {
suspension: {
until: suspension.until,
history: [{
until: suspension.until,
created_at,
}]
}
}
}
});
}
switch (status) {
case 'ACTIVE':
if (canEditName) {
update = merge(update, {
$set: {
status: {
username: {
status: 'UNSET',
history: [{
status: 'UNSET',
created_at
}]
}
}
}
});
} else {
update = merge(update, {
$set: {
status: {
username: {
status: 'SET',
history: [{
status: 'SET',
created_at
}]
}
}
}
});
}
break;
case 'BANNED':
if (canEditName) {
update = merge(update, {
$set: {
status: {
username: {
status: 'REJECTED',
history: [{
status: 'REJECTED',
created_at
}]
}
}
}
});
} else {
update = merge(update, {
$set: {
status: {
banned: {
status: true,
history: [{
status: true,
created_at
}]
},
username: {
status: 'SET',
history: [{
status: 'SET',
created_at
}]
}
}
}
});
}
break;
case 'PENDING':
update = merge(update, {
$set: {
status: {
username: {
status: 'CHANGED',
history: [{
status: 'CHANGED',
created_at
}]
}
}
}
});
break;
case 'APPROVED':
update = merge(update, {
$set: {
status: {
username: {
status: 'APPROVED',
history: [{
status: 'APPROVED',
created_at
}]
}
}
}
});
break;
default:
throw new Error(`${status} is an invalid status`);
}
bulk.find({id}).updateOne(update);
}
// Execute the bulk update operation.
await bulk.execute();
}
};
-6
View File
@@ -1,6 +0,0 @@
module.exports = [
'ACTIVE',
'BANNED',
'PENDING',
'APPROVED' // Indicates that the users' username has been approved
];
+25
View File
@@ -0,0 +1,25 @@
module.exports = [
// UNSET is used when the username can be changed, and does not necessarily
// require moderator action to become active. This can be used when the user
// signs up with a social login and has the option of setting their own
// username.
'UNSET',
// SET is used when the username has been set for the first time, but cannot
// change without the username being rejected by a moderator and that moderator
// agreeing that the username should be allowed to change.
'SET',
// APPROVED is used when the username was changed, and subsequently approved by
// said moderator.
'APPROVED',
// REJECTED is used when the username was changed, and subsequently rejected by
// said moderator.
'REJECTED',
// CHANGED is used after a user has changed their username after it was
// rejected.
'CHANGED',
];
+112 -33
View File
@@ -10,8 +10,9 @@ const can = require('../perms');
// USER_ROLES is the array of roles that is permissible as a user role.
const USER_ROLES = require('./enum/user_roles');
// USER_STATUS is the list of statuses that are permitted for the user status.
const USER_STATUS = require('./enum/user_status');
// USER_STATUS_USERNAME is the list of statuses that are supported by storing
// the username state.
const USER_STATUS_USERNAME = require('./enum/user_status_username');
// ProfileSchema is the mongoose schema defined as the representation of a
// User's profile stored in MongoDB.
@@ -73,10 +74,6 @@ const UserSchema = new Schema({
unique: true
},
// This is true when the user account is disabled, no action should be
// acknowledged when they are disabled. Logins are also prevented.
disabled: Boolean,
// This provides a source of identity proof for users who login using the
// local provider. A local provider will be assumed for users who do not
// have any social profiles.
@@ -97,41 +94,91 @@ const UserSchema = new Schema({
enum: USER_ROLES
}],
// Status provides a string that says in which state the account is.
// When the account is banned, the user login is disabled.
// Status stores the user status information regarding permissions,
// capabilities and moderation state.
status: {
type: String,
enum: USER_STATUS,
default: 'ACTIVE'
},
// Determines whether the user can edit their username.
canEditName: {
type: Boolean,
default: false
},
// Username stores the current user status for the username as well as the
// history of changes.
username: {
// User's suspension details.
suspension: {
until: {
type: Date,
default: null,
// Status stores the current username status.
status: {
type: String,
enum: USER_STATUS_USERNAME,
},
// History stores the history of username status changes.
history: [{
// Status stores the historical username status.
status: {
type: String,
enum: USER_STATUS_USERNAME,
},
// assigned_by stores the user id of the user who assigned this status.
assigned_by: {type: String, default: null},
// created_at stores the date when this status was assigned.
created_at: {type: Date, default: Date.now}
}],
},
},
// User's settings
settings: {
bio: {
type: String,
default: ''
// Banned stores the current user banned status as well as the history of
// changes.
banned: {
// Status stores the current user banned status.
status: {
type: Boolean,
required: true,
default: false,
},
history: [{
// Status stores the historical banned status.
status: Boolean,
// assigned_by stores the user id of the user who assigned this status.
assigned_by: {type: String, default: null},
// created_at stores the date when this status was assigned.
created_at: {type: Date, default: Date.now}
}],
},
// Suspension stores the current user suspension status as well as the
// history of changes.
suspension: {
// until is the date that the user is suspended until.
until: {
type: Date,
default: null,
},
history: [{
// until is the date that the user is suspended until.
until: Date,
// assigned_by stores the user id of the user who assigned this status.
assigned_by: {type: String, default: null},
// created_at stores the date when this status was assigned.
created_at: {type: Date, default: Date.now}
}]
}
},
ignoresUsers: [{
// IgnoresUsers is an array of user id's that the current user is ignoring.
ignoresUsers: [String],
// user id of another user
type: String,
}],
// Counts to store related to actions taken on the given user.
action_counts: {
default: {},
type: Object,
},
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
@@ -158,7 +205,7 @@ const UserSchema = new Schema({
}
});
// Add the indixies on the user profile data.
// Add the index on the user profile data.
UserSchema.index({
'profiles.id': 1,
'profiles.provider': 1
@@ -201,6 +248,38 @@ UserSchema.method('can', function(...actions) {
return can(this, ...actions);
});
/**
* banned returns true when the user is currently banned, and sets the banned
* status locally.
*/
UserSchema.virtual('banned')
.get(function() {
return this.status.banned.status;
})
.set(function(status) {
this.status.banned.status = status;
this.status.banned.history.push({
status,
created_at: new Date()
});
});
/**
* suspended returns true when the user is currently suspended, and sets the
* suspension status locally.
*/
UserSchema.virtual('suspended')
.get(function() {
return Boolean(this.status.suspension.until && this.status.suspension.until > new Date());
})
.set(function(until) {
this.status.suspension.until = until;
this.status.suspension.history.push({
until,
created_at: new Date()
});
});
// Create the User model.
const UserModel = mongoose.model('User', UserSchema);
+42 -34
View File
@@ -6,39 +6,30 @@
"private": true,
"scripts": {
"postinstall": "./bin/cli plugins reconcile --skip-remote",
"start": "./bin/cli serve -j -w",
"dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"yarn generate-introspection && ./bin/cli -c .env serve -j -w\"",
"prebuild": "yarn generate-introspection",
"build": "WEBPACK=TRUE NODE_ENV=production webpack -p --config webpack.config.js --bail",
"prebuild-watch": "yarn generate-introspection",
"build-watch": "WEBPACK=TRUE NODE_ENV=development webpack --progress --config webpack.config.js --watch",
"lint": "yamllint locales/*.yml && eslint --ext=.js --ext=.json bin/* .",
"lint-fix": "yarn lint --fix",
"jest-watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
"e2e-ci": "./scripts/e2e-ci.sh",
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js",
"clean": "rm -rf dist client/coral-framework/graphql/introspection.json",
"watch": "npm-run-all clean generate-introspection --parallel watch:*",
"watch:client": "NODE_ENV=development webpack --progress --watch",
"watch:server": "nodemon --config .nodemon.json",
"start:development": "NODE_ENV=development ./bin/cli -c .env serve -j -w",
"start": "NODE_ENV=production ./bin/cli serve -j -w",
"prebuild": "npm-run-all clean generate-introspection",
"build": "NODE_ENV=production webpack -p --bail",
"lint:yaml": "yamllint locales/*.yml",
"lint:js": "eslint --ext=.js --ext=.json bin/* .",
"lint": "npm-run-all lint:*",
"plugins:reconcile": "./bin/cli plugins reconcile",
"test": "npm-run-all test:client test:server",
"test:server": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test:client": "TEST_MODE=unit NODE_ENV=test jest",
"test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
"e2e": "./scripts/e2e.js",
"test": "TEST_MODE=unit NODE_ENV=test jest && TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"heroku-postbuild": "./bin/cli plugins reconcile && yarn build",
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js"
"e2e:ci": "./scripts/e2e-ci.sh",
"heroku-postbuild": "npm-run-all plugins:reconcile build"
},
"talk": {
"migration": {
"minVersion": 1496771633
}
},
"config": {
"pre-git": {
"commit-msg": [],
"pre-commit": [
"yarn lint",
"yarn test"
],
"pre-push": [
"yarn test"
],
"post-commit": [],
"post-merge": []
"minVersion": 1510174676
}
},
"repository": {
@@ -120,7 +111,6 @@
"imports-loader": "^0.7.1",
"inquirer": "^3.2.2",
"ioredis": "3.1.4",
"istanbul": "^1.1.0-alpha.1",
"joi": "^10.6.0",
"json-loader": "^0.5.7",
"jsonwebtoken": "^7.4.3",
@@ -142,6 +132,7 @@
"node-emoji": "^1.8.1",
"node-fetch": "^1.7.2",
"nodemailer": "^2.6.4",
"npm-run-all": "^4.1.2",
"passport": "^0.4.0",
"passport-jwt": "^3.0.0",
"passport-local": "^1.0.0",
@@ -170,7 +161,6 @@
"redux": "^3.6.0",
"redux-thunk": "^2.1.0",
"resolve": "^1.4.0",
"selenium-standalone": "^6.11.0",
"semver": "^5.4.1",
"simplemde": "^1.11.2",
"smoothscroll-polyfill": "^0.3.5",
@@ -197,6 +187,7 @@
"browserstack-local": "^1.3.0",
"chai": "^3.5.0",
"chai-as-promised": "^6.0.0",
"chai-datetime": "^1.5.0",
"chai-http": "^3.0.0",
"enzyme": "^3.0.0",
"enzyme-adapter-react-15": "^1.0.0",
@@ -208,7 +199,8 @@
"mocha-junit-reporter": "^1.12.1",
"nightwatch": "^0.9.16",
"nodemon": "^1.11.0",
"pre-git": "^3.15.3",
"pre-git": "^3.16.0",
"selenium-standalone": "^6.11.0",
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0",
"yaml-lint": "^1.0.0"
@@ -216,8 +208,24 @@
"engines": {
"node": "^8"
},
"config": {
"pre-git": {
"pre-commit": [
"yarn lint",
"yarn test:client",
"yarn test:server"
],
"pre-push": [
"yarn lint",
"yarn test:client",
"yarn test:server"
],
"post-commit": [],
"post-checkout": [],
"post-merge": []
}
},
"release": {
"analyzeCommits": "simple-commit-message"
},
"snyk": true
}
}
-44
View File
@@ -1,44 +0,0 @@
module.exports = {
// mutations
CREATE_COMMENT: 'CREATE_COMMENT',
CREATE_ACTION: 'CREATE_ACTION',
DELETE_ACTION: 'DELETE_ACTION',
EDIT_NAME: 'EDIT_NAME',
EDIT_COMMENT: 'EDIT_COMMENT',
REJECT_USERNAME: 'REJECT_USERNAME',
SET_USER_STATUS: 'SET_USER_STATUS',
SUSPEND_USER: 'SUSPEND_USER',
SET_COMMENT_STATUS: 'SET_COMMENT_STATUS',
ADD_COMMENT_TAG: 'ADD_COMMENT_TAG',
REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG',
UPDATE_USER_ROLES: 'UPDATE_USER_ROLES',
UPDATE_CONFIG: 'UPDATE_CONFIG',
CREATE_TOKEN: 'CREATE_TOKEN',
REVOKE_TOKEN: 'REVOKE_TOKEN',
UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS',
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
// queries
SEARCH_ASSETS: 'SEARCH_ASSETS',
SEARCH_OTHER_USERS: 'SEARCH_OTHER_USERS',
SEARCH_ACTIONS: 'SEARCH_ACTIONS',
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
VIEW_SUSPENSION_INFO: 'VIEW_SUSPENSION_INFO',
VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS',
// subscriptions
SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED',
SUBSCRIBE_COMMENT_REJECTED: 'SUBSCRIBE_COMMENT_REJECTED',
SUBSCRIBE_COMMENT_FLAGGED: 'SUBSCRIBE_COMMENT_FLAGGED',
SUBSCRIBE_ALL_COMMENT_ADDED: 'SUBSCRIBE_ALL_COMMENT_ADDED',
SUBSCRIBE_ALL_COMMENT_EDITED: 'SUBSCRIBE_ALL_COMMENT_EDITED',
SUBSCRIBE_ALL_USER_SUSPENDED: 'SUBSCRIBE_ALL_USER_SUSPENDED',
SUBSCRIBE_ALL_USER_BANNED: 'SUBSCRIBE_ALL_USER_BANNED',
SUBSCRIBE_ALL_USERNAME_REJECTED: 'SUBSCRIBE_ALL_USERNAME_REJECTED',
};
+11
View File
@@ -0,0 +1,11 @@
const merge = require('lodash/merge');
const mutation = require('./mutation');
const query = require('./query');
const subscription = require('./subscription');
module.exports = merge(...[
mutation,
query,
subscription,
]);
+21
View File
@@ -0,0 +1,21 @@
module.exports = {
CREATE_COMMENT: 'CREATE_COMMENT',
CREATE_ACTION: 'CREATE_ACTION',
CHANGE_USERNAME: 'CHANGE_USERNAME',
SET_USERNAME: 'SET_USERNAME',
DELETE_ACTION: 'DELETE_ACTION',
EDIT_COMMENT: 'EDIT_COMMENT',
SET_USER_USERNAME_STATUS: 'SET_USER_USERNAME_STATUS',
SET_USER_BAN_STATUS: 'SET_USER_BAN_STATUS',
SET_USER_SUSPENSION_STATUS: 'SET_USER_SUSPENSION_STATUS',
SET_COMMENT_STATUS: 'SET_COMMENT_STATUS',
ADD_COMMENT_TAG: 'ADD_COMMENT_TAG',
REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG',
UPDATE_USER_ROLES: 'UPDATE_USER_ROLES',
UPDATE_CONFIG: 'UPDATE_CONFIG',
CREATE_TOKEN: 'CREATE_TOKEN',
REVOKE_TOKEN: 'REVOKE_TOKEN',
UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS',
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
UPDATE_SETTINGS: 'UPDATE_SETTINGS'
};
+12
View File
@@ -0,0 +1,12 @@
module.exports = {
SEARCH_ASSETS: 'SEARCH_ASSETS',
SEARCH_OTHER_USERS: 'SEARCH_OTHER_USERS',
SEARCH_ACTIONS: 'SEARCH_ACTIONS',
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
VIEW_SUSPENSION_INFO: 'VIEW_SUSPENSION_INFO',
VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS',
};
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED',
SUBSCRIBE_COMMENT_REJECTED: 'SUBSCRIBE_COMMENT_REJECTED',
SUBSCRIBE_COMMENT_FLAGGED: 'SUBSCRIBE_COMMENT_FLAGGED',
SUBSCRIBE_ALL_COMMENT_ADDED: 'SUBSCRIBE_ALL_COMMENT_ADDED',
SUBSCRIBE_ALL_COMMENT_EDITED: 'SUBSCRIBE_ALL_COMMENT_EDITED',
SUBSCRIBE_ALL_USER_SUSPENDED: 'SUBSCRIBE_ALL_USER_SUSPENDED',
SUBSCRIBE_ALL_USER_BANNED: 'SUBSCRIBE_ALL_USER_BANNED',
SUBSCRIBE_ALL_USERNAME_REJECTED: 'SUBSCRIBE_ALL_USERNAME_REJECTED',
SUBSCRIBE_ALL_USERNAME_APPROVED: 'SUBSCRIBE_ALL_USERNAME_APPROVED'
};
+22 -31
View File
@@ -1,35 +1,28 @@
const constants = require('./constants');
const root = require('./rootReducer');
const queries = require('./queryReducer');
const mutations = require('./mutationReducer');
const subscriptions = require('./subscriptionReducer');
const reducers = require('./reducers');
const constantsArray = Object.keys(constants);
const reducers = [
root,
queries,
mutations,
subscriptions,
];
/**
* findGrant will try to check all the permissions if the user is allowed to do
* so.
*
* @param {Object} user the user being checked whether they have the required
* permissions
* @param {Array<String>} perms the array of permissions that the user must have
* in order to succeed
*/
const findGrant = (user, perms) => perms.every((perm) => {
for (let key in reducers) {
const reducer = reducers[key];
const grant = reducer(user, perm);
// this will make 'reducer' a key in this array. hm.
const allPermissions = Object.keys(constants);
const findGrant = (user, perms) => {
return perms.every((perm) => {
for (let key in reducers) {
const reducer = reducers[key];
const grant = reducer(user, perm);
if (grant !== null && typeof grant !== 'undefined') {
return grant;
}
if (typeof grant !== 'undefined' && grant !== null) {
return grant;
}
}
return false;
});
};
return false;
});
/**
* returns true, false, or null depending on whether the user has those permissions
@@ -40,10 +33,8 @@ const findGrant = (user, perms) => {
* @return {Boolean}
*/
module.exports = (user, ...perms) => {
// Make sure all the passed permissions are not typos.
const missingPerms = perms.filter((perm) => !allPermissions.includes(perm));
if (missingPerms.length > 0) {
if (perms.some((perm) => !constantsArray.includes(perm))) {
const missingPerms = perms.filter((perm) => !constantsArray.includes(perm));
throw new Error(`${missingPerms.join(' ')} are not valid permissions.`);
}
+17
View File
@@ -0,0 +1,17 @@
const mutation = require('./mutation');
const query = require('./query');
const subscription = require('./subscription');
module.exports = [
(user /* , perm*/) => {
// If a user is banned or currently suspended, then they aren't allowed to
// do anything.
if (user.banned || user.suspended) {
return false;
}
},
query,
mutation,
subscription,
];
@@ -1,30 +1,42 @@
const {check} = require('./utils');
const types = require('./constants');
const {check} = require('../utils');
const types = require('../constants');
module.exports = (user, perm) => {
switch (perm) {
case types.CHANGE_USERNAME:
return user.status.username.status === 'REJECTED';
case types.SET_USERNAME:
return user.status.username.status === 'UNSET';
case types.CREATE_COMMENT:
case types.CREATE_ACTION:
case types.DELETE_ACTION:
case types.EDIT_NAME:
case types.EDIT_COMMENT:
return true;
// Anyone can do these things if they aren't suspended, banned, or blocked
// as they're editing their username.
return !['UNSET', 'REJECTED'].includes(user.status.username.status);
case types.ADD_COMMENT_TAG:
case types.REMOVE_COMMENT_TAG:
return check(user, ['ADMIN', 'MODERATOR', 'STAFF']);
case types.UPDATE_USER_ROLES:
case types.REJECT_USERNAME:
case types.SET_USER_STATUS:
case types.SUSPEND_USER:
case types.SET_COMMENT_STATUS:
case types.SET_USER_USERNAME_STATUS:
case types.SET_USER_BAN_STATUS:
case types.SET_USER_SUSPENSION_STATUS:
case types.UPDATE_CONFIG:
case types.UPDATE_SETTINGS:
case types.UPDATE_ASSET_SETTINGS:
case types.UPDATE_ASSET_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.CREATE_TOKEN:
case types.REVOKE_TOKEN:
return check(user, ['ADMIN']);
default:
break;
}
@@ -1,28 +1,22 @@
const {check} = require('./utils');
const types = require('./constants');
const {check} = require('../utils');
const types = require('../constants');
module.exports = (user, perm) => {
switch (perm) {
case types.SEARCH_ASSETS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_OTHER_USERS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_ACTIONS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_OTHERS_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_METRICS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
return check(user, ['ADMIN', 'MODERATOR']);
case types.VIEW_SUSPENSION_INFO:
return check(user, ['ADMIN', 'MODERATOR']);
case types.VIEW_PROTECTED_SETTINGS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
default:
break;
}
@@ -1,24 +1,19 @@
const {check} = require('./utils');
const types = require('./constants');
const {check} = require('../utils');
const types = require('../constants');
module.exports = (user, perm) => {
switch (perm) {
case types.SUBSCRIBE_COMMENT_FLAGGED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_COMMENT_ACCEPTED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_COMMENT_REJECTED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_EDITED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_ADDED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USER_SUSPENDED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USER_BANNED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USERNAME_REJECTED:
case types.SUBSCRIBE_ALL_USERNAME_APPROVED:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
-10
View File
@@ -1,10 +0,0 @@
module.exports = (user /* , perm*/) => {
// this runs before everything
if (
user.status === 'BANNED' ||
(user.suspension.until && user.suspension.until > new Date())
) {
return false;
}
};
+7
View File
@@ -1,4 +1,11 @@
const intersection = require('lodash/intersection');
/**
* check will ensure that the user has the desired roles.
*
* @param {Object} user user being checked for roles
* @param {Array<String>} roles roles to check that the user has
*/
const check = (user, roles) => {
return intersection(roles, user.roles).length > 0;
};
@@ -57,27 +57,23 @@ module.exports = {
hooks: {
RootMutation: {
addTag: {
async post(obj, {tag: {name, id, item_type}}, {user, mutators: {Comment}, pubsub}, info, result) {
async post(obj, {tag: {name, id, item_type}}, {user, mutators: {Comment}, pubsub}, _info) {
if (name === 'FEATURED' && item_type === 'COMMENTS') {
const comment = await Comment.setStatus({id: id, status: 'ACCEPTED'});
if (comment) {
pubsub.publish('commentFeatured', {comment, user});
}
return result;
}
return result;
},
},
removeTag: {
async post(obj, {tag: {name, id, item_type}}, {user, loaders: {Comments}, pubsub}, info, result) {
async post(obj, {tag: {name, id, item_type}}, {user, loaders: {Comments}, pubsub}, _info) {
if (name === 'FEATURED' && item_type === 'COMMENTS') {
const comment = await Comments.get.load(id);
if (comment) {
pubsub.publish('commentUnfeatured', {comment, user});
}
return result;
}
return result;
},
},
},
@@ -35,7 +35,7 @@ class FlagDetails extends Component {
<ul className={styles.info}>
{reasons.map((reason) =>
<li key={reason} className={styles.lessDetail}>
{reason} {summaries[reason].userFlagged && `(${summaries[reason].count})`}
{t(`flags.reasons.comment.${reason.toLowerCase()}`)} {summaries[reason].userFlagged && `(${summaries[reason].count})`}
</li>
)}
</ul>
@@ -1,5 +1,6 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {t} from 'plugin-api/beta/client/services';
import styles from './UserFlagDetails.css';
class UserFlagDetails extends Component {
@@ -25,7 +26,7 @@ class UserFlagDetails extends Component {
{Object.keys(summaries)
.map((reason) => (
<li key={reason}>
{reason} ({summaries[reason].count})
{t(`flags.reasons.comment.${reason.toLowerCase()}`)} ({summaries[reason].count})
<ul className={styles.subDetail}>
{summaries[reason].actions.map((action) =>
<li key={action.user.id}>
@@ -4,12 +4,20 @@ en:
Are you sure? The language in this comment might violate our community guidelines.
You can edit the comment or submit it for moderator review.
talk-plugin-toxic-comments:
unlikely: Unlikely
highly_likely: Highly Likely
possibly: Possibly
likely: Likely
toxic_comment: Toxic Comment
unlikely: "Unlikely"
highly_likely: "Highly Likely"
possibly: "Possibly"
likely: "Likely"
toxic_comment: "Toxic Comment"
still_toxic: |
This edited comment might still violate our community guidelines.
Our moderation team will review your comment shortly.
flags:
reasons:
comment:
toxic_comment: "Highly likely to be Toxic"
es:
flags:
reasons:
comment:
toxic_comment: "Muy probable que sea tóxico"
-9
View File
@@ -98,13 +98,4 @@ router.put('/password/reset', async (req, res, next) => {
}
});
router.put('/username', authorization.needed(), async (req, res, next) => {
try {
await UsersService.editName(req.user.id, req.body.username);
res.status(204).end();
} catch (e) {
return next(e);
}
});
module.exports = router;
+3 -77
View File
@@ -4,11 +4,7 @@ const UsersService = require('../../../services/users');
const mailer = require('../../../services/mailer');
const errors = require('../../../errors');
const authorization = require('../../../middleware/authorization');
const i18n = require('../../../services/i18n');
const Limit = require('../../../services/limit');
const {
ROOT_URL
} = require('../../../config');
router.get('/', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
@@ -60,36 +56,6 @@ router.post('/:user_id/role', authorization.needed('ADMIN', 'MODERATOR'), async
}
});
// update the status of a user
router.post('/:user_id/status', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
let {status} = req.body;
try {
let user = await UsersService.setStatus(req.params.user_id, status);
if (!user) {
return next(errors.ErrNotFound);
}
if (user.status === 'BANNED') {
req.pubsub.publish('userBanned', user);
}
// TODO: investigate why this is returning a value? Also why is this a POST vs PUT?
res.status(201).json(user.status);
} catch (e) {
next(e);
}
});
router.post('/:user_id/username-enable', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
try {
await UsersService.toggleNameEdit(req.params.user_id, true);
res.status(204).end();
} catch (e) {
next(e);
}
});
router.post('/:user_id/email', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
try {
let user = await UsersService.findById(req.params.user_id);
@@ -114,26 +80,6 @@ router.post('/:user_id/email', authorization.needed('ADMIN', 'MODERATOR'), async
}
});
/**
* SendEmailConfirmation sends a confirmation email to the user.
* @param {String} userID the id for the user to send the email to
* @param {String} email the email for the user to send the email to
*/
const SendEmailConfirmation = async (user, email, referer) => {
let token = await UsersService.createEmailConfirmToken(user, email, referer);
return mailer.sendSimple({
template: 'email-confirm',
locals: {
token,
rootURL: ROOT_URL,
email
},
subject: i18n.t('email.confirm.subject'),
to: email
});
};
// create a local user.
router.post('/', async (req, res, next) => {
const {email, password, username} = req.body;
@@ -144,7 +90,7 @@ router.post('/', async (req, res, next) => {
// Send an email confirmation. The Front end will know about the
// requireEmailConfirmation as it's included in the settings get endpoint.
await SendEmailConfirmation(user, email, redirectUri);
await UsersService.sendEmailConfirmation(user, email, redirectUri);
res.status(201).json(user);
} catch (e) {
@@ -152,26 +98,6 @@ router.post('/', async (req, res, next) => {
}
});
router.post('/:user_id/actions', authorization.needed(), async (req, res, next) => {
const {
action_type,
metadata
} = req.body;
try {
let action = await UsersService.addAction(req.params.user_id, req.user.id, action_type, metadata);
// Set the user status to "pending" for review by moderators
if (action_type === 'FLAG') {
await UsersService.setStatus(req.params.user_id, 'PENDING');
}
res.status(201).json(action);
} catch (e) {
return next(e);
}
});
// This will allow 1 try every minute.
const resendRateLimiter = new Limit('/api/v1/users/resend-verify', 1, '1m');
@@ -205,7 +131,7 @@ router.post('/resend-verify', async (req, res, next) => {
throw errors.ErrNotFound;
}
await SendEmailConfirmation(user, email, redirectUri);
await UsersService.sendEmailConfirmation(user, email, redirectUri);
res.status(204).end();
} catch (e) {
@@ -234,7 +160,7 @@ router.post('/:user_id/email/confirm', authorization.needed('ADMIN', 'MODERATOR'
}
// Send the email to the first local profile that was found.
await SendEmailConfirmation(user, localProfile.id);
await UsersService.sendEmailConfirmation(user, localProfile.id);
res.status(204).end();
} catch (e) {
+45 -30
View File
@@ -12,6 +12,7 @@ const {createGraphOptions} = require('../graph');
const accepts = require('accepts');
const apollo = require('graphql-server-express');
const {DISABLE_STATIC_SERVER} = require('../config');
const SetupService = require('../services/setup');
const router = express.Router();
@@ -19,29 +20,29 @@ const router = express.Router();
// STATIC FILES
//==============================================================================
// If the application is in production mode, then add gzip rewriting for the
// content.
if (process.env.NODE_ENV === 'production') {
router.get('*.js', (req, res, next) => {
const accept = accepts(req);
if (accept.encoding(['gzip']) === 'gzip') {
// Adjsut the headers on the request by adding a content type header
// because express won't be able to detect the mime-type with the .gz
// extension and we need to decalre support for the gzip encoding.
res.set('Content-Type', 'application/javascript');
res.set('Content-Encoding', 'gzip');
// Rewrite the url so that the gzip version will be served instead.
req.url = `${req.url}.gz`;
}
next();
});
}
if (!DISABLE_STATIC_SERVER) {
// If the application is in production mode, then add gzip rewriting for the
// content.
if (process.env.NODE_ENV === 'production') {
router.get('*.js', (req, res, next) => {
const accept = accepts(req);
if (accept.encoding(['gzip']) === 'gzip') {
// Adjust the headers on the request by adding a content type header
// because express won't be able to detect the mime-type with the .gz
// extension and we need to declare support for the gzip encoding.
res.set('Content-Type', 'application/javascript');
res.set('Content-Encoding', 'gzip');
// Rewrite the url so that the gzip version will be served instead.
req.url = `${req.url}.gz`;
}
next();
});
}
/**
* Serve the directories under public/dist from this router.
*/
@@ -100,7 +101,7 @@ if (process.env.NODE_ENV !== 'production') {
});
});
// GraphQL documention.
// GraphQL documentation.
router.get('/admin/docs', (req, res) => {
res.render('admin/docs');
});
@@ -118,14 +119,28 @@ router.use('/embed', require('./embed'));
if (process.env.NODE_ENV !== 'production') {
router.use('/assets', require('./assets'));
router.get('/', (req, res) => {
return res.render('article', {
title: 'Coral Talk',
asset_url: '',
asset_id: '',
body: '',
basePath: '/client/embed/stream'
});
router.get('/', async (req, res) => {
try {
await SetupService.isAvailable();
return res.redirect('/admin/install');
} catch (e) {
return res.render('article', {
title: 'Coral Talk',
asset_url: '',
asset_id: '',
body: '',
basePath: '/client/embed/stream'
});
}
});
} else {
router.get('/', async (req, res, next) => {
try {
await SetupService.isAvailable();
return res.redirect('/admin/install');
} catch (e) {
return res.redirect('/admin');
}
});
}
+2 -2
View File
@@ -82,7 +82,7 @@ async function onListening() {
let bind = typeof addr === 'string'
? `pipe ${addr}`
: `port ${addr.port}`;
debug(`API Server Listening on ${bind}`);
console.log(`API Server Listening on ${bind}`);
}
/**
@@ -141,7 +141,7 @@ async function serve({jobs = true, websockets = true} = {}) {
// Mount the websocket server if requested.
if (websockets) {
debug(`Websocket Server Listening on ${port}`);
console.log(`Websocket Server Listening on ${port}`);
// Mount the subscriptions server on the application server.
createSubscriptionManager(server);
+58 -2
View File
@@ -1,4 +1,7 @@
const ActionModel = require('../models/action');
const CommentModel = require('../models/comment');
const UserModel = require('../models/user');
const sc = require('snake-case');
const _ = require('lodash');
const errors = require('../errors');
const events = require('./events');
@@ -143,7 +146,7 @@ module.exports = class ActionsService {
let $group = {
// group unique documents by these properties, we are leveraging the
// fact that each uuid is completly unique.
// fact that each uuid is completely unique.
_id: {
item_id: '$item_id',
action_type: '$action_type',
@@ -155,7 +158,7 @@ module.exports = class ActionsService {
$sum: 1
},
// we are leveraging the fact that each uuid is completly unique and
// we are leveraging the fact that each uuid is completely unique and
// just grabbing the last instance of the item type here.
item_type: {
$first: '$item_type'
@@ -248,3 +251,56 @@ module.exports = class ActionsService {
}, 'item_id');
}
};
const incrActionCounts = async (action, value) => {
const ACTION_TYPE = sc(action.action_type.toLowerCase());
const update = {
[`action_counts.${ACTION_TYPE}`]: value,
};
if (action.group_id && action.group_id.length > 0) {
const GROUP_ID = sc(action.group_id.toLowerCase());
update[`action_counts.${ACTION_TYPE}_${GROUP_ID}`] = value;
}
try {
switch (action.item_type) {
case 'USERS':
return UserModel.update({
id: action.item_id,
}, {
$inc: update,
});
case 'COMMENTS':
return CommentModel.update({
id: action.item_id,
}, {
$inc: update,
});
default:
throw new Error('Invalid item type for action summary monitoring');
}
} catch (err) {
console.error(`Can't mutate the action_counts.${ACTION_TYPE}:`, err);
}
};
// When a new action is created, modify the comment.
events.on(ACTIONS_NEW, async (action) => {
if (!action || (action.item_type !== 'COMMENTS' && action.item_type !== 'USERS')) {
return;
}
return incrActionCounts(action, 1);
});
// When an action is deleted, remove the action count on the comment.
events.on(ACTIONS_DELETE, async (action) => {
if (!action || (action.item_type !== 'COMMENTS' && action.item_type !== 'USERS')) {
return;
}
return incrActionCounts(action, -1);
});
+2 -2
View File
@@ -1,7 +1,7 @@
const CommentModel = require('../models/comment');
const AssetModel = require('../models/asset');
const SettingsService = require('./settings');
const domainlist = require('./domainlist');
const DomainList = require('./domain_list');
const errors = require('../errors');
const merge = require('lodash/merge');
@@ -64,7 +64,7 @@ module.exports = class AssetsService {
// Check the URL to confirm that is in the domain whitelist
return Promise.all([
domainlist.urlCheck(url),
DomainList.urlCheck(url),
SettingsService.retrieve()
]).then(([whitelisted, settings]) => {
-45
View File
@@ -4,13 +4,10 @@ const debug = require('debug')('talk:services:comments');
const ActionsService = require('./actions');
const SettingsService = require('./settings');
const sc = require('snake-case');
const cloneDeep = require('lodash/cloneDeep');
const errors = require('../errors');
const events = require('./events');
const {
ACTIONS_NEW,
ACTIONS_DELETE,
COMMENTS_NEW,
COMMENTS_EDIT,
} = require('./events/constants');
@@ -335,48 +332,6 @@ module.exports = class CommentsService {
// Event Hooks
//==============================================================================
const incrActionCounts = async (action, value) => {
const ACTION_TYPE = sc(action.action_type.toLowerCase());
const update = {
[`action_counts.${ACTION_TYPE}`]: value,
};
if (action.group_id && action.group_id.length > 0) {
const GROUP_ID = sc(action.group_id.toLowerCase());
update[`action_counts.${ACTION_TYPE}_${GROUP_ID}`] = value;
}
try {
await CommentModel.update({
id: action.item_id,
}, {
$inc: update,
});
} catch (err) {
console.error(`Can't mutate the action_counts.${ACTION_TYPE}:`, err);
}
};
// When a new action is created, modify the comment.
events.on(ACTIONS_NEW, async (action) => {
if (!action || action.item_type !== 'COMMENTS') {
return;
}
return incrActionCounts(action, 1);
});
// When an action is deleted, remove the action count on the comment.
events.on(ACTIONS_DELETE, async (action) => {
if (!action || action.item_type !== 'COMMENTS') {
return;
}
return incrActionCounts(action, -1);
});
const incrReplyCount = async (comment, value) => {
try {
await CommentModel.update({
@@ -1,4 +1,4 @@
const debug = require('debug')('talk:services:domainlist');
const debug = require('debug')('talk:services:domain_list');
const _ = require('lodash');
const SettingsService = require('./settings');
@@ -8,7 +8,7 @@ const {ROOT_URL} = require('../config');
* The root domainlist object.
* @type {Object}
*/
class Domainlist {
class DomainList {
constructor() {
this.lists = {
@@ -35,7 +35,7 @@ class Domainlist {
return;
}
this.lists.whitelist = Domainlist.parseList(lists.whitelist);
this.lists.whitelist = DomainList.parseList(lists.whitelist);
debug(`Added ${lists.whitelist.length} domains to the whitelist.`);
}
@@ -47,7 +47,7 @@ class Domainlist {
match(list, url) {
// Parse the url that we're matching with.
const domainToMatch = Domainlist.parseURL(url);
const domainToMatch = DomainList.parseURL(url);
// This will return true in the event that at least one blockword is found
// in the phrase.
@@ -61,7 +61,7 @@ class Domainlist {
* @returns {Boolean} true if the domains match
*/
static matchMount(url) {
return Domainlist.parseURL(url) === Domainlist.parseURL(ROOT_URL);
return DomainList.parseURL(url) === DomainList.parseURL(ROOT_URL);
}
/**
@@ -70,7 +70,7 @@ class Domainlist {
* @return {Array} the parsed list
*/
static parseList(list) {
return _.uniq(list.map((domain) => Domainlist.parseURL(domain)));
return _.uniq(list.map((domain) => DomainList.parseURL(domain)));
}
/**
@@ -95,7 +95,7 @@ class Domainlist {
}
static async urlCheck(url) {
const dl = new Domainlist();
const dl = new DomainList();
// Load the domain list.
await dl.load();
@@ -106,4 +106,4 @@ class Domainlist {
}
module.exports = Domainlist;
module.exports = DomainList;
+9 -4
View File
@@ -1,4 +1,9 @@
module.exports.ACTIONS_DELETE = 'actions.delete';
module.exports.ACTIONS_NEW = 'actions.new';
module.exports.COMMENTS_NEW = 'comments.new';
module.exports.COMMENTS_EDIT = 'comments.edit';
module.exports = {
ACTIONS_DELETE: 'ACTIONS_DELETE',
ACTIONS_NEW: 'ACTIONS_NEW',
COMMENTS_NEW: 'COMMENTS_NEW',
COMMENTS_EDIT: 'COMMENTS_EDIT',
USERS_SUSPENSION_CHANGE: 'USERS_SUSPENSION_CHANGE',
USERS_BAN_CHANGE: 'USERS_BAN_CHANGE',
USERS_USERNAME_STATUS_CHANGE: 'USERS_USERNAME_STATUS_CHANGE'
};
+12
View File
@@ -0,0 +1,12 @@
const ta = require('timeago.js');
ta.register('es', require('timeago.js/locales/es'));
ta.register('da', require('timeago.js/locales/da'));
ta.register('fr', require('timeago.js/locales/fr'));
ta.register('pt_BR', require('timeago.js/locales/pt_BR'));
const timeago = ta();
module.exports = (time) => {
return timeago.format(new Date(time), 'en');
};
+306 -255
View File
@@ -1,8 +1,16 @@
const assert = require('assert');
const uuid = require('uuid');
const bcrypt = require('bcryptjs');
const errors = require('../errors');
const some = require('lodash/some');
const merge = require('lodash/merge');
const events = require('./events');
const timeago = require('./timeago');
const {
USERS_SUSPENSION_CHANGE,
USERS_BAN_CHANGE,
USERS_USERNAME_STATUS_CHANGE,
} = require('./events/constants');
const {
ROOT_URL
@@ -15,7 +23,6 @@ const {
const debug = require('debug')('talk:services:users');
const UserModel = require('../models/user');
const USER_STATUS = require('../models/enum/user_status');
const USER_ROLES = require('../models/enum/user_roles');
const RECAPTCHA_WINDOW = '10m'; // 10 minutes.
@@ -23,8 +30,10 @@ const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha
const ActionsService = require('./actions');
const MailerService = require('./mailer');
const i18n = require('./i18n');
const Wordlist = require('./wordlist');
const Domainlist = require('./domainlist');
const DomainList = require('./domain_list');
const SettingsService = require('./settings');
const {escapeRegExp} = require('./regex');
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
@@ -40,7 +49,7 @@ const loginRateLimiter = new Limit('loginAttempts', RECAPTCHA_INCORRECT_TRIGGER,
// UsersService is the interface for the application to interact with the
// UserModel through.
module.exports = class UsersService {
class UsersService {
/**
* Returns a user (if found) for the given email address.
@@ -61,7 +70,7 @@ module.exports = class UsersService {
}
/**
* This records an unsucesfull login attempt for the given email address. If
* This records an unsuccessful login attempt for the given email address. If
* the maximum has been reached, the promise will be rejected with:
*
* errors.ErrLoginAttemptMaximumExceeded
@@ -81,8 +90,190 @@ module.exports = class UsersService {
}
}
static async setSuspensionStatus(id, until, assignedBy = null) {
let user = await UserModel.findOneAndUpdate({id}, {
$set: {
'status.suspension.until': until
},
$push: {
'status.suspension.history': {
until,
assigned_by: assignedBy,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
throw errors.ErrNotFound;
}
if (
user.status.suspension.until === until ||
(
user.status.suspension.until.getTime() > until.getTime() - 1000 &&
user.status.suspension.until.getTime() < until.getTime() + 1000
)
) {
return user;
}
throw new Error('suspension status change edit failed for an unknown reason');
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_SUSPENSION_CHANGE, user, until);
return user;
}
static async setBanStatus(id, status, assignedBy = null) {
let user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: status
}
}, {
$set: {
'status.banned.status': status
},
$push: {
'status.banned.history': {
status,
assigned_by: assignedBy,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
throw errors.ErrNotFound;
}
if (user.status.banned.status === status) {
return user;
}
throw new Error('ban status change edit failed for an unknown reason');
}
// Emit that the user ban status was changed.
await events.emitAsync(USERS_BAN_CHANGE, user, status);
return user;
}
static async setUsernameStatus(id, status, assignedBy = null) {
let user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: status
}
}, {
$set: {
'status.username.status': status
},
$push: {
'status.username.history': {
status,
assigned_by: assignedBy,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
user = await UserModel.findOne({id});
if (user === null) {
throw errors.ErrNotFound;
}
if (user.status.username.status === status) {
return user;
}
throw new Error('username status change edit failed for an unknown reason');
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, status);
return user;
}
static async _setUsername(id, username, fromStatus, toStatus, resetAllowed = false) {
try {
const query = {
id,
'status.username.status': fromStatus
};
if (!resetAllowed) {
query.username = {$ne: username};
}
let user = await UserModel.findOneAndUpdate(query, {
$set: {
username,
lowercaseUsername: username.toLowerCase(),
'status.username.status': toStatus,
},
$push: {
'status.username.history': {
status: toStatus,
assigned_by: id,
created_at: Date.now()
}
}
}, {
new: true
});
if (!user) {
user = await UsersService.findById(id);
if (user === null) {
throw errors.ErrNotFound;
}
if (user.status.username.status !== fromStatus) {
throw errors.ErrPermissionUpdateUsername;
}
if (!resetAllowed && user.username === username) {
throw errors.ErrSameUsernameProvided;
}
throw new Error('edit username failed for an unexpected reason');
}
// Emit that the user username status was changed.
await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, toStatus);
return user;
} catch (err) {
if (err.code === 11000) {
throw errors.ErrUsernameTaken;
}
throw err;
}
}
static async setUsername(id, username) {
return UsersService._setUsername(id, username, 'UNSET', 'SET', true);
}
static async changeUsername(id, username) {
return UsersService._setUsername(id, username, 'REJECTED', 'CHANGED');
}
/**
* This checks to see if the current login attempts against a user exeeds the
* This checks to see if the current login attempts against a user exceeds the
* maximum value allowed, if so, it rejects with:
*
* errors.ErrLoginAttemptMaximumExceeded
@@ -181,13 +372,55 @@ module.exports = class UsersService {
lowercaseUsername: username.toLowerCase(),
roles: [],
profiles: [{id, provider}],
canEditName: true
status: {
username: {
status: 'UNSET',
history: {
status: 'UNSET'
}
}
}
});
return user.save();
});
}
/**
* sendEmailConfirmation sends a confirmation email to the user.
* @param {String} user the user to send the email to
* @param {String} email the email for the user to send the email to
*/
static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) {
let token = await UsersService.createEmailConfirmToken(user, email, redirectURI);
return MailerService.sendSimple({
template: 'email-confirm',
locals: {
token,
rootURL: ROOT_URL,
email
},
subject: i18n.t('email.confirm.subject'),
to: email
});
}
static async sendEmail(user, options) {
const localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (!localProfile) {
throw new Error('user does not have an email');
}
const {id: to} = localProfile;
options = merge(options, {
to,
});
return MailerService.sendSimple(options);
}
static async changePassword(id, password) {
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
@@ -289,7 +522,15 @@ module.exports = class UsersService {
id: email,
provider: 'local'
}
]
],
status: {
username: {
status: 'SET',
history: {
status: 'SET'
}
}
}
});
try {
@@ -307,36 +548,6 @@ module.exports = class UsersService {
return user;
}
/**
* Disables a given user account.
* @param {String} id id of a user
* @param {Function} done callback after the operation is complete
*/
static disableUser(id) {
return UserModel.update({
id
}, {
$set: {
disabled: true
}
});
}
/**
* Enables a given user account.
* @param {String} id id of a user
* @param {Function} done callback after the operation is complete
*/
static enableUser(id) {
return UserModel.update({
id
}, {
$set: {
disabled: false
}
});
}
/**
* Adds a role to a user.
* @param {String} id id of a user
@@ -380,131 +591,6 @@ module.exports = class UsersService {
});
}
/**
* Set status of a user.
* @param {String} id id of a user
* @param {String} status status to set
* @param {Function} done callback after the operation is complete
*/
static async setStatus(id, status) {
// Check to see if the user status is in the allowable set of roles.
if (USER_STATUS.indexOf(status) === -1) {
// User status is not supported! Error out here.
throw new Error(`status ${status} is not supported`);
}
// TODO: current updating status behavior is weird.
// once a user has been `APPROVED` its status cannot be
// changed anymore.
const user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: 'APPROVED'
}
}, {
$set: {
status
}
}, {
new: true,
});
if (status === 'BANNED') {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'banned', // needed to know which template to render!
locals: { // specifies the template locals.
body: 'In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.'
},
subject: 'Your account has been banned',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
await MailerService.sendSimple(options);
}
}
return user;
}
/**
* Suspend a user until specified time.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static async suspendUser(id, message, until) {
const user = await UserModel.findOneAndUpdate({id}, {
$set: {
suspension: {
until,
},
}
}, {
new: true,
});
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Your account has been suspended',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
await MailerService.sendSimple(options);
}
}
return user;
}
/**
* Reject username. It changes the status to BANNED and canEditName to True.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static async rejectUsername(id, message) {
const user = await UserModel.findOneAndUpdate({id}, {
$set: {
status: 'BANNED',
canEditName: true,
}
}, {
new: true,
});
if (message) {
let localProfile = user.profiles.find(({provider}) => provider === 'local');
if (localProfile) {
const options = {
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Email Suspension',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
await MailerService.sendSimple(options);
}
}
return user;
}
/**
* Finds a user with the id.
* @param {String} id user id (uuid)
@@ -567,7 +653,7 @@ module.exports = class UsersService {
const [user, domainValidated] = await Promise.all([
UserModel.findOne({profiles: {$elemMatch: {id: email}}}),
Domainlist.urlCheck(loc),
DomainList.urlCheck(loc),
]);
if (!user) {
@@ -579,7 +665,7 @@ module.exports = class UsersService {
// If the domain didn't match any of the whitelisted domains and if it
// didn't match the mount domain, then throw an error.
if (!domainValidated && !Domainlist.matchMount(loc)) {
if (!domainValidated && !DomainList.matchMount(loc)) {
throw new Error('user supplied location exists on non-permitted domain');
}
@@ -727,7 +813,7 @@ module.exports = class UsersService {
*/
static async createEmailConfirmToken(user, email, referer = ROOT_URL) {
if (!email || typeof email !== 'string') {
throw new Error('email is required when creating a JWT for resetting passord');
throw new Error('email is required when creating a JWT for resetting password');
}
// Conform the email to lowercase.
@@ -791,97 +877,12 @@ module.exports = class UsersService {
});
}
/**
* Returns all users with pending 'ADMIN'ation actions.
* @return {Promise}
*/
static moderationQueue() {
return UserModel.find({status: 'PENDING'});
}
/**
* Gives the user the ability to edit their username.
* @param {String} id the id of the user to be toggled.
* @param {Boolean} canEditName sets whether the user can edit their name.
* @return {Promise}
*/
static toggleNameEdit(id, canEditName) {
return UserModel.update({id}, {
$set: {canEditName}
});
}
/**
* Updates the user's username.
* @param {String} id The id of the user.
* @param {String} username The new username for the user.
* @return {Promise}
*/
static async editName(id, username) {
// TODO: Revisit this when we revamped User status workflows.
const queryUsernameRejected = {
id,
username: {$ne: username},
status: 'BANNED',
canEditName: true
};
const queryCreateUsername = {
id,
status: 'ACTIVE',
canEditName: true
};
try {
const result = await UserModel.findOneAndUpdate({
$or: [queryUsernameRejected, queryCreateUsername],
}, {
$set: {
username: username,
lowercaseUsername: username.toLowerCase(),
canEditName: false,
status: 'PENDING',
}
}, {
new: true,
});
if (!result) {
const user = await UsersService.findById(id);
if (user === null) {
throw errors.ErrNotFound;
}
if (!user.canEditName) {
throw errors.ErrPermissionUpdateUsername;
}
if (user.username === username) {
throw errors.ErrSameUsernameProvided;
}
throw new Error('edit username failed for an unexpected reason');
}
return result;
}
catch(err) {
if (err.code === 11000) {
throw errors.ErrUsernameTaken;
}
throw err;
}
}
/**
* Ignore another user
* @param {String} id the id of the user that is ignoring another users
* @param {Array<String>} usersToIgnore Array of user IDs to ignore
*/
static async ignoreUsers(id, usersToIgnore) {
assert(Array.isArray(usersToIgnore), 'usersToIgnore is an array');
assert(usersToIgnore.every((u) => typeof u === 'string'), 'usersToIgnore is an array of string user IDs');
if (usersToIgnore.includes(id)) {
throw new Error('Users cannot ignore themselves');
}
@@ -891,7 +892,6 @@ module.exports = class UsersService {
throw errors.ErrCannotIgnoreStaff;
}
// TODO: For each usersToIgnore, make sure they exist?
return UserModel.update({id}, {
$addToSet: {
ignoresUsers: {
@@ -907,15 +907,66 @@ module.exports = class UsersService {
* @param {Array<String>} usersToStopIgnoring Array of user IDs to stop ignoring
*/
static async stopIgnoringUsers(id, usersToStopIgnoring) {
assert(Array.isArray(usersToStopIgnoring), 'usersToStopIgnoring is an array');
assert(usersToStopIgnoring.every((u) => typeof u === 'string'), 'usersToStopIgnoring is an array of string user IDs');
await UserModel.update({id}, {
$pullAll: {
ignoresUsers: usersToStopIgnoring
}
});
}
};
}
module.exports = UsersService;
events.on(USERS_BAN_CHANGE, async (user, status) => {
// Check to see if the user was banned now and is currently banned.
if (user.banned && status) {
await UsersService.sendEmail(user, {
template: 'banned',
locals: {
body: 'In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.'
},
subject: 'Your account has been banned',
});
}
});
events.on(USERS_SUSPENSION_CHANGE, async (user, until) => {
// Check to see if the user was suspended now and is currently suspended.
if (user.suspended && until !== null && until > Date.now()) {
const {organizationName} = await SettingsService.retrieve();
const message = i18n.t(
'suspenduser.email_message_suspend',
user.username,
organizationName,
timeago(until),
);
await UsersService.sendEmail(user, {
template: 'suspension',
locals: {
body: message,
},
subject: 'Your account has been banned',
});
}
});
events.on(USERS_USERNAME_STATUS_CHANGE, async (user, status) => {
if (status === 'REJECTED') {
const message = i18n.t('reject_username.email_message_reject');
await UsersService.sendEmail(user, {
template: 'suspension',
locals: {
body: message
},
subject: 'Username Rejected'
});
}
});
// Extract all the tokenUserNotFound plugins so we can integrate with other
// providers.
+1 -2
View File
@@ -26,7 +26,6 @@ beforeEach(async () => {
}));
});
after(function(done) {
after(async function() {
mongoose.disconnect();
done();
});
+3
View File
@@ -0,0 +1,3 @@
process.on('unhandledRejection', function(reason, promise) {
console.error(promise);
});
+1 -1
View File
@@ -48,7 +48,7 @@ describe('graph.mutations.addTag', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
'banned moderator': new UserModel({roles: ['MODERATOR'], banned: true})
}).forEach(([ userDescription, user ]) => {
it(userDescription, async () => {
const context = new Context({user});
@@ -0,0 +1,82 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const SettingsService = require('../../../../services/settings');
const UsersService = require('../../../../services/users');
const {expect} = require('chai');
describe('graph.mutations.changeUsername', () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('test@test.com', 'testpassword1!', 'kirk');
expect(user).to.have.property('username', 'kirk');
expect(user).to.have.property('lowercaseUsername', 'kirk');
expect(user.status.username.status).to.equal('SET');
});
const changeUsernameMutation = `
mutation ChangeUsername($user_id: ID!, $username: String!) {
changeUsername(id: $user_id, username: $username) {
errors {
translation_key
}
}
}
`;
[
{roles: null},
{roles: ['STAFF']},
{roles: []},
{roles: ['MODERATOR']},
{roles: ['ADMIN']},
{roles: ['ADMIN', 'MODERATOR']},
].forEach(({roles}) => {
it(`can change the username with roles ${roles && roles.length ? roles : JSON.stringify(roles)}`, async () => {
let username = 'spock';
let ctx = new Context({user});
let res = await graphql(schema, changeUsernameMutation, {}, ctx, {
user_id: user.id,
username,
});
if (res.errors && res.errors.length > 0) {
console.error(res.errors);
}
expect(res.errors).to.be.undefined;
expect(res.data.changeUsername).to.have.property('errors');
expect(res.data.changeUsername.errors).to.have.length(1);
expect(res.data.changeUsername.errors[0]).to.have.property('translation_key', 'NOT_AUTHORIZED');
// Set the user to the desired status.
user = await UsersService.setUsernameStatus(user.id, 'REJECTED');
expect(user.status.username.status, 'REJECTED');
ctx = new Context({user});
res = await graphql(schema, changeUsernameMutation, {}, ctx, {
user_id: user.id,
username,
});
if (res.errors && res.errors.length > 0) {
console.error(res.errors);
}
expect(res.errors).to.be.undefined;
expect(res.data.changeUsername).to.be.null;
user = await UsersService.findById(user.id);
expect(user.status.username.status, 'CHANGED');
});
});
});
+55 -61
View File
@@ -71,27 +71,28 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => AssetModel.create({id: '123'}));
[
{user: new UserModel({status: 'ACTIVE'}), error: null},
{user: new UserModel({status: 'BANNED'}), error: 'NOT_AUTHORIZED'},
{user: new UserModel({status: 'PENDING'}), error: null},
{user: new UserModel({status: 'APPROVED'}), error: null}
{user: new UserModel({}), error: null},
{user: new UserModel({banned: true}), error: 'NOT_AUTHORIZED'},
{user: new UserModel({suspended: new Date((new Date()).getTime() - (10 * 86400000))}), error: null},
{user: new UserModel({suspended: new Date((new Date()).getTime() + (10 * 86400000))}), error: 'NOT_AUTHORIZED'},
].forEach(({user, error}) => {
describe(`user.status=${user.status}`, () => {
it(error ? 'does not create the comment' : 'creates the comment', () => {
describe(`user.banned=${user.banned} user.suspended=${user.suspended}`, () => {
it(error ? 'does not create the comment' : 'creates the comment', async () => {
const context = new Context({user});
const {data, errors} = await graphql(schema, query, {}, context);
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
expect(errors).to.be.undefined;
if (error) {
expect(data.createComment).to.have.property('comment').null;
expect(data.createComment).to.have.property('errors').not.null;
expect(data.createComment.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
}
});
expect(errors).to.be.undefined;
if (error) {
expect(data.createComment).to.have.property('comment').null;
expect(data.createComment).to.have.property('errors').not.null;
expect(data.createComment.errors[0]).to.have.property('translation_key', error);
} else {
if (data.createComment.errors && data.createComment.errors.length > 0) {
console.error(data.createComment.errors);
}
expect(data.createComment).to.have.property('errors').null;
expect(data.createComment).to.have.property('comment').not.null;
}
});
});
});
@@ -109,7 +110,7 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => asset.save());
it(error ? 'does not create the comment' : 'creates the comment', () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
const context = new Context({user: new UserModel({})});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
@@ -142,7 +143,7 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => AssetModel.create({id: '123', settings: {moderation}}));
it(`creates comment with status=${status}`, () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
const context = new Context({user: new UserModel()});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
@@ -172,33 +173,30 @@ describe('graph.mutations.createComment', () => {
].forEach(({message, body, status, flagged}) => {
describe(message, () => {
it(`should create a comment with status=${status} and it ${flagged ? 'should' : 'should not'} be flagged`, () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
it(`should create a comment with status=${status} and it ${flagged ? 'should' : 'should not'} be flagged`, async () => {
const context = new Context({user: new UserModel({})});
return graphql(schema, query, {}, context, {
const {data, errors} = await graphql(schema, query, {}, context, {
input: {
asset_id: '123',
body
}
})
.then(({data, errors}) => {
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment.comment).to.have.property('status', status);
expect(data.createComment).to.have.property('errors').null;
});
return ActionModel.find({
item_id: data.createComment.comment.id,
action_type: 'FLAG'
});
})
.then((actions) => {
if (flagged) {
expect(actions).to.have.length(1);
} else {
expect(actions).to.have.length(0);
}
});
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment.comment).to.have.property('status', status);
expect(data.createComment).to.have.property('errors').null;
const actions = await ActionModel.find({
item_id: data.createComment.comment.id,
action_type: 'FLAG'
});
if (flagged) {
expect(actions).to.have.length(1);
} else {
expect(actions).to.have.length(0);
}
});
});
@@ -217,30 +215,26 @@ describe('graph.mutations.createComment', () => {
].forEach(({roles, tag}) => {
describe(`user.roles=${JSON.stringify(roles)}`, () => {
it(`creates comment ${tag ? `with tag=${tag}` : 'without tags'}`, () => {
it(`creates comment ${tag ? `with tag=${tag}` : 'without tags'}`, async () => {
const context = new Context({user: new UserModel({roles})});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
if (errors) {
console.error(errors);
}
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
const {data, errors} = await graphql(schema, query, {}, context);
return CommentsService.findById(data.createComment.comment.id);
})
.then(({tags}) => {
if (tag) {
expect(tags).to.have.length(1);
expect(tags[0].tag.name).to.have.equal(tag);
} else {
expect(tags).length(0);
}
});
if (errors) {
console.error(errors);
}
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
const {tags} = await CommentsService.findById(data.createComment.comment.id);
if (tag) {
expect(tags).to.have.length(1);
expect(tags[0].tag.name).to.have.equal(tag);
} else {
expect(tags).length(0);
}
});
});
});
+1 -1
View File
@@ -66,7 +66,7 @@ describe('graph.mutations.removeTag', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
'banned moderator': new UserModel({roles: ['MODERATOR'], banned: true})
}).forEach(([userDescription, user]) => {
it(userDescription, async function () {
const context = new Context({user});
@@ -0,0 +1,104 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const SettingsService = require('../../../../services/settings');
const UserModel = require('../../../../models/user');
const UsersService = require('../../../../services/users');
const {expect} = require('chai');
describe('graph.mutations.setUserBanStatus', () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
});
const setUserBanStatusMutation = `
mutation SetUserBanStatus($user_id: ID!, $status: Boolean!) {
setUserBanStatus(input: {
id: $user_id,
status: $status
}) {
errors {
translation_key
}
}
}
`;
[
{self: true, error: 'NOT_AUTHORIZED', roles: null},
{self: true, error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{self: true, error: 'NOT_AUTHORIZED', roles: []},
{error: 'NOT_AUTHORIZED', roles: null},
{error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{error: 'NOT_AUTHORIZED', roles: []},
{error: false, roles: ['MODERATOR']},
{error: false, roles: ['ADMIN']},
{error: false, roles: ['ADMIN', 'MODERATOR']},
].forEach(({self, error, roles}) => {
it(`${error ? 'can not' : 'can'} ban ${self ? 'themself' : 'another user'} as a user with roles ${roles && roles.length ? roles : JSON.stringify(roles)}`, async () => {
const actor = new UserModel({roles});
// If we're testing self assign, set the id of the actor to the user
// we're acting on.
if (self) {
actor.id = user.id;
}
const ctx = new Context({user: actor});
const {data, errors} = await graphql(schema, setUserBanStatusMutation, {}, ctx, {
user_id: user.id,
status: true
});
if (errors && errors.length > 0) {
console.error(errors);
}
expect(errors).to.be.undefined;
if (error) {
expect(data.setUserBanStatus).to.have.property('errors').not.null;
expect(data.setUserBanStatus.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.setUserBanStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
expect(user.status.banned.status).to.be.true;
expect(user.status.banned.history).to.have.length(1);
expect(user.status.banned.history[0]).to.have.property('status', true);
expect(user.status.banned.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.banned.history[0]).to.have.property('created_at').not.null;
expect(user.banned).to.be.true;
const res = await graphql(schema, setUserBanStatusMutation, {}, ctx, {
user_id: user.id,
status: false
});
if (res.errors && res.errors.length > 0) {
console.error(res.errors);
}
expect(res.errors).to.be.undefined;
expect(res.data.setUserBanStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
expect(user.status.banned.status).to.be.false;
expect(user.status.banned.history).to.have.length(2);
expect(user.status.banned.history[0]).to.have.property('status').to.be.true;
expect(user.status.banned.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.banned.history[0]).to.have.property('created_at').not.null;
expect(user.status.banned.history[1]).to.have.property('status').to.be.false;
expect(user.status.banned.history[1]).to.have.property('assigned_by', actor.id);
expect(user.status.banned.history[1]).to.have.property('created_at').not.null;
expect(user.banned).to.be.false;
}
});
});
});
@@ -0,0 +1,115 @@
const {graphql} = require('graphql');
const timekeeper = require('timekeeper');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const SettingsService = require('../../../../services/settings');
const UserModel = require('../../../../models/user');
const UsersService = require('../../../../services/users');
const chai = require('chai');
chai.use(require('chai-datetime'));
const {expect} = chai;
describe('graph.mutations.setUserSuspensionStatus', () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
});
const setUserSuspensionStatusMutation = `
mutation SetUserUsernameStatus($user_id: ID!, $until: Date) {
setUserSuspensionStatus(input: {
id: $user_id,
until: $until
}) {
errors {
translation_key
}
}
}
`;
[
{self: true, error: 'NOT_AUTHORIZED', roles: null},
{self: true, error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{self: true, error: 'NOT_AUTHORIZED', roles: []},
{error: 'NOT_AUTHORIZED', roles: null},
{error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{error: 'NOT_AUTHORIZED', roles: []},
{error: false, roles: ['MODERATOR']},
{error: false, roles: ['ADMIN']},
{error: false, roles: ['ADMIN', 'MODERATOR']},
].forEach(({self, error, roles}) => {
it(`${error ? 'can not' : 'can'} suspend ${self ? 'themself' : 'another user'} as a user with roles ${roles && roles.length ? roles : JSON.stringify(roles)}`, async () => {
const actor = new UserModel({roles});
// If we're testing self assign, set the id of the actor to the user
// we're acting on.
if (self) {
actor.id = user.id;
}
const ctx = new Context({user: actor});
const now = new Date();
const oneHourFromNow = new Date(new Date(now).setHours(now.getHours() + 1));
const {data, errors} = await graphql(schema, setUserSuspensionStatusMutation, {}, ctx, {
user_id: user.id,
until: oneHourFromNow
});
if (errors && errors.length > 0) {
console.error(errors);
}
expect(errors).to.be.undefined;
if (error) {
expect(data.setUserSuspensionStatus).to.have.property('errors').not.null;
expect(data.setUserSuspensionStatus.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.setUserSuspensionStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
// Mongoose messes with the date, check within a 2 second window.
expect(user.status.suspension.until).to.be.withinTime(new Date(oneHourFromNow.getTime() - 1000), new Date(oneHourFromNow.getTime() + 1000));
expect(user.status.suspension.history).to.have.length(1);
expect(user.status.suspension.history[0]).to.have.property('until').to.be.withinTime(new Date(oneHourFromNow.getTime() - 1000), new Date(oneHourFromNow.getTime() + 1000));
expect(user.status.suspension.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.suspension.history[0]).to.have.property('created_at').not.null;
expect(user.suspended).to.be.true;
timekeeper.travel(new Date(oneHourFromNow.getTime() + 10000));
expect(user.suspended).to.be.false;
timekeeper.reset();
const res = await graphql(schema, setUserSuspensionStatusMutation, {}, ctx, {
user_id: user.id,
until: null
});
if (res.errors && res.errors.length > 0) {
console.error(res.errors);
}
expect(res.errors).to.be.undefined;
expect(res.data.setUserSuspensionStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
// Mongoose messes with the date, check within a 2 second window.
expect(user.status.suspension.until).to.be.null;
expect(user.status.suspension.history).to.have.length(2);
expect(user.status.suspension.history[0]).to.have.property('until').to.be.withinTime(new Date(oneHourFromNow.getTime() - 1000), new Date(oneHourFromNow.getTime() + 1000));
expect(user.status.suspension.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.suspension.history[0]).to.have.property('created_at').not.null;
expect(user.status.suspension.history[1]).to.have.property('until').to.be.null;
expect(user.status.suspension.history[1]).to.have.property('assigned_by', actor.id);
expect(user.status.suspension.history[1]).to.have.property('created_at').not.null;
expect(user.suspended).to.be.false;
}
});
});
});
@@ -0,0 +1,87 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const SettingsService = require('../../../../services/settings');
const UserModel = require('../../../../models/user');
const UsersService = require('../../../../services/users');
const chai = require('chai');
chai.use(require('chai-datetime'));
const {expect} = chai;
[
{status: 'APPROVED', name: 'approve', mutation: 'approveUsername'},
{status: 'REJECTED', name: 'reject', mutation: 'rejectUsername'}
].forEach(({status, name, mutation}) => {
describe(`graph.mutations.${mutation}`, () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
});
const setUserUsernameStatusMutation = `
mutation SetUserUsernameStatus($user_id: ID!) {
${mutation}(id: $user_id) {
errors {
translation_key
}
}
}
`;
[
{self: true, error: 'NOT_AUTHORIZED', roles: null},
{self: true, error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{self: true, error: 'NOT_AUTHORIZED', roles: []},
{error: 'NOT_AUTHORIZED', roles: null},
{error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{error: 'NOT_AUTHORIZED', roles: []},
{error: false, roles: ['MODERATOR']},
{error: false, roles: ['ADMIN']},
{error: false, roles: ['ADMIN', 'MODERATOR']},
].forEach(({self, error, roles}) => {
it(`${error ? 'can not' : 'can'} ${name} a username with the user roles ${roles && roles.length ? roles : JSON.stringify(roles)}${self ? ' on themself' : ''}`, async () => {
const actor = new UserModel({roles});
// If we're testing self assign, set the id of the actor to the user
// we're acting on.
if (self) {
actor.id = user.id;
}
const ctx = new Context({user: actor});
const {data, errors} = await graphql(schema, setUserUsernameStatusMutation, {}, ctx, {
user_id: user.id,
});
if (errors && errors.length > 0) {
console.error(errors);
}
expect(errors).to.be.undefined;
if (error) {
expect(data[mutation]).to.have.property('errors').not.null;
expect(data[mutation].errors[0]).to.have.property('translation_key', error);
} else {
expect(data[mutation]).to.be.null;
user = await UserModel.findOne({id: user.id});
expect(user.status.username.status).to.equal(status);
expect(user.status.username.history).to.have.length(2);
expect(user.status.username.history[0]).to.have.property('status', 'SET');
expect(user.status.username.history[0]).to.have.property('assigned_by').is.null;
expect(user.status.username.history[0]).to.have.property('created_at').not.null;
expect(user.status.username.history[1]).to.have.property('status', status);
expect(user.status.username.history[1]).to.have.property('assigned_by', actor.id);
expect(user.status.username.history[1]).to.have.property('created_at').not.null;
expect(user.status.username.history[1].created_at).afterTime(user.status.username.history[0].created_at);
}
});
});
});
});
@@ -22,8 +22,7 @@ describe('graph.mutations.updateAssetSettings', () => {
translation_key
}
}
}
`;
}`;
describe('context with different user roles', () => {
@@ -21,8 +21,7 @@ describe('graph.mutations.updateSettings', () => {
translation_key
}
}
}
`;
}`;
describe('context with different user roles', () => {
@@ -0,0 +1,203 @@
const migration = require('../../../migrations/1510174676_user_status');
const UserModel = require('../../../models/user');
const chai = require('chai');
chai.use(require('chai-datetime'));
const {expect} = chai;
describe('migration.1510174676_user_status', () => {
describe('active user', () => {
beforeEach(async () => {
await UserModel.collection.insert({
id: '123',
username: 'Kirk',
lowercaseUsername: 'kirk',
status: 'ACTIVE',
canEditName: false
});
});
it('completes the migration', async () => {
let user = await UserModel.collection.findOne({id: '123'});
expect(user).to.have.property('status', 'ACTIVE');
expect(user).to.have.property('canEditName', false);
// Perform the migration.
await migration.up();
user = await UserModel.collection.findOne({id: '123'});
// Check that it was correct.
expect(user).to.have.property('status');
expect(user.status).to.have.property('username');
expect(user.status.username).to.have.property('status', 'SET');
expect(user.status.username.history).to.have.length(1);
});
});
describe('social user', () => {
beforeEach(async () => {
await UserModel.collection.insert({
id: '123',
username: 'Kirk',
lowercaseUsername: 'kirk',
status: 'ACTIVE',
canEditName: true
});
});
it('completes the migration', async () => {
let user = await UserModel.collection.findOne({id: '123'});
expect(user).to.have.property('status', 'ACTIVE');
expect(user).to.have.property('canEditName', true);
// Perform the migration.
await migration.up();
user = await UserModel.collection.findOne({id: '123'});
// Check that it was correct.
expect(user).to.have.property('status');
expect(user.status).to.have.property('username');
expect(user.status.username).to.have.property('status', 'UNSET');
expect(user.status.username.history).to.have.length(1);
});
});
describe('rejected username', () => {
beforeEach(async () => {
await UserModel.collection.insert({
id: '123',
username: 'Kirk',
lowercaseUsername: 'kirk',
status: 'BANNED',
canEditName: true
});
});
it('completes the migration', async () => {
let user = await UserModel.collection.findOne({id: '123'});
expect(user).to.have.property('status');
expect(user.status).to.equal('BANNED');
expect(user.canEditName).to.equal(true);
// Perform the migration.
await migration.up();
user = await UserModel.collection.findOne({id: '123'});
// Check that it was correct.
expect(user).to.have.property('status');
expect(user.status).to.have.property('banned');
expect(user.status.banned).to.have.property('status', false);
expect(user.status.username).to.have.property('status', 'REJECTED');
expect(user.status.username.history).to.have.length(1);
});
});
describe('approved username', () => {
beforeEach(async () => {
await UserModel.collection.insert({
id: '123',
username: 'Kirk',
lowercaseUsername: 'kirk',
status: 'APPROVED',
canEditName: false
});
});
it('completes the migration', async () => {
let user = await UserModel.collection.findOne({id: '123'});
expect(user).to.have.property('status');
expect(user.status).to.equal('APPROVED');
expect(user.canEditName).to.equal(false);
// Perform the migration.
await migration.up();
user = await UserModel.collection.findOne({id: '123'});
// Check that it was correct.
expect(user).to.have.property('status');
expect(user.status).to.have.property('banned');
expect(user.status.banned).to.have.property('status', false);
expect(user.status.username).to.have.property('status', 'APPROVED');
expect(user.status.username.history).to.have.length(1);
});
});
describe('suspended user', () => {
beforeEach(async () => {
await UserModel.collection.insert({
id: '123',
username: 'Kirk',
lowercaseUsername: 'kirk',
status: 'ACTIVE',
suspension: {
until: new Date()
}
});
});
it('completes the migration', async () => {
let user = await UserModel.collection.findOne({id: '123'});
expect(user).to.have.property('suspension');
expect(user.suspension).to.have.property('until');
expect(user.suspension.until).to.not.be.null;
const until = user.suspension.until;
// Perform the migration.
await migration.up();
user = await UserModel.collection.findOne({id: '123'});
// Check that it was correct.
expect(user).to.have.property('status');
expect(user.status).to.have.property('suspension');
expect(user.status.suspension).to.have.property('until');
expect(user.status.suspension.until).to.not.be.null;
expect(user.status.suspension.until).to.be.withinTime(new Date(until.getTime() - 1000), new Date(until.getTime() + 1000));
});
});
describe('banned user', () => {
beforeEach(async () => {
await UserModel.collection.insert({
id: '123',
username: 'Kirk',
lowercaseUsername: 'kirk',
status: 'BANNED',
canEditName: false
});
});
it('completes the migration', async () => {
let user = await UserModel.collection.findOne({id: '123'});
expect(user).to.have.property('status');
expect(user.status).to.equal('BANNED');
// Perform the migration.
await migration.up();
user = await UserModel.collection.findOne({id: '123'});
// Check that it was correct.
expect(user).to.have.property('status');
expect(user.status).to.have.property('banned');
expect(user.status.banned).to.have.property('status', true);
});
});
});
-57
View File
@@ -1,57 +0,0 @@
const passport = require('../../../passport');
const app = require('../../../../../app');
const UsersService = require('../../../../../services/users');
const SettingsService = require('../../../../../services/settings');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
const chai = require('chai');
chai.should();
chai.use(require('chai-http'));
const expect = chai.expect;
describe('/api/v1/account/username', () => {
let mockUser;
beforeEach(async () => {
await SettingsService.init(settings);
mockUser = await UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
});
describe('#put', () => {
it('it should enable a user to edit their username if canEditName is enabled', async () => {
await chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}));
const res = await chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: mockUser.id, roles: []}))
.send({username: 'MojoJojo'});
expect(res).to.have.status(204);
});
it('it should return an error if the wrong user tries to edit a username', async () => {
await chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}));
let res = chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: 'wrongid', roles: []}))
.send({username: 'MojoJojo'});
return expect(res).to.eventually.be.rejected;
});
it('it should return an error when the user tries to edit their username if canEditName is disabled', () => {
let res = chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: mockUser.id, roles: []}))
.send({username: 'MojoJojo'});
return expect(res).to.eventually.be.rejected;
});
});
});
-49
View File
@@ -48,52 +48,3 @@ describe('/api/v1/users/:user_id/email/confirm', () => {
});
});
});
describe('/api/v1/users/:user_id/actions', () => {
let mockUser;
beforeEach(() => SettingsService.init(settings).then(() => {
return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
}));
describe('#post', () => {
it('it should update actions', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/actions`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.send({'action_type': 'FLAG', metadata: {reason: 'Bio is too awesome.'}})
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.body;
expect(res.body).to.have.property('action_type', 'FLAG');
expect(res.body).to.have.property('item_id', mockUser.id);
});
});
});
});
describe('/api/v1/users/:user_id/username-enable', () => {
let mockUser;
beforeEach(() => SettingsService.init(settings).then(() => {
return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
}));
describe('#post', () => {
it('it should enable a user to edit their username', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(204);
});
});
});
});
+2 -2
View File
@@ -75,7 +75,7 @@ describe('services.ActionsService', () => {
expect(retrievedAction).has.property('item_id', comment.id);
});
it('fires the callback sucesfully', async () => {
it('fires the callback successfully', async () => {
const srcAction = {
action_type: 'LIKE',
item_type: 'COMMENTS',
@@ -113,7 +113,7 @@ describe('services.ActionsService', () => {
expect(retrievedAction).is.null;
});
it('fires the callback sucesfully', async () => {
it('fires the callback successfully', async () => {
const spy = sinon.spy();
events.once(ACTIONS_DELETE, spy);
@@ -1,27 +1,27 @@
const expect = require('chai').expect;
const Domainlist = require('../../../services/domainlist');
const DomainList = require('../../../services/domain_list');
const SettingsService = require('../../../services/settings');
describe('services.Domainlist', () => {
describe('services.DomainList', () => {
const domainlists = {
const domainLists = {
whitelist: [
'nytimes.com',
'wapo.com'
]
};
let domainlist = new Domainlist();
let domainList = new DomainList();
const settings = {id: '1', moderation: 'PRE', domainlist: {whitelist: ['nytimes.com', 'wapo.com']}};
beforeEach(() => SettingsService.init(settings));
describe('#init', () => {
before(() => domainlist.upsert(domainlists));
before(() => domainList.upsert(domainLists));
it('has entries', () => {
expect(domainlist.lists.whitelist).to.not.be.empty;
expect(domainList.lists.whitelist).to.not.be.empty;
});
});
@@ -92,21 +92,21 @@ describe('services.Domainlist', () => {
['google.Ca:80', 'google.ca'],
['google.Ca:443', 'google.ca'],
].forEach(([domain, hostname]) => {
expect(Domainlist.parseURL(domain), `domain ${domain} should be parsed as ${hostname}`).to.equal(hostname);
expect(DomainList.parseURL(domain), `domain ${domain} should be parsed as ${hostname}`).to.equal(hostname);
});
});
});
describe('#match', () => {
const whiteList = Domainlist.parseList(domainlists['whitelist']);
const whiteList = DomainList.parseList(domainLists['whitelist']);
it('does match on an included domain', () => {
[
'http://wapo.com',
'nytimes.com'
].forEach((domain) => {
expect(domainlist.match(whiteList, domain)).to.be.true;
expect(domainList.match(whiteList, domain)).to.be.true;
});
});
@@ -116,7 +116,7 @@ describe('services.Domainlist', () => {
'www.badsite.com',
'otherexample.com'
].forEach((domain) => {
expect(domainlist.match(whiteList, domain)).to.be.false;
expect(domainList.match(whiteList, domain)).to.be.false;
});
});
});
-22
View File
@@ -1,22 +0,0 @@
describe('services.scraper', () => {
describe('#create', () => {
it('should create a new kue job');
});
describe('#scrape', () => {
it('should scrape complete information');
it('should scrape what it can');
});
describe('#update', () => {
it('should update the database record entries from the meta');
});
describe('#process', () => {
it('should start the processor to scrape assets');
});
describe('#shutdown', () => {
it('should shutdown the job processor');
});
});
+78 -105
View File
@@ -151,21 +151,6 @@ describe('services.UsersService', () => {
});
describe('#setStatus', () => {
it('should set the status to active', () => {
return UsersService
.setStatus(mockUsers[0].id, 'ACTIVE')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'ACTIVE');
})
.then(() => {
expect(MailerService.sendSimple).to.not.have.been.called;
});
});
});
describe('#ignoreUser', () => {
it('should add user id to ignoredUsers set', async () => {
const user = mockUsers[0];
@@ -194,54 +179,6 @@ describe('services.UsersService', () => {
});
});
describe('#ban', () => {
it('should set the status to banned', () => {
return UsersService
.setStatus(mockUsers[0].id, 'BANNED')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'BANNED');
})
.then(() => {
expect(MailerService.sendSimple).to.have.been.calledWithMatch({
template: 'banned',
to: mockUsers[0].profiles[0].id
});
});
});
it('should still disable and ban the user if there is no comment', () => {
return UsersService
.setStatus(mockUsers[0].id, 'BANNED')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'BANNED');
});
});
});
describe('#unban', () => {
it('should set the status to active', () => {
return UsersService
.setStatus(mockUsers[0].id, 'ACTIVE')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'ACTIVE');
});
});
});
describe('#toggleNameEdit', () => {
it('should toggle the canEditName field', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('canEditName', true);
});
});
});
describe('#search', () => {
it('should return all the results without a value', async () => {
expect(await UsersService.search()).to.have.length(3);
@@ -286,53 +223,90 @@ describe('services.UsersService', () => {
});
});
describe('#editName', () => {
it('should let the user edit their username if the proper toggle is set', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.editName(mockUsers[0].id, 'Jojo'))
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('username', 'Jojo');
expect(user).to.have.property('canEditName', false);
[
{func: 'changeUsername', okStatus: 'REJECTED', notOKStatus: 'UNSET', newStatus: 'CHANGED'},
{func: 'setUsername', okStatus: 'UNSET', notOKStatus: 'REJECTED', newStatus: 'SET'},
].forEach(({func, okStatus, notOKStatus, newStatus}) => {
describe(`#${func}`, () => {
[
{status: okStatus},
{error: 'EDIT_USERNAME_NOT_AUTHORIZED', status: notOKStatus},
{error: 'EDIT_USERNAME_NOT_AUTHORIZED', status: 'SET'},
{error: 'EDIT_USERNAME_NOT_AUTHORIZED', status: 'APPROVED'},
{error: 'EDIT_USERNAME_NOT_AUTHORIZED', status: 'CHANGED'},
].forEach(({status, error}) => {
it(`${error ? 'should not' : 'should'} let them change the username if they have the status of ${status}`, async () => {
const user = mockUsers[0];
// Set the user to the desired status.
await UsersService.setUsernameStatus(user.id, status);
try {
await UsersService[func](user.id, 'spock');
} catch (err) {
if (error) {
expect(err).have.property('translation_key', error);
} else {
throw err;
}
}
});
});
});
it('should let the user submit the same username if user is not banned (create username)', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('username', mockUsers[0].username);
expect(user).to.have.property('canEditName', false);
});
});
it(`should change the status to ${newStatus} when changed`, async () => {
const user = mockUsers[0];
it('should return error when a banned user submits the same username (rejected username)', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.setStatus(mockUsers[0].id, 'BANNED'))
.then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
.then(() => UsersService.findById(mockUsers[0].id))
.then(() => {
throw new Error('Error expected');
})
.catch((err) => {
expect(err.status).to.equal(400);
expect(err.translation_key).to.equal('SAME_USERNAME_PROVIDED');
});
});
// Set the user to the desired status.
await UsersService.setUsernameStatus(user.id, okStatus);
it('should return an error if canEditName is false', async () => {
return expect(UsersService.editName(mockUsers[0].id, 'Jojo')).to.eventually.be.rejected;
});
const editedUser = await UsersService[func](user.id, 'spock');
it('should return an error if the username is already taken', async () => {
await UsersService.toggleNameEdit(mockUsers[0].id, true);
return expect(UsersService.editName(mockUsers[0].id, 'Marvel')).to.eventually.be.rejected;
});
expect(editedUser.status.username.status).to.equal(newStatus);
try {
await UsersService[func](user.id, 'spock');
throw new Error('edit was processed successfully');
} catch (err) {
expect(err).have.property('translation_key', 'EDIT_USERNAME_NOT_AUTHORIZED');
}
});
it(`${func === 'changeUsername' ? 'should' : 'should not'} refuse changing the username to the same username`, async () => {
const user = mockUsers[0];
// Set the user to the desired status.
await UsersService.setUsernameStatus(user.id, okStatus);
if (func === 'changeUsername') {
try {
await UsersService[func](user.id, user.username);
throw new Error('edit was processed successfully');
} catch (err) {
expect(err).have.property('translation_key', 'SAME_USERNAME_PROVIDED');
}
} else {
await UsersService[func](user.id, user.username);
}
});
it('should refuse changing the username to one already taken', async () => {
const user = mockUsers[0];
const otherUser = mockUsers[1];
// Set the user to the desired status.
await UsersService.setUsernameStatus(user.id, okStatus);
try {
await UsersService[func](user.id, otherUser.username);
throw new Error('edit was processed successfully');
} catch (err) {
expect(err).have.property('translation_key', 'USERNAME_IN_USE');
}
});
});
});
describe('#isValidUsername', () => {
it('should not allow non-alphanumeric characters in usernames', () => {
return UsersService
.isValidUsername('hi🖕')
@@ -344,5 +318,4 @@ describe('services.UsersService', () => {
});
});
});
});
+175 -101
View File
@@ -72,7 +72,7 @@ abab@^1.0.0, abab@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e"
abbrev@1, abbrev@1.0.x:
abbrev@1:
version "1.0.9"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135"
@@ -174,9 +174,9 @@ always-error@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/always-error/-/always-error-1.0.0.tgz#95c84042cfa86f38c86ca6c2cc42c0a0103441b2"
am-i-a-dependency@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/am-i-a-dependency/-/am-i-a-dependency-1.0.0.tgz#7c0e2eb126045350852e26e44f6781b3ed387919"
am-i-a-dependency@1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/am-i-a-dependency/-/am-i-a-dependency-1.1.2.tgz#f9d3422304d6f642f821e4c407565035f6167f1f"
amdefine@>=0.0.4:
version "1.0.1"
@@ -192,10 +192,6 @@ ansi-escapes@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
ansi-escapes@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-2.0.0.tgz#5bae52be424878dd9783e8910e3fc2922e83c81b"
ansi-escapes@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92"
@@ -306,6 +302,10 @@ array-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
array-filter@~0.0.0:
version "0.0.1"
resolved "https://registry.yarnpkg.com/array-filter/-/array-filter-0.0.1.tgz#7da8cf2e26628ed732803581fd21f67cacd2eeec"
array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
@@ -317,6 +317,14 @@ array-includes@^3.0.3:
define-properties "^1.1.2"
es-abstract "^1.7.0"
array-map@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662"
array-reduce@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b"
array-union@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
@@ -389,10 +397,6 @@ async-limiter@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
async@1.x, async@^1.4.0, async@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
async@2.1.4:
version "2.1.4"
resolved "https://registry.yarnpkg.com/async/-/async-2.1.4.tgz#2d2160c7788032e4dd6cbe2502f1f9a2c8f6cde4"
@@ -405,6 +409,10 @@ async@2.4.1, async@^2.1.2, async@^2.1.4:
dependencies:
lodash "^4.14.0"
async@^1.4.0, async@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
async@~0.9.0:
version "0.9.2"
resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
@@ -1126,7 +1134,7 @@ bluebird@3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
bluebird@^3.3.4, bluebird@^3.4.6, bluebird@^3.5.0, bluebird@^3.5.1:
bluebird@3.5.1, bluebird@^3.3.4, bluebird@^3.4.6, bluebird@^3.5.0, bluebird@^3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
@@ -1429,6 +1437,12 @@ chai-as-promised@^6.0.0:
dependencies:
check-error "^1.0.2"
chai-datetime@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/chai-datetime/-/chai-datetime-1.5.0.tgz#3742f18b024c75b76a2b7eee291662324467596c"
dependencies:
chai ">1.9.0"
chai-http@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/chai-http/-/chai-http-3.0.0.tgz#5460d8036e1f1a12b0b5b5cbd529e6dc1d31eb4b"
@@ -1446,6 +1460,17 @@ chai-nightwatch@~0.1.x:
assertion-error "1.0.0"
deep-eql "0.1.3"
chai@>1.9.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.1.2.tgz#0f64584ba642f0f2ace2806279f4f06ca23ad73c"
dependencies:
assertion-error "^1.0.1"
check-error "^1.0.1"
deep-eql "^3.0.0"
get-func-name "^2.0.0"
pathval "^1.0.0"
type-detect "^4.0.0"
chai@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/chai/-/chai-3.5.0.tgz#4d02637b067fe958bdbfdd3a40ec56fef7373247"
@@ -1458,9 +1483,9 @@ chain-function@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
chalk@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.0.1.tgz#dbec49436d2ae15f536114e76d14656cdbc0f44d"
chalk@2.3.0, chalk@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba"
dependencies:
ansi-styles "^3.1.0"
escape-string-regexp "^1.0.5"
@@ -1476,7 +1501,7 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
strip-ansi "^3.0.0"
supports-color "^2.0.0"
chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0:
chalk@^2.0.0, chalk@^2.0.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.2.0.tgz#477b3bf2f9b8fd5ca9e429747e37f724ee7af240"
dependencies:
@@ -1508,7 +1533,7 @@ chdir-promise@0.4.1:
q "1.5.0"
spots "0.5.0"
check-error@^1.0.2:
check-error@^1.0.1, check-error@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
@@ -2177,14 +2202,13 @@ cz-conventional-changelog@1.1.5:
dependencies:
word-wrap "^1.0.3"
cz-conventional-changelog@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-2.0.0.tgz#55a979afdfe95e7024879d2a0f5924630170b533"
cz-conventional-changelog@2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cz-conventional-changelog/-/cz-conventional-changelog-2.1.0.tgz#2f4bc7390e3244e4df293e6ba351e4c740a7c764"
dependencies:
conventional-commit-types "^2.0.0"
lodash.map "^4.5.1"
longest "^1.0.1"
pad-right "^0.2.2"
right-pad "^1.0.1"
word-wrap "^1.0.3"
@@ -2256,6 +2280,12 @@ deep-eql@0.1.3, deep-eql@^0.1.3:
dependencies:
type-detect "0.1.1"
deep-eql@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df"
dependencies:
type-detect "^4.0.0"
deep-extend@~0.4.0:
version "0.4.2"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
@@ -2587,13 +2617,13 @@ errno@^0.1.3, errno@^0.1.4:
dependencies:
prr "~0.0.0"
error-ex@^1.2.0:
error-ex@^1.2.0, error-ex@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc"
dependencies:
is-arrayish "^0.2.1"
es-abstract@^1.6.1, es-abstract@^1.7.0:
es-abstract@^1.4.3, es-abstract@^1.6.1, es-abstract@^1.7.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.9.0.tgz#690829a07cae36b222e7fd9b75c0d0573eb25227"
dependencies:
@@ -3271,6 +3301,10 @@ get-caller-file@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
get-func-name@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
get-stream@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
@@ -3317,29 +3351,30 @@ ggit@1.23.1:
ramda "0.24.1"
semver "5.4.1"
ggit@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ggit/-/ggit-2.0.1.tgz#6cdfbb2bac9b63108a9609923eeca44205a9e8f4"
ggit@2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/ggit/-/ggit-2.4.0.tgz#b99d981f3ede2a3a8a8e4bbff578bc277d400588"
dependencies:
always-error "1.0.0"
bluebird "3.5.0"
bluebird "3.5.1"
chdir-promise "0.4.1"
check-more-types "2.24.0"
cli-table "0.3.1"
colors "1.1.2"
commander "2.11.0"
d3-helpers "0.3.0"
debug "2.6.8"
debug "3.1.0"
find-up "2.1.0"
glob "7.1.2"
lazy-ass "1.6.0"
lodash "4.17.4"
moment "2.18.1"
moment "2.19.1"
moment-timezone "0.5.13"
optimist "0.6.1"
pluralize "6.0.0"
pluralize "7.0.0"
q "2.0.3"
quote "0.4.0"
ramda "0.24.1"
ramda "0.25.0"
semver "5.4.1"
git-up@^2.0.0:
@@ -4031,26 +4066,7 @@ inquirer@0.8.2:
rx "^2.4.3"
through "^2.3.6"
inquirer@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.2.1.tgz#06ceb0f540f45ca548c17d6840959878265fa175"
dependencies:
ansi-escapes "^2.0.0"
chalk "^2.0.0"
cli-cursor "^2.1.0"
cli-width "^2.0.0"
external-editor "^2.0.4"
figures "^2.0.0"
lodash "^4.3.0"
mute-stream "0.0.7"
run-async "^2.2.0"
rx-lite "^4.0.8"
rx-lite-aggregates "^4.0.8"
string-width "^2.1.0"
strip-ansi "^4.0.0"
through "^2.3.6"
inquirer@^3.0.6, inquirer@^3.2.2:
inquirer@3.3.0, inquirer@^3.0.6, inquirer@^3.2.2:
version "3.3.0"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9"
dependencies:
@@ -4412,7 +4428,7 @@ isstream@0.1.x, isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
istanbul-api@^1.1.0-alpha, istanbul-api@^1.1.1:
istanbul-api@^1.1.1:
version "1.1.14"
resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-1.1.14.tgz#25bc5701f7c680c0ffff913de46e3619a3a6e680"
dependencies:
@@ -4475,19 +4491,6 @@ istanbul-reports@^1.1.2:
dependencies:
handlebars "^4.0.3"
istanbul@^1.1.0-alpha.1:
version "1.1.0-alpha.1"
resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-1.1.0-alpha.1.tgz#781795656018a2174c5f60f367ee5d361cb57b77"
dependencies:
abbrev "1.0.x"
async "1.x"
istanbul-api "^1.1.0-alpha"
js-yaml "3.x"
mkdirp "0.5.x"
nopt "3.x"
which "^1.1.1"
wordwrap "^1.0.0"
items@2.x.x:
version "2.1.1"
resolved "https://registry.yarnpkg.com/items/-/items-2.1.1.tgz#8bd16d9c83b19529de5aea321acaada78364a198"
@@ -4759,7 +4762,7 @@ js-yaml@0.3.x:
version "0.3.7"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-0.3.7.tgz#d739d8ee86461e54b354d6a7d7d1f2ad9a167f62"
js-yaml@3.x, js-yaml@^3.4.3, js-yaml@^3.5.2, js-yaml@^3.7.0, js-yaml@^3.9.1:
js-yaml@^3.4.3, js-yaml@^3.5.2, js-yaml@^3.7.0, js-yaml@^3.9.1:
version "3.10.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc"
dependencies:
@@ -4854,6 +4857,10 @@ json-loader@^0.5.4, json-loader@^0.5.7:
version "0.5.7"
resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d"
json-parse-better-errors@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.1.tgz#50183cd1b2d25275de069e9e71b467ac9eab973a"
json-schema-traverse@^0.3.0:
version "0.3.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
@@ -5105,6 +5112,15 @@ load-json-file@^2.0.0:
pify "^2.0.0"
strip-bom "^3.0.0"
load-json-file@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
dependencies:
graceful-fs "^4.1.2"
parse-json "^4.0.0"
pify "^3.0.0"
strip-bom "^3.0.0"
loader-runner@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2"
@@ -5541,6 +5557,10 @@ memory-fs@^0.4.0, memory-fs@~0.4.1:
errno "^0.1.3"
readable-stream "^2.0.1"
memorystream@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2"
merge-descriptors@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
@@ -5697,11 +5717,17 @@ mocha@^3.1.2:
mkdirp "0.5.1"
supports-color "3.1.2"
moment-timezone@0.5.13:
version "0.5.13"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.13.tgz#99ce5c7d827262eb0f1f702044177f60745d7b90"
dependencies:
moment ">= 2.9.0"
moment@2.18.1:
version "2.18.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f"
moment@2.x.x, moment@^2.10.3, moment@^2.18.1:
moment@2.19.1, moment@2.x.x, "moment@>= 2.9.0", moment@^2.10.3, moment@^2.18.1:
version "2.19.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.19.1.tgz#56da1a2d1cbf01d38b7e1afc31c10bcfa1929167"
@@ -6042,12 +6068,6 @@ nomnom@~1.6.2:
colors "0.5.x"
underscore "~1.4.4"
nopt@3.x:
version "3.0.6"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9"
dependencies:
abbrev "1"
nopt@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
@@ -6089,6 +6109,20 @@ normalize-url@^1.4.0:
query-string "^4.1.0"
sort-keys "^1.0.0"
npm-run-all@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.2.tgz#90d62d078792d20669139e718621186656cea056"
dependencies:
ansi-styles "^3.2.0"
chalk "^2.1.0"
cross-spawn "^5.1.0"
memorystream "^0.3.1"
minimatch "^3.0.4"
ps-tree "^1.1.0"
read-pkg "^3.0.0"
shell-quote "^1.6.1"
string.prototype.padend "^3.0.0"
npm-run-path@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
@@ -6307,12 +6341,6 @@ package-json@^4.0.0:
registry-url "^3.0.3"
semver "^5.1.0"
pad-right@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/pad-right/-/pad-right-0.2.2.tgz#6fbc924045d244f2a2a244503060d3bfc6009774"
dependencies:
repeat-string "^1.5.2"
pako@~0.2.0:
version "0.2.9"
resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
@@ -6342,6 +6370,13 @@ parse-json@^2.2.0:
dependencies:
error-ex "^1.2.0"
parse-json@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
dependencies:
error-ex "^1.3.1"
json-parse-better-errors "^1.0.1"
parse-url@^1.3.0:
version "1.3.11"
resolved "https://registry.yarnpkg.com/parse-url/-/parse-url-1.3.11.tgz#57c15428ab8a892b1f43869645c711d0e144b554"
@@ -6441,6 +6476,16 @@ path-type@^2.0.0:
dependencies:
pify "^2.0.0"
path-type@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f"
dependencies:
pify "^3.0.0"
pathval@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
pause-stream@0.0.11:
version "0.0.11"
resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
@@ -6515,7 +6560,7 @@ pluralize@6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-6.0.0.tgz#d9b51afad97d3d51075cc1ddba9b132cacccb7ba"
pluralize@^7.0.0:
pluralize@7.0.0, pluralize@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
@@ -6946,22 +6991,22 @@ postcss@^6.0.1:
source-map "^0.6.1"
supports-color "^4.4.0"
pre-git@^3.15.3:
version "3.15.3"
resolved "https://registry.yarnpkg.com/pre-git/-/pre-git-3.15.3.tgz#834a598a0608b821ba1e9e217f58d510bc7b0ca9"
pre-git@^3.16.0:
version "3.16.0"
resolved "https://registry.yarnpkg.com/pre-git/-/pre-git-3.16.0.tgz#a7656bc5f277185fd213c78f39f24f2cb603eb61"
dependencies:
bluebird "3.5.0"
chalk "2.0.1"
bluebird "3.5.1"
chalk "2.3.0"
check-more-types "2.24.0"
conventional-commit-message "1.1.0"
cz-conventional-changelog "2.0.0"
debug "2.6.8"
ggit "2.0.1"
inquirer "3.2.1"
cz-conventional-changelog "2.1.0"
debug "2.6.9"
ggit "2.4.0"
inquirer "3.3.0"
lazy-ass "1.6.0"
require-relative "0.8.7"
shelljs "0.7.8"
simple-commit-message "3.3.1"
simple-commit-message "3.3.2"
validate-commit-msg "2.14.0"
word-wrap "1.2.3"
@@ -7271,6 +7316,10 @@ ramda@0.24.1, ramda@^0.24.1:
version "0.24.1"
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857"
ramda@0.25.0:
version "0.25.0"
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9"
randexp@^0.4.2:
version "0.4.6"
resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3"
@@ -7485,6 +7534,14 @@ read-pkg@^2.0.0:
normalize-package-data "^2.3.2"
path-type "^2.0.0"
read-pkg@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
dependencies:
load-json-file "^4.0.0"
normalize-package-data "^2.3.2"
path-type "^3.0.0"
readable-stream@1.1, readable-stream@1.1.x:
version "1.1.13"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.13.tgz#f6eef764f514c89e2b9e23146a75ba106756d23e"
@@ -8053,6 +8110,15 @@ shebang-regex@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
shell-quote@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.6.1.tgz#f4781949cce402697127430ea3b3c5476f481767"
dependencies:
array-filter "~0.0.0"
array-map "~0.0.0"
array-reduce "~0.0.0"
jsonify "~0.0.0"
shelljs@0.3.x:
version "0.3.0"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.3.0.tgz#3596e6307a781544f591f37da618360f31db57b1"
@@ -8073,13 +8139,13 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
simple-commit-message@3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/simple-commit-message/-/simple-commit-message-3.3.1.tgz#b69026a9692e0f470233a4c5c1df3432cdb50560"
simple-commit-message@3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/simple-commit-message/-/simple-commit-message-3.3.2.tgz#52bdadb7f4f680d8b29c07af1826a6611f7ee783"
dependencies:
am-i-a-dependency "1.0.0"
am-i-a-dependency "1.1.2"
check-more-types "2.24.0"
debug "2.6.8"
debug "2.6.9"
ggit "1.23.1"
hr "0.1.3"
inquirer "0.12.0"
@@ -8335,6 +8401,14 @@ string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^4.0.0"
string.prototype.padend@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.0.0.tgz#f3aaef7c1719f170c5eab1c32bf780d96e21f2f0"
dependencies:
define-properties "^1.1.2"
es-abstract "^1.4.3"
function-bind "^1.0.2"
string_decoder@^0.10.25, string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
@@ -9079,7 +9153,7 @@ which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
which@^1.1.1, which@^1.2.12, which@^1.2.9:
which@^1.2.12, which@^1.2.9:
version "1.3.0"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a"
dependencies:
@@ -9139,14 +9213,14 @@ wordwrap@0.0.2:
version "0.0.2"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"
wordwrap@^1.0.0, wordwrap@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
wordwrap@~0.0.2:
version "0.0.3"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
wordwrap@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
worker-farm@^1.3.1:
version "1.5.0"
resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.0.tgz#adfdf0cd40581465ed0a1f648f9735722afd5c8d"