Merge with master

This commit is contained in:
Dan Zajdband
2016-12-09 10:17:55 -05:00
34 changed files with 394 additions and 186 deletions
+1
View File
@@ -6,6 +6,7 @@ dist/coral-admin/bundle.js
tests/e2e/reports
.DS_Store
*.iml
*.swp
dump.rdb
.env
gaba.cfg
+22 -8
View File
@@ -3,8 +3,14 @@ A commenting platform from The Coral Project. [https://coralproject.net](https:/
## Contributing to Talk
### Product Roadmap
You can view what the Coral Team is working on next here: https://www.pivotaltracker.com/n/projects/1863625
You can view product ideas and our longer term roadmap here: https://trello.com/b/ILND751a/talk
### Local Dependencies
Node
Mongo
### Getting Started
@@ -19,13 +25,19 @@ Runs Talk.
The Talk application requires specific configuration options to be available
inside the environment in order to run, those variables are listed here:
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
secure cookies
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
secure cookies.
- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook
Login enabled app.
Login enabled app.
- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your
Facebook Login enabled app.
- `TALK_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `<scheme>://<host>` without the path.
Facebook Login enabled app.
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
available in the format: `<scheme>://<host>` without the path.
- `TALK_SMTP_PROVIDER` (*required*) - SMTP provider name.
- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using.
- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using.
- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`.
- `TALK_SMTP_PORT` (*required*) - SMTP port.
### Running with Docker
Make sure you have Docker running first and then run `docker-compose up -d`
@@ -37,9 +49,11 @@ Make sure you have Docker running first and then run `docker-compose up -d`
`npm run lint`
### Helpful URLs
Bare comment stream: http://localhost:5000/client/coral-embed-stream/
Comment stream embedded on sample article: http://localhost:5000/client/coral-embed-stream/samplearticle.html
Moderator view: http://localhost:5000/admin/
Comment stream: http://localhost:3000/
Comment stream embedded on sample article: http://localhost:3000/assets/samplearticle.html
Moderator view: http://localhost:3000/admin
### Docs
`swagger.yaml`
+3 -1
View File
@@ -94,7 +94,9 @@ app.use((req, res, next) => {
// returning a status code that makes sense.
app.use('/api', (err, req, res, next) => {
if (err !== ErrNotFound) {
console.error(err);
if (app.get('env') !== 'test') {
console.error(err);
}
}
res.status(err.status || 500);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+1
View File
@@ -0,0 +1 @@
<mxfile userAgent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36" version="6.0.1.8" editor="www.draw.io" type="device"><diagram name="Page-1">7Vpbc6M2FP41nrYP6wFksP1oO0k7nd1O2nTa7qMMCtZGICpE4vTXVwIJkCVnnQbbWU/z4MDRBen7zk0HRmCVbX9ksNh8ogkio8BLtiNwNQqC6dwTv1Lw3AjCYNoIUoaTRuR3gjv8D1JCNS6tcIJKoyOnlHBcmMKY5jmKuSGDjNEns9s9JeZTC5giS3AXQ2JL/8QJ3zTSWeh18p8QTjf6yb6nWtYwfkgZrXL1vFEA7uu/pjmDei7Vv9zAhD71ROB6BFaMUt5cZdsVIhJaDVsz7mZPa7tuhnJ+yIBJ1Ix4hKRCesn1wvizBkOMELiLm6VYbSGFMaGVmGD5tMEc3RUwlsInoQlCtuEZEXe+uFRTI8bRdu/6/HbXQpkQzRBnz6KLGhBpjdCK1Nw9daQEGvpNn5C56giVIqTtzB0Y4kLh4cYGOKBIhJqoW8r4hqY0h+S6ky5r7pGcwTPBQFvM/5LicajuPuuWXCys1yRvP6sJSg4ZX0iNrmGHZYljLb7BpJ08T+xOQtjr8gVx/qwsDVacClG3g4+UFqpfyRl9QCtKKKv3DLz6r23R9tARbLEpIKMVixVmCkWx4hSpXoHSOgnni1rAEIEcP5pW+RZOw/kxOPV7jHrjaWiQalA6Bt8IqTvU/BeWQ+9EpEb+8UkNTFLHfmTyGrbtt4hhsQXEviETHoDtJpKcgO3AiliLohCCO8REqBkFERErWib4UVym8vL7X0R6MgpWcp/bgqGyHH8pf9AdxeN6fS1FOijEOdCyot7eEAemZojzdZbRC3L+xBXkZt7b0Zx8Pf4fH4I2fVIQABcEMxcEkwEgiCbHdx8vhYTatVyil/AnjtB/Ii/huxTbcg2LJMP5SObIN+JXHmOYWAbN9zkHewLpet6JI/F1dqwdSTCzrShwWFE0hBGF5zWi6cUaUXhGIwotI1pRBmWX62yNkvIdaDmYOWLFsbTcn9vJRyWeGni3jD5i4T7OAUmkE2Bt+L59TPY9YEMCBoCkXW4HySeIiUrIsNj1OQAJDECmDhVxlQ2GwCME53WEl+oHdc3gLCfMwC6T/UzXwta9XyskJI5Dx0OF3tcxY7aTYx/qN6cDGAVwBBKCxWa+kxjeib1biIhtcRMEU8dymkvLuRe6uiOCBKe51GkxvzyFLyVIOIZkoRoynCS12blQ34Pra4qWhxxnpg6kgyGQjo7hfuyi5WW4mDe4DjX0lmIxY0t9sHOYB7ucNi5Njepoff1EjR+0Jqr1o93PYSWAo5TPTJVpg5Sjzn056mRGLDC3I1ZzLD1BxJrYdZz/X14ci9TwRKQC+/hxw2jtNq7zRJ5CSJXivHTlIwzBWHS8YSiptoemJfqFXxOLE1qt6xbf0BV/TyQd9k3g7rkvmJzw3DeZWsCrYpFQNFU+imAmd52vy8JdK1rRLKtyzJ/39f1qtekTzGHa1bgPJK/KyCKWC+0yoY9wjcgtLXFd7AJXa8o5zUQHIhuW7UvsniWp19h2NsXpDtntO21pd4V+GyKVA+epEg+gEMAMkD6w9QEcSx1mdjrbI9eiDWXrujLwrmkbnhLgOHcfixKdYvco+Q0l2C7HaHgTyGEp4N13Dhj4O4adigSIbGjmcxsa3x8AG/2sgXMBqyRxifE+1J8fnaH8GjpKazRP6dV+p3FqrTYNPnAWHl3v7QZR68CCx3C+PXyivyv5RdVSFhU+qPrAQvQg6J53rbuR975JrexpZMOHslZPOYs/Kbb2LLpS/jskD+LfgsWSj5hXjfgKw5TBrBcUmseZS3ghJTjz9v4QLWNfUkiKDXzlNi6p1mNmIbZbb2VvrDTL3L39LrA55nffXoLrfwE=</diagram></mxfile>
+1 -1
View File
@@ -15,7 +15,7 @@ const mongoose = require('../mongoose');
const Setting = require('../models/setting');
const util = require('../util');
// Regeister the shutdown criteria.
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
+22 -9
View File
@@ -34,6 +34,7 @@ function createUser(options) {
email: options.email,
password: options.password,
displayName: options.name,
role: options.role
});
}
@@ -62,6 +63,11 @@ function createUser(options) {
name: 'displayName',
description: 'Display Name',
required: true
},
{
name: 'role',
description: 'User Role',
required: false
}
], (err, result) => {
if (err) {
@@ -76,15 +82,21 @@ function createUser(options) {
});
})
.then((result) => {
return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim());
})
.then((user) => {
console.log(`Created user ${user.id}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
return User
.addRoleToUser(user.id, result.role.trim())
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
});
})
.catch((err) => {
console.error(err);
util.shutdown();
});
});
}
@@ -330,6 +342,7 @@ program
.option('--email [email]', 'Email to use')
.option('--password [password]', 'Password to use')
.option('--name [name]', 'Name to use')
.option('--role [role]', 'Role to add')
.option('-f, --flag_mode', 'Source from flags instead of prompting')
.description('create a new user')
.action(createUser);
@@ -81,9 +81,11 @@ class ModerationQueue extends React.Component {
singleView={singleView}
commentIds={
comments.get('ids')
.filter(id => !comments.get('byId')
.filter(id =>
comments
.get('byId')
.get(id)
.get('status'))
.get('status') === 'premod')
}
comments={comments.get('byId')}
users={users.get('byId')}
+26 -7
View File
@@ -67,23 +67,42 @@ class CommentStream extends Component {
// Set up messaging between embedded Iframe an parent component
this.pym = new Pym.Child({polling: 100});
const path = /https?\:\/\/([^?#]+)/.exec(this.pym.parentUrl);
const path = this.pym.parentUrl.split('#')[0];
this.props.getStream(path[1] || window.location);
this.props.getStream(path || window.location);
this.path = path;
this.pym.sendMessage('childReady');
this.pym.onMessage('DOMContentLoaded', hash => {
const commentId = hash.replace('#', 'c_');
let count = 0;
const interval = setInterval(() => {
if (document.getElementById(commentId)) {
window.clearInterval(interval);
this.pym.scrollParentToChildEl(commentId);
}
if (++count > 100) { // ~10 seconds
// give up waiting for the comments to load.
// it would be weird for the page to jump after that long.
window.clearInterval(interval);
}
}, 100);
});
}
render () {
const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0];
const rootItem = this.props.items.assets && this.props.items.assets[rootItemId];
const {actions, users, comments} = this.props.items;
const {loggedIn, user, showSignInDialog} = this.props.auth;
const {loggedIn, user, showSignInDialog, signInOffset} = this.props.auth;
const {status, closedMessage} = this.props.config;
const {activeTab} = this.state;
return <div className={showSignInDialog ? 'expandForSignin' : ''}>
const expandForLogin = showSignInDialog ? {
minHeight: document.body.scrollHeight + 150
} : {};
return <div style={expandForLogin}>
{
rootItem
? <div className="commentStream">
@@ -116,7 +135,7 @@ class CommentStream extends Component {
</div>
: <p>{closedMessage}</p>
}
{!loggedIn && <SignInContainer />}
{!loggedIn && <SignInContainer offset={signInOffset} />}
{
rootItem.comments && rootItem.comments.map((commentId) => {
const comment = comments[commentId];
@@ -272,7 +291,7 @@ const mapDispatchToProps = (dispatch) => ({
getStream: (rootId) => dispatch(getStream(rootId)),
addNotification: (type, text) => dispatch(addNotification(type, text)),
clearNotification: () => dispatch(clearNotification()),
showSignInDialog: () => dispatch(showSignInDialog()),
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
postAction: (item, action, user, itemType) => dispatch(postAction(item, action, user, itemType)),
deleteAction: (item, action, user, itemType) => dispatch(deleteAction(item, action, user, itemType)),
appendItemArray: (item, property, value, addToFront, itemType) => dispatch(appendItemArray(item, property, value, addToFront, itemType)),
+1 -2
View File
@@ -8,7 +8,7 @@ body {
}
.expandForSignin {
min-height: 550px;
min-height: 600px;
}
button {
@@ -113,7 +113,6 @@ hr {
/* Comment styles */
.comment {
position: relative;
margin-bottom: 10px;
position: relative;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import coralApi, {base} from '../helpers/response';
import {addItem} from './items';
// Dialog Actions
export const showSignInDialog = () => ({type: actions.SHOW_SIGNIN_DIALOG});
export const showSignInDialog = (offset = 0) => ({type: actions.SHOW_SIGNIN_DIALOG, offset});
export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG});
export const changeView = view => dispatch =>
+12 -13
View File
@@ -1,5 +1,7 @@
import timeago from 'timeago.js';
import esTA from '../../../../node_modules/timeago.js/locales/es';
import has from 'lodash/has';
import get from 'lodash/get';
/**
* Default locales, this should be overriden by config file
@@ -46,21 +48,18 @@ class i18n {
* it takes a string with the translation key and returns
* the translation value or the key itself if not found
* it works with nested translations (my.page.title)
*
* any extra parameters are optional and replace a variable marked by {0}, {1}, etc in the translation.
*/
this.t = (key) => {
const arr = key.split('.');
let translation = this.translations;
try {
for (let i = 0; i < arr.length; i++) {translation = translation[arr[i]];}
} catch (error) {
console.warn(`${key} language key not set`);
return key;
}
const val = String(translation);
if (val) {
return val;
this.t = (key, ...replacements) => {
if (has(this.translations, key)) {
let translation = get(this.translations, key);
// replace any {n} with the arguments passed to this method
replacements.forEach((str, i) => {
translation = translation.replace(new RegExp(`\\{${i}\\}`, 'g'), str);
});
return translation;
} else {
console.warn(`${key} language key not set`);
return key;
+2 -1
View File
@@ -22,7 +22,8 @@ export default function auth (state = initialState, action) {
switch (action.type) {
case actions.SHOW_SIGNIN_DIALOG :
return state
.set('showSignInDialog', true);
.set('showSignInDialog', true)
.set('signInOffset', action.offset);
case actions.HIDE_SIGNIN_DIALOG :
return state.merge(Map({
isLoading: false,
+2 -1
View File
@@ -8,7 +8,8 @@ const FlagButton = ({flag, id, postAction, deleteAction, addItem, showSignInDial
const flagged = flag && flag.current_user;
const onFlagClick = () => {
if (!currentUser) {
showSignInDialog();
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
showSignInDialog(offset);
return;
}
if (!flagged) {
+2 -1
View File
@@ -8,7 +8,8 @@ const LikeButton = ({like, id, postAction, deleteAction, addItem, showSignInDial
const liked = like && like.current_user;
const onLikeClick = () => {
if (!currentUser) {
showSignInDialog();
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
showSignInDialog(offset);
return;
}
if (!liked) {
@@ -6,8 +6,14 @@ import SignInContent from './SignInContent';
import SingUpContent from './SignUpContent';
import ForgotContent from './ForgotContent';
const SignDialog = ({open, view, handleClose, ...props}) => (
<Dialog className={styles.dialog} open={open}>
const SignDialog = ({open, view, handleClose, offset, ...props}) => (
<Dialog
className={styles.dialog}
open={open}
style={{
position: 'relative',
top: offset !== 0 && offset
}}>
<span className={styles.close} onClick={handleClose}>×</span>
{view === 'SIGNIN' && <SignInContent {...props} />}
{view === 'SIGNUP' && <SingUpContent {...props} />}
@@ -129,7 +129,7 @@ class SignInContainer extends Component {
}
render() {
const {auth, showSignInDialog, noButton} = this.props;
const {auth, showSignInDialog, noButton, offset} = this.props;
return (
<div>
{!noButton && <Button id='coralSignInButton' onClick={showSignInDialog} full>
@@ -138,6 +138,7 @@ class SignInContainer extends Component {
<SignDialog
open={auth.showSignInDialog}
view={auth.view}
offset={offset}
{...this}
{...this.state}
{...this.props}
+1
View File
@@ -7,6 +7,7 @@ services:
restart: always
ports:
- "5000:5000"
- "2525:2525"
environment:
- "TALK_PORT=5000"
- "TALK_MONGO_URL=mongodb://mongo"
+73
View File
@@ -0,0 +1,73 @@
// The maximum depth to recurse into nested objects checking for mongoose
// objects.
const maxRecursion = 3;
/**
* Middleware to wrap the `res.json` function to ensure that we can filter the
* payload response first based on user and role.
*/
module.exports = (req, res, next) => {
/**
* Updates the original document based on filtering out for roles.
* @param {Mixed} o original object to be modified
* @param {Integer} l current level of depth in the first object
* @return {Mixed} (possibly) modified object
*/
const wrap = (o, d = 0) => {
if (d > maxRecursion) {
return o;
}
// If this is an array, we need to walk over all the object's elements.
if (Array.isArray(o)) {
// Map each of the array elements.
return o.map((ob) => wrap(ob, d + 1));
} else if (o && o.constructor && o.constructor.name === 'model') {
// The object here is definitly a mongoose model.
// Check to see if it has a `filterForUser` method.
if (typeof o.filterForUser === 'function') {
// The object here actually has the `filterForUser` function, so filter
// the object!
o = o.filterForUser(req.user);
}
} else if (typeof o === 'object') {
// Iterate over the props, find only properties owned by the object.
for (let prop in o) {
// If and only if the object owns the property do we actually pull the
// property out.
if (typeof o.hasOwnProperty === 'function' && o.hasOwnProperty(prop)) {
// Wrap the property with one more layer down.
o[prop] = wrap(o[prop], d + 1);
}
}
}
return o;
};
// Save a reference to the original json function.
const json = res.json;
// Override the original json function.
res.json = (payload) => {
// Restore the old pointer.
res.json = json;
// Send it down the pipe after we've filtered it.
res.json(wrap(payload));
};
// Now that we've overridden the `res.json`, let's hand it down.
next();
};
+2 -6
View File
@@ -44,11 +44,7 @@ const AssetSchema = new Schema({
subsection: String,
author: String,
publication_date: Date,
modified_date: Date,
status: {
type: String,
default: 'open'
}
modified_date: Date
}, {
versionKey: false,
timestamps: {
@@ -100,7 +96,7 @@ AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([
// If the asset exists and has settings then return the merged object.
if (asset && asset.settings) {
return Object.assign({}, settings, asset.settings);
settings.merge(asset.settings);
}
return settings;
+32 -29
View File
@@ -1,5 +1,6 @@
const mongoose = require('../mongoose');
const Schema = mongoose.Schema;
const _ = require('lodash');
const uuid = require('uuid');
const Action = require('./action');
@@ -45,7 +46,7 @@ const CommentSchema = new Schema({
},
asset_id: String,
author_id: String,
status: [StatusSchema],
status_history: [StatusSchema],
parent_id: String
}, {
timestamps: {
@@ -59,22 +60,7 @@ const CommentSchema = new Schema({
* output.
*/
CommentSchema.options.toJSON = {};
CommentSchema.options.toJSON.hide = '_id status';
CommentSchema.options.toJSON.transform = (doc, ret, options) => {
if (options.hide) {
options.hide.split(' ').forEach((prop) => {
delete ret[prop];
});
}
return ret;
};
/**
* toJSON overrides to remove fields from the json
* output.
*/
CommentSchema.options.toJSON = {};
CommentSchema.options.toJSON.virtuals = true;
CommentSchema.options.toJSON.hide = '_id';
CommentSchema.options.toJSON.transform = (doc, ret, options) => {
if (options.hide) {
@@ -86,14 +72,31 @@ CommentSchema.options.toJSON.transform = (doc, ret, options) => {
return ret;
};
/**
* Filters the object for the given user only allowing those with the allowed
* roles/permissions to access particular parameters.
*/
CommentSchema.method('filterForUser', function(user = false) {
if (!user || !user.roles.includes('admin')) {
return _.pick(this.toJSON(), ['id', 'body', 'asset_id', 'author_id', 'parent_id', 'status']);
}
return this.toJSON();
});
/**
* Sets up a virtual getter function on a comment such that when you try and
* access the `comment.last_status` it returns the last status in the array
* of status's on the comment, or `null` if there was no status.
* of status's on the comment, or `null` if there was no status_history.
*/
CommentSchema.virtual('last_status').get(function() {
if (this.status && this.status.length > 0) {
return this.status[this.status.length - 1].type;
CommentSchema.virtual('status').get(function() {
// Here we are taking advantage of the fact that when documents are inserted
// for the new status on a comment that they are always appended to the end
// of the list in the order that they are inserted, hence, the last status
// is always the most recent.
if (this.status_history && this.status_history.length > 0) {
return this.status_history[this.status_history.length - 1].type;
}
return null;
@@ -123,7 +126,7 @@ CommentSchema.statics.publicCreate = (comment) => {
body,
asset_id,
parent_id,
status: status ? [{
status_history: status ? [{
type: status,
created_at: new Date()
}] : [],
@@ -157,7 +160,7 @@ CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({
*/
CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({
asset_id,
'status.type': 'accepted'
'status_history.type': 'accepted'
});
/**
@@ -169,10 +172,10 @@ CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({
asset_id,
$or: [
{
'status.type': 'accepted'
'status_history.type': 'accepted'
},
{
status: {
status_history: {
$size: 0
}
}
@@ -202,7 +205,7 @@ CommentSchema.statics.findIdsByActionType = (action_type) => Action
.then((actions) => actions.map(a => a.item_id));
/**
* Find comments by their status.
* Find comments by their status_history.
* @param {String} status the status of the comment to search for
* @return {Promise}
*/
@@ -210,9 +213,9 @@ CommentSchema.statics.findByStatus = (status = false) => {
let q = {};
if (status) {
q['status.type'] = status;
q['status_history.type'] = status;
} else {
q.status = {$size: 0};
q.status_history = {$size: 0};
}
return Comment.find(q);
@@ -269,7 +272,7 @@ CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => {
*/
CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, {
$push: {
status: {
status_history: {
type: status,
created_at: new Date(),
assigned_by
+39 -9
View File
@@ -46,6 +46,42 @@ const SettingSchema = new Schema({
}
});
/**
* toJSON provides settings overrides to this object's serialization methods.
*/
SettingSchema.options.toJSON = {};
SettingSchema.options.toJSON.virtuals = true;
/**
* Merges two settings objects.
*/
SettingSchema.method('merge', function(src) {
SettingSchema.eachPath((path) => {
// Exclude internal fields...
if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) {
return;
}
// If the source object contains the path, shallow copy it.
if (path in src) {
this[path] = src[path];
}
});
});
/**
* Filters the object for the given user only allowing those with the allowed
* roles/permissions to access particular parameters.
*/
SettingSchema.method('filterForUser', function(user = false) {
if (!user || !user.roles.includes('admin')) {
return _.pick(this.toJSON(), ['moderation', 'infoBoxEnable', 'infoBoxContent']);
}
return this.toJSON();
});
/**
* The Mongo Mongoose object.
*/
@@ -72,7 +108,9 @@ const EXPIRY_TIME = 60 * 2;
* Gets the entire settings record and sends it back
* @return {Promise} settings the whole settings record
*/
SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => Setting.findOne(selector));
SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => {
return Setting.findOne(selector);
}).then((setting) => new Setting(setting));
/**
* This will update the settings object with whatever you pass in
@@ -93,14 +131,6 @@ SettingService.update = (settings) => Setting.findOneAndUpdate(selector, {
.then(() => settings);
});
/**
* Filters the document to ensure that the resulting document is indeed ready
* for non authenticated users.
* @param {Object} settings the source settings object
* @return {Object} the filtered settings object
*/
SettingService.public = (settings) => _.pick(settings, ['moderation', 'infoBoxEnable', 'infoBoxContent', 'closedMessage']);
/**
* This is run once when the app starts to ensure settings are populated.
* @return {Promise} null initialize the global settings object
+10 -12
View File
@@ -1,5 +1,6 @@
const mongoose = require('../mongoose');
const uuid = require('uuid');
const _ = require('lodash');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
@@ -105,7 +106,8 @@ UserSchema.index({
* output.
*/
UserSchema.options.toJSON = {};
UserSchema.options.toJSON.hide = '_id password profiles roles disabled';
UserSchema.options.toJSON.hide = '_id password';
UserSchema.options.toJSON.virtuals = true;
UserSchema.options.toJSON.transform = (doc, ret, options) => {
if (options.hide) {
options.hide.split(' ').forEach((prop) => {
@@ -117,20 +119,16 @@ UserSchema.options.toJSON.transform = (doc, ret, options) => {
};
/**
* toObject overrides to remove the password field from the toObject
* output.
* Filters the object for the given user only allowing those with the allowed
* roles/permissions to access particular parameters.
*/
UserSchema.options.toObject = {};
UserSchema.options.toObject.hide = 'password';
UserSchema.options.toObject.transform = (doc, ret, options) => {
if (options.hide) {
options.hide.split(' ').forEach((prop) => {
delete ret[prop];
});
UserSchema.method('filterForUser', function(user = false) {
if (!user || !user.roles.includes('admin')) {
return _.pick(this.toJSON(), ['id', 'displayName', 'settings', 'created_at', 'updated_at']);
}
return ret;
};
return this.toJSON();
});
// Create the User model.
const UserModel = mongoose.model('User', UserSchema);
+1 -1
View File
@@ -110,7 +110,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
.then((comment) => {
// The comment was created! Send back the created comment.
res.status(201).send(comment);
res.status(201).json(comment);
})
.catch((err) => {
next(err);
+4
View File
@@ -1,8 +1,12 @@
const express = require('express');
const authorization = require('../../middleware/authorization');
const payloadFilter = require('../../middleware/payload-filter');
const router = express.Router();
// Filter all content going down the pipe based on user roles.
router.use(payloadFilter);
router.use('/asset', authorization.needed('admin'), require('./asset'));
router.use('/settings', authorization.needed('admin'), require('./settings'));
router.use('/queue', authorization.needed('admin'), require('./queue'));
+14 -3
View File
@@ -1,21 +1,32 @@
const express = require('express');
const _ = require('lodash');
const scraper = require('../../../services/scraper');
const url = require('url');
const Comment = require('../../../models/comment');
const User = require('../../../models/user');
const Action = require('../../../models/action');
const Asset = require('../../../models/asset');
const Setting = require('../../../models/setting');
const ErrInvalidAssetURL = new Error('asset_url is invalid');
ErrInvalidAssetURL.status = 400;
const router = express.Router();
router.get('/', (req, res, next) => {
let asset_url = decodeURIComponent(req.query.asset_url);
// Verify that the asset_url is parsable.
let parsed_asset_url = url.parse(asset_url);
if (!parsed_asset_url.protocol) {
return next(ErrInvalidAssetURL);
}
// Get the asset_id for this url (or create it if it doesn't exist)
Promise.all([
// Find or create the asset by url.
Asset.findOrCreateByUrl(decodeURIComponent(req.query.asset_url))
Asset.findOrCreateByUrl(asset_url)
// Add the found asset to the scraper if it's not already scraped.
.then((asset) => {
@@ -34,7 +45,7 @@ router.get('/', (req, res, next) => {
// Merge the asset specific settings with the returned settings object in
// the event that the asset that was returned also had settings.
if (asset && asset.settings) {
settings = Object.assign({}, settings, asset.settings);
settings.merge(asset.settings);
}
// Fetch the appropriate comments stream.
@@ -98,7 +109,7 @@ router.get('/', (req, res, next) => {
comments,
users,
actions,
settings: Setting.public(settings)
settings
});
})
.catch(error => {
+20 -27
View File
@@ -26,26 +26,15 @@ router.get('/', authorization.needed('admin'), (req, res, next) => {
.limit(limit),
User.count()
])
.then(([data, count]) => {
const users = data.map((user) => {
const {id, displayName, created_at} = user;
return {
id,
displayName,
created_at,
profiles: user.toObject().profiles,
roles: user.toObject().roles
};
});
.then(([result, count]) => {
res.json({
result: users,
result,
limit: Number(limit),
count,
page: Number(page),
totalPages: Math.ceil(count / limit)
totalPages: Math.ceil(count / (limit === 0 ? 1 : limit))
});
})
.catch(next);
});
@@ -53,8 +42,8 @@ router.get('/', authorization.needed('admin'), (req, res, next) => {
router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => {
User
.addRoleToUser(req.params.user_id, req.body.role)
.then(role => {
res.send(role);
.then(() => {
res.status(204).end();
})
.catch(next);
});
@@ -65,13 +54,17 @@ router.post('/', (req, res, next) => {
User
.createLocalUser(email, password, displayName)
.then(user => {
res.status(201).send(user);
res.status(201).json(user);
})
.catch(err => {
next(err);
});
});
const ErrPasswordTooShort = new Error('password must be at least 8 characters');
ErrPasswordTooShort.status = 400;
/**
* expects 2 fields in the body of the request
* 1) the token that was in the url of the email link {String}
@@ -81,7 +74,7 @@ router.post('/update-password', (req, res, next) => {
const {token, password} = req.body;
if (!password || password.length < 8) {
return res.status(400).send('Password must be at least 8 characters');
return next(ErrPasswordTooShort);
}
User.verifyPasswordResetToken(token)
@@ -93,7 +86,8 @@ router.post('/update-password', (req, res, next) => {
})
.catch(error => {
console.error(error);
res.status(401).send('Not Authorized');
next(authorization.ErrNotAuthorized);
});
});
@@ -133,10 +127,8 @@ router.post('/request-password-reset', (req, res, next) => {
// if we fail on missing emails, it would reveal if people are registered or not.
res.status(204).end();
})
.catch(error => {
const errorMsg = typeof error === 'string' ? error : error.message;
res.status(500).json({error: errorMsg});
.catch((err) => {
next(err);
});
});
@@ -150,10 +142,11 @@ router.put('/:user_id/bio', (req, res, next) => {
User
.addBio(user_id, bio)
.then(user => res.status(200).send(user))
.catch(error => {
const errorMsg = typeof error === 'string' ? error : error.message;
res.status(500).json({error: errorMsg});
.then(user => {
res.json(user);
})
.catch((err) => {
next(err);
});
});
+25 -3
View File
@@ -1,10 +1,32 @@
const nodemailer = require('nodemailer');
if (!process.env.TALK_SMTP_CONNECTION_URL) {
console.error('TALK_SMTP_CONNECTION_URL should be defined if you would like to send password reset emails from Talk');
const smtpRequiredProps = [
'TALK_SMTP_USERNAME',
'TALK_SMTP_PASSWORD',
'TALK_SMTP_HOST'
];
smtpRequiredProps.forEach(prop => {
if (!process.env[prop]) {
console.error(`process.env.${prop} should be defined if you would like to send password reset emails from Talk`);
}
});
const options = {
host: process.env.TALK_SMTP_HOST,
auth: {
user: process.env.TALK_SMTP_USERNAME,
pass: process.env.TALK_SMTP_PASSWORD
}
};
if (process.env.TALK_SMTP_PORT) {
options.port = process.env.TALK_SMTP_PORT;
} else {
options.port = 25;
}
const defaultTransporter = nodemailer.createTransport(process.env.TALK_SMTP_CONNECTION_URL);
const defaultTransporter = nodemailer.createTransport(options);
const mailer = {
+2 -2
View File
@@ -23,7 +23,7 @@ const scraper = {
title: `Scrape for asset ${asset.id}`,
asset_id: asset.id
})
.attempts(10)
.attempts(3)
.delay(1000)
.backoff({type: 'exponential'})
.save((err) => {
@@ -106,7 +106,7 @@ const scraper = {
// Handle errors that occur.
.catch((err) => {
console.error(`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, err);
debug(`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, err);
done(err);
});
+20 -24
View File
@@ -11,14 +11,14 @@ describe('models.Comment', () => {
const comments = [{
body: 'comment 10',
asset_id: '123',
status: [],
status_history: [],
parent_id: '',
author_id: '123',
id: '1'
}, {
body: 'comment 20',
asset_id: '123',
status: [{
status_history: [{
type: 'accepted'
}],
parent_id: '',
@@ -27,14 +27,14 @@ describe('models.Comment', () => {
}, {
body: 'comment 30',
asset_id: '456',
status: [],
status_history: [],
parent_id: '',
author_id: '456',
id: '3'
}, {
body: 'comment 40',
asset_id: '123',
status: [{
status_history: [{
type: 'rejected'
}],
parent_id: '',
@@ -43,7 +43,7 @@ describe('models.Comment', () => {
}, {
body: 'comment 50',
asset_id: '1234',
status: [{
status_history: [{
type: 'premod'
}],
parent_id: '',
@@ -52,7 +52,7 @@ describe('models.Comment', () => {
}, {
body: 'comment 60',
asset_id: '1234',
status: [{
status_history: [{
type: 'premod'
}],
parent_id: '',
@@ -99,8 +99,7 @@ describe('models.Comment', () => {
expect(c).to.not.be.null;
expect(c.id).to.not.be.null;
expect(c.id).to.be.uuid;
expect(c.status).to.have.length(1);
expect(c.status[0]).to.have.property('type', 'accepted');
expect(c.status).to.be.equal('accepted');
});
});
@@ -116,17 +115,15 @@ describe('models.Comment', () => {
}]).then(([c1, c2, c3]) => {
expect(c1).to.not.be.null;
expect(c1.id).to.be.uuid;
expect(c1.status).to.have.length(1);
expect(c1.status[0]).to.have.property('type', 'accepted');
expect(c1.status).to.be.equal('accepted');
expect(c2).to.not.be.null;
expect(c2.id).to.be.uuid;
expect(c2.status).to.have.length(0);
expect(c2.status).to.be.null;
expect(c3).to.not.be.null;
expect(c3.id).to.be.uuid;
expect(c3.status).to.have.length(1);
expect(c3.status[0]).to.have.property('type', 'rejected');
expect(c3.status).to.be.equal('rejected');
});
});
@@ -220,17 +217,16 @@ describe('models.Comment', () => {
return Comment.findById(comment_id)
.then((c) => {
expect(c).to.have.property('status');
expect(c.status).to.have.length(0);
expect(c.status).to.be.null;
return Comment.pushStatus(comment_id, 'rejected', '123');
})
.then(() => Comment.findById(comment_id))
.then((c) => {
expect(c).to.have.property('status');
expect(c.status).to.have.length(1);
expect(c.status[0]).to.have.property('type', 'rejected');
expect(c.status[0]).to.have.property('assigned_by', '123');
expect(c.status_history).to.have.length(1);
expect(c.status_history[0]).to.have.property('type', 'rejected');
expect(c.status_history[0]).to.have.property('assigned_by', '123');
});
});
@@ -238,13 +234,13 @@ describe('models.Comment', () => {
return Comment.pushStatus(comments[1].id, 'rejected', '123')
.then(() => Comment.findById(comments[1].id))
.then((c) => {
expect(c).to.have.property('status');
expect(c.status).to.have.length(2);
expect(c.status[0]).to.have.property('type', 'accepted');
expect(c.status[0]).to.have.property('assigned_by', null);
expect(c).to.have.property('status_history');
expect(c.status_history).to.have.length(2);
expect(c.status_history[0]).to.have.property('type', 'accepted');
expect(c.status_history[0]).to.have.property('assigned_by', null);
expect(c.status[1]).to.have.property('type', 'rejected');
expect(c.status[1]).to.have.property('assigned_by', '123');
expect(c.status_history[1]).to.have.property('type', 'rejected');
expect(c.status_history[1]).to.have.property('assigned_by', '123');
});
});
+14
View File
@@ -39,4 +39,18 @@ describe('models.Setting', () => {
});
});
});
describe('#merge', () => {
it('should merge a settings object and its overrides', () => {
return Setting
.retrieve()
.then((settings) => {
let ovrSett = {moderation: 'post'};
settings.merge(ovrSett);
expect(settings).to.have.property('moderation', 'post');
});
});
});
});
+9 -13
View File
@@ -21,10 +21,8 @@ describe('/api/v1/comments', () => {
// Ensure that the settings are always available.
beforeEach(() => Promise.all([
Setting.init(settings),
wordlist.insert([
'bad words'
])
wordlist.insert(['bad words']),
Setting.init(settings)
]));
describe('#get', () => {
@@ -40,13 +38,13 @@ describe('/api/v1/comments', () => {
body: 'comment 20',
asset_id: 'asset',
author_id: '456',
status: [{
status_history: [{
type: 'rejected'
}]
}, {
body: 'comment 30',
asset_id: '456',
status: [{
status_history: [{
type: 'accepted'
}]
}];
@@ -173,8 +171,7 @@ describe('/api/v1/comments', () => {
.then((res) => {
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('status').and.to.have.length(1);
expect(res.body.status[0]).to.have.property('type', 'rejected');
expect(res.body).to.have.property('status', 'rejected');
});
});
@@ -196,8 +193,7 @@ describe('/api/v1/comments', () => {
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('asset_id');
expect(res.body).to.have.property('status').and.to.have.length(1);
expect(res.body.status[0]).to.have.property('type', 'premod');
expect(res.body).to.have.property('status', 'premod');
});
});
@@ -350,20 +346,20 @@ describe('/api/v1/comments/:comment_id/actions', () => {
body: 'comment 10',
asset_id: 'asset',
author_id: '123',
status: []
status_history: []
}, {
id: 'def',
body: 'comment 20',
asset_id: 'asset',
author_id: '456',
status: [{
status_history: [{
type: 'rejected'
}]
}, {
id: 'hij',
body: 'comment 30',
asset_id: '456',
status: [{
status_history: [{
type: 'accepted'
}]
}];
+3 -3
View File
@@ -21,7 +21,7 @@ describe('/api/v1/queue', () => {
body: 'comment 10',
asset_id: 'asset',
author_id: '123',
status: [{
status_history: [{
type: 'rejected'
}]
}, {
@@ -29,14 +29,14 @@ describe('/api/v1/queue', () => {
body: 'comment 20',
asset_id: 'asset',
author_id: '456',
status: [{
status_history: [{
type: 'premod'
}]
}, {
id: 'hij',
body: 'comment 30',
asset_id: '456',
status: [{
status_history: [{
type: 'accepted'
}]
}];
+15 -4
View File
@@ -25,7 +25,7 @@ describe('/api/v1/stream', () => {
body: 'comment 10',
author_id: '',
parent_id: '',
status: [{
status_history: [{
type: 'accepted'
}]
}, {
@@ -33,21 +33,21 @@ describe('/api/v1/stream', () => {
body: 'comment 20',
author_id: '',
parent_id: '',
status: []
status_history: []
}, {
id: 'uio',
body: 'comment 30',
asset_id: 'asset',
author_id: '456',
parent_id: '',
status: [{
status_history: [{
type: 'accepted'
}]
}, {
id: 'hij',
body: 'comment 40',
asset_id: '456',
status: [{
status_history: [{
type: 'rejected'
}]
}];
@@ -116,6 +116,16 @@ describe('/api/v1/stream', () => {
});
});
it('should reject requests without a scheme in the asset_url', () => {
return chai.request(app)
.get('/api/v1/stream')
.query({asset_url: 'test.com'})
.catch((err) => {
expect(err).to.have.status(400);
expect(err.response.body.message).to.contain('asset_url is invalid');
});
});
it('should merge the settings when the asset contains settings to override it with', () => {
return chai.request(app)
.get('/api/v1/stream')
@@ -126,6 +136,7 @@ describe('/api/v1/stream', () => {
expect(res.body.comments.length).to.equal(1);
expect(res.body.users.length).to.equal(1);
expect(res.body.settings).to.have.property('moderation', 'pre');
expect(res.body.settings).to.not.have.property('wordlist');
});
});
});