merge master

This commit is contained in:
riley
2017-05-16 16:33:41 -06:00
219 changed files with 3845 additions and 1847 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ module.exports = class AssetsService {
* @return {Promise} resolves to list of Assets
*/
static findMultipleById(ids) {
const query = ids.map(id => ({id}));
const query = ids.map((id) => ({id}));
return AssetModel.find(query);
}
+81 -26
View File
@@ -1,19 +1,10 @@
const CommentModel = require('../models/comment');
const EDIT_WINDOW_MS = CommentModel.EDIT_WINDOW_MS;
const ActionModel = require('../models/action');
const ActionsService = require('./actions');
// const ALLOWED_TAGS = [
// {name: 'STAFF'},
// {name: 'BEST'},
// ];
const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
'NONE',
];
const errors = require('../errors');
module.exports = class CommentsService {
@@ -33,16 +24,88 @@ module.exports = class CommentsService {
status = 'NONE',
} = comment;
comment.status_history = status ? [{
type: status,
created_at: new Date()
}] : [];
let commentModel = new CommentModel(comment);
const commentModel = new CommentModel(Object.assign({
status_history: status ? [{
type: status,
created_at: new Date()
}] : [],
body_history: [{
body: comment.body,
created_at: new Date()
}]
}, comment));
return commentModel.save();
}
/**
* Edit a Comment
* @param {String} id comment.id you want to edit (or its ID)
* @param {String} author_id user.id of the user trying to edit the comment (will err if not comment author)
* @param {String} body the new Comment body
* @param {String} status the new Comment status
*/
static async edit(id, author_id, {body, status, ignoreEditWindow = false}) {
const query = {
id,
author_id
};
// Establish the edit window (if it exists) and add the condition to the
// original query.
const lastEditableCommentCreatedAt = new Date((new Date()).getTime() - EDIT_WINDOW_MS);
if (!ignoreEditWindow) {
query.created_at = {
$gt: lastEditableCommentCreatedAt,
};
}
const {
value: comment
} = await CommentModel.findOneAndUpdate(query, {
$set: {
body,
status,
},
$push: {
body_history: {
body,
created_at: new Date(),
},
status_history: {
type: status,
created_at: new Date(),
}
},
}, {
new: true,
rawResult: true
});
if (comment === null) {
// Try to get the comment.
const comment = await CommentsService.findById(id);
if (comment === null) {
throw errors.ErrNotFound;
}
// Check to see if the user was't allowed to edit it.
if (comment.author_id !== author_id) {
throw errors.ErrNotAuthorized;
}
// Check to see if the edit window expired.
if (!ignoreEditWindow && comment.created_at <= lastEditableCommentCreatedAt) {
throw errors.ErrEditWindowHasEnded;
}
throw new Error('comment edit failed for an unexpected reason');
}
return comment;
}
/**
* Adds a tag if it doesn't already exist on the comment.
* @throws if tag is already added to the comment
@@ -175,7 +238,7 @@ module.exports = class CommentsService {
static findIdsByActionType(action_type) {
return ActionsService
.findCommentsIdByActionType(action_type, 'COMMENTS')
.then((actions) => actions.map(a => a.item_id));
.then((actions) => actions.map((a) => a.item_id));
}
/**
@@ -213,14 +276,6 @@ module.exports = class CommentsService {
* @return {Promise}
*/
static pushStatus(id, status, assigned_by = null) {
// Check to see if the comment status is in the allowable set of statuses.
if (STATUSES.indexOf(status) === -1) {
// Comment status is not supported! Error out here.
return Promise.reject(new Error(`status ${status} is not supported`));
}
return CommentModel.findOneAndUpdate({id}, {
$push: {
status_history: {
+13 -16
View File
@@ -5,16 +5,13 @@ const path = require('path');
const fs = require('fs');
const _ = require('lodash');
const smtpRequiredProps = [
'TALK_SMTP_FROM_ADDRESS',
'TALK_SMTP_USERNAME',
'TALK_SMTP_PASSWORD',
'TALK_SMTP_HOST'
];
if (smtpRequiredProps.some(prop => !process.env[prop])) {
console.error(`${smtpRequiredProps.join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`);
}
const {
SMTP_HOST,
SMTP_USERNAME,
SMTP_PORT,
SMTP_PASSWORD,
SMTP_FROM_ADDRESS
} = require('../config');
// load all the templates as strings
const templates = {
@@ -56,15 +53,15 @@ templates.render = (name, format = 'txt', context) => new Promise((resolve, reje
});
const options = {
host: process.env.TALK_SMTP_HOST,
host: SMTP_HOST,
auth: {
user: process.env.TALK_SMTP_USERNAME,
pass: process.env.TALK_SMTP_PASSWORD
user: SMTP_USERNAME,
pass: SMTP_PASSWORD
}
};
if (process.env.TALK_SMTP_PORT) {
options.port = process.env.TALK_SMTP_PORT;
if (SMTP_PORT) {
options.port = SMTP_PORT;
} else {
options.port = 25;
}
@@ -126,7 +123,7 @@ const mailer = module.exports = {
debug(`Starting to send mail for Job[${id}]`);
// Set the `from` field.
data.message.from = process.env.TALK_SMTP_FROM_ADDRESS;
data.message.from = SMTP_FROM_ADDRESS;
// Actually send the email.
defaultTransporter.sendMail(data.message, (err) => {
+6 -13
View File
@@ -1,7 +1,12 @@
const mongoose = require('mongoose');
const debug = require('debug')('talk:db');
const enabled = require('debug').enabled;
const queryDebuger = require('debug')('talk:db:query');
const {
MONGO_URL
} = require('../config');
// Loading the formatter from Mongoose:
//
// https://github.com/Automattic/mongoose/blob/1a93d1f4d12e441e17ddf451e96fbc5f6e8f54b8/lib/drivers/node-mongodb-native/collection.js#L182
@@ -24,18 +29,6 @@ function debugQuery(name, i, ...args) {
queryDebuger(functionCall + params);
}
const enabled = require('debug').enabled;
// Pull the mongo url out of the environment.
let url = process.env.TALK_MONGO_URL;
// Reset the mongo url in the event it hasn't been overrided and we are in a
// testing environment. Every new mongo instance comes with a test database by
// default, this is consistent with common testing and use case practices.
if (process.env.NODE_ENV === 'test' && !url) {
url = 'mongodb://localhost/test';
}
// Use native promises
mongoose.Promise = global.Promise;
@@ -48,7 +41,7 @@ if (enabled('talk:db')) {
}
// Connect to the Mongo instance.
mongoose.connect(url, (err) => {
mongoose.connect(MONGO_URL, (err) => {
if (err) {
throw err;
}
+125 -56
View File
@@ -3,33 +3,39 @@ const UsersService = require('./users');
const SettingsService = require('./settings');
const fetch = require('node-fetch');
const FormData = require('form-data');
const JWT = require('jsonwebtoken');
const LocalStrategy = require('passport-local').Strategy;
const errors = require('../errors');
const uuid = require('uuid');
const debug = require('debug')('talk:passport');
const {createClient} = require('./redis');
//==============================================================================
// SESSION SERIALIZATION
//==============================================================================
// Create a redis client to use for authentication.
const client = createClient();
passport.serializeUser((user, done) => {
done(null, user.id);
const {
JWT_SECRET,
JWT_ISSUER,
JWT_EXPIRY,
JWT_AUDIENCE,
RECAPTCHA_SECRET,
RECAPTCHA_ENABLED
} = require('../config');
// GenerateToken will sign a token to include all the authorization information
// needed for the front end.
const GenerateToken = (user) => JWT.sign({}, JWT_SECRET, {
jwtid: uuid.v4(),
expiresIn: JWT_EXPIRY,
issuer: JWT_ISSUER,
subject: user.id,
audience: JWT_AUDIENCE
});
passport.deserializeUser((id, done) => {
UsersService
.findById(id)
.then((user) => {
done(null, user);
})
.catch((err) => {
done(err);
});
});
/**
* This sends back the user data as JSON.
*/
const HandleAuthCallback = (req, res, next) => (err, user) => {
// HandleGenerateCredentials validates that an authentication scheme did indeed
// return a user, if it did, then sign and return the user and token to be used
// by the frontend to display and update the UI.
const HandleGenerateCredentials = (req, res, next) => (err, user) => {
if (err) {
return next(err);
}
@@ -38,15 +44,11 @@ const HandleAuthCallback = (req, res, next) => (err, user) => {
return next(errors.ErrNotAuthorized);
}
// Perform the login of the user!
req.logIn(user, (err) => {
if (err) {
return next(err);
}
// Generate the token to re-issue to the frontend.
const token = GenerateToken(user);
// We logged in the user! Let's send back the user data and the CSRF token.
res.json({user});
});
// Send back the details!
res.json({user, token});
};
/**
@@ -54,22 +56,18 @@ const HandleAuthCallback = (req, res, next) => (err, user) => {
*/
const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
if (err) {
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
return res.render('auth-callback', {auth: JSON.stringify({err, data: null})});
}
if (!user) {
return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null});
return res.render('auth-callback', {auth: JSON.stringify({err: errors.ErrNotAuthorized, data: null})});
}
// Perform the login of the user!
req.logIn(user, (err) => {
if (err) {
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
}
// Generate the token to re-issue to the frontend.
const token = GenerateToken(user);
// We logged in the user! Let's send back the user data.
res.render('auth-callback', {err: null, data: JSON.stringify(user)});
});
// We logged in the user! Let's send back the user data.
res.render('auth-callback', {auth: JSON.stringify({err: null, data: {user, token}})});
};
/**
@@ -119,7 +117,91 @@ function ValidateUserLogin(loginProfile, user, done) {
}
//==============================================================================
// STRATEGIES
// JWT STRATEGY
//==============================================================================
/**
* Revoke the token on the request.
*/
const HandleLogout = (req, res, next) => {
const {jwt} = req;
const now = new Date();
const expiry = (jwt.exp - now.getTime() / 1000).toFixed(0);
client.set(`jtir[${jwt.jti}]`, now.toISOString(), 'EX', expiry, (err) => {
if (err) {
return next(err);
}
res.status(204).end();
});
};
/**
* Check if the given token is already blacklisted, throw an error if it is.
*/
const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => {
client.get(`jtir[${jwt.jti}]`, (err, expiry) => {
if (err) {
return reject(err);
}
if (expiry != null) {
return reject(new errors.ErrAuthentication('token was revoked'));
}
return resolve();
});
});
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
// Extract the JWT from the 'Authorization' header with the 'Bearer' scheme.
passport.use(new JwtStrategy({
// Prepare the extractor from the header.
jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('Bearer'),
// Use the secret passed in which is loaded from the environment. This can be
// a certificate (loaded) or a HMAC key.
secretOrKey: JWT_SECRET,
// Verify the issuer.
issuer: JWT_ISSUER,
// Verify the audience.
audience: JWT_AUDIENCE,
// Enable only the HS256 algorithm.
algorithms: ['HS256'],
// Pass the request objecto back to the callback so we can attach the JWT to
// it.
passReqToCallback: true
}, async (req, jwt, done) => {
// Load the user from the environment, because we just got a user from the
// header.
try {
// Check to see if the token has been revoked
await CheckBlacklisted(jwt);
let user = await UsersService.findById(jwt.sub);
// Attach the JWT to the request.
req.jwt = jwt;
return done(null, user);
} catch(e) {
return done(e);
}
}));
//==============================================================================
// LOCAL STRATEGY
//==============================================================================
/**
@@ -157,21 +239,6 @@ const CheckIfNeedsRecaptcha = (user, email) => {
return false;
};
/**
* This stores the Recaptcha secret.
*/
const RECAPTCHA_SECRET = process.env.TALK_RECAPTCHA_SECRET;
const RECAPTCHA_PUBLIC = process.env.TALK_RECAPTCHA_PUBLIC;
/**
* This is true when the recaptcha secret is provided and the Recaptcha feature
* is to be enabled.
*/
const RECAPTCHA_ENABLED = RECAPTCHA_SECRET && RECAPTCHA_SECRET.length > 0 && RECAPTCHA_PUBLIC && RECAPTCHA_PUBLIC.length > 0;
if (!RECAPTCHA_ENABLED) {
console.log('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.');
}
/**
* This sends the request details down Google to check to see if the response is
* genuine or not.
@@ -356,6 +423,8 @@ module.exports = {
passport,
ValidateUserLogin,
HandleFailedAttempt,
HandleAuthCallback,
HandleAuthPopupCallback
HandleAuthPopupCallback,
HandleGenerateCredentials,
HandleLogout,
CheckBlacklisted
};
+4 -2
View File
@@ -1,9 +1,11 @@
const redis = require('redis');
const debug = require('debug')('talk:redis');
const url = process.env.TALK_REDIS_URL || 'redis://localhost';
const {
REDIS_URL
} = require('../config');
const connectionOptions = {
url,
url: REDIS_URL,
retry_strategy: function(options) {
if (options.error && options.error.code === 'ECONNREFUSED') {
-36
View File
@@ -1,36 +0,0 @@
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redis = require('./redis');
//==============================================================================
// SESSION MIDDLEWARE
//==============================================================================
const session_opts = {
secret: process.env.TALK_SESSION_SECRET,
httpOnly: true,
rolling: true,
saveUninitialized: true,
resave: true,
unset: 'destroy',
name: 'talk.sid',
cookie: {
secure: false,
maxAge: 8.64e+7, // 24 hours for session token expiry
},
store: new RedisStore({
client: redis.createClient(),
})
};
if (process.env.NODE_ENV === 'production') {
// Enable the secure cookie when we are in production mode.
session_opts.cookie.secure = true;
} else if (process.env.NODE_ENV === 'test') {
// Add in the secret during tests.
session_opts.secret = 'keyboard cat';
}
module.exports = session(session_opts);
+4 -1
View File
@@ -2,6 +2,9 @@ const UsersService = require('./users');
const SettingsService = require('./settings');
const SettingsModel = require('../models/setting');
const errors = require('../errors');
const {
INSTALL_LOCK
} = require('../config');
/**
* This service is used when we want to setup the application. It is consumed by
@@ -15,7 +18,7 @@ module.exports = class SetupService {
static isAvailable() {
// Check if we have an install lock present.
if (process.env.TALK_INSTALL_LOCK === 'TRUE') {
if (INSTALL_LOCK) {
return Promise.reject(errors.ErrInstallLock);
}
+8 -2
View File
@@ -1,12 +1,18 @@
const session = require('./session');
const passport = require('./passport');
const authentication = require('../middleware/authentication');
// Session data does not automatically attach to websocket req objects.
// This middleware code looks for a user in the session and, if it exists,
// attaches it to the graph req.
const deserializeUser = (req) => {
return new Promise((resolve, reject) => {
session(req, {}, () => {
// This uses the authentication connect middleware to establish the session
// user details from the headers.
authentication(req, null, (err) => {
if (err) {
return reject(err);
}
if ('session' in req && 'passport' in req.session && 'user' in req.session.passport) {
passport.deserializeUser(req.session.passport.user, (err, user) => {
+11 -17
View File
@@ -1,12 +1,14 @@
const assert = require('assert');
const uuid = require('uuid');
const bcrypt = require('bcrypt');
const url = require('url');
const jwt = require('jsonwebtoken');
const Wordlist = require('./wordlist');
const errors = require('../errors');
const uuid = require('uuid');
const {
JWT_SECRET,
ROOT_URL
} = require('../config');
const redis = require('./redis');
const redisClient = redis.createClient();
@@ -22,14 +24,6 @@ const SettingsService = require('./settings');
const ActionsService = require('./actions');
const MailerService = require('./mailer');
// In the event that the TALK_SESSION_SECRET is missing but we are testing, then
// set the process.env.TALK_SESSION_SECRET.
if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) {
process.env.TALK_SESSION_SECRET = 'keyboard cat';
} else if (!process.env.TALK_SESSION_SECRET) {
throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality');
}
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
@@ -561,7 +555,7 @@ module.exports = class UsersService {
version: user.__v
};
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
return jwt.sign(payload, JWT_SECRET, {
algorithm: 'HS256',
expiresIn: '1d',
subject: PASSWORD_RESET_JWT_SUBJECT
@@ -580,7 +574,7 @@ module.exports = class UsersService {
// Set the allowed algorithms.
options.algorithms = ['HS256'];
jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => {
jwt.verify(token, JWT_SECRET, options, (err, decoded) => {
if (err) {
return reject(err);
}
@@ -694,7 +688,7 @@ module.exports = class UsersService {
* @param {String} email The email that we are needing to get confirmed.
* @return {Promise}
*/
static createEmailConfirmToken(userID = null, email, referer = process.env.TALK_ROOT_URL) {
static createEmailConfirmToken(userID = null, email, referer = ROOT_URL) {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required when creating a JWT for resetting passord');
}
@@ -737,7 +731,7 @@ module.exports = class UsersService {
email,
referer,
userID: user.id
}, process.env.TALK_SESSION_SECRET, tokenOptions);
}, JWT_SECRET, tokenOptions);
});
}
@@ -842,7 +836,7 @@ module.exports = class UsersService {
*/
static ignoreUsers(userId, usersToIgnore) {
assert(Array.isArray(usersToIgnore), 'usersToIgnore is an array');
assert(usersToIgnore.every(u => typeof u === 'string'), 'usersToIgnore is an array of string user IDs');
assert(usersToIgnore.every((u) => typeof u === 'string'), 'usersToIgnore is an array of string user IDs');
if (usersToIgnore.includes(userId)) {
throw new Error('Users cannot ignore themselves');
}
@@ -864,7 +858,7 @@ module.exports = class UsersService {
*/
static async stopIgnoringUsers(userId, usersToStopIgnoring) {
assert(Array.isArray(usersToStopIgnoring), 'usersToStopIgnoring is an array');
assert(usersToStopIgnoring.every(u => typeof u === 'string'), 'usersToStopIgnoring is an array of string user IDs');
assert(usersToStopIgnoring.every((u) => typeof u === 'string'), 'usersToStopIgnoring is an array of string user IDs');
await UserModel.update({id: userId}, {
$pullAll: {
ignoresUsers: usersToStopIgnoring