Merge pull request #6232 from HabitRPG/api-v3-utils

[API v3] Port Utils
This commit is contained in:
Matteo Pagliazzi
2015-11-15 20:01:48 +01:00
19 changed files with 431 additions and 19 deletions

View File

@@ -12,10 +12,10 @@
"babel": "^5.5.4", "babel": "^5.5.4",
"babelify": "^7.2.0", "babelify": "^7.2.0",
"body-parser": "^1.14.1", "body-parser": "^1.14.1",
"compression": "^1.6.0",
"bower": "~1.3.12", "bower": "~1.3.12",
"browserify": "~12.0.1", "browserify": "~12.0.1",
"coffee-script": "1.6.x", "coffee-script": "1.6.x",
"compression": "^1.6.0",
"connect-ratelimit": "0.0.7", "connect-ratelimit": "0.0.7",
"cookie-parser": "^1.4.0", "cookie-parser": "^1.4.0",
"cookie-session": "^1.2.0", "cookie-session": "^1.2.0",
@@ -64,7 +64,7 @@
"nconf": "~0.8.2", "nconf": "~0.8.2",
"newrelic": "~1.23.0", "newrelic": "~1.23.0",
"nib": "~1.0.1", "nib": "~1.0.1",
"nodemailer": "~0.5.2", "nodemailer": "^1.9.0",
"pageres": "^1.0.1", "pageres": "^1.0.1",
"passport": "~0.2.1", "passport": "~0.2.1",
"passport-facebook": "2.0.0", "passport-facebook": "2.0.0",

View File

@@ -2,7 +2,6 @@ import mongoose from 'mongoose';
import autoinc from 'mongoose-id-autoinc'; import autoinc from 'mongoose-id-autoinc';
import logger from '../website/src/libs/api-v3/logger'; import logger from '../website/src/libs/api-v3/logger';
import nconf from 'nconf'; import nconf from 'nconf';
import utils from '../website/src/libs/utils';
import repl from 'repl'; import repl from 'repl';
import gulp from 'gulp'; import gulp from 'gulp';
@@ -19,8 +18,6 @@ let improveRepl = (context) => {
process.stdout.write('\u001B[2J\u001B[0;0f'); process.stdout.write('\u001B[2J\u001B[0;0f');
}}); }});
utils.setupConfig();
context.Challenge = require('../website/src/models/challenge').model; context.Challenge = require('../website/src/models/challenge').model;
context.Group = require('../website/src/models/group').model; context.Group = require('../website/src/models/group').model;
context.User = require('../website/src/models/user').model; context.User = require('../website/src/models/user').model;

View File

@@ -0,0 +1,217 @@
import request from 'request';
import nconf from 'nconf';
import nodemailer from 'nodemailer';
import Q from 'q';
import logger from '../../../../../website/src/libs/api-v3/logger';
function getUser () {
return {
_id: 'random _id',
auth: {
local: {
username: 'username',
email: 'email@email',
},
facebook: {
emails: [{
value: 'email@facebook'
}],
displayName: 'fb display name',
}
},
profile: {
name: 'profile name',
},
preferences: {
emailNotifications: {
unsubscribeFromAll: false
},
},
};
};
describe('emails', () => {
let pathToEmailLib = '../../../../../website/src/libs/api-v3/email';
beforeEach(() => {
delete require.cache[require.resolve(pathToEmailLib)];
});
describe('sendEmail', () => {
it('can send an email using the default transport', () => {
let sendMailSpy = sandbox.stub().returns(Q.defer().promise);
sandbox.stub(nodemailer, 'createTransport').returns({
sendMail: sendMailSpy,
});
let attachEmail = require(pathToEmailLib);
attachEmail.send();
expect(sendMailSpy).to.be.calledOnce;
});
it('logs errors', (done) => {
let deferred = Q.defer();
let sendMailSpy = sandbox.stub().returns(deferred.promise);
sandbox.stub(nodemailer, 'createTransport').returns({
sendMail: sendMailSpy,
});
sandbox.stub(logger, 'error');
let attachEmail = require(pathToEmailLib);
attachEmail.send();
expect(sendMailSpy).to.be.calledOnce;
deferred.reject();
deferred.promise.catch((err) => {
expect(logger.error).to.be.calledOnce;
done();
});
});
});
describe('getUserInfo', () => {
it('returns an empty object if no field request', () => {
let attachEmail = require(pathToEmailLib);
let getUserInfo = attachEmail.getUserInfo;
expect(getUserInfo({}, [])).to.be.empty;
});
it('returns correct user data', () => {
let attachEmail = require(pathToEmailLib);
let getUserInfo = attachEmail.getUserInfo;
let user = getUser();
let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
expect(data).to.have.property('name', user.profile.name);
expect(data).to.have.property('email', user.auth.local.email);
expect(data).to.have.property('_id', user._id);
expect(data).to.have.property('canSend', true);
});
it('returns correct user data [facebook users]', () => {
let attachEmail = require(pathToEmailLib);
let getUserInfo = attachEmail.getUserInfo;
let user = getUser();
delete user.profile['name'];
delete user.auth['local'];
let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
expect(data).to.have.property('name', user.auth.facebook.displayName);
expect(data).to.have.property('email', user.auth.facebook.emails[0].value);
expect(data).to.have.property('_id', user._id);
expect(data).to.have.property('canSend', true);
});
it('has fallbacks for missing data', () => {
let attachEmail = require(pathToEmailLib);
let getUserInfo = attachEmail.getUserInfo;
let user = getUser();
delete user.profile['name'];
delete user.auth.local['email']
delete user.auth['facebook'];
let data = getUserInfo(user, ['name', 'email', '_id', 'canSend']);
expect(data).to.have.property('name', user.auth.local.username);
expect(data).not.to.have.property('email');
expect(data).to.have.property('_id', user._id);
expect(data).to.have.property('canSend', true);
});
});
describe('sendTxnEmail', () => {
beforeEach(() => {
sandbox.stub(request, 'post');
});
afterEach(() => {
sandbox.restore();
});
it('can send a txn email to one recipient', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
let attachEmail = require(pathToEmailLib);
let sendTxnEmail = attachEmail.sendTxn;
let emailType = 'an email type';
let mailingInfo = {
name: 'my name',
email: 'my@email',
};
sendTxnEmail(mailingInfo, emailType);
expect(request.post).to.be.calledWith(sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match((value) => {
return Array.isArray(value) && value[0].name === mailingInfo.name;
}, 'matches mailing info array'),
}
}
}));
});
it('does not send email if address is missing', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
let attachEmail = require(pathToEmailLib);
let sendTxnEmail = attachEmail.sendTxn;
let emailType = 'an email type';
let mailingInfo = {
name: 'my name',
//email: 'my@email',
};
sendTxnEmail(mailingInfo, emailType);
expect(request.post).not.to.be.called;
});
it('uses getUserInfo in case of user data', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
let attachEmail = require(pathToEmailLib);
let sendTxnEmail = attachEmail.sendTxn;
let emailType = 'an email type';
let mailingInfo = getUser();
sendTxnEmail(mailingInfo, emailType);
expect(request.post).to.be.calledWith(sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
to: sinon.match(val => val[0]._id === mailingInfo._id),
}
}
}));
});
it('sends email with some default variables', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
let attachEmail = require(pathToEmailLib);
let sendTxnEmail = attachEmail.sendTxn;
let emailType = 'an email type';
let mailingInfo = {
name: 'my name',
email: 'my@email',
};
let variables = [1,2,3];
sendTxnEmail(mailingInfo, emailType, variables);
expect(request.post).to.be.calledWith(sinon.match({
json: {
data: {
variables: sinon.match((value) => {
return value[0].name === 'BASE_URL';
}, 'matches variables'),
personalVariables: sinon.match((value) => {
return (value[0].rcpt === mailingInfo.email
&& value[0].vars[0].name === 'RECIPIENT_NAME'
&& value[0].vars[1].name === 'RECIPIENT_UNSUB_URL'
);
}, 'matches personal variables'),
}
}
}));
});
});
});

View File

@@ -0,0 +1,15 @@
import {
encrypt,
decrypt,
} from '../../../../../website/src/libs/api-v3/encryption';
describe('encryption', () => {
it('can encrypt and decrypt', () => {
let data = 'some secret text';
let encrypted = encrypt(data);
let decrypted = decrypt(encrypted);
expect(encrypted).not.to.equal(data);
expect(data).to.equal(decrypted);
});
});

View File

@@ -8,7 +8,7 @@ var Group = require('../../../website/src/models/group').model;
var groupsController = require('../../../website/src/controllers/api-v2/groups'); var groupsController = require('../../../website/src/controllers/api-v2/groups');
describe('Groups Controller', function() { describe('Groups Controller', function() {
var utils = require('../../../website/src/libs/utils'); var utils = require('../../../website/src/libs/api-v2/utils');
describe('#invite', function() { describe('#invite', function() {
var res, req, user, group; var res, req, user, group;

View File

@@ -3,7 +3,7 @@ var validator = require('validator');
var passport = require('passport'); var passport = require('passport');
var shared = require('../../../../common'); var shared = require('../../../../common');
var async = require('async'); var async = require('async');
var utils = require('../../libs/utils'); var utils = require('../../libs/api-v2/utils');
var nconf = require('nconf'); var nconf = require('nconf');
var request = require('request'); var request = require('request');
var FirebaseTokenGenerator = require('firebase-token-generator'); var FirebaseTokenGenerator = require('firebase-token-generator');

View File

@@ -9,7 +9,7 @@ var Group = require('./../../models/group').model;
var Challenge = require('./../../models/challenge').model; var Challenge = require('./../../models/challenge').model;
var logging = require('./../../libs/api-v2/logging'); var logging = require('./../../libs/api-v2/logging');
var csv = require('express-csv'); var csv = require('express-csv');
var utils = require('../../libs/utils'); var utils = require('../../libs/api-v2/utils');
var api = module.exports; var api = module.exports;
var pushNotify = require('./../pushNotifications'); var pushNotify = require('./../pushNotifications');

View File

@@ -9,7 +9,7 @@ var _ = require('lodash');
var nconf = require('nconf'); var nconf = require('nconf');
var async = require('async'); var async = require('async');
var Q = require('q'); var Q = require('q');
var utils = require('./../../libs/utils'); var utils = require('./../../libs/api-v2/utils');
var shared = require('../../../../common'); var shared = require('../../../../common');
var User = require('./../../models/user').model; var User = require('./../../models/user').model;
var Group = require('./../../models/group').model; var Group = require('./../../models/group').model;

View File

@@ -5,7 +5,7 @@ var api = module.exports;
var async = require('async'); var async = require('async');
var _ = require('lodash'); var _ = require('lodash');
var shared = require('../../../../common'); var shared = require('../../../../common');
var utils = require('../../libs/utils'); var utils = require('../../libs/api-v2/utils');
var nconf = require('nconf'); var nconf = require('nconf');
var pushNotify = require('./../pushNotifications'); var pushNotify = require('./../pushNotifications');

View File

@@ -1,6 +1,6 @@
var User = require('../../models/user').model; var User = require('../../models/user').model;
var EmailUnsubscription = require('../../models/emailUnsubscription').model; var EmailUnsubscription = require('../../models/emailUnsubscription').model;
var utils = require('../../libs/utils'); var utils = require('../../libs/api-v2/utils');
var i18n = require('../../../../common').i18n; var i18n = require('../../../../common').i18n;
var api = module.exports = {}; var api = module.exports = {};

View File

@@ -5,7 +5,7 @@ var nconf = require('nconf');
var async = require('async'); var async = require('async');
var shared = require('../../../../common'); var shared = require('../../../../common');
var User = require('./../../models/user').model; var User = require('./../../models/user').model;
var utils = require('./../../libs/utils'); var utils = require('./../../libs/api-v2/utils');
var analytics = utils.analytics; var analytics = utils.analytics;
var Group = require('./../../models/group').model; var Group = require('./../../models/group').model;
var Challenge = require('./../../models/challenge').model; var Challenge = require('./../../models/challenge').model;

View File

@@ -1,7 +1,7 @@
var _ = require('lodash'); var _ = require('lodash');
var shared = require('../../../../common'); var shared = require('../../../../common');
var nconf = require('nconf'); var nconf = require('nconf');
var utils = require('./../../libs/utils'); var utils = require('./../../libs/api-v2/utils');
var moment = require('moment'); var moment = require('moment');
var isProduction = nconf.get("NODE_ENV") === "production"; var isProduction = nconf.get("NODE_ENV") === "production";
var stripe = require('./stripe'); var stripe = require('./stripe');

View File

@@ -9,13 +9,13 @@ var logger = require('./libs/api-v3/logger');
// Initialize configuration // Initialize configuration
var setupNconf = require('./libs/api-v3/setupNconf'); var setupNconf = require('./libs/api-v3/setupNconf');
setupNconf(); setupNconf();
var utils = require('./libs/utils');
utils.setupConfig();
var IS_PROD = nconf.get('IS_PROD'); var IS_PROD = nconf.get('IS_PROD');
var IS_DEV = nconf.get('IS_DEV'); var IS_DEV = nconf.get('IS_DEV');
var cores = Number(nconf.get('WEB_CONCURRENCY')) || 0; var cores = Number(nconf.get('WEB_CONCURRENCY')) || 0;
if (IS_DEV) Error.stackTraceLimit = Infinity;
// Setup the cluster module // Setup the cluster module
if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) { if (cores !== 0 && cluster.isMaster && (IS_DEV || IS_PROD)) {
// Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production)

View File

@@ -0,0 +1,158 @@
import { createTransport } from 'nodemailer';
import nconf from 'nconf';
import logger from './logger';
import { encrypt } from './encryption';
import request from 'request';
const IS_PROD = nconf.get('IS_PROD');
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER:url'),
auth: {
user: nconf.get('EMAIL_SERVER:authUser'),
password: nconf.get('EMAIL_SERVER:authPassword'),
},
};
const BASE_URL = nconf.get('BASE_URL');
let smtpTransporter = createTransport({
service: nconf.get('SMTP_SERVICE'),
auth: {
user: nconf.get('SMTP_USER'),
pass: nconf.get('SMTP_PASS'),
},
});
// Send email directly from the server using the smtpTransporter,
// used only to send password reset emails because users unsubscribed on Mandrill wouldn't get them
export function send (mailData) {
return smtpTransporter
.sendMail(mailData)
.catch((error) => logger.error(error));
}
export function getUserInfo (user, fields = []) {
let info = {};
if (fields.indexOf('name') !== -1) {
info.name = user.profile && user.profile.name;
if (!info.name) {
if (user.auth.local && user.auth.local.username) {
info.name = user.auth.local.username;
} else if (user.auth.facebook) {
info.name = user.auth.facebook.displayName || user.auth.facebook.username;
}
}
}
if (fields.indexOf('email') !== -1) {
if (user.auth.local && user.auth.local.email) {
info.email = user.auth.local.email;
} else if (user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0] && user.auth.facebook.emails[0].value) {
info.email = user.auth.facebook.emails[0].value;
}
}
if (fields.indexOf('_id') !== -1) {
info._id = user._id;
}
if (fields.indexOf('canSend') !== -1) {
if (user.preferences && user.preferences.emailNotifications) {
info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true;
}
}
return info;
}
// Send a transactional email using Mandrill through the external email server
export function sendTxn (mailingInfoArray, emailType, variables, personalVariables) {
mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray];
variables = [
{name: 'BASE_URL', content: BASE_URL},
].concat(variables || []);
// It's important to pass at least a user with its `preferences` as we need to check if he unsubscribed
mailingInfoArray = mailingInfoArray.map((mailingInfo) => {
return mailingInfo._id ? getUserInfo(mailingInfo, ['_id', 'email', 'name', 'canSend']) : mailingInfo;
}).filter((mailingInfo) => {
// Always send reset-password emails
// Don't check canSend for non registered users as already checked before
return mailingInfo.email && (!mailingInfo._id || mailingInfo.canSend || emailType === 'reset-password');
});
// Personal variables are personal to each email recipient, if they are missing
// we manually create a structure for them with RECIPIENT_NAME and RECIPIENT_UNSUB_URL
// otherwise we just add RECIPIENT_NAME and RECIPIENT_UNSUB_URL to the existing personal variables
if (!personalVariables || personalVariables.length === 0) {
personalVariables = mailingInfoArray.map((mailingInfo) => {
return {
rcpt: mailingInfo.email,
vars: [
{
name: 'RECIPIENT_NAME',
content: mailingInfo.name,
},
{
name: 'RECIPIENT_UNSUB_URL',
content: `/unsubscribe?code=${encrypt(JSON.stringify({
_id: mailingInfo._id,
email: mailingInfo.email,
}))}`,
},
],
};
});
} else {
let temporaryPersonalVariables = {};
mailingInfoArray.forEach((mailingInfo) => {
temporaryPersonalVariables[mailingInfo.email] = {
name: mailingInfo.name,
_id: mailingInfo._id,
};
});
personalVariables.forEach((singlePersonalVariables) => {
singlePersonalVariables.vars.push(
{
name: 'RECIPIENT_NAME',
content: temporaryPersonalVariables[singlePersonalVariables.rcpt].name,
},
{
name: 'RECIPIENT_UNSUB_URL',
content: `/unsubscribe?code=${encrypt(JSON.stringify({
_id: temporaryPersonalVariables[singlePersonalVariables.rcpt]._id,
email: singlePersonalVariables.rcpt,
}))}`,
}
);
});
}
if (IS_PROD && mailingInfoArray.length > 0) {
request.post({
url: `${EMAIL_SERVER.url}/job`,
auth: {
user: EMAIL_SERVER.auth.user,
pass: EMAIL_SERVER.auth.password,
},
json: {
type: 'email',
data: {
emailType,
to: mailingInfoArray,
variables,
personalVariables,
},
options: {
priority: 'high',
attempts: 5,
backoff: {delay: 10 * 60 * 1000, type: 'fixed'},
},
},
}, (err) => logger.error(err));
}
}

View File

@@ -0,0 +1,25 @@
import {
createCipher,
createDecipher,
} from 'crypto';
import nconf from 'nconf';
// TODO check this is secure
const algorithm = 'aes-256-ctr';
const SESSION_SECRET = nconf.get('SESSION_SECRET');
export function encrypt (text) {
let cipher = createCipher(algorithm, SESSION_SECRET);
let crypted = cipher.update(text, 'utf8', 'hex');
crypted += cipher.final('hex');
return crypted;
}
export function decrypt (text) {
let decipher = createDecipher(algorithm, SESSION_SECRET);
let dec = decipher.update(text, 'hex', 'utf8');
dec += decipher.final('utf8');
return dec;
}

View File

@@ -12,7 +12,9 @@ if (IS_PROD) {
// log errors to console too // log errors to console too
} else { } else {
logger logger
.add(winston.transports.Console); .add(winston.transports.Console, {
colorize: true,
});
} }
export default logger; export default logger;

View File

@@ -1,6 +1,6 @@
var nconf = require('nconf'); var nconf = require('nconf');
var _ = require('lodash'); var _ = require('lodash');
var utils = require('../libs/utils'); var utils = require('../libs/api-v2/utils');
var shared = require('../../../common'); var shared = require('../../../common');
var i18n = require('../libs/api-v2/i18n'); var i18n = require('../libs/api-v2/i18n');
var buildManifest = require('../libs/api-v2/buildManifest'); var buildManifest = require('../libs/api-v2/buildManifest');

View File

@@ -2,7 +2,6 @@
import nconf from 'nconf'; import nconf from 'nconf';
import logger from './libs/api-v3/logger'; import logger from './libs/api-v3/logger';
import utils from './libs/utils';
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
// import path from 'path'; // import path from 'path';
@@ -15,7 +14,6 @@ import mongoose from 'mongoose';
import Q from 'q'; import Q from 'q';
import domainMiddleware from './middlewares/api-v3/domain'; import domainMiddleware from './middlewares/api-v3/domain';
import attachMiddlewares from './middlewares/api-v3/index'; import attachMiddlewares from './middlewares/api-v3/index';
utils.setupConfig();
// Setup translations // Setup translations
// let i18n = require('./libs/api-v2/i18n'); // let i18n = require('./libs/api-v2/i18n');