Merge branch 'develop' into srrvnn-develop

This commit is contained in:
Blade Barringer
2015-11-03 07:31:48 -06:00
209 changed files with 3864 additions and 3819 deletions
+379
View File
@@ -0,0 +1,379 @@
var _ = require('lodash');
var validator = require('validator');
var passport = require('passport');
var shared = require('../../../../common');
var async = require('async');
var utils = require('../../libs/utils');
var nconf = require('nconf');
var request = require('request');
var FirebaseTokenGenerator = require('firebase-token-generator');
var User = require('../../models/user').model;
var EmailUnsubscription = require('../../models/emailUnsubscription').model;
var analytics = utils.analytics;
var i18n = require('./../../libs/i18n');
var isProd = nconf.get('NODE_ENV') === 'production';
var api = module.exports;
var NO_TOKEN_OR_UID = { err: shared.i18n.t('messageAuthMustIncludeTokens') };
var NO_USER_FOUND = {err: shared.i18n.t('messageAuthNoUserFound') };
var NO_SESSION_FOUND = { err: shared.i18n.t('messageAuthMustBeLoggedIn') };
var accountSuspended = function(uuid){
return {
err: 'Account has been suspended, please contact leslie@habitica.com with your UUID ('+uuid+') for assistance.',
code: 'ACCOUNT_SUSPENDED'
};
}
api.auth = function(req, res, next) {
var uid = req.headers['x-api-user'];
var token = req.headers['x-api-key'];
if (!(uid && token)) return res.json(401, NO_TOKEN_OR_UID);
User.findOne({_id: uid, apiToken: token}, function(err, user) {
if (err) return next(err);
if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND);
if (user.auth.blocked) return res.json(401, accountSuspended(user._id));
res.locals.wasModified = req.query._v ? +user._v !== +req.query._v : true;
res.locals.user = user;
req.session.userId = user._id;
return next();
});
};
api.authWithSession = function(req, res, next) { //[todo] there is probably a more elegant way of doing this...
if (!(req.session && req.session.userId))
return res.json(401, NO_SESSION_FOUND);
User.findOne({_id: req.session.userId}, function(err, user) {
if (err) return next(err);
if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND);
res.locals.user = user;
next();
});
};
api.authWithUrl = function(req, res, next) {
User.findOne({_id:req.query._id, apiToken:req.query.apiToken}, function(err,user){
if (err) return next(err);
if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND);
res.locals.user = user;
next();
});
}
api.registerUser = function(req, res, next) {
var email = req.body.email && req.body.email.toLowerCase();
var username = req.body.username;
// Get the lowercase version of username to check that we do not have duplicates
// So we can search for it in the database and then reject the choosen username if 1 or more results are found
var lowerCaseUsername = username && username.toLowerCase();
async.auto({
validate: function(cb) {
if (!(username && req.body.password && email))
return cb({code:401, err: shared.i18n.t('messageAuthCredentialsRequired')});
if (req.body.password !== req.body.confirmPassword)
return cb({code:401, err: shared.i18n.t('messageAuthPasswordMustMatch')});
if (!validator.isEmail(email))
return cb({code:401, err: ":email invalid"});
cb();
},
findReg: function(cb) {
// Search for duplicates using lowercase version of username
User.findOne({$or:[{'auth.local.email': email}, {'auth.local.lowerCaseUsername': lowerCaseUsername}]}, {'auth.local':1}, cb);
},
findFacebook: function(cb){
User.findOne({_id: req.headers['x-api-user'], apiToken: req.headers['x-api-key']}, {auth:1}, cb);
},
register: ['validate', 'findReg', 'findFacebook', function(cb, data) {
if (data.findReg) {
if (email === data.findReg.auth.local.email) return cb({code:401, err:"Email already taken"});
// Check that the lowercase username isn't already used
if (lowerCaseUsername === data.findReg.auth.local.lowerCaseUsername) return cb({code:401, err: shared.i18n.t('messageAuthUsernameTaken')});
}
var salt = utils.makeSalt();
var newUser = {
auth: {
local: {
username: username,
lowerCaseUsername: lowerCaseUsername, // Store the lowercase version of the username
email: email, // Store email as lowercase
salt: salt,
hashed_password: utils.encryptPassword(req.body.password, salt)
},
timestamps: {created: +new Date(), loggedIn: +new Date()}
}
};
// existing user, allow them to add local authentication
if (data.findFacebook) {
data.findFacebook.auth.local = newUser.auth.local;
data.findFacebook.save(cb);
// new user, register them
} else {
newUser.preferences = newUser.preferences || {};
newUser.preferences.language = req.language; // User language detected from browser, not saved
var user = new User(newUser);
var analyticsData = {
category: 'acquisition',
type: 'local',
gaLabel: 'local',
uuid: user._id,
};
analytics.track('register', analyticsData)
user.save(function(err, savedUser){
// Clean previous email preferences
// TODO when emails added to EmailUnsubcription they should use lowercase version
EmailUnsubscription.remove({email: savedUser.auth.local.email}, function(){
utils.txnEmail(savedUser, 'welcome');
});
cb.apply(cb, arguments);
});
}
}]
}, function(err, data) {
if (err) return err.code ? res.json(err.code, err) : next(err);
res.json(200, data.register[0]);
});
};
/*
Register new user with uname / password
*/
api.loginLocal = function(req, res, next) {
var username = req.body.username;
var password = req.body.password;
if (!(username && password)) return res.json(401, {err:'Missing :username or :password in request body, please provide both'});
var login = validator.isEmail(username) ?
{'auth.local.email':username.toLowerCase()} : // Emails are all lowercase
{'auth.local.username':username}; // Use the username as the user typed it
User.findOne(login, {auth:1}, function(err, user){
if (err) return next(err);
if (!user) return res.json(401, {err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."});
if (user.auth.blocked) return res.json(401, accountSuspended(user._id));
// We needed the whole user object first so we can get his salt to encrypt password comparison
User.findOne(
{$and: [login, {'auth.local.hashed_password': utils.encryptPassword(password, user.auth.local.salt)}]}
, {_id:1, apiToken:1}
, function(err, user){
if (err) return next(err);
if (!user) return res.json(401,{err:"Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\"."});
res.json({id: user._id,token: user.apiToken});
password = null;
});
});
};
/*
POST /user/auth/social
*/
api.loginSocial = function(req, res, next) {
var access_token = req.body.authResponse.access_token,
network = req.body.network;
if (network!=='facebook')
return res.json(401, {err:"Only Facebook supported currently."});
async.auto({
profile: function (cb) {
passport._strategies[network].userProfile(access_token, cb);
},
user: ['profile', function (cb, results) {
var q = {};
q['auth.' + network + '.id'] = results.profile.id;
User.findOne(q, {_id: 1, apiToken: 1, auth: 1}, cb);
}],
register: ['profile', 'user', function (cb, results) {
if (results.user) return cb(null, results.user);
// Create new user
var prof = results.profile;
var user = {
preferences: {
language: req.language // User language detected from browser, not saved
},
auth: {
timestamps: {created: +new Date(), loggedIn: +new Date()}
}
};
user.auth[network] = prof;
user = new User(user);
user.save(function(err, savedUser){
// Clean previous email preferences
if(savedUser.auth.facebook.emails && savedUser.auth.facebook.emails[0] && savedUser.auth.facebook.emails[0].value){
EmailUnsubscription.remove({email: savedUser.auth.facebook.emails[0].value}, function(){
utils.txnEmail(savedUser, 'welcome');
});
}
cb.apply(cb, arguments);
});
var analyticsData = {
category: 'acquisition',
type: network,
gaLabel: network,
uuid: user._id,
};
analytics.track('register', analyticsData)
}]
}, function(err, results){
if (err) return res.json(401, {err: err.toString ? err.toString() : err});
var acct = results.register[0] ? results.register[0] : results.register;
if (acct.auth.blocked) return res.json(401, accountSuspended(acct._id));
return res.json(200, {id:acct._id, token:acct.apiToken});
})
};
/**
* DELETE /user/auth/social
*/
api.deleteSocial = function(req,res,next){
if (!res.locals.user.auth.local.username)
return res.json(401, {err:"Account lacks another authentication method, can't detach Facebook"});
//FIXME for some reason, the following gives https://gist.github.com/lefnire/f93eb306069b9089d123
//res.locals.user.auth.facebook = null;
//res.locals.user.auth.save(function(err, saved){
User.update({_id:res.locals.user._id}, {$unset:{'auth.facebook':1}}, function(err){
if (err) return next(err);
res.send(200);
})
}
api.resetPassword = function(req, res, next){
var email = req.body.email && req.body.email.toLowerCase(), // Emails are all lowercase
salt = utils.makeSalt(),
newPassword = utils.makeSalt(), // use a salt as the new password too (they'll change it later)
hashed_password = utils.encryptPassword(newPassword, salt);
if(!email) return res.json(400, {err: "Email not provided"});
User.findOne({'auth.local.email': email}, function(err, user){
if (err) return next(err);
if (!user) return res.send(401, {err:"Sorry, we can't find a user registered with email " + email + "\n- Make sure your email address is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login."});
user.auth.local.salt = salt;
user.auth.local.hashed_password = hashed_password;
utils.sendEmail({
from: "Habitica <admin@habitica.com>",
to: email,
subject: "Password Reset for Habitica",
text: "Password for " + user.auth.local.username + " has been reset to " + newPassword + " Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at " + nconf.get('BASE_URL') + ". After you've logged in, head to " + nconf.get('BASE_URL') + "/#/options/settings/settings and change your password.",
html: "Password for <strong>" + user.auth.local.username + "</strong> has been reset to <strong>" + newPassword + "</strong><br /><br />Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.<br /><br />Log in at " + nconf.get('BASE_URL') + ". After you've logged in, head to " + nconf.get('BASE_URL') + "/#/options/settings/settings and change your password."
});
user.save(function(err){
if(err) return next(err);
res.send('New password sent to '+ email);
email = salt = newPassword = hashed_password = null;
});
});
};
var invalidPassword = function(user, password){
var hashed_password = utils.encryptPassword(password, user.auth.local.salt);
if (hashed_password !== user.auth.local.hashed_password)
return {code:401, err:"Incorrect password"};
return false;
}
api.changeUsername = function(req, res, next) {
var user = res.locals.user;
var username = req.body.username;
var lowerCaseUsername = username && username.toLowerCase(); // we search for the lowercased version to intercept duplicates
if(!username) return res.json(400, {err: "Username not provided"});
async.waterfall([
function(cb){
User.findOne({'auth.local.lowerCaseUsername': lowerCaseUsername}, {auth:1}, cb);
},
function(found, cb){
if (found) return cb({code:401, err: "Username already taken"});
if (invalidPassword(user, req.body.password)) return cb(invalidPassword(user, req.body.password));
user.auth.local.username = username;
user.auth.local.lowerCaseUsername = lowerCaseUsername;
user.save(cb);
}
], function(err){
if (err) return err.code ? res.json(err.code, err) : next(err);
res.send(200);
})
}
api.changeEmail = function(req, res, next){
var email = req.body.email && req.body.email.toLowerCase(); // emails are all lowercase
if(!email) return res.json(400, {err: "Email not provided"});
async.waterfall([
function(cb){
User.findOne({'auth.local.email': email}, {auth:1}, cb);
},
function(found, cb){
if(found) return cb({code:401, err: shared.i18n.t('messageAuthEmailTaken')});
if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password));
res.locals.user.auth.local.email = email;
res.locals.user.save(cb);
}
], function(err){
if (err) return err.code ? res.json(err.code,err) : next(err);
res.send(200);
})
}
api.changePassword = function(req, res, next) {
var user = res.locals.user,
oldPassword = req.body.oldPassword,
newPassword = req.body.newPassword,
confirmNewPassword = req.body.confirmNewPassword;
if (newPassword != confirmNewPassword)
return res.json(401, {err: "Password & Confirm don't match"});
var salt = user.auth.local.salt,
hashed_old_password = utils.encryptPassword(oldPassword, salt),
hashed_new_password = utils.encryptPassword(newPassword, salt);
if (hashed_old_password !== user.auth.local.hashed_password)
return res.json(401, {err:"Old password doesn't match"});
user.auth.local.hashed_password = hashed_new_password;
user.save(function(err, saved){
if (err) next(err);
res.send(200);
})
};
var firebaseTokenGeneratorInstance = new FirebaseTokenGenerator(nconf.get('FIREBASE:SECRET'));
api.getFirebaseToken = function(req, res, next) {
var user = res.locals.user;
// Expires 24 hours after now (60*60*24*1000) (in milliseconds)
var expires = new Date();
expires.setTime(expires.getTime() + 86400000);
var token = firebaseTokenGeneratorInstance
.createToken({
uid: user._id,
isHabiticaUser: true
}, {
expires: expires
});
res.json(200, {
token: token,
expires: expires
});
};
/*
Registers a new user. Only accepting username/password registrations, no Facebook
*/
api.setupPassport = function(router) {
router.get('/logout', i18n.getUserLanguage, function(req, res) {
req.logout();
delete req.session.userId;
res.redirect('/');
})
};
@@ -0,0 +1,445 @@
// @see ../routes for routing
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var shared = require('../../../../common');
var User = require('./../../models/user').model;
var Group = require('./../../models/group').model;
var Challenge = require('./../../models/challenge').model;
var logging = require('./../../libs/logging');
var csv = require('express-csv');
var utils = require('../../libs/utils');
var api = module.exports;
var pushNotify = require('./../pushNotifications');
/*
------------------------------------------------------------------------
Challenges
------------------------------------------------------------------------
*/
api.list = function(req, res, next) {
var user = res.locals.user;
async.waterfall([
function(cb){
// Get all available groups I belong to
Group.find({members: {$in: [user._id]}}).select('_id').exec(cb);
},
function(gids, cb){
// and their challenges
Challenge.find({
$or:[
{leader: user._id},
{members:{$in:[user._id]}}, // all challenges I belong to (is this necessary? thought is a left a group, but not its challenge)
{group:{$in:gids}}, // all challenges in my groups
{group: 'habitrpg'} // public group
],
_id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'} // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug
})
.select('name leader description group memberCount prize official')
.select({members:{$elemMatch:{$in:[user._id]}}})
.sort('-official -timestamp')
.populate('group', '_id name type')
.populate('leader', 'profile.name')
.exec(cb);
}
], function(err, challenges){
if (err) return next(err);
_.each(challenges, function(c){
c._isMember = c.members.length > 0;
})
res.json(challenges);
user = null;
});
}
// GET
api.get = function(req, res, next) {
var user = res.locals.user;
// TODO use mapReduce() or aggregate() here to
// 1) Find the sum of users.tasks.values within the challnege (eg, {'profile.name':'tyler', 'sum': 100})
// 2) Sort by the sum
// 3) Limit 30 (only show the 30 users currently in the lead)
Challenge.findById(req.params.cid)
.populate('members', 'profile.name _id')
.populate('group', '_id name type')
.populate('leader', 'profile.name')
.exec(function(err, challenge){
if(err) return next(err);
if (!challenge) return res.json(404, {err: 'Challenge ' + req.params.cid + ' not found'});
challenge._isMember = !!(_.find(challenge.members, function(member) {
return member._id === user._id;
}));
res.json(challenge);
});
}
api.csv = function(req, res, next) {
var cid = req.params.cid;
var challenge;
async.waterfall([
function(cb){
Challenge.findById(cid,cb)
},
function(_challenge,cb) {
challenge = _challenge;
if (!challenge) return cb('Challenge ' + cid + ' not found');
User.aggregate([
{$match:{'_id':{ '$in': challenge.members}}}, //yes, we want members
{$project:{'profile.name':1,tasks:{$setUnion:["$habits","$dailys","$todos","$rewards"]}}},
{$unwind:"$tasks"},
{$match:{"tasks.challenge.id":cid}},
{$sort:{'tasks.type':1,'tasks.id':1}},
{$group:{_id:"$_id", "tasks":{$push:"$tasks"},"name":{$first:"$profile.name"}}}
], cb);
}
],function(err,users){
if(err) return next(err);
var output = ['UUID','name'];
_.each(challenge.tasks,function(t){
//output.push(t.type+':'+t.text);
//not the right order yet
output.push('Task');
output.push('Value');
output.push('Notes');
})
output = [output];
_.each(users, function(u){
var uData = [u._id,u.name];
_.each(u.tasks,function(t){
uData = uData.concat([t.type+':'+t.text, t.value, t.notes]);
})
output.push(uData);
});
res.header('Content-disposition', 'attachment; filename='+cid+'.csv');
res.csv(output);
challenge = cid = null;
})
}
api.getMember = function(req, res, next) {
var cid = req.params.cid;
var uid = req.params.uid;
// We need to start using the aggregation framework instead of in-app filtering, see http://docs.mongodb.org/manual/aggregation/
// See code at 32c0e75 for unwind/group example
//http://stackoverflow.com/questions/24027213/how-to-match-multiple-array-elements-without-using-unwind
var proj = {'profile.name':'$profile.name'};
_.each(['habits','dailys','todos','rewards'], function(type){
proj[type] = {
$setDifference: [{
$map: {
input: '$'+type,
as: "el",
in: {
$cond: [{$eq: ["$$el.challenge.id", cid]}, '$$el', false]
}
}
}, [false]]
}
});
User.aggregate()
.match({_id: uid})
.project(proj)
.exec(function(err, member){
if (err) return next(err);
if (!member) return res.json(404, {err: 'Member '+uid+' for challenge '+cid+' not found'});
res.json(member[0]);
uid = cid = null;
});
}
// CREATE
api.create = function(req, res, next){
var user = res.locals.user;
async.auto({
get_group: function(cb){
var q = {_id:req.body.group};
if (req.body.group!='habitrpg') q.members = {$in:[user._id]}; // make sure they're a member of the group
Group.findOne(q, cb);
},
save_chal: ['get_group', function(cb, results){
var group = results.get_group,
prize = +req.body.prize;
if (!group)
return cb({code:404, err:"Group." + req.body.group + " not found"});
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id)
return cb({code:401, err: "Only the group leader can create challenges"});
// If they're adding a prize, do some validation
if (prize < 0)
return cb({code:401, err: 'Challenge prize must be >= 0'});
if (req.body.group=='habitrpg' && prize < 1)
return cb({code:401, err: 'Prize must be at least 1 Gem for public challenges.'});
if (prize > 0) {
var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0);
var prizeCost = prize/4; // I really should have stored user.balance as gems rather than dollars... stupid...
if (prizeCost > user.balance + groupBalance)
return cb("You can't afford this prize. Purchase more gems or lower the prize amount.")
if (groupBalance >= prizeCost) {
// Group pays for all of prize
group.balance -= prizeCost;
} else if (groupBalance > 0) {
// User pays remainder of prize cost after group
var remainder = prizeCost - group.balance;
group.balance = 0;
user.balance -= remainder;
} else {
// User pays for all of prize
user.balance -= prizeCost;
}
}
req.body.leader = user._id;
req.body.official = user.contributor.admin && req.body.official;
var chal = new Challenge(req.body); // FIXME sanitize
chal.members.push(user._id);
chal.save(cb);
}],
save_group: ['save_chal', function(cb, results){
results.get_group.challenges.push(results.save_chal[0]._id);
results.get_group.save(cb);
}],
sync_user: ['save_group', function(cb, results){
// Auto-join creator to challenge (see members.push above)
results.save_chal[0].syncToUser(user, cb);
}]
}, function(err, results){
if (err) return err.code? res.json(err.code, err) : next(err);
return res.json(results.save_chal[0]);
user = null;
})
}
// UPDATE
api.update = function(req, res, next){
var cid = req.params.cid;
var user = res.locals.user;
var before;
async.waterfall([
function(cb){
// We first need the original challenge data, since we're going to compare against new & decide to sync users
Challenge.findById(cid, cb);
},
function(_before, cb) {
if (!_before) return cb('Challenge ' + cid + ' not found');
if (_before.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionEditChallenge', req.language));
// Update the challenge, since syncing will need the updated challenge. But store `before` we're going to do some
// before-save / after-save comparison to determine if we need to sync to users
before = _before;
var attrs = _.pick(req.body, 'name shortName description habits dailys todos rewards date'.split(' '));
Challenge.findByIdAndUpdate(cid, {$set:attrs}, {new: true}, cb);
},
function(saved, cb) {
// Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers
if (before.isOutdated(req.body)) {
User.find({_id: {$in: saved.members}}, function(err, users){
logging.info('Challenge updated, sync to subscribers');
if (err) throw err;
_.each(users, function(user){
saved.syncToUser(user);
})
})
}
// after saving, we're done as far as the client's concerned. We kick off syncing (heavy task) in the background
cb(null, saved);
}
], function(err, saved){
if(err) next(err);
res.json(saved);
cid = user = before = null;
})
}
/**
* Called by either delete() or selectWinner(). Will delete the challenge and set the "broken" property on all users' subscribed tasks
* @param {cid} the challenge id
* @param {broken} the object representing the broken status of the challenge. Eg:
* {broken: 'CHALLENGE_DELETED', id: CHALLENGE_ID}
* {broken: 'CHALLENGE_CLOSED', id: CHALLENGE_ID, winner: USER_NAME}
*/
function closeChal(cid, broken, cb) {
var removed;
async.waterfall([
function(cb2){
Challenge.findOneAndRemove({_id:cid}, cb2)
},
function(_removed, cb2) {
removed = _removed;
var pull = {'$pull':{}}; pull['$pull'][_removed._id] = 1;
Group.findByIdAndUpdate(_removed.group, {new: true}, pull);
User.find({_id:{$in: removed.members}}, cb2);
},
function(users, cb2) {
var parallel = [];
_.each(users, function(user){
var tag = _.find(user.tags, {id:cid});
if (tag) tag.challenge = undefined;
_.each(user.tasks, function(task){
if (task.challenge && task.challenge.id == removed._id) {
_.merge(task.challenge, broken);
}
})
parallel.push(function(cb3){
user.save(cb3);
})
})
async.parallel(parallel, cb2);
removed = null;
}
], cb);
}
/**
* Delete & close
*/
api.delete = function(req, res, next){
var user = res.locals.user;
var cid = req.params.cid;
async.waterfall([
function(cb){
Challenge.findById(cid, cb);
},
function(chal, cb){
if (!chal) return cb('Challenge ' + cid + ' not found');
if (chal.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionDeleteChallenge', req.language));
if (chal.group != 'habitrpg') user.balance += chal.prize/4; // Refund gems to user if a non-tavern challenge
user.save(cb);
},
function(save, num, cb){
closeChal(req.params.cid, {broken: 'CHALLENGE_DELETED'}, cb);
}
], function(err){
if (err) return next(err);
res.send(200);
user = cid = null;
});
}
/**
* Select Winner & Close
*/
api.selectWinner = function(req, res, next) {
if (!req.query.uid) return res.json(401, {err: 'Must select a winner'});
var user = res.locals.user;
var cid = req.params.cid;
var chal;
async.waterfall([
function(cb){
Challenge.findById(cid, cb);
},
function(_chal, cb){
chal = _chal;
if (!chal) return cb('Challenge ' + cid + ' not found');
if (chal.leader != user._id && !user.contributor.admin) return cb(shared.i18n.t('noPermissionCloseChallenge', req.language));
User.findById(req.query.uid, cb)
},
function(winner, cb){
if (!winner) return cb('Winner ' + req.query.uid + ' not found.');
_.defaults(winner.achievements, {challenges: []});
winner.achievements.challenges.push(chal.name);
winner.balance += chal.prize/4;
winner.save(cb);
},
function(saved, num, cb) {
if(saved.preferences.emailNotifications.wonChallenge !== false){
utils.txnEmail(saved, 'won-challenge', [
{name: 'CHALLENGE_NAME', content: chal.name}
]);
}
pushNotify.sendNotify(saved, shared.i18n.t('wonChallenge'), chal.name);
closeChal(cid, {broken: 'CHALLENGE_CLOSED', winner: saved.profile.name}, cb);
}
], function(err){
if (err) return next(err);
res.send(200);
user = cid = chal = null;
})
}
api.join = function(req, res, next){
var user = res.locals.user;
var cid = req.params.cid;
async.waterfall([
function(cb) {
Challenge.findByIdAndUpdate(cid, {$addToSet:{members:user._id}}, {new: true}, cb);
},
function(chal, cb) {
// Trigger updating challenge member count in the background. We can't do it above because we don't have
// _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above.
Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec();
if (!~user.challenges.indexOf(cid))
user.challenges.unshift(cid);
// Add all challenge's tasks to user's tasks
chal.syncToUser(user, function(err){
if (err) return cb(err);
cb(null, chal); // we want the saved challenge in the return results, due to ng-resource
});
}
], function(err, chal){
if(err) return next(err);
chal._isMember = true;
res.json(chal);
user = cid = null;
});
}
api.leave = function(req, res, next){
var user = res.locals.user;
var cid = req.params.cid;
// whether or not to keep challenge's tasks. strictly default to true if "keep-all" isn't provided
var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all';
async.waterfall([
function(cb){
Challenge.findByIdAndUpdate(cid, {$pull:{members:user._id}}, {new: true}, cb);
},
function(chal, cb){
// Trigger updating challenge member count in the background. We can't do it above because we don't have
// _.size(challenge.members). We can't do it in pre(save) because we're calling findByIdAndUpdate above.
if (chal)
Challenge.update({_id:cid}, {$set:{memberCount:_.size(chal.members)}}).exec();
var i = user.challenges.indexOf(cid)
if (~i) user.challenges.splice(i,1);
user.unlink({cid:cid, keep:keep}, function(err){
if (err) return cb(err);
cb(null, chal);
})
}
], function(err, chal){
if(err) return next(err);
if (chal) chal._isMember = false;
res.json(chal);
user = cid = keep = null;
});
}
api.unlink = function(req, res, next) {
// they're scoring the task - commented out, we probably don't need it due to route ordering in api.js
//var urlParts = req.originalUrl.split('/');
//if (_.contains(['up','down'], urlParts[urlParts.length -1])) return next();
var user = res.locals.user;
var tid = req.params.id;
var cid = user.tasks[tid].challenge.id;
if (!req.query.keep)
return res.json(400, {err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'});
user.unlink({cid:cid, keep:req.query.keep, tid:tid}, function(err, saved){
if (err) return next(err);
res.send(200);
user = tid = cid = null;
});
}
+36
View File
@@ -0,0 +1,36 @@
var _ = require('lodash');
var Coupon = require('./../../models/coupon').model;
var api = module.exports;
var csv = require('express-csv');
var async = require('async');
api.ensureAdmin = function(req, res, next) {
if (!res.locals.user.contributor.sudo) return res.json(401, {err:"You don't have admin access"});
next();
}
api.generateCoupons = function(req,res,next) {
Coupon.generate(req.params.event, req.query.count, function(err){
if(err) return next(err);
res.send(200);
});
}
api.getCoupons = function(req,res,next) {
var options = {sort:'seq'};
if (req.query.limit) options.limit = req.query.limit;
if (req.query.skip) options.skip = req.query.skip;
Coupon.find({},{}, options, function(err,coupons){
//res.header('Content-disposition', 'attachment; filename=coupons.csv');
res.csv([['code']].concat(_.map(coupons, function(c){
return [c._id];
})));
});
}
api.enterCode = function(req,res,next) {
Coupon.apply(res.locals.user,req.params.code,function(err,user){
if (err) return res.json(400,{err:err});
res.json(user);
});
}
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var shared = require('../../../../common');
var User = require('./../../models/user').model;
var Group = require('./../../models/group').model;
var api = module.exports;
api.ensureAdmin = function(req, res, next) {
var user = res.locals.user;
if (!(user.contributor && user.contributor.admin)) return res.json(401, {err:"You don't have admin access"});
next();
}
api.getHeroes = function(req,res,next) {
User.find({'contributor.level':{$gt:0}})
.select('contributor backer balance profile.name')
.sort('-contributor.level')
.exec(function(err, users){
if (err) return next(err);
res.json(users);
});
}
api.getPatrons = function(req,res,next){
var page = req.query.page || 0,
perPage = 50;
User.find({'backer.tier':{$gt:0}})
.select('contributor backer profile.name')
.sort('-backer.tier')
.skip(page*perPage)
.limit(perPage)
.exec(function(err, users){
if (err) return next(err);
res.json(users);
});
}
api.getHero = function(req,res,next) {
User.findById(req.params.uid)
.select('contributor balance profile.name purchased items')
.select('auth.local.username auth.local.email auth.facebook auth.blocked')
.exec(function(err, user){
if (err) return next(err)
if (!user) return res.json(400,{err:'User not found'});
res.json(user);
});
}
api.updateHero = function(req,res,next) {
async.waterfall([
function(cb){
User.findById(req.params.uid, cb);
},
function(member, cb){
if (!member) return res.json(404, {err: "User not found"});
member.balance = req.body.balance || 0;
var newTier = req.body.contributor.level; // tier = level in this context
var oldTier = member.contributor && member.contributor.level || 0;
if (newTier > oldTier) {
member.flags.contributor = true;
var gemsPerTier = {1:3, 2:3, 3:3, 4:4, 5:4, 6:4, 7:4, 8:0, 9:0}; // e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff
var tierDiff = newTier - oldTier; // can be 2+ tier increases at once
while (tierDiff) {
member.balance += gemsPerTier[newTier] / 4; // balance is in $
tierDiff--;
newTier--; // give them gems for the next tier down if they weren't aready that tier
}
}
member.contributor = req.body.contributor;
member.purchased.ads = req.body.purchased.ads;
if (member.contributor.level >= 6) member.items.pets['Dragon-Hydra'] = 5;
if (req.body.itemPath && req.body.itemVal
&& req.body.itemPath.indexOf('items.') === 0
&& User.schema.paths[req.body.itemPath]) {
shared.dotSet(member, req.body.itemPath, req.body.itemVal); // Sanitization at 5c30944 (deemed unnecessary)
}
if (_.isBoolean(req.body.auth.blocked)) member.auth.blocked = req.body.auth.blocked;
member.save(cb);
}
], function(err, saved){
if (err) return next(err);
res.json(204);
})
}
+126
View File
@@ -0,0 +1,126 @@
var User = require('mongoose').model('User');
var groups = require('../../models/group');
var partyFields = require('./groups').partyFields
var api = module.exports;
var async = require('async');
var _ = require('lodash');
var shared = require('../../../../common');
var utils = require('../../libs/utils');
var nconf = require('nconf');
var pushNotify = require('./../pushNotifications');
var fetchMember = function(uuid, restrict){
return function(cb){
var q = User.findById(uuid);
if (restrict) q.select(partyFields);
q.exec(function(err, member){
if (err) return cb(err);
if (!member) return cb({code:404, err: 'User not found'});
return cb(null, member);
})
}
}
var sendErr = function(err, res, next){
err.code ? res.json(err.code, {err: err.err}) : next(err);
}
api.getMember = function(req, res, next) {
fetchMember(req.params.uuid, true)(function(err, member){
if (err) return sendErr(err, res, next);
res.json(member);
})
}
api.sendMessage = function(user, member, data){
var msg;
if (!data.type) {
msg = data.message
} else {
msg = "`Hello " + member.profile.name + ", " + user.profile.name + " has sent you ";
msg += (data.type=='gems') ? data.gems.amount + " gems!`" : shared.content.subscriptionBlocks[data.subscription.key].months + " months of subscription!`";
msg += data.message;
}
shared.refPush(member.inbox.messages, groups.chatDefaults(msg, user));
member.inbox.newMessages++;
member._v++;
member.markModified('inbox.messages');
shared.refPush(user.inbox.messages, _.defaults({sent:true}, groups.chatDefaults(msg, member)));
user.markModified('inbox.messages');
}
api.sendPrivateMessage = function(req, res, next){
var fetchedMember;
async.waterfall([
fetchMember(req.params.uuid),
function(member, cb) {
fetchedMember = member;
if (~member.inbox.blocks.indexOf(res.locals.user._id) // can't send message if that user blocked me
|| ~res.locals.user.inbox.blocks.indexOf(member._id) // or if I blocked them
|| member.inbox.optOut) { // or if they've opted out of messaging
return cb({code: 401, err: "Can't send message to this user."});
}
api.sendMessage(res.locals.user, member, {message:req.body.message});
async.parallel([
function (cb2) { member.save(cb2) },
function (cb2) { res.locals.user.save(cb2) }
], cb);
}
], function(err){
if (err) return sendErr(err, res, next);
if(fetchedMember.preferences.emailNotifications.newPM !== false){
utils.txnEmail(fetchedMember, 'new-pm', [
{name: 'SENDER', content: utils.getUserInfo(res.locals.user, ['name']).name},
{name: 'PMS_INBOX_URL', content: '/#/options/groups/inbox'}
]);
}
res.send(200);
})
}
api.sendGift = function(req, res, next){
async.waterfall([
fetchMember(req.params.uuid),
function(member, cb) {
// Gems
switch (req.body.type) {
case "gems":
var amt = req.body.gems.amount / 4,
user = res.locals.user;
if (member.id == user.id)
return cb({code: 401, err: "Cannot send gems to yourself. Try a subscription instead."});
if (!amt || amt <=0 || user.balance < amt)
return cb({code: 401, err: "Amount must be within 0 and your current number of gems."});
member.balance += amt;
user.balance -= amt;
api.sendMessage(user, member, req.body);
var byUsername = utils.getUserInfo(user, ['name']).name;
if(member.preferences.emailNotifications.giftedGems !== false){
utils.txnEmail(member, 'gifted-gems', [
{name: 'GIFTER', content: byUsername},
{name: 'X_GEMS_GIFTED', content: req.body.gems.amount}
]);
}
pushNotify.sendNotify(member, shared.i18n.t('giftedGems'), shared.i18n.t('giftedGemsInfo', { amount: req.body.gems.amount, name: byUsername }));
return async.parallel([
function (cb2) { member.save(cb2) },
function (cb2) { user.save(cb2) }
], cb);
case "subscription":
return cb();
default:
return cb({code:400, err:"Body must contain a gems:{amount,fromBalance} or subscription:{months} object"});
}
}
], function(err) {
if (err) return sendErr(err, res, next);
res.send(200);
});
}
@@ -0,0 +1,36 @@
var User = require('../../models/user').model;
var EmailUnsubscription = require('../../models/emailUnsubscription').model;
var utils = require('../../libs/utils');
var i18n = require('../../../../common').i18n;
var api = module.exports = {};
api.unsubscribe = function(req, res, next){
if(!req.query.code) return res.json(500, {err: 'Missing unsubscription code.'});
var data = JSON.parse(utils.decrypt(req.query.code));
if(data._id){
User.update({_id: data._id}, {
$set: {'preferences.emailNotifications.unsubscribeFromAll': true}
}, {multi: false}, function(err, updateRes){
if(err) return next(err);
if(updateRes !== 1) return res.json(404, {err: 'User not found'});
res.send('<h1>' + i18n.t('unsubscribedSuccessfully', null, req.language) + '</h1>' + i18n.t('unsubscribedTextUsers', null, req.language));
});
}else{
EmailUnsubscription.findOne({email: data.email}, function(err, doc){
if(err) return next(err);
var okRes = '<h1>' + i18n.t('unsubscribedSuccessfully', null, req.language) + '</h1>' + i18n.t('unsubscribedTextOthers', null, req.language);
if(doc) return res.send(okRes);
EmailUnsubscription.create({email: data.email}, function(err, doc){
if(err) return next(err);
res.send(okRes);
})
});
}
};
+665
View File
@@ -0,0 +1,665 @@
/* @see ./routes.coffee for routing*/
var url = require('url');
var ipn = require('paypal-ipn');
var _ = require('lodash');
var nconf = require('nconf');
var async = require('async');
var shared = require('../../../../common');
var User = require('./../../models/user').model;
var utils = require('./../../libs/utils');
var analytics = utils.analytics;
var Group = require('./../../models/group').model;
var Challenge = require('./../../models/challenge').model;
var moment = require('moment');
var logging = require('./../../libs/logging');
var acceptablePUTPaths;
var api = module.exports;
var qs = require('qs');
var firebase = require('../../libs/firebase');
var webhook = require('../../libs/webhook');
// api.purchase // Shared.ops
api.getContent = function(req, res, next) {
var language = 'en';
if (typeof req.query.language != 'undefined')
language = req.query.language.toString(); //|| 'en' in i18n
var content = _.cloneDeep(shared.content);
var walk = function(obj, lang){
_.each(obj, function(item, key, source){
if (_.isPlainObject(item) || _.isArray(item)) return walk(item, lang);
if (_.isFunction(item) && item.i18nLangFunc) source[key] = item(lang);
});
}
walk(content, language);
res.json(content);
}
api.getModelPaths = function(req,res,next){
res.json(_.reduce(User.schema.paths,function(m,v,k){
m[k] = v.instance || 'Boolean';
return m;
},{}));
}
/*
------------------------------------------------------------------------
Tasks
------------------------------------------------------------------------
*/
/*
Local Methods
---------------
*/
var findTask = function(req, res) {
return res.locals.user.tasks[req.params.id];
};
/*
API Routes
---------------
*/
/**
This is called form deprecated.coffee's score function, and the req.headers are setup properly to handle the login
Export it also so we can call it from deprecated.coffee
*/
api.score = function(req, res, next) {
var id = req.params.id,
direction = req.params.direction,
user = res.locals.user,
task;
var clearMemory = function(){user = task = id = direction = null;}
// Send error responses for improper API call
if (!id) return res.json(400, {err: ':id required'});
if (direction !== 'up' && direction !== 'down') {
if (direction == 'unlink' || direction == 'sort') return next();
return res.json(400, {err: ":direction must be 'up' or 'down'"});
}
// If exists already, score it
if (task = user.tasks[id]) {
// Set completed if type is daily or todo and task exists
if (task.type === 'daily' || task.type === 'todo') {
task.completed = direction === 'up';
}
} else {
// If it doesn't exist, this is likely a 3rd party up/down - create a new one, then score it
// Defaults. Other defaults are handled in user.ops.addTask()
task = {
id: id,
type: req.body && req.body.type,
text: req.body && req.body.text,
notes: (req.body && req.body.notes) || "This task was created by a third-party service. Feel free to edit, it won't harm the connection to that service. Additionally, multiple services may piggy-back off this task."
};
if (task.type === 'daily' || task.type === 'todo')
task.completed = direction === 'up';
task = user.ops.addTask({body:task});
}
var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language});
user.save(function(err, saved){
if (err) return next(err);
var userStats = saved.toJSON().stats;
var resJsonData = _.extend({ delta: delta, _tmp: user._tmp }, userStats);
res.json(200, resJsonData);
var webhookData = _generateWebhookTaskData(
task, direction, delta, userStats, user
);
webhook.sendTaskWebhook(user.preferences.webhooks, webhookData);
if (
(!task.challenge || !task.challenge.id || task.challenge.broken) // If it's a challenge task, sync the score. Do it in the background, we've already sent down a response and the user doesn't care what happens back there
|| (task.type == 'reward') // we don't want to update the reward GP cost
) return clearMemory();
Challenge.findById(task.challenge.id, 'habits dailys todos rewards', function(err, chal) {
if (err) return next(err);
if (!chal) {
task.challenge.broken = 'CHALLENGE_DELETED';
user.save();
return clearMemory();
}
var t = chal.tasks[task.id];
// this task was removed from the challenge, notify user
if (!t) {
chal.syncToUser(user);
return clearMemory();
}
t.value += delta;
if (t.type == 'habit' || t.type == 'daily')
t.history.push({value: t.value, date: +new Date});
chal.save();
clearMemory();
});
});
};
/**
* Get all tasks
*/
api.getTasks = function(req, res, next) {
var user = res.locals.user;
if (req.query.type) {
return res.json(user[req.query.type+'s']);
} else {
return res.json(_.toArray(user.tasks));
}
};
/**
* Get Task
*/
api.getTask = function(req, res, next) {
var task = findTask(req,res);
if (!task) return res.json(404, {err: shared.i18n.t('messageTaskNotFound')});
return res.json(200, task);
};
/*
Update Task
*/
//api.deleteTask // see Shared.ops
// api.updateTask // handled in Shared.ops
// api.addTask // handled in Shared.ops
// api.sortTask // handled in Shared.ops #TODO updated api, mention in docs
/*
------------------------------------------------------------------------
Items
------------------------------------------------------------------------
*/
// api.buy // handled in Shard.ops
api.getBuyList = function (req, res, next) {
var list = shared.updateStore(res.locals.user);
return res.json(200, list);
};
/*
------------------------------------------------------------------------
User
------------------------------------------------------------------------
*/
/**
* Get User
*/
api.getUser = function(req, res, next) {
var user = res.locals.user.toJSON();
user.stats.toNextLevel = shared.tnl(user.stats.lvl);
user.stats.maxHealth = shared.maxHealth;
user.stats.maxMP = res.locals.user._statsComputed.maxMP;
delete user.apiToken;
if (user.auth && user.auth.local) {
delete user.auth.local.hashed_password;
delete user.auth.local.salt;
}
return res.json(200, user);
};
/**
* Get anonymized User
*/
api.getUserAnonymized = function(req, res, next) {
var user = res.locals.user.toJSON();
user.stats.toNextLevel = shared.tnl(user.stats.lvl);
user.stats.maxHealth = shared.maxHealth;
user.stats.maxMP = res.locals.user._statsComputed.maxMP;
delete user.apiToken;
if (user.auth) {
delete user.auth.local;
delete user.auth.facebook;
}
delete user.newMessages;
delete user.profile;
delete user.purchased.plan;
delete user.contributor;
delete user.invitations;
delete user.items.special.nyeReceived;
delete user.items.special.valentineReceived;
delete user.webhooks;
delete user.achievements.challenges;
_.forEach(user.inbox.messages, function(msg){
msg.text = "inbox message text";
});
_.forEach(user.tags, function(tag){
tag.name = "tag";
tag.challenge = "challenge";
});
function cleanChecklist(task){
var checklistIndex = 0;
_.forEach(task.checklist, function(c){
c.text = "item" + checklistIndex++;
});
}
_.forEach(user.habits, function(task){
task.text = "task text";
task.notes = "task notes";
});
_.forEach(user.rewards, function(task){
task.text = "task text";
task.notes = "task notes";
});
_.forEach(user.dailys, function(task){
task.text = "task text";
task.notes = "task notes";
cleanChecklist(task);
});
_.forEach(user.todos, function(task){
task.text = "task text";
task.notes = "task notes";
cleanChecklist(task);
});
return res.json(200, user);
};
/**
* This tells us for which paths users can call `PUT /user` (or batch-update equiv, which use `User.set()` on our client).
* The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs)
* FIXME - one-by-one we want to widdle down this list, instead replacing each needed set path with API operations
*/
acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, function(m,v,leaf){
var found= _.find('achievements filters flags invitations lastCron party preferences profile stats inbox'.split(' '), function(root){
return leaf.indexOf(root) == 0;
});
if (found) m[leaf]=true;
return m;
}, {})
_.each('stats.class'.split(' '), function(removePath){
delete acceptablePUTPaths[removePath];
})
/**
* Update user
* Send up PUT /user as `req.body={path1:val, path2:val, etc}`. Example:
* PUT /user {'stats.hp':50, 'tasks.TASK_ID.repeat.m':false}
* See acceptablePUTPaths for which user paths are supported
*/
api.update = function(req, res, next) {
var user = res.locals.user;
var errors = [];
if (_.isEmpty(req.body)) return res.json(200, user);
_.each(req.body, function(v, k) {
if (acceptablePUTPaths[k])
user.fns.dotSet(k, v);
else
errors.push(shared.i18n.t('messageUserOperationProtected', { operation: k }));
return true;
});
user.save(function(err) {
if (!_.isEmpty(errors)) return res.json(401, {err: errors});
if (err) return next(err);
res.json(200, user);
user = errors = null;
});
};
api.cron = function(req, res, next) {
var user = res.locals.user,
progress = user.fns.cron({analytics:utils.analytics}),
ranCron = user.isModified(),
quest = shared.content.quests[user.party.quest.key];
if (ranCron) res.locals.wasModified = true;
if (!ranCron) return next(null,user);
Group.tavernBoss(user,progress);
if (!quest) return user.save(next);
// If user is on a quest, roll for boss & player, or handle collections
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
async.waterfall([
function(cb){
user.save(cb); // make sure to save the cron effects
},
function(saved, count, cb){
var type = quest.boss ? 'boss' : 'collect';
Group[type+'Quest'](user,progress,cb);
},
function(){
var cb = arguments[arguments.length-1];
// User has been updated in boss-grapple, reload
User.findById(user._id, cb);
}
], function(err, saved) {
res.locals.user = saved;
next(err,saved);
user = progress = quest = null;
});
};
// api.reroll // Shared.ops
// api.reset // Shared.ops
api.delete = function(req, res, next) {
var user = res.locals.user;
var plan = user.purchased.plan;
if (plan && plan.customerId && !plan.dateTerminated){
return res.json(400,{err:"You have an active subscription, cancel your plan before deleting your account."});
}
Group.find({
members: {
'$in': [user._id]
}
}, function(err, groups){
if(err) return next(err);
async.each(groups, function(group, cb){
group.leave(user, 'remove-all', cb);
}, function(err){
if(err) return next(err);
user.remove(function(err){
if(err) return next(err);
firebase.deleteUser(user._id);
res.send(200);
});
});
});
}
/*
------------------------------------------------------------------------
Development Only Operations
------------------------------------------------------------------------
*/
if (nconf.get('NODE_ENV') === 'development') {
api.addTenGems = function(req, res, next) {
var user = res.locals.user;
user.balance += 2.5;
user.save(function(err){
if (err) return next(err);
res.send(204);
});
};
api.addHourglass = function(req, res, next) {
var user = res.locals.user;
user.purchased.plan.consecutive.trinkets += 1;
user.save(function(err){
if (err) return next(err);
res.send(204);
});
};
}
/*
------------------------------------------------------------------------
Tags
------------------------------------------------------------------------
*/
// api.deleteTag // handled in Shared.ops
// api.addTag // handled in Shared.ops
// api.updateTag // handled in Shared.ops
// api.sortTag // handled in Shared.ops
/*
------------------------------------------------------------------------
Spells
------------------------------------------------------------------------
*/
api.cast = function(req, res, next) {
var user = res.locals.user,
targetType = req.query.targetType,
targetId = req.query.targetId,
klass = shared.content.spells.special[req.params.spell] ? 'special' : user.stats.class,
spell = shared.content.spells[klass][req.params.spell];
if (!spell) return res.json(404, {err: 'Spell "' + req.params.spell + '" not found.'});
if (spell.mana > user.stats.mp) return res.json(400, {err: 'Not enough mana to cast spell'});
var done = function(){
var err = arguments[0];
var saved = _.size(arguments == 3) ? arguments[2] : arguments[1];
if (err) return next(err);
res.json(saved);
user = targetType = targetId = klass = spell = null;
}
switch (targetType) {
case 'task':
if (!user.tasks[targetId]) return res.json(404, {err: 'Task "' + targetId + '" not found.'});
spell.cast(user, user.tasks[targetId]);
user.save(done);
break;
case 'self':
spell.cast(user);
user.save(done);
break;
case 'party':
case 'user':
async.waterfall([
function(cb){
Group.findOne({type: 'party', members: {'$in': [user._id]}}).populate('members', 'profile.name stats achievements items.special').exec(cb);
},
function(group, cb) {
// Solo player? let's just create a faux group for simpler code
var g = group ? group : {members:[user]};
var series = [], found;
if (targetType == 'party') {
spell.cast(user, g.members);
series = _.transform(g.members, function(m,v,k){
m.push(function(cb2){v.save(cb2)});
});
} else {
found = _.find(g.members, {_id: targetId})
spell.cast(user, found);
series.push(function(cb2){found.save(cb2)});
}
if (group && !spell.silent) {
series.push(function(cb2){
var message = '`'+user.profile.name+' casts '+spell.text() + (targetType=='user' ? ' on '+found.profile.name : ' for the party')+'.`';
group.sendChat(message);
group.save(cb2);
})
}
series.push(function(cb2){g = group = series = found = null;cb2();})
async.series(series, cb);
},
function(whatever, cb){
user.save(cb);
}
], done);
break;
}
}
// It supports guild too now but we'll stick to partyInvite for backward compatibility
api.sessionPartyInvite = function(req,res,next){
if (!req.session.partyInvite) return next();
var inv = res.locals.user.invitations;
if (inv.party && inv.party.id) return next(); // already invited to a party
async.waterfall([
function(cb){
Group.findOne({_id:req.session.partyInvite.id, members:{$in:[req.session.partyInvite.inviter]}})
.select('invites members type').exec(cb);
},
function(group, cb){
if (!group){
// Don't send error as it will prevent users from using the site
delete req.session.partyInvite;
return cb();
}
if (group.type == 'guild'){
inv.guilds.push(req.session.partyInvite);
} else{
//req.body.type in 'guild', 'party'
inv.party = req.session.partyInvite;
}
inv.party = req.session.partyInvite;
delete req.session.partyInvite;
if (!~group.invites.indexOf(res.locals.user._id))
group.invites.push(res.locals.user._id); //$addToSt
group.save(cb);
},
function(saved, cb){
res.locals.user.save(cb);
}
], next);
}
/**
* All other user.ops which can easily be mapped to habitrpg-shared/index.coffee, not requiring custom API-wrapping
*/
_.each(shared.wrap({}).ops, function(op,k){
if (!api[k]) {
api[k] = function(req, res, next) {
res.locals.user.ops[k](req,function(err, response){
// If we want to send something other than 500, pass err as {code: 200, message: "Not enough GP"}
if (err) {
if (!err.code) return next(err);
if (err.code >= 400) return res.json(err.code,{err:err.message});
// In the case of 200s, they're friendly alert messages like "You're pet has hatched!" - still send the op
}
res.locals.user.save(function(err){
if (err) return next(err);
res.json(200,response);
})
}, analytics);
}
}
})
/*
------------------------------------------------------------------------
Batch Update
Run a bunch of updates all at once
------------------------------------------------------------------------
*/
api.batchUpdate = function(req, res, next) {
if (_.isEmpty(req.body)) req.body = []; // cases of {} or null
if (req.body[0] && req.body[0].data)
return res.json(501, {err: "API has been updated, please refresh your browser or upgrade your mobile app."})
var user = res.locals.user;
var oldSend = res.send;
var oldJson = res.json;
// Stash user.save, we'll queue the save op till the end (so we don't overload the server)
var oldSave = user.save;
user.save = function(cb){cb(null,user)}
// Setup the array of functions we're going to call in parallel with async
res.locals.ops = [];
var ops = _.transform(req.body, function(m,_req){
if (_.isEmpty(_req)) return;
_req.language = req.language;
m.push(function() {
var cb = arguments[arguments.length-1];
res.locals.ops.push(_req);
res.send = res.json = function(code, data) {
if (_.isNumber(code) && code >= 500)
return cb(code+": "+ (data.message ? data.message : data.err ? data.err : JSON.stringify(data)));
return cb();
};
if(!api[_req.op]) { return cb(shared.i18n.t('messageUserOperationNotFound', { operation: _req.op })); }
api[_req.op](_req, res, cb);
});
})
// Finally, save user at the end
.concat(function(){
user.save = oldSave;
user.save(arguments[arguments.length-1]);
});
// call all the operations, then return the user object to the requester
async.waterfall(ops, function(err,_user) {
res.json = oldJson;
res.send = oldSend;
if (err) return next(err);
var response = _user.toJSON();
response.wasModified = res.locals.wasModified;
user.fns.nullify();
user = res.locals.user = oldSend = oldJson = oldSave = null;
// return only drops & streaks
if (response._tmp && response._tmp.drop){
res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v});
// Fetch full user object
} else if (response.wasModified){
// Preen 3-day past-completed To-Dos from Angular & mobile app
response.todos = shared.preenTodos(response.todos);
res.json(200, response);
// return only the version number
} else{
res.json(200, {_v: response._v});
}
});
};
function _generateWebhookTaskData(task, direction, delta, stats, user) {
var extendedStats = _.extend(stats, {
toNextLevel: shared.tnl(user.stats.lvl),
maxHealth: shared.maxHealth,
maxMP: user._statsComputed.maxMP
});
var userData = {
_id: user._id,
_tmp: user._tmp,
stats: extendedStats
};
var taskData = {
details: task,
direction: direction,
delta: delta
}
return {
task: taskData,
user: userData
}
}