diff --git a/website/src/controllers/auth.js b/website/src/controllers/auth.js deleted file mode 100644 index e400c07a06..0000000000 --- a/website/src/controllers/auth.js +++ /dev/null @@ -1,307 +0,0 @@ -var _ = require('lodash'); -var validator = require('validator'); -var passport = require('passport'); -var shared = require('../../../common'); -var async = require('async'); -var utils = require('../utils'); -var nconf = require('nconf'); -var request = require('request'); -var User = require('../models/user').model; -var ga = require('./../utils').ga; -var i18n = require('./../i18n'); - -var isProd = nconf.get('NODE_ENV') === 'production'; - -var api = module.exports; - -var NO_TOKEN_OR_UID = { err: "You must include a token and uid (user id) in your request"}; -var NO_USER_FOUND = {err: "No user found."}; -var NO_SESSION_FOUND = { err: "You must be logged in." }; -var accountSuspended = function(uuid){ - return { - err: 'Account has been suspended, please contact leslie@habitrpg.com with your UUID ('+uuid+') for assistance.', - code: 'ACCOUNT_SUSPENDED' - }; -} -// Allow case-insensitive regex searching for Mongo queries. See http://stackoverflow.com/a/3561711/362790 -var RegexEscape = function(s){ - return new RegExp('^' + s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + '$', 'i'); -} - -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) { - async.auto({ - validate: function(cb) { - if (!(req.body.username && req.body.password && req.body.email)) - return cb({code:401, err: ":username, :email, :password, :confirmPassword required"}); - if (req.body.password !== req.body.confirmPassword) - return cb({code:401, err: ":password and :confirmPassword don't match"}); - if (!validator.isEmail(req.body.email)) - return cb({code:401, err: ":email invalid"}); - cb(); - }, - findEmail: function(cb) { - User.findOne({'auth.local.email': RegexEscape(req.body.email)}, {_id:1}, cb); - }, - findUname: function(cb) { - User.findOne({'auth.local.username': RegexEscape(req.body.username)}, {_id:1}, cb); - }, - findFacebook: function(cb){ - User.findOne({_id: req.headers['x-api-user'], apiToken: req.headers['x-api-key']}, {auth:1}, cb); - }, - register: ['validate', 'findEmail', 'findUname', 'findFacebook', function(cb, data) { - if (data.findEmail) return cb({code:401, err:"Email already taken"}); - if (data.findUname) return cb({code:401, err:"Username already taken"}); - var salt = utils.makeSalt(); - var newUser = { - auth: { - local: { - username: req.body.username, - email: req.body.email, - 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); - utils.txnEmail(user, 'welcome'); - ga.event('register', 'Local').send(); - user.save(cb); - } - }] - }, 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} : {'auth.local.username':username}; - User.findOne(login, {auth:1}, function(err, user){ - if (err) return next(err); - if (!user) return res.json(401, {err:"Username or password incorrect. Click 'Forgot Password' for help with either. (Note: usernames are case-sensitive)"}); - 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:"Username or password incorrect. Click 'Forgot Password' for help with either. (Note: usernames are case-sensitive)"}); - 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(cb); - - utils.txnEmail(user, 'welcome'); - ga.event('register', network).send(); - }] - }, 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, - 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); - - User.findOne({'auth.local.email': RegexEscape(email)}, function(err, user){ - if (err) return next(err); - if (!user) return res.send(500, {err:"Couldn't find a user registered for email " + email}); - user.auth.local.salt = salt; - user.auth.local.hashed_password = hashed_password; - utils.txnEmail(user, 'reset-password', [ - {name: "NEW_PASSWORD", content: newPassword}, - {name: "USERNAME", content: user.auth.local.username} - ]); - 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) { - async.waterfall([ - function(cb){ - User.findOne({'auth.local.username': RegexEscape(req.body.username)}, {auth:1}, cb); - }, - function(found, cb){ - if (found) return cb({code:401, err: "Username already taken"}); - if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password)); - res.locals.user.auth.local.username = req.body.username; - res.locals.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){ - async.waterfall([ - function(cb){ - User.findOne({'auth.local.email': RegexEscape(req.body.email)}, {auth:1}, cb); - }, - function(found, cb){ - if(found) return cb({code:401, err: "Email already taken"}); - if (invalidPassword(res.locals.user, req.body.password)) return cb(invalidPassword(res.locals.user, req.body.password)); - res.locals.user.auth.local.email = req.body.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); - }) -} - -/* - 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('/'); - }) - -}; diff --git a/website/src/controllers/challenges.js b/website/src/controllers/challenges.js deleted file mode 100644 index 5fabd0fb1c..0000000000 --- a/website/src/controllers/challenges.js +++ /dev/null @@ -1,431 +0,0 @@ -// @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('./../logging'); -var csv = require('express-csv'); -var utils = require('../utils'); -var api = module.exports; - - -/* - ------------------------------------------------------------------------ - 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') - .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) { - // 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') - .exec(function(err, challenge){ - if(err) return next(err); - if (!challenge) return res.json(404, {err: 'Challenge ' + req.params.cid + ' not found'}); - 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) return cb("You don't have permissions to edit this challenge"); - // 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}, 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, 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) return cb("You don't have permissions to edit this challenge"); - 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) return cb("You don't have permissions to edit this challenge"); - 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} - ]); - } - 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}}, 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}}, 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; - }); -} diff --git a/website/src/controllers/coupon.js b/website/src/controllers/coupon.js deleted file mode 100644 index b8450d34f3..0000000000 --- a/website/src/controllers/coupon.js +++ /dev/null @@ -1,36 +0,0 @@ -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); - }); -} diff --git a/website/src/controllers/dataexport.js b/website/src/controllers/dataexport.js deleted file mode 100644 index 8a48ee1638..0000000000 --- a/website/src/controllers/dataexport.js +++ /dev/null @@ -1,138 +0,0 @@ -var _ = require('lodash'); -var csv = require('express-csv'); -var express = require('express'); -var nconf = require('nconf'); -var moment = require('moment'); -var dataexport = module.exports; -var js2xmlparser = require("js2xmlparser"); -var pd = require('pretty-data').pd; -var User = require('../models/user').model; - -// Avatar screenshot/static-page includes -var Pageres = require('pageres'); //https://github.com/sindresorhus/pageres -var AWS = require('aws-sdk'); -AWS.config.update({accessKeyId: nconf.get("S3:accessKeyId"), secretAccessKey: nconf.get("S3:secretAccessKey")}); -var s3Stream = require('s3-upload-stream')(new AWS.S3()); //https://github.com/nathanpeck/s3-upload-stream -var bucket = nconf.get("S3:bucket"); -var request = require('request'); - -/* - ------------------------------------------------------------------------ - Data export - ------------------------------------------------------------------------ -*/ - -dataexport.history = function(req, res) { - var user = res.locals.user; - var output = [ - ["Task Name", "Task ID", "Task Type", "Date", "Value"] - ]; - _.each(user.tasks, function(task) { - _.each(task.history, function(history) { - output.push( - [task.text, task.id, task.type, moment(history.date).format("MM-DD-YYYY HH:mm:ss"), history.value] - ); - }); - }); - return res.csv(output); -} - -var userdata = function(user) { - if(user.auth && user.auth.local) { - delete user.auth.local.salt; - delete user.auth.local.hashed_password; - } - return user; -} - -dataexport.leanuser = function(req, res, next) { - User.findOne({_id: res.locals.user._id}).lean().exec(function(err, user) { - if (err) return res.json(500, {err: err}); - if (_.isEmpty(user)) return res.json(401, NO_USER_FOUND); - res.locals.user = user; - return next(); - }); -}; - -dataexport.userdata = { - xml: function(req, res) { - var user = userdata(res.locals.user); - return res.xml({data: JSON.stringify(user), rootname: 'user'}); - }, - json: function(req, res) { - var user = userdata(res.locals.user); - return res.jsonstring(user); - } -} - -/* - ------------------------------------------------------------------------ - Express Extensions (should be refactored into a module) - ------------------------------------------------------------------------ -*/ - -var expressres = express.response || http.ServerResponse.prototype; - -expressres.xml = function(obj, headers, status) { - var body = ''; - this.charset = this.charset || 'utf-8'; - this.header('Content-Type', 'text/xml'); - this.header('Content-Disposition', 'attachment'); - body = pd.xml(js2xmlparser(obj.rootname,obj.data)); - return this.send(body, headers, status); -}; - -expressres.jsonstring = function(obj, headers, status) { - var body = ''; - this.charset = this.charset || 'utf-8'; - this.header('Content-Type', 'application/json'); - this.header('Content-Disposition', 'attachment'); - body = pd.json(JSON.stringify(obj)); - return this.send(body, headers, status); -}; - -/* - ------------------------------------------------------------------------ - Static page and image screenshot of avatar - ------------------------------------------------------------------------ - */ - - -dataexport.avatarPage = function(req, res) { - User.findById(req.params.uuid).select('stats profile items achievements preferences backer contributor').exec(function(err, user){ - res.render('avatar-static', { - title: user.profile.name, - env: _.defaults({user:user},res.locals.habitrpg) - }); - }) -}; - -dataexport.avatarImage = function(req, res, next) { - var filename = 'avatar-'+req.params.uuid+'.png'; - request.head('https://'+bucket+'.s3.amazonaws.com/'+filename, function(err,response,body) { - // cache images for 10 minutes on aws, else upload a new one - if (response.statusCode==200 && moment().diff(response.headers['last-modified'], 'minutes') < 10) - return res.redirect(301, 'https://' + bucket + '.s3.amazonaws.com/' + filename); - new Pageres()//{delay:1} - .src(nconf.get('BASE_URL') + '/export/avatar-' + req.params.uuid + '.html', ['140x147'], {crop: true, filename: filename.replace('.png', '')}) - .run(function (err, file) { - if (err) return next(err); - // see http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#createMultipartUpload-property - var upload = s3Stream.upload({ - Bucket: bucket, - Key: filename, - ACL: "public-read", - StorageClass: "REDUCED_REDUNDANCY", - ContentType: "image/png", - Expires: +moment().add({minutes: 3}) - }); - upload.on('error', function (err) { - next(err); - }); - upload.on('uploaded', function (details) { - res.redirect(details.Location); - }); - file[0].pipe(upload); - }); - }) -}; diff --git a/website/src/controllers/groups.js b/website/src/controllers/groups.js deleted file mode 100644 index a34c552b52..0000000000 --- a/website/src/controllers/groups.js +++ /dev/null @@ -1,866 +0,0 @@ -// @see ../routes for routing - -function clone(a) { - return JSON.parse(JSON.stringify(a)); -} - -var _ = require('lodash'); -var nconf = require('nconf'); -var async = require('async'); -var utils = require('./../utils'); -var shared = require('../../../common'); -var User = require('./../models/user').model; -var Group = require('./../models/group').model; -var Challenge = require('./../models/challenge').model; -var isProd = nconf.get('NODE_ENV') === 'production'; -var api = module.exports; - -/* - ------------------------------------------------------------------------ - Groups - ------------------------------------------------------------------------ -*/ - -var partyFields = api.partyFields = 'profile preferences stats achievements party backer contributor auth.timestamps items'; -var nameFields = 'profile.name'; -var challengeFields = '_id name'; -var guildPopulate = {path: 'members', select: nameFields, options: {limit: 15} }; -/** - * For parties, we want a lot of member details so we can show their avatars in the header. For guilds, we want very - * limited fields - and only a sampling of the members, beacuse they can be in the thousands - * @param type: 'party' or otherwise - * @param q: the Mongoose query we're building up - * @param additionalFields: if we want to populate some additional field not fetched normally - * pass it as a string, parties only - */ -var populateQuery = function(type, q, additionalFields){ - if (type == 'party') - q.populate('members', partyFields + (additionalFields ? (' ' + additionalFields) : '')); - else - q.populate(guildPopulate); - q.populate('invites', nameFields); - q.populate({ - path: 'challenges', - match: (type=='habitrpg') ? {_id:{$ne:'95533e05-1ff9-4e46-970b-d77219f199e9'}} : undefined, // remove the Spread the Word Challenge for now, will revisit when we fix the closing-challenge bug - select: challengeFields, - options: {sort: {official: -1, timestamp: -1}} - }); - return q; -} - -/** - * Fetch groups list. This no longer returns party or tavern, as those can be requested indivdually - * as /groups/party or /groups/tavern - */ -api.list = function(req, res, next) { - var user = res.locals.user; - var groupFields = 'name description memberCount balance leader'; - var sort = '-memberCount'; - var type = req.query.type || 'party,guilds,public,tavern'; - - async.parallel({ - - // unecessary given our ui-router setup - party: function(cb){ - if (!~type.indexOf('party')) return cb(null, {}); - Group.findOne({type: 'party', members: {'$in': [user._id]}}) - .select(groupFields).exec(function(err, party){ - if (err) return cb(err); - cb(null, (party === null ? [] : [party])); // return as an array for consistent ngResource use - }); - }, - - guilds: function(cb) { - if (!~type.indexOf('guilds')) return cb(null, []); - Group.find({members: {'$in': [user._id]}, type:'guild'}) - .select(groupFields).sort(sort).exec(cb); - }, - - 'public': function(cb) { - if (!~type.indexOf('public')) return cb(null, []); - Group.find({privacy: 'public'}) - .select(groupFields + ' members') - .sort(sort) - .exec(function(err, groups){ - if (err) return cb(err); - _.each(groups, function(g){ - // To save some client-side performance, don't send down the full members arr, just send down temp var _isMember - if (~g.members.indexOf(user._id)) g._isMember = true; - g.members = undefined; - }); - cb(null, groups); - }); - }, - - // unecessary given our ui-router setup - tavern: function(cb) { - if (!~type.indexOf('tavern')) return cb(null, {}); - Group.findById('habitrpg').select(groupFields).exec(function(err, tavern){ - if (err) return cb(err); - cb(null, [tavern]); // return as an array for consistent ngResource use - }); - } - - }, function(err, results){ - if (err) return next(err); - // ngResource expects everything as arrays. We used to send it down as a structured object: {public:[], party:{}, guilds:[], tavern:{}} - // but unfortunately ngResource top-level attrs are considered the ngModels in the list, so we had to do weird stuff and multiple - // requests to get it to work properly. Instead, we're not depending on the client to do filtering / organization, and we're - // just sending down a merged array. Revisit - var arr = _.reduce(results, function(m,v){ - if (_.isEmpty(v)) return m; - return m.concat(_.isArray(v) ? v : [v]); - }, []) - res.json(arr); - - user = groupFields = sort = type = null; - }) -}; - -/** - * Get group - * TODO: implement requesting fields ?fields=chat,members - */ -api.get = function(req, res, next) { - var user = res.locals.user; - var gid = req.params.gid; - - var q = (gid == 'party') - ? Group.findOne({type: 'party', members: {'$in': [user._id]}}) - : Group.findOne({$or:[ - {_id:gid, privacy:'public'}, - {_id:gid, privacy:'private', members: {$in:[user._id]}} // if the group is private, only return if they have access - ]}); - populateQuery(gid, q); - q.exec(function(err, group){ - if (err) return next(err); - if (!group && gid!=='party') return res.json(404,{err: "Group not found or you don't have access."}); - res.json(group); - gid = null; - }); -}; - - -api.create = function(req, res, next) { - var group = new Group(req.body); - var user = res.locals.user; - group.members = [user._id]; - group.leader = user._id; - - if(group.type === 'guild'){ - if(user.balance < 1) return res.json(401, {err: 'Not enough gems!'}); - - group.balance = 1; - user.balance--; - - async.waterfall([ - function(cb){user.save(cb)}, - function(saved,ct,cb){group.save(cb)}, - function(saved,ct,cb){saved.populate('members',nameFields,cb)} - ],function(err,saved){ - if (err) return next(err); - res.json(saved); - group = user = null; - }); - - }else{ - async.waterfall([ - function(cb){ - Group.findOne({type:'party',members:{$in:[user._id]}},cb); - }, - function(found, cb){ - if (found) return cb('Already in a party, try refreshing.'); - group.save(cb); - }, - function(saved, count, cb){ - saved.populate('members', nameFields, cb); - } - ], function(err, populated){ - if (err == 'Already in a party, try refreshing.') return res.json(400,{err:err}); - if (err) return next(err); - return res.json(populated); - group = user = null; - }) - } -} - -api.update = function(req, res, next) { - var group = res.locals.group; - var user = res.locals.user; - - if(group.leader !== user._id) - return res.json(401, {err: "Only the group leader can update the group!"}); - - 'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){ - group[attr] = req.body[attr]; - }); - - group.save(function(err, saved){ - if (err) return next(err); - res.send(204); - }); -} - -api.attachGroup = function(req, res, next) { - var gid = req.params.gid; - var q = (gid == 'party') ? Group.findOne({type: 'party', members: {'$in': [res.locals.user._id]}}) : Group.findById(gid); - q.exec(function(err, group){ - if(err) return next(err); - if(!group) return res.json(404, {err: "Group not found"}); - res.locals.group = group; - next(); - }) -} - -api.getChat = function(req, res, next) { - // TODO: This code is duplicated from api.get - pull it out into a function to remove duplication. - var user = res.locals.user; - var gid = req.params.gid; - var q = (gid == 'party') - ? Group.findOne({type: 'party', members: {$in:[user._id]}}) - : Group.findOne({$or:[ - {_id:gid, privacy:'public'}, - {_id:gid, privacy:'private', members: {$in:[user._id]}} - ]}); - populateQuery(gid, q); - q.exec(function(err, group){ - if (err) return next(err); - if (!group && gid!=='party') return res.json(404,{err: "Group not found or you don't have access."}); - res.json(res.locals.group.chat); - gid = null; - }); -}; - -/** - * TODO make this it's own ngResource so we don't have to send down group data with each chat post - */ -api.postChat = function(req, res, next) { - var user = res.locals.user - var group = res.locals.group; - if (group.type!='party' && user.flags.chatRevoked) return res.json(401,{err:'Your chat privileges have been revoked.'}); - var lastClientMsg = req.query.previousMsg; - var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false; - - group.sendChat(req.query.message, user); // FIXME this should be body, but ngResource is funky - - if (group.type === 'party') { - user.party.lastMessageSeen = group.chat[0].id; - user.save(); - } - - group.save(function(err, saved){ - if (err) return next(err); - return chatUpdated ? res.json({chat: group.chat}) : res.json({message: saved.chat[0]}); - group = chatUpdated = null; - }); -} - -api.deleteChatMessage = function(req, res, next){ - var user = res.locals.user - var group = res.locals.group; - var message = _.find(group.chat, {id: req.params.messageId}); - - if(!message) return res.json(404, {err: "Message not found!"}); - - if(user._id !== message.uuid && !(user.backer && user.contributor.admin)) - return res.json(401, {err: "Not authorized to delete this message!"}) - - var lastClientMsg = req.query.previousMsg; - var chatUpdated = (lastClientMsg && group.chat && group.chat[0] && group.chat[0].id !== lastClientMsg) ? true : false; - - Group.update({_id:group._id}, {$pull:{chat:{id: req.params.messageId}}}, function(err){ - if(err) return next(err); - chatUpdated ? res.json({chat: group.chat}) : res.send(204); - group = chatUpdated = null; - }); -} - -api.flagChatMessage = function(req, res, next){ - var user = res.locals.user - var group = res.locals.group; - var message = _.find(group.chat, {id: req.params.mid}); - - if(!message) return res.json(404, {err: "Message not found!"}); - if(message.uuid == user._id) return res.json(401, {err: "Can't report your own message."}); - - User.findOne({_id: message.uuid}, {auth: 1}, function(err, author){ - if(err) return next(err); - - // Log user ids that have flagged the message - if(!message.flags) message.flags = {}; - if(message.flags[user._id] && !user.contributor.admin) return res.json(401, {err: "You have already reported this message"}); - message.flags[user._id] = true; - - // Log total number of flags (publicly viewable) - if(!message.flagCount) message.flagCount = 0; - if(user.contributor.admin){ - // Arbitraty amount, higher than 2 - message.flagCount = 5; - } else { - message.flagCount++ - } - - group.markModified('chat'); - group.save(function(err,_saved){ - if(err) return next(err); - var addressesToSendTo = JSON.parse(nconf.get('FLAG_REPORT_EMAIL')); - - if(Array.isArray(addressesToSendTo)){ - addressesToSendTo = addressesToSendTo.map(function(email){ - return {email: email, canSend: true} - }); - }else{ - addressesToSendTo = {email: addressesToSendTo} - } - - utils.txnEmail(addressesToSendTo, 'flag-report-to-mods', [ - {name: "MESSAGE_TIME", content: (new Date(message.timestamp)).toString()}, - {name: "MESSAGE_TEXT", content: message.text}, - - {name: "REPORTER_USERNAME", content: user.profile.name}, - {name: "REPORTER_UUID", content: user._id}, - {name: "REPORTER_EMAIL", content: user.auth.local ? user.auth.local.email : ((user.auth.facebook && user.auth.facebook.emails && user.auth.facebook.emails[0]) ? user.auth.facebook.emails[0].value : null)}, - {name: "REPORTER_MODAL_URL", content: "https://habitrpg.com/static/front/#?memberId=" + user._id}, - - {name: "AUTHOR_USERNAME", content: message.user}, - {name: "AUTHOR_UUID", content: message.uuid}, - {name: "AUTHOR_EMAIL", content: author.auth.local ? author.auth.local.email : ((author.auth.facebook && author.auth.facebook.emails && author.auth.facebook.emails[0]) ? author.auth.facebook.emails[0].value : null)}, - {name: "AUTHOR_MODAL_URL", content: "https://habitrpg.com/static/front/#?memberId=" + message.uuid}, - - {name: "GROUP_NAME", content: group.name}, - {name: "GROUP_TYPE", content: group.type}, - {name: "GROUP_ID", content: group._id}, - {name: "GROUP_URL", content: group._id == 'habitrpg' ? (nconf.get('BASE_URL') + '/#/options/groups/tavern') : (group.type === 'guild' ? (nconf.get('BASE_URL')+ '/#/options/groups/guilds/' + group._id) : 'party')}, - ]); - - return res.send(204); - }); - }); - -} - -api.clearFlagCount = function(req, res, next){ - var user = res.locals.user - var group = res.locals.group; - var message = _.find(group.chat, {id: req.params.mid}); - - if(!message) return res.json(404, {err: "Message not found!"}); - - if(user.contributor.admin){ - message.flagCount = 0; - - group.markModified('chat'); - group.save(function(err,_saved){ - if(err) return next(err); - return res.send(204); - }); - }else{ - return res.json(401, {err: "Only an admin can clear the flag count!"}) - } - -} - -api.seenMessage = function(req,res,next){ - // Skip the auth step, we want this to be fast. If !found with uuid/token, then it just doesn't save - // Check for req.params.gid to exist - if(req.params.gid){ - var update = {$unset:{}}; - update['$unset']['newMessages.'+req.params.gid] = ''; - User.update({_id:req.headers['x-api-user'], apiToken:req.headers['x-api-key']},update).exec(); - } - res.send(200); -} - -api.likeChatMessage = function(req, res, next) { - var user = res.locals.user; - var group = res.locals.group; - var message = _.find(group.chat, {id: req.params.mid}); - if (!message) return res.json(404, {err: "Message not found!"}); - if (message.uuid == user._id) return res.json(401, {err: "Can't like your own message. Don't be that person."}); - if (!message.likes) message.likes = {}; - if (message.likes[user._id]) { - delete message.likes[user._id]; - } else { - message.likes[user._id] = true; - } - group.markModified('chat'); - group.save(function(err,_saved){ - if (err) return next(err); - return res.send(_saved.chat); - }) -} - -api.join = function(req, res, next) { - var user = res.locals.user, - group = res.locals.group; - - if (group.type == 'party' && group._id == (user.invitations && user.invitations.party && user.invitations.party.id)) { - User.update({_id:user.invitations.party.inviter}, {$inc:{'items.quests.basilist':1}}).exec(); // Reward inviter - user.invitations.party = undefined; // Clear invite - user.save(); - // invite new user to pending quest - if (group.quest.key && !group.quest.active) { - group.quest.members[user._id] = undefined; - group.markModified('quest.members'); - } - } - else if (group.type == 'guild' && user.invitations && user.invitations.guilds) { - var i = _.findIndex(user.invitations.guilds, {id:group._id}); - if (~i) user.invitations.guilds.splice(i,1); - user.save(); - } - - if (!_.contains(group.members, user._id)){ - group.members.push(user._id); - group.invites.splice(_.indexOf(group.invites, user._id), 1); - } - - async.series([ - function(cb){ - group.save(cb); - }, - function(cb){ - populateQuery(group.type, Group.findById(group._id)).exec(cb); - } - ], function(err, results){ - if (err) return next(err); - - // Return the group? Or not? - res.json(results[1]); - group = null; - }); -} - -api.leave = function(req, res, next) { - var user = res.locals.user, - group = res.locals.group; - // When removing the user from challenges, should we keep the tasks? - var keep = (/^remove-all/i).test(req.query.keep) ? 'remove-all' : 'keep-all'; - async.parallel([ - // Remove active quest from user if they're leaving the party - function(cb){ - if (group.type != 'party') return cb(null,{},1); - user.party.quest = Group.cleanQuestProgress(); - user.save(cb); - }, - // Remove user from group challenges - function(cb){ - async.waterfall([ - // Find relevant challenges - function(cb2) { - Challenge.find({ - _id: {$in: user.challenges}, // Challenges I am in - group: group._id // that belong to the group I am leaving - }, cb2); - }, - // Update each challenge - function(challenges, cb2) { - Challenge.update( - {_id:{$in: _.pluck(challenges, '_id')}}, - {$pull:{members:user._id}}, - {multi: true}, - function(err) { - cb2(err, challenges); // pass `challenges` above to cb - } - ); - }, - // Unlink the challenge tasks from user - function(challenges, cb2) { - async.waterfall(challenges.map(function(chal) { - return function(cb3) { - var i = user.challenges.indexOf(chal._id) - if (~i) user.challenges.splice(i,1); - user.unlink({cid:chal._id, keep:keep}, cb3); - } - }), cb2); - } - ], cb); - }, - // Update the group - function(cb){ - var update = {$pull:{members:user._id}}; - if (group.type == 'party' && group.quest.key){ - update['$unset'] = {}; - update['$unset']['quest.members.' + user._id] = 1; - } - // FIXME do we want to remove the group `if group.members.length == 0` ? (well, 1 since the update hasn't gone through yet) - if (group.members.length > 1) { - var seniorMember = _.find(group.members, function (m) {return m != user._id}); - // If the leader is leaving (or if the leader previously left, and this wasn't accounted for) - var leader = group.leader; - if (leader == user._id || !~group.members.indexOf(leader)) { - update['$set'] = update['$set'] || {}; - update['$set'].leader = seniorMember; - } - leader = group.quest && group.quest.leader; - if (leader && (leader == user._id || !~group.members.indexOf(leader))) { - update['$set'] = update['$set'] || {}; - update['$set']['quest.leader'] = seniorMember; - } - } - update['$inc'] = {memberCount: -1}; - Group.update({_id:group._id},update,cb); - } - ],function(err){ - if (err) return next(err); - return res.send(204); - user = group = keep = null; - }) -} - -api.invite = function(req, res, next) { - var group = res.locals.group; - var uuid = req.query.uuid; - - User.findById(uuid, function(err,invite){ - if (err) return next(err); - if (!invite) - return res.json(400,{err:'User with id "' + uuid + '" not found'}); - if (group.type == 'guild') { - if (_.contains(group.members,uuid)) - return res.json(400,{err: "User already in that group"}); - if (invite.invitations && invite.invitations.guilds && _.find(invite.invitations.guilds, {id:group._id})) - return res.json(400, {err:"User already invited to that group"}); - sendInvite(); - } else if (group.type == 'party') { - if (invite.invitations && !_.isEmpty(invite.invitations.party)) - return res.json(400,{err:"User already pending invitation."}); - Group.find({type:'party', members:{$in:[uuid]}}, function(err, groups){ - if (err) return next(err); - if (!_.isEmpty(groups)) - return res.json(400,{err:"User already in a party."}) - sendInvite(); - }); - } - - function sendInvite (){ - if(group.type === 'guild'){ - invite.invitations.guilds.push({id: group._id, name: group.name, inviter:res.locals.user._id}); - }else{ - //req.body.type in 'guild', 'party' - invite.invitations.party = {id: group._id, name: group.name, inviter:res.locals.user._id}; - } - - group.invites.push(invite._id); - - async.series([ - function(cb){ - invite.save(cb); - }, - function(cb){ - group.save(cb); - }, - function(cb){ - populateQuery(group.type, Group.findById(group._id)).exec(cb); - } - ], function(err, results){ - if (err) return next(err); - - if(invite.preferences.emailNotifications['invited' + (group.type == 'guild' ? 'Guild' : 'Party')] !== false){ - var emailVars = [ - {name: 'INVITER', content: utils.getUserInfo(res.locals.user, ['name']).name} - ]; - - if(group.type == 'guild'){ - emailVars.push( - {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: nconf.get('BASE_URL') + '/#/options/groups/guilds/public'} - ); - }else{ - emailVars.push( - {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} - ) - } - - utils.txnEmail(invite, ('invited-' + (group.type == 'guild' ? 'guild' : 'party')), emailVars); - } - - // Have to return whole group and its members for angular to show the invited user - res.json(results[2]); - group = uuid = null; - }); - } - }); -} - -api.removeMember = function(req, res, next){ - var group = res.locals.group; - var uuid = req.query.uuid; - var user = res.locals.user; - - if(group.leader !== user._id){ - return res.json(401, {err: "Only group leader can remove a member!"}); - } - - if(_.contains(group.members, uuid)){ - var update = {$pull:{members:uuid}}; - if(group.quest && group.quest.members){ - // remove member from quest - update['$unset'] = {}; - update['$unset']['quest.members.' + uuid] = ""; - // TODO: run cleanQuestProgress and return scroll to member if member was quest owner - } - update['$inc'] = {memberCount: -1}; - Group.update({_id:group._id},update, function(err, saved){ - if (err) return next(err); - - // Sending an empty 204 because Group.update doesn't return the group - // see http://mongoosejs.com/docs/api.html#model_Model.update - return res.send(204); - }); - }else if(_.contains(group.invites, uuid)){ - User.findById(uuid, function(err,invited){ - var invitations = invited.invitations; - if(group.type === 'guild'){ - invitations.guilds.splice(_.indexOf(invitations.guilds, group._id), 1); - }else{ - invitations.party = undefined; - } - - async.series([ - function(cb){ - invited.save(cb); - }, - function(cb){ - Group.update({_id:group._id},{$pull:{invites:uuid}}, cb); - } - ], function(err, results){ - if (err) return next(err); - - // Sending an empty 204 because Group.update doesn't return the group - // see http://mongoosejs.com/docs/api.html#model_Model.update - return res.send(204); - group = uuid = null; - }); - - }); - }else{ - return res.json(400, {err: "User not found among group's members!"}); - group = uuid = null; - } -} - -// ------------------------------------ -// Quests -// ------------------------------------ - -questStart = function(req, res, next) { - var group = res.locals.group; - var force = req.query.force; - - // if (group.quest.active) return res.json(400,{err:'Quest already began.'}); - // temporarily send error email, until we know more about this issue (then remove below, uncomment above). - if (group.quest.active) return next('Quest already began.'); - - group.markModified('quest'); - - // Not ready yet, wait till everyone's accepted, rejected, or we force-start - var statuses = _.values(group.quest.members); - if (!force && (~statuses.indexOf(undefined) || ~statuses.indexOf(null))) { - return group.save(function(err,saved){ - if (err) return next(err); - res.json(saved); - }) - } - - var parallel = [], - questMembers = {}, - key = group.quest.key, - quest = shared.content.quests[key], - collected = quest.collect ? _.transform(quest.collect, function(m,v,k){m[k]=0}) : {}; - - _.each(group.members, function(m){ - var updates = {$set:{},$inc:{'_v':1}}; - if (m == group.quest.leader) - updates['$inc']['items.quests.'+key] = -1; - if (group.quest.members[m] == true) { - // See https://github.com/HabitRPG/habitrpg/issues/2168#issuecomment-31556322 , we need to *not* reset party.quest.progress.up - //updates['$set']['party.quest'] = Group.cleanQuestProgress({key:key,progress:{collect:collected}}); - updates['$set']['party.quest.key'] = key; - updates['$set']['party.quest.progress.down'] = 0; - updates['$set']['party.quest.progress.collect'] = collected; - updates['$set']['party.quest.completed'] = null; - questMembers[m] = true; - } else { - updates['$set']['party.quest'] = Group.cleanQuestProgress(); - } - parallel.push(function(cb2){ - User.update({_id:m},updates,cb2); - }); - }) - - group.quest.active = true; - if (quest.boss) { - group.quest.progress.hp = quest.boss.hp; - if (quest.boss.rage) group.quest.progress.rage = 0; - } else { - group.quest.progress.collect = collected; - } - group.quest.members = questMembers; - group.markModified('quest'); // members & progress.collect are both Mixed types - parallel.push(function(cb2){group.save(cb2)}); - - parallel.push(function(cb){ - // Fetch user.auth to send email, then remove it from data sent to the client - populateQuery(group.type, Group.findById(group._id), 'auth.facebook auth.local').exec(cb); - }); - - async.parallel(parallel,function(err, results){ - if (err) return next(err); - - var lastIndex = results.length -1; - var groupClone = clone(group); - - groupClone.members = results[lastIndex].members; - - // Send quest started email and remove auth information - _.each(groupClone.members, function(user){ - - if(user.preferences.emailNotifications.questStarted !== false && - user._id !== res.locals.user._id && - group.quest.members[user._id] == true - ){ - utils.txnEmail(user, 'quest-started', [ - {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} - ]); - } - - // Remove sensitive data from what is sent to the public - user.auth.facebook = undefined; - user.auth.local = undefined; - }); - - group = null; - - return res.json(groupClone); - }); -} - -api.questAccept = function(req, res, next) { - var group = res.locals.group; - var user = res.locals.user; - var key = req.query.key; - - if (!group) return res.json(400, {err: "Must be in a party to start quests."}); - - // If ?key=xxx is provided, we're starting a new quest and inviting the party. Otherwise, we're a party member accepting the invitation - if (key) { - var quest = shared.content.quests[key]; - if (!quest) return res.json(404,{err:'Quest ' + key + ' not found'}); - if (quest.lvl && user.stats.lvl < quest.lvl) return res.json(400, {err: "You must be level "+quest.lvl+" to begin this quest."}); - if (group.quest.key) return res.json(400, {err: 'Party already on a quest (and only have one quest at a time)'}); - if (!user.items.quests[key]) return res.json(400, {err: "You don't own that quest scroll"}); - group.quest.key = key; - group.quest.members = {}; - // Invite everyone. true means "accepted", false="rejected", undefined="pending". Once we click "start quest" - // or everyone has either accepted/rejected, then we store quest key in user object. - _.each(group.members, function(m){ - if (m == user._id) { - group.quest.members[m] = true; - group.quest.leader = user._id; - } else { - group.quest.members[m] = undefined; - } - }); - - User.find({ - _id: { - $in: _.without(group.members, user._id) - } - }, {auth: 1, preferences: 1, profile: 1}, function(err, members){ - if(err) return next(err); - - var inviterName = utils.getUserInfo(user, ['name']).name; - - _.each(members, function(member){ - if(member.preferences.emailNotifications.invitedQuest !== false){ - utils.txnEmail(member, ('invite-' + (quest.boss ? 'boss' : 'collection') + '-quest'), [ - {name: 'QUEST_NAME', content: quest.text()}, - {name: 'INVITER', content: inviterName}, - {name: 'PARTY_URL', content: nconf.get('BASE_URL') + '/#/options/groups/party'} - ]); - } - }); - - questStart(req,res,next); - }); - - // Party member accepting the invitation - } else { - if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'}); - group.quest.members[user._id] = true; - questStart(req,res,next); - } -} - -api.questReject = function(req, res, next) { - var group = res.locals.group; - var user = res.locals.user; - - if (!group.quest.key) return res.json(400,{err:'No quest invitation has been sent out yet.'}); - group.quest.members[user._id] = false; - questStart(req,res,next); -} - -api.questCancel = function(req, res, next){ - // Cancel a quest BEFORE it has begun (i.e., in the invitation stage) - // Quest scroll has not yet left quest owner's inventory so no need to return it. - // Do not wipe quest progress for members because they'll want it to be applied to the next quest that's started. - var group = res.locals.group; - async.parallel([ - function(cb){ - if (! group.quest.active) { - // Do not cancel active quests because this function does - // not do the clean-up required for that. - // TODO: return an informative error when quest is active - group.quest = {key:null,progress:{},leader:null}; - group.markModified('quest'); - group.save(cb); - } - } - ], function(err){ - if (err) return next(err); - res.json(group); - group = null; - }) -} - -api.questAbort = function(req, res, next){ - // Abort a quest AFTER it has begun (see questCancel for BEFORE) - var group = res.locals.group; - async.parallel([ - function(cb){ - User.update( - {_id:{$in: _.keys(group.quest.members)}}, - { - $set: {'party.quest':Group.cleanQuestProgress()}, - $inc: {_v:1} - }, - {multi:true}, - cb); - }, - // Refund party leader quest scroll - function(cb){ - if (group.quest.active) { - var update = {$inc:{}}; - update['$inc']['items.quests.' + group.quest.key] = 1; - User.update({_id:group.quest.leader}, update).exec(); - } - group.quest = {key:null,progress:{},leader:null}; - group.markModified('quest'); - group.save(cb); - }, function(cb){ - populateQuery(group.type, Group.findById(group._id)).exec(cb); - } - ], function(err, results){ - if (err) return next(err); - - var groupClone = clone(group); - - groupClone.members = results[2].members; - - res.json(groupClone); - group = null; - }) -} diff --git a/website/src/controllers/hall.js b/website/src/controllers/hall.js deleted file mode 100644 index 8754bc2e94..0000000000 --- a/website/src/controllers/hall.js +++ /dev/null @@ -1,85 +0,0 @@ -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); - }) -} diff --git a/website/src/controllers/members.js b/website/src/controllers/members.js deleted file mode 100644 index 9102df985a..0000000000 --- a/website/src/controllers/members.js +++ /dev/null @@ -1,119 +0,0 @@ -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('../utils'); -var nconf = require('nconf'); - -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: nconf.get('BASE_URL') + '/#/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); - if(member.preferences.emailNotifications.giftedGems !== false){ - utils.txnEmail(member, 'gifted-gems', [ - {name: 'GIFTER', content: utils.getUserInfo(user, ['name']).name}, - {name: 'X_GEMS_GIFTED', content: req.body.gems.amount} - ]); - } - 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); - }); -} diff --git a/website/src/controllers/payments/iap.js b/website/src/controllers/payments/iap.js deleted file mode 100644 index 29b03fedfb..0000000000 --- a/website/src/controllers/payments/iap.js +++ /dev/null @@ -1,129 +0,0 @@ -var iap = require('in-app-purchase'); -var async = require('async'); -var payments = require('./index'); -var nconf = require('nconf'); - -var inAppPurchase = require('in-app-purchase'); -inAppPurchase.config({ - // this is the path to the directory containing iap-sanbox/iap-live files - googlePublicKeyPath: nconf.get("IAP_GOOGLE_KEYDIR") -}); - -// Validation ERROR Codes -var INVALID_PAYLOAD = 6778001; -var CONNECTION_FAILED = 6778002; -var PURCHASE_EXPIRED = 6778003; - -exports.androidVerify = function(req, res, next) { - var iapBody = req.body; - var user = res.locals.user; - - iap.setup(function (error) { - if (error) { - var resObj = { - ok: false, - data: 'IAP Error' - }; - - console.error('IAP Setup ERROR'); - console.error(error); - - res.json(resObj); - - return; - } - - /* - google receipt must be provided as an object - { - "data": "{stringified data object}", - "signature": "signature from google" - } - */ - var testObj = { - data: iapBody.transaction.receipt, - signature: iapBody.transaction.signature - }; - - // iap is ready - iap.validate(iap.GOOGLE, testObj, function (err, googleRes) { - if (err) { - var resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString() - } - }; - - res.json(resObj); - console.error(err); - return; - } - - if (iap.isValidated(googleRes)) { - var resObj = { - ok: true, - data: googleRes - }; - - payments.buyGems({user:user, paymentMethod:'IAP GooglePlay'}); - - // yay good! - res.json(resObj); - } - }); - }); -}; - -exports.iosVerify = function(req, res, next) { - console.info(req.body); - - var iapBody = req.body; - var user = res.locals.user; - - iap.setup(function (error) { - if (error) { - var resObj = { - ok: false, - data: 'IAP Error' - }; - - console.error('IAP Setup ERROR'); - console.error(error); - - res.json(resObj); - - return; - } - - // iap is ready - iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) { - if (err) { - var resObj = { - ok: false, - data: { - code: INVALID_PAYLOAD, - message: err.toString() - } - }; - - res.json(resObj); - console.error(err); - return; - } - - if (iap.isValidated(appleRes)) { - var resObj = { - ok: true, - data: appleRes - }; - - payments.buyGems({user:user, paymentMethod:'IAP AppleStore'}); - - // yay good! - res.json(resObj); - } - }); - }); -}; \ No newline at end of file diff --git a/website/src/controllers/payments/index.js b/website/src/controllers/payments/index.js deleted file mode 100644 index 639fe18ebf..0000000000 --- a/website/src/controllers/payments/index.js +++ /dev/null @@ -1,156 +0,0 @@ -/* @see ./routes.coffee for routing*/ -var _ = require('lodash'); -var shared = require('../../../../common'); -var nconf = require('nconf'); -var utils = require('./../../utils'); -var moment = require('moment'); -var isProduction = nconf.get("NODE_ENV") === "production"; -var stripe = require('./stripe'); -var paypal = require('./paypal'); -var members = require('../members') -var async = require('async'); -var iap = require('./iap'); -var mongoose= require('mongoose'); -var cc = require('coupon-code'); - -function revealMysteryItems(user) { - _.each(shared.content.gear.flat, function(item) { - if ( - item.klass === 'mystery' && - moment().isAfter(shared.content.mystery[item.mystery].start) && - moment().isBefore(shared.content.mystery[item.mystery].end) && - !user.items.gear.owned[item.key] && - !~user.purchased.plan.mysteryItems.indexOf(item.key) - ) { - user.purchased.plan.mysteryItems.push(item.key); - } - }); -} - -exports.createSubscription = function(data, cb) { - var recipient = data.gift ? data.gift.member : data.user; - //if (!recipient.purchased.plan) recipient.purchased.plan = {}; // FIXME double-check, this should never be the case - var p = recipient.purchased.plan; - var block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key]; - var months = +block.months; - - if (data.gift) { - if (p.customerId && !p.dateTerminated) { // User has active plan - p.extraMonths += months; - } else { - p.dateTerminated = moment(p.dateTerminated).add({months: months}).toDate(); - if (!p.dateUpdated) p.dateUpdated = new Date(); - } - if (!p.customerId) p.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId - } else { - _(p).merge({ // override with these values - planId: block.key, - customerId: data.customerId, - dateUpdated: new Date(), - gemsBought: 0, - paymentMethod: data.paymentMethod, - extraMonths: +p.extraMonths - + +(p.dateTerminated ? moment(p.dateTerminated).diff(new Date(),'months',true) : 0), - dateTerminated: null - }).defaults({ // allow non-override if a plan was previously used - dateCreated: new Date(), - mysteryItems: [] - }); - } - - // Block sub perks - var perks = Math.floor(months/3); - if (perks) { - p.consecutive.offset += months; - p.consecutive.gemCapExtra += perks*5; - if (p.consecutive.gemCapExtra > 25) p.consecutive.gemCapExtra = 25; - p.consecutive.trinkets += perks; - } - revealMysteryItems(recipient); - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'subscription-begins'); - utils.ga.event('subscribe', data.paymentMethod).send(); - utils.ga.transaction(data.user._id, block.price).item(block.price, 1, data.paymentMethod.toLowerCase() + '-subscription', data.paymentMethod).send(); - } - data.user.purchased.txnCount++; - if (data.gift){ - members.sendMessage(data.user, data.gift.member, data.gift); - if(data.gift.member.preferences.emailNotifications.giftedSubscription !== false){ - utils.txnEmail(data.gift.member, 'gifted-subscription', [ - {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, - {name: 'X_MONTHS_SUBSCRIPTION', content: months} - ]); - } - } - async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} - ], cb); -} - -/** - * Sets their subscription to be cancelled later - */ -exports.cancelSubscription = function(data, cb) { - var p = data.user.purchased.plan, - now = moment(), - remaining = data.nextBill ? moment(data.nextBill).diff(new Date, 'days') : 30; - - p.dateTerminated = - moment( now.format('MM') + '/' + moment(p.dateUpdated).format('DD') + '/' + now.format('YYYY') ) - .add({days: remaining}) // end their subscription 1mo from their last payment - .add({months: Math.ceil(p.extraMonths)})// plus any extra time (carry-over, gifted subscription, etc) they have. FIXME: moment can't add months in fractions... - .toDate(); - p.extraMonths = 0; // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated - - data.user.save(cb); - utils.txnEmail(data.user, 'cancel-subscription'); - utils.ga.event('unsubscribe', data.paymentMethod).send(); -} - -exports.buyGems = function(data, cb) { - var amt = data.gift ? data.gift.gems.amount/4 : 5; - (data.gift ? data.gift.member : data.user).balance += amt; - data.user.purchased.txnCount++; - if(isProduction) { - if (!data.gift) utils.txnEmail(data.user, 'donation'); - utils.ga.event('checkout', data.paymentMethod).send(); - //TODO ga.transaction to reflect whether this is gift or self-purchase - utils.ga.transaction(data.user._id, amt).item(amt, 1, data.paymentMethod.toLowerCase() + "-checkout", "Gems > " + data.paymentMethod).send(); - } - if (data.gift){ - members.sendMessage(data.user, data.gift.member, data.gift); - if(data.gift.member.preferences.emailNotifications.giftedGems !== false){ - utils.txnEmail(data.gift.member, 'gifted-gems', [ - {name: 'GIFTER', content: utils.getUserInfo(data.user, ['name']).name}, - {name: 'X_GEMS_GIFTED', content: data.gift.gems.amount || 20} - ]); - } - } - async.parallel([ - function(cb2){data.user.save(cb2)}, - function(cb2){data.gift ? data.gift.member.save(cb2) : cb2(null);} - ], cb); -} - -exports.validCoupon = function(req, res, next){ - mongoose.model('Coupon').findOne({_id:cc.validate(req.params.code), event:'google_6mo'}, function(err, coupon){ - if (err) return next(err); - if (!coupon) return res.json(401, {err:"Invalid coupon code"}); - return res.send(200); - }); -} - -exports.stripeCheckout = stripe.checkout; -exports.stripeSubscribeCancel = stripe.subscribeCancel; -exports.stripeSubscribeEdit = stripe.subscribeEdit; - -exports.paypalSubscribe = paypal.createBillingAgreement; -exports.paypalSubscribeSuccess = paypal.executeBillingAgreement; -exports.paypalSubscribeCancel = paypal.cancelSubscription; -exports.paypalCheckout = paypal.createPayment; -exports.paypalCheckoutSuccess = paypal.executePayment; -exports.paypalIPN = paypal.ipn; - -exports.iapAndroidVerify = iap.androidVerify; -exports.iapIosVerify = iap.iosVerify; \ No newline at end of file diff --git a/website/src/controllers/payments/paypal.js b/website/src/controllers/payments/paypal.js deleted file mode 100644 index 094171e3af..0000000000 --- a/website/src/controllers/payments/paypal.js +++ /dev/null @@ -1,216 +0,0 @@ -var nconf = require('nconf'); -var moment = require('moment'); -var async = require('async'); -var _ = require('lodash'); -var url = require('url'); -var User = require('mongoose').model('User'); -var payments = require('./index'); -var logger = require('../../logging'); -var ipn = require('paypal-ipn'); -var paypal = require('paypal-rest-sdk'); -var shared = require('../../../../common'); -var mongoose = require('mongoose'); -var cc = require('coupon-code'); - -// This is the plan.id for paypal subscriptions. You have to set up billing plans via their REST sdk (they don't have -// a web interface for billing-plan creation), see ./paypalBillingSetup.js for how. After the billing plan is created -// there, get it's plan.id and store it in config.json -_.each(shared.content.subscriptionBlocks, function(block){ - block.paypalKey = nconf.get("PAYPAL:billing_plans:"+block.key); -}); - -paypal.configure({ - 'mode': nconf.get("PAYPAL:mode"), //sandbox or live - 'client_id': nconf.get("PAYPAL:client_id"), - 'client_secret': nconf.get("PAYPAL:client_secret") -}); - -var parseErr = function(res, err){ - //var error = err.response ? err.response.message || err.response.details[0].issue : err; - var error = JSON.stringify(err); - return res.json(400,{err:error}); -} - -exports.createBillingAgreement = function(req,res,next){ - var sub = shared.content.subscriptionBlocks[req.query.sub]; - async.waterfall([ - function(cb){ - if (!sub.discount) return cb(null, null); - if (!req.query.coupon) return cb('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb); - }, - function(coupon, cb){ - if (sub.discount && !coupon) return cb('Invalid coupon code.'); - var billingPlanTitle = "HabitRPG Subscription" + ' ($'+sub.price+' every '+sub.months+' months, recurring)'; - var billingAgreementAttributes = { - "name": billingPlanTitle, - "description": billingPlanTitle, - "start_date": moment().add({minutes:5}).format(), - "plan": { - "id": sub.paypalKey - }, - "payer": { - "payment_method": "paypal" - } - }; - paypal.billingAgreement.create(billingAgreementAttributes, cb); - } - ], function(err, billingAgreement){ - if (err) return parseErr(res, err); - // For approving subscription via Paypal, first redirect user to: approval_url - req.session.paypalBlock = req.query.sub; - var approval_url = _.find(billingAgreement.links, {rel:'approval_url'}).href; - res.redirect(approval_url); - }); -} - -exports.executeBillingAgreement = function(req,res,next){ - var block = shared.content.subscriptionBlocks[req.session.paypalBlock]; - delete req.session.paypalBlock; - async.auto({ - exec: function (cb) { - paypal.billingAgreement.execute(req.query.token, {}, cb); - }, - get_user: function (cb) { - User.findById(req.session.userId, cb); - }, - create_sub: ['exec', 'get_user', function (cb, results) { - payments.createSubscription({ - user: results.get_user, - customerId: results.exec.id, - paymentMethod: 'Paypal', - sub: block - }, cb); - }] - },function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - }) -} - -exports.createPayment = function(req, res) { - // if we're gifting to a user, put it in session for the `execute()` - req.session.gift = req.query.gift || undefined; - var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var price = !gift ? 5.00 - : gift.type=='gems' ? Number(gift.gems.amount/4).toFixed(2) - : Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2); - var description = !gift ? "HabitRPG Gems" - : gift.type=='gems' ? "HabitRPG Gems (Gift)" - : shared.content.subscriptionBlocks[gift.subscription.key].months + "mo. HabitRPG Subscription (Gift)"; - var create_payment = { - "intent": "sale", - "payer": { - "payment_method": "paypal" - }, - "redirect_urls": { - "return_url": nconf.get('BASE_URL') + '/paypal/checkout/success', - "cancel_url": nconf.get('BASE_URL') - }, - "transactions": [{ - "item_list": { - "items": [{ - "name": description, - //"sku": "1", - "price": price, - "currency": "USD", - "quantity": 1 - }] - }, - "amount": { - "currency": "USD", - "total": price - }, - "description": description - }] - }; - paypal.payment.create(create_payment, function (err, payment) { - if (err) return parseErr(res, err); - var link = _.find(payment.links, {rel: 'approval_url'}).href; - res.redirect(link); - }); -} - -exports.executePayment = function(req, res) { - var paymentId = req.query.paymentId, - PayerID = req.query.PayerID, - gift = req.session.gift ? JSON.parse(req.session.gift) : undefined; - delete req.session.gift; - async.waterfall([ - function(cb){ - paypal.payment.execute(paymentId, {payer_id: PayerID}, cb); - }, - function(payment, cb){ - async.parallel([ - function(cb2){ User.findById(req.session.userId, cb2); }, - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2); } - ], cb); - }, - function(results, cb){ - if (_.isEmpty(results[0])) return cb("User not found when completing paypal transaction"); - var data = {user:results[0], customerId:PayerID, paymentMethod:'Paypal', gift:gift} - var method = 'buyGems'; - if (gift) { - gift.member = results[1]; - if (gift.type=='subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb); - } - ],function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - }) -} - -exports.cancelSubscription = function(req, res, next){ - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.json(401, {err: "User does not have a plan subscription"}); - async.auto({ - get_cus: function(cb){ - paypal.billingAgreement.get(user.purchased.plan.customerId, cb); - }, - verify_cus: ['get_cus', function(cb, results){ - var hasntBilledYet = results.get_cus.agreement_details.cycles_completed == "0"; - if (hasntBilledYet) - return cb("The plan hasn't activated yet (due to a PayPal bug). It will begin "+results.get_cus.agreement_details.next_billing_date+", after which you can cancel to retain your full benefits"); - cb(); - }], - del_cus: ['verify_cus', function(cb, results){ - paypal.billingAgreement.cancel(user.purchased.plan.customerId, {note: "Canceling the subscription"}, cb); - }], - cancel_sub: ['get_cus', 'verify_cus', function(cb, results){ - var data = {user: user, paymentMethod: 'Paypal', nextBill: results.get_cus.agreement_details.next_billing_date}; - payments.cancelSubscription(data, cb) - }] - }, function(err){ - if (err) return parseErr(res, err); - res.redirect('/'); - user = null; - }); -} - -/** - * General IPN handler. We catch cancelled HabitRPG subscriptions for users who manually cancel their - * recurring paypal payments in their paypal dashboard. Remove this when we can move to webhooks or some other solution - */ -exports.ipn = function(req, res, next) { - console.log('IPN Called'); - res.send(200); // Must respond to PayPal IPN request with an empty 200 first - ipn.verify(req.body, function(err, msg) { - if (err) return logger.error(msg); - switch (req.body.txn_type) { - // TODO what's the diff b/w the two data.txn_types below? The docs recommend subscr_cancel, but I'm getting the other one instead... - case 'recurring_payment_profile_cancel': - case 'subscr_cancel': - User.findOne({'purchased.plan.customerId':req.body.recurring_payment_id},function(err, user){ - if (err) return logger.error(err); - if (_.isEmpty(user)) return; // looks like the cancellation was already handled properly above (see api.paypalSubscribeCancel) - payments.cancelSubscription({user:user, paymentMethod: 'Paypal'}); - }); - break; - } - }); -}; - diff --git a/website/src/controllers/payments/paypalBillingSetup.js b/website/src/controllers/payments/paypalBillingSetup.js deleted file mode 100644 index 88e0650d44..0000000000 --- a/website/src/controllers/payments/paypalBillingSetup.js +++ /dev/null @@ -1,93 +0,0 @@ -// This file is used for creating paypal billing plans. PayPal doesn't have a web interface for setting up recurring -// payment plan definitions, instead you have to create it via their REST SDK and keep it updated the same way. So this -// file will be used once for initing your billing plan (then you get the resultant plan.id to store in config.json), -// and once for any time you need to edit the plan thereafter -require('coffee-script'); -var path = require('path'); -var nconf = require('nconf'); -_ = require('lodash'); -nconf.argv().env().file('user', path.join(path.resolve(__dirname, '../../../config.json'))); -var paypal = require('paypal-rest-sdk'); -var blocks = require('../../../../common').content.subscriptionBlocks; -var live = nconf.get('PAYPAL:mode')=='live'; - -var OP = 'create'; // list create update remove - -paypal.configure({ - 'mode': nconf.get("PAYPAL:mode"), //sandbox or live - 'client_id': nconf.get("PAYPAL:client_id"), - 'client_secret': nconf.get("PAYPAL:client_secret") -}); - -// https://developer.paypal.com/docs/api/#billing-plans-and-agreements -var billingPlanTitle ="HabitRPG Subscription"; -var billingPlanAttributes = { - "name": billingPlanTitle, - "description": billingPlanTitle, - "type": "INFINITE", - "merchant_preferences": { - "auto_bill_amount": "yes", - "cancel_url": live ? 'https://habitrpg.com' : 'http://localhost:3000', - "return_url": (live ? 'https://habitrpg.com' : 'http://localhost:3000') + '/paypal/subscribe/success' - }, - payment_definitions: [{ - "type": "REGULAR", - "frequency": "MONTH", - "cycles": "0" - }] -}; -_.each(blocks, function(block){ - block.definition = _.cloneDeep(billingPlanAttributes); - _.merge(block.definition.payment_definitions[0], { - "name": billingPlanTitle + ' ($'+block.price+' every '+block.months+' months, recurring)', - "frequency_interval": ""+block.months, - "amount": { - "currency": "USD", - "value": ""+block.price - } - }); -}) - -switch(OP) { - case "list": - paypal.billingPlan.list({status: 'ACTIVE'}, function(err, plans){ - console.log({err:err, plans:plans}); - }); - break; - case "get": - paypal.billingPlan.get(nconf.get("PAYPAL:billing_plans:12"), function (err, plan) { - console.log({err:err, plan:plan}); - }) - break; - case "update": - var update = { - "op": "replace", - "path": "/merchant_preferences", - "value": { - "cancel_url": "https://habitrpg.com" - } - }; - paypal.billingPlan.update(nconf.get("PAYPAL:billing_plans:12"), update, function (err, res) { - console.log({err:err, plan:res}); - }); - break; - case "create": - paypal.billingPlan.create(blocks["google_6mo"].definition, function(err,plan){ - if (err) return console.log(err); - if (plan.state == "ACTIVE") - return console.log({err:err, plan:plan}); - var billingPlanUpdateAttributes = [{ - "op": "replace", - "path": "/", - "value": { - "state": "ACTIVE" - } - }]; - // Activate the plan by changing status to Active - paypal.billingPlan.update(plan.id, billingPlanUpdateAttributes, function(err, response){ - console.log({err:err, response:response, id:plan.id}); - }); - }); - break; - case "remove": break; -} diff --git a/website/src/controllers/payments/stripe.js b/website/src/controllers/payments/stripe.js deleted file mode 100644 index e86fa4ea86..0000000000 --- a/website/src/controllers/payments/stripe.js +++ /dev/null @@ -1,123 +0,0 @@ -var nconf = require('nconf'); -var stripe = require("stripe")(nconf.get('STRIPE_API_KEY')); -var async = require('async'); -var payments = require('./index'); -var User = require('mongoose').model('User'); -var shared = require('../../../../common'); -var mongoose = require('mongoose'); -var cc = require('coupon-code'); - -/* - Setup Stripe response when posting payment - */ -exports.checkout = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var gift = req.query.gift ? JSON.parse(req.query.gift) : undefined; - var sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false; - - async.waterfall([ - function(cb){ - if (sub) { - async.waterfall([ - function(cb2){ - if (!sub.discount) return cb2(null, null); - if (!req.query.coupon) return cb2('Please provide a coupon code for this plan.'); - mongoose.model('Coupon').findOne({_id:cc.validate(req.query.coupon), event:sub.key}, cb2); - }, - function(coupon, cb2){ - if (sub.discount && !coupon) return cb2('Invalid coupon code.'); - var customer = { - email: req.body.email, - metadata: {uuid: user._id}, - card: token, - plan: sub.key - }; - stripe.customers.create(customer, cb2); - } - ], cb); - } else { - stripe.charges.create({ - amount: !gift ? "500" //"500" = $5 - : gift.type=='subscription' ? ""+shared.content.subscriptionBlocks[gift.subscription.key].price*100 - : ""+gift.gems.amount/4*100, - currency: "usd", - card: token - }, cb); - } - }, - function(response, cb) { - if (sub) return payments.createSubscription({user:user, customerId:response.id, paymentMethod:'Stripe', sub:sub}, cb); - async.waterfall([ - function(cb2){ User.findById(gift ? gift.uuid : undefined, cb2) }, - function(member, cb2){ - var data = {user:user, customerId:response.id, paymentMethod:'Stripe', gift:gift}; - var method = 'buyGems'; - if (gift) { - gift.member = member; - if (gift.type=='subscription') method = 'createSubscription'; - data.paymentMethod = 'Gift'; - } - payments[method](data, cb2); - } - ], cb); - } - ], function(err){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.send(200); - user = token = null; - }); -}; - -exports.subscribeCancel = function(req, res, next) { - var user = res.locals.user; - if (!user.purchased.plan.customerId) - return res.json(401, {err: "User does not have a plan subscription"}); - - async.auto({ - get_cus: function(cb){ - stripe.customers.retrieve(user.purchased.plan.customerId, cb); - }, - del_cus: ['get_cus', function(cb, results){ - stripe.customers.del(user.purchased.plan.customerId, cb); - }], - cancel_sub: ['get_cus', function(cb, results) { - var data = { - user: user, - nextBill: results.get_cus.subscription.current_period_end*1000, // timestamp is in seconds - paymentMethod: 'Stripe' - }; - payments.cancelSubscription(data, cb); - }] - }, function(err, results){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.redirect('/'); - user = null; - }); -}; - -exports.subscribeEdit = function(req, res, next) { - var token = req.body.id; - var user = res.locals.user; - var user_id = user.purchased.plan.customerId; - var sub_id; - - async.waterfall([ - function(cb){ - stripe.customers.listSubscriptions(user_id, cb); - }, - function(response, cb) { - sub_id = response.data[0].id; - console.warn(sub_id); - console.warn([user_id, sub_id, { card: token }]); - stripe.customers.updateSubscription(user_id, sub_id, { card: token }, cb); - }, - function(response, cb) { - user.save(cb); - } - ], function(err, saved){ - if (err) return res.send(500, err.toString()); // don't json this, let toString() handle errors - res.send(200); - token = user = user_id = sub_id; - }); -}; diff --git a/website/src/controllers/user.js b/website/src/controllers/user.js deleted file mode 100644 index cd004452ed..0000000000 --- a/website/src/controllers/user.js +++ /dev/null @@ -1,572 +0,0 @@ -/* @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('./../utils'); -var ga = utils.ga; -var Group = require('./../models/group').model; -var Challenge = require('./../models/challenge').model; -var moment = require('moment'); -var logging = require('./../logging'); -var acceptablePUTPaths; -var api = module.exports; -var qs = require('qs'); -var request = require('request'); -var validator = require('validator'); - -// 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." - }; - task = user.ops.addTask({body:task}); - if (task.type === 'daily' || task.type === 'todo') - task.completed = direction === 'up'; - } - var delta = user.ops.score({params:{id:task.id, direction:direction}, language: req.language}); - - user.save(function(err,saved){ - if (err) return next(err); - // TODO this should be return {_v,task,stats,_tmp}, instead of merging everything togther at top-level response - // However, this is the most commonly used API route, and changing it will mess with all 3rd party consumers. Bad idea :( - res.json(200, _.extend({ - delta: delta, - _tmp: user._tmp - }, saved.toJSON().stats)); - - // Webhooks - _.each(user.preferences.webhooks, function(h){ - if (!h.enabled || !validator.isURL(h.url)) return; - request.post({ - url: h.url, - //form: {task: task, delta: delta, user: _.pick(user, ['stats', '_tmp'])} // this is causing "Maximum Call Stack Exceeded" - body: {direction:direction, task: task, delta: delta, user: _.pick(user, ['_id', 'stats', '_tmp'])}, json:true - }); - }); - - 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: "No task found."}); - 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 = 50; - user.stats.maxMP = res.locals.user._statsComputed.maxMP; - delete user.apiToken; - if (user.auth) { - delete user.auth.hashed_password; - delete user.auth.salt; - } - 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; -}, {}) - -//// Uncomment this if we we want to disable GP-restoring (eg, holiday events) -//_.each('stats.gp'.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("path `" + k + "` was not saved, as it's a protected path. See https://github.com/HabitRPG/habitrpg/blob/develop/API.md for PUT /api/v2/user."); - 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(), - 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 plan = res.locals.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."}); - res.locals.user.remove(function(err){ - if (err) return next(err); - res.send(200); - }) -} - -/* - ------------------------------------------------------------------------ - Gems - ------------------------------------------------------------------------ - */ - -// api.unlock // see Shared.ops - -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); - }) -} - -/* - ------------------------------------------------------------------------ - 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) { - 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; - } -} - -/** - * POST /user/invite-friends - */ -api.inviteFriends = function(req, res, next) { - Group.findOne({type:'party', members:{'$in': [res.locals.user._id]}}).select('_id name').exec(function(err,party){ - if (err) return next(err); - - _.each(req.body.emails, function(invite){ - if (invite.email) { - - User.findOne({$or: [ - {'auth.local.email': invite.email}, - {'auth.facebook.emails.value': invite.email} - ]}).select({_id: true, 'preferences.emailNotifications': true}) - .exec(function(err, userToContact){ - if(err) return next(err); - - var link = nconf.get('BASE_URL')+'?partyInvite='+ utils.encrypt(JSON.stringify({id:party._id, inviter:res.locals.user._id, name:party.name})); - - var variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || utils.getUserInfo(res.locals.user, ['name']).name} - ]; - - invite.canSend = true; - - // We check for unsubscribeFromAll here because don't pass through utils.getUserInfo - if(!userToContact || (userToContact.preferences.emailNotifications.invitedParty !== false && - userToContact.preferences.emailNotifications.unsubscribeFromAll !== true)){ - // TODO implement "users can only be invited once" - utils.txnEmail(invite, 'invite-friend', variables); - } - }); - - } - }); - res.send(200); - }) -} - -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, type:'party', members:{$in:[req.session.partyInvite.inviter]}}) - .select('invites members').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(); - } - 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); - }) - }, ga); - } - } -}) - -/* - ------------------------------------------------------------------------ - 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(); - }; - 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 = _.where(response.todos, function(t) { - return !t.completed || (t.challenge && t.challenge.id) || moment(t.dateCompleted).isAfter(moment().subtract({days:3})); - }); - res.json(200, response); - - // return only the version number - }else{ - res.json(200, {_v: response._v}); - } - }); -}; diff --git a/website/src/i18n.js b/website/src/i18n.js deleted file mode 100644 index 271036754a..0000000000 --- a/website/src/i18n.js +++ /dev/null @@ -1,141 +0,0 @@ -var fs = require('fs'), - path = require('path'), - _ = require('lodash'), - User = require('./models/user').model, - shared = require('../../common'), - translations = {}; - -var localePath = path.join(__dirname, "/../../common/locales/") - -var loadTranslations = function(locale){ - var files = fs.readdirSync(path.join(localePath, locale)); - translations[locale] = {}; - _.each(files, function(file){ - if(path.extname(file) !== '.json') return; - _.merge(translations[locale], require(path.join(localePath, locale, file))); - }); -}; - -// First fetch english so we can merge with missing strings in other languages -loadTranslations('en'); - -fs.readdirSync(localePath).forEach(function(file) { - if(file === 'en' || fs.statSync(path.join(localePath, file)).isDirectory() === false) return; - loadTranslations(file); - // Merge missing strings from english - _.defaults(translations[file], translations.en); -}); - -var langCodes = Object.keys(translations); - -var avalaibleLanguages = _.map(langCodes, function(langCode){ - return { - code: langCode, - name: translations[langCode].languageName - } -}); - -// Load MomentJS localization files -var momentLangs = {}; - -// Handle different language codes from MomentJS and /locales -var momentLangsMapping = { - 'en': 'en-gb', - 'en_GB': 'en-gb', - 'no': 'nn', - 'zh': 'zh-cn', - 'es_419': 'es' -}; - -var momentLangs = {}; - -_.each(langCodes, function(code){ - var lang = _.find(avalaibleLanguages, {code: code}); - lang.momentLangCode = (momentLangsMapping[code] || code); - try{ - // MomentJS lang files are JS files that has to be executed in the browser so we load them as plain text files - var f = fs.readFileSync(path.join(__dirname, '/../../node_modules/moment/locale/' + lang.momentLangCode + '.js'), 'utf8'); - momentLangs[code] = f; - }catch (e){} -}); - -// Remove en_GB from langCodes checked by browser to avaoi it being -// used in place of plain original 'en' -var defaultLangCodes = _.without(langCodes, 'en_GB'); - -var getUserLanguage = function(req, res, next){ - var getFromBrowser = function(){ - var acceptable = _(req.acceptedLanguages).map(function(lang){ - return lang.slice(0, 2); - }).uniq().value(); - var matches = _.intersection(acceptable, defaultLangCodes); - if(matches.length > 0 && matches[0].toLowerCase() === 'es'){ - var acceptedCompleteLang = _.find(req.acceptedLanguages, function(accepted){ - return accepted.slice(0, 2) == 'es'; - }); - - if(acceptedCompleteLang){ - acceptedCompleteLang = acceptedCompleteLang.toLowerCase(); - }else{ - return 'en'; - } - - var latinAmericanSpanishes = ['es-419', 'es-mx', 'es-gt', 'es-cr', 'es-pa', 'es-do', 'es-ve', 'es-co', 'es-pe', - 'es-ar', 'es-ec', 'es-cl', 'es-uy', 'es-py', 'es-bo', 'es-sv', 'es-hn', - 'es-ni', 'es-pr']; - - return (latinAmericanSpanishes.indexOf(acceptedCompleteLang) !== -1) ? 'es_419' : 'es'; - }else if(matches.length > 0){ - return matches[0].toLowerCase(); - }else{ - return 'en'; - } - }; - - var getFromUser = function(user){ - var lang; - if(user && user.preferences.language && translations[user.preferences.language]){ - lang = user.preferences.language; - }else{ - var preferred = getFromBrowser(); - lang = translations[preferred] ? preferred : 'en'; - } - req.language = lang; - next(); - }; - - if(req.query.lang){ - req.language = translations[req.query.lang] ? (req.query.lang) : 'en'; - next(); - }else if(req.locals && req.locals.user){ - getFromUser(req.locals.user); - }else if(req.session && req.session.userId){ - User.findOne({_id: req.session.userId}, function(err, user){ - if(err) return next(err); - getFromUser(user); - }); - }else{ - getFromUser(null); - } -}; - -shared.i18n.translations = translations; - -module.exports = { - translations: translations, - avalaibleLanguages: avalaibleLanguages, - langCodes: langCodes, - getUserLanguage: getUserLanguage, - momentLangs: momentLangs -}; - - -// Export en strings only, temporary solution for mobile -// This is copied from middleware.js#module.exports.locals#t() -module.exports.enTranslations = function(){ // stringName and vars are the allowed parameters - var language = _.find(avalaibleLanguages, {code: 'en'}); - //language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); - var args = Array.prototype.slice.call(arguments, 0); - args.push(language.code); - return shared.i18n.t.apply(null, args); -}; diff --git a/website/src/logging.js b/website/src/logging.js deleted file mode 100644 index 16b7b497f2..0000000000 --- a/website/src/logging.js +++ /dev/null @@ -1,73 +0,0 @@ -var nconf = require('nconf'); -var winston = require('winston'); -require('winston-mail').Mail; -require('winston-newrelic'); - -var logger, loggly; - -// Currently disabled -if (nconf.get('LOGGLY:enabled')){ - loggly = require('loggly').createClient({ - token: nconf.get('LOGGLY:token'), - subdomain: nconf.get('LOGGLY:subdomain'), - auth: { - username: nconf.get('LOGGLY:username'), - password: nconf.get('LOGGLY:password') - }, - // - // Optional: Tag to send with EVERY log message - // - tags: [('heroku-'+nconf.get('BASE_URL'))], - json: true - }); -} - -if (logger == null) { - logger = new (winston.Logger)({}); - if (nconf.get('NODE_ENV') == 'production') { - //logger.add(winston.transports.newrelic, {}); - if (!nconf.get('DISABLE_ERROR_EMAILS')) { - logger.add(winston.transports.Mail, { - to: nconf.get('ADMIN_EMAIL') || nconf.get('SMTP_USER'), - from: "HabitRPG <" + nconf.get('SMTP_USER') + ">", - subject: "HabitRPG Error", - host: nconf.get('SMTP_HOST'), - port: nconf.get('SMTP_PORT'), - tls: nconf.get('SMTP_TLS'), - username: nconf.get('SMTP_USER'), - password: nconf.get('SMTP_PASS'), - level: 'error' - }); - } - } else { - logger.add(winston.transports.Console, {colorize:true}); - logger.add(winston.transports.File, {filename: 'habitrpg.log'}); - } -} - -// A custom log function that wraps Winston. Makes it easy to instrument code -// and still possible to replace Winston in the future. -module.exports.log = function(/* variable args */) { - if (logger) - logger.log.apply(logger, arguments); -}; - -module.exports.info = function(/* variable args */) { - if (logger) - logger.info.apply(logger, arguments); -}; - -module.exports.warn = function(/* variable args */) { - if (logger) - logger.warn.apply(logger, arguments); -}; - -module.exports.error = function(/* variable args */) { - if (logger) - logger.error.apply(logger, arguments); -}; - -module.exports.loggly = function(/* variable args */){ - if (loggly) - loggly.log.apply(loggly, arguments); -}; diff --git a/website/src/middleware.js b/website/src/middleware.js deleted file mode 100644 index f597d0546c..0000000000 --- a/website/src/middleware.js +++ /dev/null @@ -1,210 +0,0 @@ -var nconf = require('nconf'); -var _ = require('lodash'); -var fs = require('fs'); -var path = require('path'); -var User = require('./models/user').model -var limiter = require('connect-ratelimit'); -var logging = require('./logging'); -var domainMiddleware = require('domain-middleware'); -var cluster = require('cluster'); -var i18n = require('./i18n.js'); -var shared = require('../../common'); -var request = require('request'); -var os = require('os'); -var moment = require('moment'); -var utils = require('./utils'); - -module.exports.apiThrottle = function(app) { - if (nconf.get('NODE_ENV') !== 'production') return; - app.use(limiter({ - end:false, - catagories:{ - normal: { - // 2 req/s, but split as minutes - totalRequests: 80, - every: 60000 - } - } - })).use(function(req,res,next){ - //logging.info(res.ratelimit); - if (res.ratelimit.exceeded) return res.json(429,{err:'Rate limit exceeded'}); - next(); - }); -} - -module.exports.domainMiddleware = function(server,mongoose) { - if (nconf.get('NODE_ENV')=='production') { - var mins = 3, // how often to run this check - useAvg = false, // use average over 3 minutes, or simply the last minute's report - url = 'https://api.newrelic.com/v2/applications/'+nconf.get('NEW_RELIC_APPLICATION_ID')+'/metrics/data.json?names[]=Apdex&values[]=score'; - setInterval(function(){ - // see https://docs.newrelic.com/docs/apm/apis/api-v2-examples/average-response-time-examples-api-v2, https://rpm.newrelic.com/api/explore/applications/data - request({ - url: useAvg ? url+'&from='+moment().subtract({minutes:mins}).utc().format()+'&to='+moment().utc().format()+'&summarize=true' : url, - headers: {'X-Api-Key': nconf.get('NEW_RELIC_API_KEY')} - }, function(err, response, body){ - var ts = JSON.parse(body).metric_data.metrics[0].timeslices, - score = ts[ts.length-1].values.score, - apdexBad = score < .75 || score == 1, - memory = os.freemem() / os.totalmem(), - memoryHigh = false; //memory < 0.1; - if (apdexBad || memoryHigh) throw "[Memory Leak] Apdex="+score+" Memory="+parseFloat(memory).toFixed(3)+" Time="+moment().format(); - }) - }, mins*60*1000); - } - - return domainMiddleware({ - server: { - close:function(){ - server.close(); - mongoose.connection.close(); - } - }, - killTimeout: 10000 - }); -} - -module.exports.errorHandler = function(err, req, res, next) { - //res.locals.domain.emit('error', err); - // when we hit an error, send it to admin as an email. If no ADMIN_EMAIL is present, just send it to yourself (SMTP_USER) - var stack = (err.stack ? err.stack : err.message ? err.message : err) + - "\n ----------------------------\n" + - "\n\noriginalUrl: " + req.originalUrl + - "\n\nauth: " + req.headers['x-api-user'] + ' | ' + req.headers['x-api-key'] + - "\n\nheaders: " + JSON.stringify(req.headers) + - "\n\nbody: " + JSON.stringify(req.body) + - (res.locals.ops ? "\n\ncompleted ops: " + JSON.stringify(res.locals.ops) : ""); - logging.error(stack); - /*logging.loggly({ - error: "Uncaught error", - stack: (err.stack || err.message || err), - body: req.body, headers: req.header, - auth: req.headers['x-api-user'], - originalUrl: req.originalUrl - });*/ - var message = err.message ? err.message : err; - message = (message.length < 200) ? message : message.substring(0,100) + message.substring(message.length-100,message.length); - res.json(500,{err:message}); //res.end(err.message); -} - - -module.exports.forceSSL = function(req, res, next){ - var baseUrl = nconf.get("BASE_URL"); - // Note x-forwarded-proto is used by Heroku & nginx, you'll have to do something different if you're not using those - if (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-proto'] !== 'https' - && nconf.get('NODE_ENV') === 'production' - && baseUrl.indexOf('https') === 0) { - return res.redirect(baseUrl + req.url); - } - next() -} - -module.exports.cors = function(req, res, next) { - res.header("Access-Control-Allow-Origin", req.headers.origin || "*"); - res.header("Access-Control-Allow-Methods", "OPTIONS,GET,POST,PUT,HEAD,DELETE"); - res.header("Access-Control-Allow-Headers", "Content-Type,Accept,Content-Encoding,X-Requested-With,x-api-user,x-api-key"); - if (req.method === 'OPTIONS') return res.send(200); - return next(); -}; - -var siteVersion = 1; - -module.exports.forceRefresh = function(req, res, next){ - if(req.query.siteVersion && req.query.siteVersion != siteVersion){ - return res.json(400, {needRefresh: true}); - } - - return next(); -}; - -var buildFiles = []; - -var walk = function(folder){ - var res = fs.readdirSync(folder); - - res.forEach(function(fileName){ - file = folder + '/' + fileName; - if(fs.statSync(file).isDirectory()){ - walk(file); - }else{ - var relFolder = path.relative(path.join(__dirname, "/../build"), folder); - var old = fileName.replace(/-.{8}(\.[\d\w]+)$/, '$1'); - - if(relFolder){ - old = relFolder + '/' + old; - fileName = relFolder + '/' + fileName; - } - - buildFiles[old] = fileName - } - }); -} - -walk(path.join(__dirname, "/../build")); - -var getBuildUrl = function(url){ - if(buildFiles[url]) return '/' + buildFiles[url]; - - return '/' + url; -} - -var manifestFiles = require("../public/manifest.json"); - -var getManifestFiles = function(page){ - var files = manifestFiles[page]; - - if(!files) throw new Error("Page not found!"); - - var code = ''; - - if(nconf.get('NODE_ENV') === 'production'){ - code += ''; - code += ''; - }else{ - _.each(files.css, function(file){ - code += ''; - }); - _.each(files.js, function(file){ - code += ''; - }); - } - - return code; -} - -module.exports.locals = function(req, res, next) { - var language = _.find(i18n.avalaibleLanguages, {code: req.language}); - var isStaticPage = req.url.split('/')[1] === 'static'; // If url contains '/static/' - - // Load moment.js language file only when not on static pages - language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined); - - var tavern = require('./models/group').tavern; - var envVars = _.pick(nconf.get(), 'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY'.split(' ')); - res.locals.habitrpg = _.merge(envVars, { - IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')), - getManifestFiles: getManifestFiles, - getBuildUrl: getBuildUrl, - avalaibleLanguages: i18n.avalaibleLanguages, - language: language, - isStaticPage: isStaticPage, - translations: i18n.translations[language.code], - t: function(){ // stringName and vars are the allowed parameters - var args = Array.prototype.slice.call(arguments, 0); - args.push(language.code); - return shared.i18n.t.apply(null, args); - }, - siteVersion: siteVersion, - Content: shared.content, - mods: require('./models/user').mods, - tavern: tavern, // for world boss - worldDmg: (tavern && tavern.quest && tavern.quest.extra && tavern.quest.extra.worldDmg) || {} - }); - - // Put query-string party invitations into session to be handled later - try{ - req.session.partyInvite = JSON.parse(utils.decrypt(req.query.partyInvite)) - } catch(e){} - - next(); -} diff --git a/website/src/models/challenge.js b/website/src/models/challenge.js deleted file mode 100644 index d44b798bc0..0000000000 --- a/website/src/models/challenge.js +++ /dev/null @@ -1,120 +0,0 @@ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var TaskSchemas = require('./task'); - -var ChallengeSchema = new Schema({ - _id: {type: String, 'default': shared.uuid}, - name: String, - shortName: String, - description: String, - official: {type: Boolean,'default':false}, - habits: [TaskSchemas.HabitSchema], - dailys: [TaskSchemas.DailySchema], - todos: [TaskSchemas.TodoSchema], - rewards: [TaskSchemas.RewardSchema], - leader: {type: String, ref: 'User'}, - group: {type: String, ref: 'Group'}, - timestamp: {type: Date, 'default': Date.now}, - members: [{type: String, ref: 'User'}], - memberCount: {type: Number, 'default': 0}, - prize: {type: Number, 'default': 0} -}); - -ChallengeSchema.virtual('tasks').get(function () { - var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); - var tasks = _.object(_.pluck(tasks,'id'), tasks); - return tasks; -}); - -ChallengeSchema.methods.toJSON = function(){ - var doc = this.toObject(); - doc._isMember = this._isMember; - return doc; -} - -// -------------- -// Syncing logic -// -------------- - -function syncableAttrs(task) { - var t = (task.toObject) ? task.toObject() : task; // lodash doesn't seem to like _.omit on EmbeddedDocument - // only sync/compare important attrs - var omitAttrs = 'challenge history tags completed streak notes'.split(' '); - if (t.type != 'reward') omitAttrs.push('value'); - return _.omit(t, omitAttrs); -} - -/** - * Compare whether any changes have been made to tasks. If so, we'll want to sync those changes to subscribers - */ -function comparableData(obj) { - return JSON.stringify( - _(obj.habits.concat(obj.dailys).concat(obj.todos).concat(obj.rewards)) - .sortBy('id') // we don't want to update if they're sort-order is different - .transform(function(result, task){ - result.push(syncableAttrs(task)); - }) - .value()) -} - -ChallengeSchema.methods.isOutdated = function(newData) { - return comparableData(this) !== comparableData(newData); -} - -/** - * Syncs all new tasks, deleted tasks, etc to the user object - * @param user - * @return nothing, user is modified directly. REMEMBER to save the user! - */ -ChallengeSchema.methods.syncToUser = function(user, cb) { - if (!user) return; - var self = this; - self.shortName = self.shortName || self.name; - - // Add challenge to user.challenges - if (!_.contains(user.challenges, self._id)) { - user.challenges.push(self._id); - } - - // Sync tags - var tags = user.tags || []; - var i = _.findIndex(tags, {id: self._id}) - if (~i) { - if (tags[i].name !== self.shortName) { - // update the name - it's been changed since - user.tags[i].name = self.shortName; - } - } else { - user.tags.push({ - id: self._id, - name: self.shortName, - challenge: true - }); - } - - // Sync new tasks and updated tasks - _.each(self.tasks, function(task){ - var list = user[task.type+'s']; - var userTask = user.tasks[task.id] || (list.push(syncableAttrs(task)), list[list.length-1]); - if (!userTask.notes) userTask.notes = task.notes; // don't override the notes, but provide it if not provided - userTask.challenge = {id:self._id}; - userTask.tags = userTask.tags || {}; - userTask.tags[self._id] = true; - _.merge(userTask, syncableAttrs(task)); - }) - - // Flag deleted tasks as "broken" - _.each(user.tasks, function(task){ - if (task.challenge && task.challenge.id==self._id && !self.tasks[task.id]) { - task.challenge.broken = 'TASK_DELETED'; - } - }) - - user.save(cb); -}; - - -module.exports.schema = ChallengeSchema; -module.exports.model = mongoose.model("Challenge", ChallengeSchema); diff --git a/website/src/models/coupon.js b/website/src/models/coupon.js deleted file mode 100644 index 3b0afb3f2a..0000000000 --- a/website/src/models/coupon.js +++ /dev/null @@ -1,59 +0,0 @@ -var mongoose = require("mongoose"); -var shared = require('../../../common'); -var _ = require('lodash'); -var async = require('async'); -var cc = require('coupon-code'); -var autoinc = require('mongoose-id-autoinc'); - -var CouponSchema = new mongoose.Schema({ - _id: {type: String, 'default': cc.generate}, - event: {type:String, enum:['wondercon','google_6mo']}, - user: {type: 'String', ref: 'User'} -}); - -CouponSchema.statics.generate = function(event, count, callback) { - async.times(count, function(n,cb){ - mongoose.model('Coupon').create({event: event}, cb); - }, callback); -} - -CouponSchema.statics.apply = function(user, code, next){ - async.auto({ - get_coupon: function (cb) { - mongoose.model('Coupon').findById(cc.validate(code), cb); - }, - apply_coupon: ['get_coupon', function (cb, results) { - if (!results.get_coupon) return cb("Invalid coupon code"); - if (results.get_coupon.user) return cb("Coupon already used"); - switch (results.get_coupon.event) { - case 'wondercon': - user.items.gear.owned.eyewear_special_wondercon_red = true; - user.items.gear.owned.eyewear_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_black = true; - user.items.gear.owned.back_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_red = true; - user.items.gear.owned.body_special_wondercon_black = true; - user.items.gear.owned.body_special_wondercon_gold = true; - user.extra = {signupEvent: 'wondercon'}; - user.save(cb); - break; - } - }], - expire_coupon: ['apply_coupon', function (cb, results) { - results.get_coupon.user = user._id; - results.get_coupon.save(cb); - }] - }, function(err, results){ - if (err) return next(err); - next(null,results.apply_coupon[0]); - }) -} - -CouponSchema.plugin(autoinc.plugin, { - model: 'Coupon', - field: 'seq' -}); - -module.exports.schema = CouponSchema; -module.exports.model = mongoose.model("Coupon", CouponSchema); - diff --git a/website/src/models/group.js b/website/src/models/group.js deleted file mode 100644 index d776377488..0000000000 --- a/website/src/models/group.js +++ /dev/null @@ -1,370 +0,0 @@ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var async = require('async'); -var logging = require('../logging'); - -var GroupSchema = new Schema({ - _id: {type: String, 'default': shared.uuid}, - name: String, - description: String, - leader: {type: String, ref: 'User'}, - members: [{type: String, ref: 'User'}], - invites: [{type: String, ref: 'User'}], - type: {type: String, "enum": ['guild', 'party']}, - privacy: {type: String, "enum": ['private', 'public'], 'default':'private'}, - //_v: {type: Number,'default': 0}, - chat: Array, - /* - # [{ - # timestamp: Date - # user: String - # text: String - # contributor: String - # uuid: String - # id: String - # }] - */ - leaderOnly: { // restrict group actions to leader (members can't do them) - challenges: {type:Boolean, 'default':false}, - //invites: {type:Boolean, 'default':false} - }, - memberCount: {type: Number, 'default': 0}, - challengeCount: {type: Number, 'default': 0}, - balance: Number, - logo: String, - leaderMessage: String, - challenges: [{type:'String', ref:'Challenge'}], // do we need this? could depend on back-ref instead (Challenge.find({group:GID})) - quest: { - key: String, - active: {type:Boolean, 'default':false}, - leader: {type:String, ref:'User'}, - progress:{ - hp: Number, - collect: {type:Schema.Types.Mixed, 'default':{}}, // {feather: 5, ingot: 3} - rage: Number, // limit break / "energy stored in shell", for explosion-attacks - }, - - //Shows boolean for each party-member who has accepted the quest. Eg {UUID: true, UUID: false}. Once all users click - //'Accept', the quest begins. If a false user waits too long, probably a good sign to prod them or boot them. - //TODO when booting user, remove from .joined and check again if we can now start the quest - members: Schema.Types.Mixed, - extra: Schema.Types.Mixed - } -}, { - strict: 'throw', - minimize: false // So empty objects are returned -}); - -/** - * Derby duplicated stuff. This is a temporary solution, once we're completely off derby we'll run an mongo migration - * to remove duplicates, then take these fucntions out - */ -function removeDuplicates(doc){ - // Remove duplicate members - if (doc.members) { - var uniqMembers = _.uniq(doc.members); - if (uniqMembers.length != doc.members.length) { - doc.members = uniqMembers; - } - } -} - -// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate() -// @see https://github.com/LearnBoost/mongoose/issues/964 -GroupSchema.pre('save', function(next){ - removeDuplicates(this); - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); - next(); -}) - -GroupSchema.methods.toJSON = function(){ - var doc = this.toObject(); - removeDuplicates(doc); - doc._isMember = this._isMember; - - //fix(groups): temp fix to remove chat entries stored as strings (not sure why that's happening..). - // Required as angular 1.3 is strict on dupes, and no message.id to `track by` - _.remove(doc.chat,function(msg){return !msg.id}); - - // @see pre('save') comment above - this.memberCount = _.size(this.members); - this.challengeCount = _.size(this.challenges); - - return doc; -} - -var chatDefaults = module.exports.chatDefaults = function(msg,user){ - var message = { - id: shared.uuid(), - text: msg, - timestamp: +new Date, - likes: {}, - flags: {}, - flagCount: 0 - }; - if (user) { - _.defaults(message, { - uuid: user._id, - contributor: user.contributor && user.contributor.toObject(), - backer: user.backer && user.backer.toObject(), - user: user.profile.name - }); - } else { - message.uuid = 'system'; - } - return message; -} -GroupSchema.methods.sendChat = function(message, user){ - var group = this; - group.chat.unshift(chatDefaults(message,user)); - group.chat.splice(200); - // Kick off chat notifications in the background. - var lastSeenUpdate = {$set:{}, $inc:{_v:1}}; - lastSeenUpdate['$set']['newMessages.'+group._id] = {name:group.name,value:true}; - if (group._id == 'habitrpg') { - // TODO For Tavern, only notify them if their name was mentioned - // var profileNames = [] // get usernames from regex of @xyz. how to handle space-delimited profile names? - // User.update({'profile.name':{$in:profileNames}},lastSeenUpdate,{multi:true}).exec(); - } else { - mongoose.model('User').update({_id:{$in:group.members, $ne: user ? user._id : ''}},lastSeenUpdate,{multi:true}).exec(); - } -} - -var cleanQuestProgress = function(merge){ - var clean = { - key: null, - progress: { - up: 0, - down: 0, - collect: {} - }, - completed: null - }; - merge = merge || {progress:{}}; - _.merge(clean, _.omit(merge,'progress')); - _.merge(clean.progress, merge.progress); - return clean; -} -GroupSchema.statics.cleanQuestProgress = cleanQuestProgress; - -// Participants: Grant rewards & achievements, finish quest -GroupSchema.methods.finishQuest = function(quest, cb) { - var group = this; - var questK = quest.key; - var updates = {$inc:{},$set:{}}; - - updates['$inc']['achievements.quests.' + questK] = 1; - updates['$inc']['stats.gp'] = +quest.drop.gp; - updates['$inc']['stats.exp'] = +quest.drop.exp; - updates['$inc']['_v'] = 1; - if (group._id == 'habitrpg') { - updates['$set']['party.quest.completed'] = questK; // Just show the notif - } else { - updates['$set']['party.quest'] = cleanQuestProgress({completed: questK}); // clear quest progress - } - - _.each(quest.drop.items, function(item){ - var dropK = item.key; - switch (item.type) { - case 'gear': - // TODO This means they can lose their new gear on death, is that what we want? - updates['$set']['items.gear.owned.'+dropK] = true; - break; - case 'eggs': - case 'food': - case 'hatchingPotions': - case 'quests': - updates['$inc']['items.'+item.type+'.'+dropK] = _.where(quest.drop.items,{type:item.type,key:item.key}).length; - break; - case 'pets': - updates['$set']['items.pets.'+dropK] = 5; - break; - case 'mounts': - updates['$set']['items.mounts.'+dropK] = true; - break; - } - }) - var q = group._id === 'habitrpg' ? {} : {_id:{$in:_.keys(group.quest.members)}}; - group.quest = {};group.markModified('quest'); - mongoose.model('User').update(q, updates, {multi:true}, cb); -} - -// FIXME this is a temporary measure, we need to remove quests from users when they traverse parties -function isOnQuest(user,progress,group){ - return group && progress && user.party.quest.key && user.party.quest.key == group.quest.key; -} - -GroupSchema.statics.collectQuest = function(user, progress, cb) { - this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ - if (!isOnQuest(user,progress,group)) return cb(null); - var quest = shared.content.quests[group.quest.key]; - - _.each(progress.collect,function(v,k){ - group.quest.progress.collect[k] += v; - }); - - var foundText = _.reduce(progress.collect, function(m,v,k){ - m.push(v + ' ' + quest.collect[k].text('en')); - return m; - }, []); - foundText = foundText ? foundText.join(', ') : 'nothing'; - group.sendChat("`" + user.profile.name + " found "+foundText+".`"); - group.markModified('quest.progress.collect'); - - // Still needs completing - if (_.find(shared.content.quests[group.quest.key].collect, function(v,k){ - return group.quest.progress.collect[k] < v.count; - })) return group.save(cb); - - async.series([ - function(cb2){ - group.finishQuest(quest,cb2); - }, - function(cb2){ - group.sendChat('`All items found! Party has received their rewards.`'); - group.save(cb2); - } - ],cb); - }) -} - -// to set a boss: `db.groups.update({_id:'habitrpg'},{$set:{quest:{key:'dilatory',active:true,progress:{hp:1000,rage:1500}}}})` -module.exports.tavern = {}; -var tavernQ = {_id:'habitrpg','quest.key':{$ne:null}}; -process.nextTick(function(){ - mongoose.model('Group').findOne(tavernQ,function(err,tavern){ - module.exports.tavern = tavern; - }); -}) -GroupSchema.statics.tavernBoss = function(user,progress) { - if (!progress) return; - - // hack: prevent crazy damage to world boss - var dmg = Math.min(900, Math.abs(progress.up||0)), - rage = -Math.min(900, Math.abs(progress.down||0)); - - async.waterfall([ - function(cb){ - mongoose.model('Group').findOne(tavernQ,cb); - }, - function(tavern,cb){ - if (!(tavern && tavern.quest && tavern.quest.key)) return cb(true); - module.exports.tavern = tavern; - - var quest = shared.content.quests[tavern.quest.key]; - if (tavern.quest.progress.hp <= 0) { - tavern.sendChat(quest.completionChat('en')); - tavern.finishQuest(quest, function(){}); - tavern.save(cb); - module.exports.tavern = undefined; - } else { - // Deal damage. Note a couple things here, str & def are calculated. If str/def are defined in the database, - // use those first - which allows us to update the boss on the go if things are too easy/hard. - if (!tavern.quest.extra) tavern.quest.extra = {}; - tavern.quest.progress.hp -= dmg / (tavern.quest.extra.def || quest.boss.def); - tavern.quest.progress.rage -= rage * (tavern.quest.extra.str || quest.boss.str); - if (tavern.quest.progress.rage >= quest.boss.rage.value) { - if (!tavern.quest.extra.worldDmg) tavern.quest.extra.worldDmg = {}; - var wd = tavern.quest.extra.worldDmg; - // var scene = wd.tavern ? wd.stables ? wd.market ? false : 'market' : 'stables' : 'tavern'; // Dilatory attacks tavern, stables, market - var scene = wd.stables ? wd.bailey ? wd.guide ? false : 'guide' : 'bailey' : 'stables'; // Stressbeast attacks stables, Bailey, Justin - if (!scene) { - tavern.sendChat('`'+quest.boss.name('en')+' tries to unleash '+quest.boss.rage.title('en')+', but is too tired.`'); - tavern.quest.progress.rage = 0 //quest.boss.rage.value; - } else { - tavern.sendChat(quest.boss.rage[scene]('en')); - tavern.quest.extra.worldDmg[scene] = true; - tavern.quest.extra.worldDmg.recent = scene; - tavern.markModified('quest.extra.worldDmg'); - tavern.quest.progress.rage = 0; - tavern.quest.progress.hp += (quest.boss.rage.healing * tavern.quest.progress.hp); - } - } - if ((tavern.quest.progress.hp < quest.boss.desperation.threshold) && !tavern.quest.extra.desperate) { - tavern.sendChat(quest.boss.desperation.text('en')); - tavern.quest.extra.desperate = true; - tavern.quest.extra.def = quest.boss.desperation.def; - tavern.quest.extra.str = quest.boss.desperation.str; - tavern.markModified('quest.extra'); - } - tavern.save(cb); - } - } - ],function(err,res){ - if (err === true) return; // no current quest - if (err) return logging.error(err); - dmg = rage = null; - }) -} - -GroupSchema.statics.bossQuest = function(user, progress, cb) { - this.findOne({type: 'party', members: {'$in': [user._id]}},function(err, group){ - if (!isOnQuest(user,progress,group)) return cb(null); - var quest = shared.content.quests[group.quest.key]; - if (!progress || !quest) return cb(null); // FIXME why is this ever happening, progress should be defined at this point - var down = progress.down * quest.boss.str; // multiply by boss strength - - group.quest.progress.hp -= progress.up; - group.sendChat("`" + user.profile.name + " attacks " + quest.boss.name('en') + " for " + (progress.up.toFixed(1)) + " damage, " + quest.boss.name('en') + " attacks party for " + Math.abs(down).toFixed(1) + " damage.`"); //TODO Create a party preferred language option so emits like this can be localized - - // If boss has Rage, increment Rage as well - if (quest.boss.rage) { - group.quest.progress.rage += Math.abs(down); - if (group.quest.progress.rage >= quest.boss.rage.value) { - group.sendChat(quest.boss.rage.effect('en')); - group.quest.progress.rage = 0; - if (quest.boss.rage.healing) group.quest.progress.hp += (group.quest.progress.hp * quest.boss.rage.healing); //TODO To make Rage effects more expandable, let's turn these into functions in quest.boss.rage - if (group.quest.progress.hp > quest.boss.hp) group.quest.progress.hp = quest.boss.hp; - } - } - // Everyone takes damage - var series = [ - function(cb2){ - mongoose.models.User.update({_id:{$in: _.keys(group.quest.members)}}, {$inc:{'stats.hp':down, _v:1}}, {multi:true}, cb2); - } - ] - - // Boss slain, finish quest - if (group.quest.progress.hp <= 0) { - group.sendChat('`You defeated ' + quest.boss.name('en') + '! Questing party members receive the rewards of victory.`'); - // Participants: Grant rewards & achievements, finish quest - series.push(function(cb2){ - group.finishQuest(quest,cb2); - }); - } - - series.push(function(cb2){group.save(cb2)}); - async.series(series,cb); - }) -} - -GroupSchema.methods.toJSON = function() { - var doc = this.toObject(); - if(doc.chat){ - doc.chat.forEach(function(msg){ - msg.flags = {}; - }); - } - - return doc; -}; - - -module.exports.schema = GroupSchema; -var Group = module.exports.model = mongoose.model("Group", GroupSchema); - -// initialize tavern if !exists (fresh installs) -Group.count({_id:'habitrpg'},function(err,ct){ - if (ct > 0) return; - new Group({ - _id: 'habitrpg', - chat: [], - leader: '9', - name: 'HabitRPG', - type: 'guild', - privacy:'public' - }).save(); -}) diff --git a/website/src/models/task.js b/website/src/models/task.js deleted file mode 100644 index 2646263e80..0000000000 --- a/website/src/models/task.js +++ /dev/null @@ -1,104 +0,0 @@ -// User.js -// ======= -// Defines the user data model (schema) for use via the API. - -// Dependencies -// ------------ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); - -// Task Schema -// ----------- - -var TaskSchema = { - //_id:{type: String,'default': helpers.uuid}, - id: {type: String,'default': shared.uuid}, - dateCreated: {type:Date, 'default':Date.now}, - text: String, - notes: {type: String, 'default': ''}, - tags: {type: Schema.Types.Mixed, 'default': {}}, //{ "4ddf03d9-54bd-41a3-b011-ca1f1d2e9371" : true }, - value: {type: Number, 'default': 0}, // redness - priority: {type: Number, 'default': '1'}, - attribute: {type: String, 'default': "str", enum: ['str','con','int','per']}, - challenge: { - id: {type: 'String', ref:'Challenge'}, - broken: String, // CHALLENGE_DELETED, TASK_DELETED, UNSUBSCRIBED, CHALLENGE_CLOSED - winner: String // user.profile.name - // group: {type: 'Strign', ref: 'Group'} // if we restore this, rename `id` above to `challenge` - } -}; - -var HabitSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'habit'}, - history: Array, // [{date:Date, value:Number}], // this causes major performance problems - up: {type: Boolean, 'default': true}, - down: {type: Boolean, 'default': true} - }, TaskSchema) - , { _id: false, minimize:false } -); - -var collapseChecklist = {type:Boolean, 'default':false}; -var checklist = [{ - completed:{type:Boolean,'default':false}, - text: String, - _id:false, - id: {type:String,'default':shared.uuid} -}]; - -var DailySchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'daily'}, - history: Array, - completed: {type: Boolean, 'default': false}, - repeat: { - m: {type: Boolean, 'default': true}, - t: {type: Boolean, 'default': true}, - w: {type: Boolean, 'default': true}, - th: {type: Boolean, 'default': true}, - f: {type: Boolean, 'default': true}, - s: {type: Boolean, 'default': true}, - su: {type: Boolean, 'default': true} - }, - collapseChecklist:collapseChecklist, - checklist:checklist, - streak: {type: Number, 'default': 0} - }, TaskSchema) - , { _id: false, minimize:false } -) - -var TodoSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'todo'}, - completed: {type: Boolean, 'default': false}, - dateCompleted: Date, - date: String, // due date for todos // FIXME we're getting parse errors, people have stored as "today" and "3/13". Need to run a migration & put this back to type: Date - collapseChecklist:collapseChecklist, - checklist:checklist - }, TaskSchema) - , { _id: false, minimize:false } -); - -var RewardSchema = new Schema( - _.defaults({ - type: {type:String, 'default': 'reward'} - }, TaskSchema) - , { _id: false, minimize:false } -); - -/** - * Workaround for bug when _id & id were out of sync, we can remove this after challenges has been running for a while - */ -//_.each([HabitSchema, DailySchema, TodoSchema, RewardSchema], function(schema){ -// schema.post('init', function(doc){ -// if (!doc.id && doc._id) doc.id = doc._id; -// }) -//}) - -module.exports.TaskSchema = TaskSchema; -module.exports.HabitSchema = HabitSchema; -module.exports.DailySchema = DailySchema; -module.exports.TodoSchema = TodoSchema; -module.exports.RewardSchema = RewardSchema; diff --git a/website/src/models/user.js b/website/src/models/user.js deleted file mode 100644 index d1445f79e2..0000000000 --- a/website/src/models/user.js +++ /dev/null @@ -1,533 +0,0 @@ -// User.js -// ======= -// Defines the user data model (schema) for use via the API. - -// Dependencies -// ------------ -var mongoose = require("mongoose"); -var Schema = mongoose.Schema; -var shared = require('../../../common'); -var _ = require('lodash'); -var TaskSchemas = require('./task'); -var Challenge = require('./challenge').model; -var moment = require('moment'); - -// User Schema -// ----------- - -var UserSchema = new Schema({ - // ### UUID and API Token - _id: { - type: String, - 'default': shared.uuid - }, - apiToken: { - type: String, - 'default': shared.uuid - }, - - // ### Mongoose Update Object - // We want to know *every* time an object updates. Mongoose uses __v to designate when an object contains arrays which - // have been updated (http://goo.gl/gQLz41), but we want *every* update - _v: { type: Number, 'default': 0 }, - achievements: { - originalUser: Boolean, - helpedHabit: Boolean, - ultimateGear: Boolean, - beastMaster: Boolean, - beastMasterCount: Number, - mountMaster: Boolean, - mountMasterCount: Number, - triadBingo: Boolean, - triadBingoCount: Number, - veteran: Boolean, - snowball: Number, - spookDust: Number, - streak: Number, - challenges: Array, - quests: Schema.Types.Mixed, - rebirths: Number, - rebirthLevel: Number, - perfect: Number, - habitBirthday: Boolean, // TODO: Deprecate this. Superseded by habitBirthdays - habitBirthdays: Number, - valentine: Number, - costumeContest: Boolean, - nye: Number - }, - auth: { - blocked: Boolean, - facebook: Schema.Types.Mixed, - local: { - email: String, - hashed_password: String, - salt: String, - username: String - }, - timestamps: { - created: {type: Date,'default': Date.now}, - loggedin: {type: Date,'default': Date.now} - } - }, - - backer: { - tier: Number, - npc: String, - tokensApplied: Boolean - }, - - contributor: { - level: Number, // 1-9, see https://trello.com/c/wkFzONhE/277-contributor-gear https://github.com/HabitRPG/habitrpg/issues/3801 - admin: Boolean, - sudo: Boolean, - text: String, // Artisan, Friend, Blacksmith, etc - contributions: String, // a markdown textarea to list their contributions + links - critical: String - }, - - balance: {type: Number, 'default':0}, - filters: {type: Schema.Types.Mixed, 'default': {}}, - - purchased: { - ads: {type: Boolean, 'default': false}, - skin: {type: Schema.Types.Mixed, 'default': {}}, // eg, {skeleton: true, pumpkin: true, eb052b: true} - hair: {type: Schema.Types.Mixed, 'default': {}}, - shirt: {type: Schema.Types.Mixed, 'default': {}}, - background: {type: Schema.Types.Mixed, 'default': {}}, - txnCount: {type: Number, 'default':0}, - mobileChat: Boolean, - plan: { - planId: String, - paymentMethod: String, //enum: ['Paypal','Stripe', 'Gift', '']} - customerId: String, - dateCreated: Date, - dateTerminated: Date, - dateUpdated: Date, - extraMonths: {type:Number, 'default':0}, - gemsBought: {type: Number, 'default': 0}, - mysteryItems: {type: Array, 'default': []}, - consecutive: { - count: {type:Number, 'default':0}, - offset: {type:Number, 'default':0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0 - gemCapExtra: {type:Number, 'default':0}, - trinkets: {type:Number, 'default':0} - } - } - }, - - flags: { - customizationsNotification: {type: Boolean, 'default': false}, - showTour: {type: Boolean, 'default': true}, - dropsEnabled: {type: Boolean, 'default': false}, - itemsEnabled: {type: Boolean, 'default': false}, - newStuff: {type: Boolean, 'default': false}, - rewrite: {type: Boolean, 'default': true}, - partyEnabled: Boolean, // FIXME do we need this? - contributor: Boolean, - classSelected: {type: Boolean, 'default': false}, - mathUpdates: Boolean, - rebirthEnabled: {type: Boolean, 'default': false}, - freeRebirth: {type: Boolean, 'default': false}, - levelDrops: {type:Schema.Types.Mixed, 'default':{}}, - chatRevoked: Boolean, - // Used to track the status of recapture emails sent to each user, - // can be 0 - no email sent - 1, 2, 3 or 4 - 4 means no more email will be sent to the user - recaptureEmailsPhase: {type: Number, 'default': 0}, - communityGuidelinesAccepted: {type: Boolean, 'default': false} - }, - history: { - exp: Array, // [{date: Date, value: Number}], // big peformance issues if these are defined - todos: Array //[{data: Date, value: Number}] // big peformance issues if these are defined - }, - - // FIXME remove? - invitations: { - guilds: {type: Array, 'default': []}, - party: Schema.Types.Mixed - }, - items: { - gear: { - owned: _.transform(shared.content.gear.flat, function(m,v,k){ - m[v.key] = {type: Boolean}; - if (v.key.match(/[weapon|armor|head|shield]_warrior_0/)) - m[v.key]['default'] = true; - }), - - equipped: { - weapon: {type: String, 'default': 'weapon_warrior_0'}, - armor: {type: String, 'default': 'armor_base_0'}, - head: {type: String, 'default': 'head_base_0'}, - shield: {type: String, 'default': 'shield_base_0'}, - back: String, - headAccessory: String, - eyewear: String, - body: String - }, - costume: { - weapon: {type: String, 'default': 'weapon_base_0'}, - armor: {type: String, 'default': 'armor_base_0'}, - head: {type: String, 'default': 'head_base_0'}, - shield: {type: String, 'default': 'shield_base_0'}, - back: String, - headAccessory: String, - eyewear: String, - body: String - }, - }, - - special:{ - snowball: {type: Number, 'default': 0}, - spookDust: {type: Number, 'default': 0}, - valentine: Number, - valentineReceived: Array, // array of strings, by sender name - nye: Number, - nyeReceived: Array - }, - - // -------------- Animals ------------------- - // Complex bit here. The result looks like: - // pets: { - // 'Wolf-Desert': 0, // 0 means does not own - // 'PandaCub-Red': 10, // Number represents "Growth Points" - // etc... - // } - pets: - _.defaults( - // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, function(m,v,k){ m[k] = Number; }), - // Then add quest pets - _.transform(shared.content.questPets, function(m,v,k){ m[k] = Number; }), - // Then add additional pets (backer, contributor) - _.transform(shared.content.specialPets, function(m,v,k){ m[k] = Number; }) - ), - currentPet: String, // Cactus-Desert - - // eggs: { - // 'PandaCub': 0, // 0 indicates "doesn't own" - // 'Wolf': 5 // Number indicates "stacking" - // } - eggs: _.transform(shared.content.eggs, function(m,v,k){ m[k] = Number; }), - - // hatchingPotions: { - // 'Desert': 0, // 0 indicates "doesn't own" - // 'CottonCandyBlue': 5 // Number indicates "stacking" - // } - hatchingPotions: _.transform(shared.content.hatchingPotions, function(m,v,k){ m[k] = Number; }), - - // Food: { - // 'Watermelon': 0, // 0 indicates "doesn't own" - // 'RottenMeat': 5 // Number indicates "stacking" - // } - food: _.transform(shared.content.food, function(m,v,k){ m[k] = Number; }), - - // mounts: { - // 'Wolf-Desert': true, - // 'PandaCub-Red': false, - // etc... - // } - mounts: _.defaults( - // First transform to a 1D eggs/potions mapping - _.transform(shared.content.pets, function(m,v,k){ m[k] = Boolean; }), - // Then add quest pets - _.transform(shared.content.questPets, function(m,v,k){ m[k] = Boolean; }), - // Then add additional pets (backer, contributor) - _.transform(shared.content.specialMounts, function(m,v,k){ m[k] = Boolean; }) - ), - currentMount: String, - - // Quests: { - // 'boss_0': 0, // 0 indicates "doesn't own" - // 'collection_honey': 5 // Number indicates "stacking" - // } - quests: _.transform(shared.content.quests, function(m,v,k){ m[k] = Number; }), - - lastDrop: { - date: {type: Date, 'default': Date.now}, - count: {type: Number, 'default': 0} - } - }, - - lastCron: {type: Date, 'default': Date.now}, - - // {GROUP_ID: Boolean}, represents whether they have unseen chat messages - newMessages: {type: Schema.Types.Mixed, 'default': {}}, - - party: { - // id // FIXME can we use a populated doc instead of fetching party separate from user? - order: {type:String, 'default':'level'}, - orderAscending: {type:String, 'default':'ascending'}, - quest: { - key: String, - progress: { - up: {type: Number, 'default': 0}, - down: {type: Number, 'default': 0}, - collect: {type: Schema.Types.Mixed, 'default': {}} // {feather:1, ingot:2} - }, - completed: String // When quest is done, we move it from key => completed, and it's a one-time flag (for modal) that they unset by clicking "ok" in browser - } - }, - preferences: { - armorSet: String, - dayStart: {type:Number, 'default': 0, min: 0, max: 23}, - size: {type:String, enum: ['broad','slim'], 'default': 'slim'}, - hair: { - color: {type: String, 'default': 'red'}, - base: {type: Number, 'default': 3}, - bangs: {type: Number, 'default': 1}, - beard: {type: Number, 'default': 0}, - mustache: {type: Number, 'default': 0}, - flower: {type: Number, 'default': 1} - }, - hideHeader: {type:Boolean, 'default':false}, - skin: {type:String, 'default':'915533'}, - shirt: {type: String, 'default': 'blue'}, - timezoneOffset: Number, - sound: {type:String, 'default':'off', enum: ['off','danielTheBard', 'wattsTheme']}, - language: String, - automaticAllocation: Boolean, - allocationMode: {type:String, enum: ['flat','classbased','taskbased'], 'default': 'flat'}, - costume: Boolean, - dateFormat: {type: String, enum:['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'], 'default': 'MM/dd/yyyy'}, - sleep: {type: Boolean, 'default': false}, - stickyHeader: {type: Boolean, 'default': true}, - disableClasses: {type: Boolean, 'default': false}, - newTaskEdit: {type: Boolean, 'default': false}, - dailyDueDefaultView: {type: Boolean, 'default': false}, - tagsCollapsed: {type: Boolean, 'default': false}, - advancedCollapsed: {type: Boolean, 'default': false}, - toolbarCollapsed: {type:Boolean, 'default':false}, - background: String, - webhooks: {type: Schema.Types.Mixed, 'default': {}}, - // For this fields make sure to use strict comparison when searching for falsey values (=== false) - // As users who didn't login after these were introduced may have them undefined/null - emailNotifications: { - unsubscribeFromAll: {type: Boolean, 'default': false}, - newPM: {type: Boolean, 'default': true}, - wonChallenge: {type: Boolean, 'default': true}, - giftedGems: {type: Boolean, 'default': true}, - giftedSubscription: {type: Boolean, 'default': true}, - invitedParty: {type: Boolean, 'default': true}, - invitedGuild: {type: Boolean, 'default': true}, - questStarted: {type: Boolean, 'default': true}, - invitedQuest: {type: Boolean, 'default': true}, - //remindersToLogin: {type: Boolean, 'default': true}, - importantAnnouncements: {type: Boolean, 'default': true} - } - }, - profile: { - blurb: String, - imageUrl: String, - name: String, - }, - stats: { - hp: {type: Number, 'default': 50}, - mp: {type: Number, 'default': 10}, - exp: {type: Number, 'default': 0}, - gp: {type: Number, 'default': 0}, - lvl: {type: Number, 'default': 1}, - - // Class System - 'class': {type: String, enum: ['warrior','rogue','wizard','healer'], 'default': 'warrior'}, - points: {type: Number, 'default': 0}, - str: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0}, - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - buffs: { - str: {type: Number, 'default': 0}, - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0}, - stealth: {type: Number, 'default': 0}, - streaks: {type: Boolean, 'default': false}, - snowball: {type: Boolean, 'default': false}, - spookDust: {type: Boolean, 'default': false} - }, - training: { - int: {type: Number, 'default': 0}, - per: {type: Number, 'default': 0}, - str: {type: Number, 'default': 0}, - con: {type: Number, 'default': 0} - } - }, - - tags: {type: [{ - _id: false, - id: { type: String, 'default': shared.uuid }, - name: String, - challenge: String - }]}, - - challenges: [{type: 'String', ref:'Challenge'}], - - inbox: { - newMessages: {type:Number, 'default':0}, - blocks: {type:Array, 'default':[]}, - messages: {type:Schema.Types.Mixed, 'default':{}}, //reflist - optOut: {type:Boolean, 'default':false} - }, - - habits: {type:[TaskSchemas.HabitSchema]}, - dailys: {type:[TaskSchemas.DailySchema]}, - todos: {type:[TaskSchemas.TodoSchema]}, - rewards: {type:[TaskSchemas.RewardSchema]}, - - extra: Schema.Types.Mixed - -}, { - strict: true, - minimize: false // So empty objects are returned -}); - -UserSchema.methods.deleteTask = function(tid) { - this.ops.deleteTask({params:{id:tid}},function(){}); // TODO remove this whole method, since it just proxies, and change all references to this method -} - -UserSchema.methods.toJSON = function() { - var doc = this.toObject(); - doc.id = doc._id; - - // FIXME? Is this a reference to `doc.filters` or just disabled code? Remove? - doc.filters = {}; - doc._tmp = this._tmp; // be sure to send down drop notifs - - return doc; -}; - -//UserSchema.virtual('tasks').get(function () { -// var tasks = this.habits.concat(this.dailys).concat(this.todos).concat(this.rewards); -// var tasks = _.object(_.pluck(tasks,'id'), tasks); -// return tasks; -//}); - -UserSchema.post('init', function(doc){ - shared.wrap(doc); -}) - -UserSchema.pre('save', function(next) { - - // Populate new users with default content - if (this.isNew){ - //TODO for some reason this doesn't work here: `_.merge(this, shared.content.userDefaults);` - var self = this; - _.each(['habits', 'dailys', 'todos', 'rewards', 'tags'], function(taskType){ - self[taskType] = _.map(shared.content.userDefaults[taskType], function(task){ - var newTask = _.cloneDeep(task); - - // Render task's text and notes in user's language - if(taskType === 'tags'){ - // tasks automatically get id=helpers.uuid() from TaskSchema id.default, but tags are Schema.Types.Mixed - so we need to manually invoke here - newTask.id = shared.uuid(); - newTask.name = newTask.name(self.preferences.language); - }else{ - newTask.text = newTask.text(self.preferences.language); - newTask.notes = newTask.notes(self.preferences.language); - - if(newTask.checklist){ - newTask.checklist = _.map(newTask.checklist, function(checklistItem){ - checklistItem.text = checklistItem.text(self.preferences.language); - return checklistItem; - }); - } - } - - return newTask; - }); - }); - } - - //this.markModified('tasks'); - if (_.isNaN(this.preferences.dayStart) || this.preferences.dayStart < 0 || this.preferences.dayStart > 23) { - this.preferences.dayStart = 0; - } - - if (!this.profile.name) { - var fb = this.auth.facebook; - this.profile.name = - (this.auth.local && this.auth.local.username) || - (fb && (fb.displayName || fb.name || fb.username || (fb.first_name && fb.first_name + ' ' + fb.last_name))) || - 'Anonymous'; - } - - // Determines if Beast Master should be awarded - var petCount = shared.countPets(_.reduce(this.items.pets,function(m,v){ - //HOTFIX - Remove when solution is found, the first argument passed to reduce is a function - if(_.isFunction(v)) return m; - return m+(v?1:0)},0), this.items.pets); - - if (petCount >= 90 || this.achievements.beastMasterCount > 0) { - this.achievements.beastMaster = true - } - - // Determines if Mount Master should be awarded - var mountCount = shared.countMounts(_.reduce(this.items.mounts,function(m,v){ - //HOTFIX - Remove when solution is found, the first argument passed to reduce is a function - if(_.isFunction(v)) return m; - return m+(v?1:0)},0), this.items.mounts); - - if (mountCount >= 90 || this.achievements.mountMasterCount > 0) { - this.achievements.mountMaster = true - } - - // Determines if Triad Bingo should be awarded - - var triadCount = shared.countTriad(this.items.pets); - - if ((mountCount >= 90 && triadCount >= 90) || this.achievements.triadBingoCount > 0) { - this.achievements.triadBingo = true; - } - - // EXAMPLE CODE for allowing all existing and new players to be - // automatically granted an item during a certain time period: - // if (!this.items.pets['JackOLantern-Base'] && moment().isBefore('2014-11-01')) - // this.items.pets['JackOLantern-Base'] = 5; - - //our own version incrementer - if (_.isNaN(this._v) || !_.isNumber(this._v)) this._v = 0; - this._v++; - - next(); -}); - -UserSchema.methods.unlink = function(options, cb) { - var cid = options.cid, keep = options.keep, tid = options.tid; - var self = this; - switch (keep) { - case 'keep': - self.tasks[tid].challenge = {}; - break; - case 'remove': - self.deleteTask(tid); - break; - case 'keep-all': - _.each(self.tasks, function(t){ - if (t.challenge && t.challenge.id == cid) { - t.challenge = {}; - } - }); - break; - case 'remove-all': - _.each(self.tasks, function(t){ - if (t.challenge && t.challenge.id == cid) { - self.deleteTask(t.id); - } - }) - break; - } - self.markModified('habits'); - self.markModified('dailys'); - self.markModified('todos'); - self.markModified('rewards'); - self.save(cb); -} - -module.exports.schema = UserSchema; -module.exports.model = mongoose.model("User", UserSchema); - -mongoose.model("User") - .find({'contributor.admin':true}) - .sort('-contributor.level -backer.npc profile.name') - .select('profile contributor backer') - .exec(function(err,mods){ - module.exports.mods = mods -}); diff --git a/website/src/routes/apiv1.js b/website/src/routes/apiv1.js deleted file mode 100644 index f37619f195..0000000000 --- a/website/src/routes/apiv1.js +++ /dev/null @@ -1,173 +0,0 @@ -var express = require('express'); -var router = new express.Router(); -var _ = require('lodash'); -var async = require('async'); -var icalendar = require('icalendar'); -var api = require('./../controllers/user'); -var auth = require('./../controllers/auth'); -var middleware = require('../middleware'); -var logging = require('./../logging'); -var i18n = require('./../i18n'); - -/* ---------- Deprecated API ------------*/ - -var initDeprecated = function(req, res, next) { - req.headers['x-api-user'] = req.params.uid; - req.headers['x-api-key'] = req.body.apiToken; - return next(); -}; - -router.post('/v1/users/:uid/tasks/:taskId/:direction', initDeprecated, auth.auth, i18n.getUserLanguage, api.score); - -// FIXME add this back in -router.get('/v1/users/:uid/calendar.ics', i18n.getUserLanguage, function(req, res, next) { - return next() //disable for now - - var apiToken, model, query, uid; - uid = req.params.uid; - apiToken = req.query.apiToken; - model = req.getModel(); - query = model.query('users').withIdAndToken(uid, apiToken); - return query.fetch(function(err, result) { - var formattedIcal, ical, tasks, tasksWithDates; - if (err) { - return res.send(500, err); - } - tasks = result.get('tasks'); - /* tasks = result[0].tasks*/ - - tasksWithDates = _.filter(tasks, function(task) { - return !!task.date; - }); - if (_.isEmpty(tasksWithDates)) { - return res.send(500, "No events found"); - } - ical = new icalendar.iCalendar(); - ical.addProperty('NAME', 'HabitRPG'); - _.each(tasksWithDates, function(task) { - var d, event; - event = new icalendar.VEvent(task.id); - event.setSummary(task.text); - d = new Date(task.date); - d.date_only = true; - event.setDate(d); - ical.addComponent(event); - return true; - }); - res.type('text/calendar'); - formattedIcal = ical.toString().replace(/DTSTART\:/g, 'DTSTART;VALUE=DATE:'); - return res.send(200, formattedIcal); - }); -}); - -/* - ------------------------------------------------------------------------ - Batch Update - This is super-deprecated, and will be removed once apiv2 is running against mobile for a while - ------------------------------------------------------------------------ - */ -var batchUpdate = function(req, res, next) { - var user = res.locals.user; - var oldSend = res.send; - var oldJson = res.json; - var performAction = function(action, cb) { - - // req.body=action.data; delete action.data; _.defaults(req.params, action) - // Would require changing action.dir on mobile app - req.params.id = action.data && action.data.id; - req.params.direction = action.dir; - req.params.type = action.type; - req.body = action.data; - res.send = res.json = function(code, data) { - if (_.isNumber(code) && code >= 400) { - logging.error({ - code: code, - data: data - }); - } - //FIXME send error messages down - return cb(); - }; - switch (action.op) { - case "score": - api.score(req, res); - break; - case "addTask": - api.addTask(req, res); - break; - case "delTask": - api.deleteTask(req, res); - break; - case "revive": - api.revive(req, res); - break; - default: - cb(); - break; - } - }; - - // Setup the array of functions we're going to call in parallel with async - var actions = _.transform(req.body || [], function(result, action) { - if (!_.isEmpty(action)) { - result.push(function(cb) { - performAction(action, cb); - }); - } - }); - - // call all the operations, then return the user object to the requester - async.series(actions, function(err) { - res.json = oldJson; - res.send = oldSend; - if (err) return res.json(500, {err: err}); - var response = user.toJSON(); - response.wasModified = res.locals.wasModified; - if (response._tmp && response._tmp.drop){ - res.json(200, {_tmp: {drop: response._tmp.drop}, _v: response._v}); - }else if(response.wasModified){ - res.json(200, response); - }else{ - res.json(200, {_v: response._v}); - } - }); -}; - -/* - ------------------------------------------------------------------------ - API v1 Routes - ------------------------------------------------------------------------ - */ - - -var cron = api.cron; - -router.get('/status', i18n.getUserLanguage, function(req, res) { - return res.json({ - status: 'up' - }); -}); - -// Scoring -router.post('/user/task/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); -router.post('/user/tasks/:id/:direction', auth.auth, i18n.getUserLanguage, cron, api.score); - -// Tasks -router.get('/user/tasks', auth.auth, i18n.getUserLanguage, cron, api.getTasks); -router.get('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.getTask); -router["delete"]('/user/task/:id', auth.auth, i18n.getUserLanguage, cron, api.deleteTask); -router.post('/user/task', auth.auth, i18n.getUserLanguage, cron, api.addTask); - -// User -router.get('/user', auth.auth, i18n.getUserLanguage, cron, api.getUser); -router.post('/user/revive', auth.auth, i18n.getUserLanguage, cron, api.revive); -router.post('/user/batch-update', middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron, batchUpdate); - -function deprecated(req, res) { - res.json(404, {err:'API v1 is no longer supported, please use API v2 instead (https://github.com/HabitRPG/habitrpg/blob/develop/API.md)'}); -} -router.get('*', i18n.getUserLanguage, deprecated); -router.post('*', i18n.getUserLanguage, deprecated); -router.put('*', i18n.getUserLanguage, deprecated); - -module.exports = router; diff --git a/website/src/routes/apiv2.coffee b/website/src/routes/apiv2.coffee deleted file mode 100644 index 21bf382782..0000000000 --- a/website/src/routes/apiv2.coffee +++ /dev/null @@ -1,799 +0,0 @@ -### ----------- /api/v2 API ------------ -see https://github.com/wordnik/swagger-node-express -Every url added to router is prefaced by /api/v2 -Note: Many user-route ops exist in ../../common/script/index.coffee#user.ops, so that they can (1) be called both -client and server. -v1 user. Requires x-api-user (user id) and x-api-key (api key) headers, Test with: -$ mocha test/user.mocha.coffee -### - -user = require("../controllers/user") -groups = require("../controllers/groups") -members = require("../controllers/members") -auth = require("../controllers/auth") -hall = require("../controllers/hall") -challenges = require("../controllers/challenges") -dataexport = require("../controllers/dataexport") -nconf = require("nconf") -middleware = require("../middleware") -cron = user.cron -_ = require('lodash') -content = require('../../../common').content -i18n = require('../i18n') - - -module.exports = (swagger, v2) -> - [path,body,query] = [swagger.pathParam, swagger.bodyParam, swagger.queryParam] - - swagger.setAppHandler(v2) - swagger.setErrorHandler("next") - swagger.setHeaders = -> #disable setHeaders, since we have our own thing going on in middleware.js (and which requires `req`, which swagger doesn't pass in) - swagger.configureSwaggerPaths("", "/api-docs", "") - - api = - - '/status': - spec: - description: "Returns the status of the server (up or down)" - action: (req, res) -> - res.json status: "up" - - '/content': - spec: - description: "Get all available content objects. This is essential, since Habit often depends on item keys (eg, when purchasing a weapon)." - parameters: [ - query("language","Optional language to use for content's strings. Default is english.","string") - ] - action: user.getContent - - '/content/paths': - spec: - description: "Show user model tree" - action: user.getModelPaths - - "/export/history": - spec: - description: "Export user history" - method: 'GET' - middleware: [auth.auth, i18n.getUserLanguage] - action: dataexport.history #[todo] encode data output options in the data controller and use these to build routes - - # --------------------------------- - # User - # --------------------------------- - - # Scoring - - "/user/tasks/{id}/{direction}": - spec: - #notes: "Simple scoring of a task." - description: "Simple scoring of a task. This is most-likely the only API route you'll be using as a 3rd-party developer. The most common operation is for the user to gain or lose points based on some action (browsing Reddit, running a mile, 1 Pomodor, etc). Call this route, if the task you're trying to score doesn't exist, it will be created for you. When random events occur, the user._tmp variable will be filled. Critical hits can be accessed through user._tmp.crit. The Streakbonus can be accessed through user._tmp.streakBonus. Both will contain the multiplier value. When random drops occur, the following values are available: user._tmp.drop = {text,type,dialog,value,key,notes}" - parameters: [ - path("id", "ID of the task to score. If this task doesn't exist, a task will be created automatically", "string") - path("direction", "Either 'up' or 'down'", "string") - body '',"If you're creating a 3rd-party task, pass up any task attributes in the body (see TaskSchema).",'object' - ] - method: 'POST' - action: user.score - - # Tasks - "/user/tasks:GET": - spec: - path: '/user/tasks' - description: "Get all user's tasks" - action: user.getTasks - - "/user/tasks:POST": - spec: - path: '/user/tasks' - description: "Create a task" - method: 'POST' - parameters: [ body "","Send up the whole task (see TaskSchema)","object" ] - action: user.addTask - - "/user/tasks/{id}:GET": - spec: - path: '/user/tasks/{id}' - description: "Get an individual task" - parameters: [ - path("id", "Task ID", "string") - ] - action: user.getTask - - "/user/tasks/{id}:PUT": - spec: - path: '/user/tasks/{id}' - description: "Update a user's task" - method: 'PUT' - parameters: [ - path "id", "Task ID", "string" - body "","Send up the whole task (see TaskSchema)","object" - ] - action: user.updateTask - - "/user/tasks/{id}:DELETE": - spec: - path: '/user/tasks/{id}' - description: "Delete a task" - method: 'DELETE' - parameters: [ path("id", "Task ID", "string") ] - action: user.deleteTask - - - "/user/tasks/{id}/sort": - spec: - method: 'POST' - description: 'Sort tasks' - parameters: [ - path("id", "Task ID", "string") - query("from","Index where you're sorting from (0-based)","integer") - query("to","Index where you're sorting to (0-based)","integer") - ] - action: user.sortTask - - - "/user/tasks/clear-completed": - spec: - method: 'POST' - description: "Clears competed To-Dos (needed periodically for performance)." - action: user.clearCompleted - - - "/user/tasks/{id}/unlink": - spec: - method: 'POST' - description: 'Unlink a task from its challenge' - parameters: [ - path("id", "Task ID", "string") - query 'keep',"When unlinking a challenge task, how to handle the orphans?",'string',['keep','keep-all','remove','remove-all'] - ] - middleware: [auth.auth, i18n.getUserLanguage] ## removing cron since they may want to remove task first - action: challenges.unlink - - - # Inventory - "/user/inventory/buy": - spec: - description: "Get a list of buyable gear" - action: user.getBuyList - - "/user/inventory/buy/{key}": - spec: - method: 'POST' - description: "Buy a gear piece and equip it automatically" - parameters:[ - path 'key',"The key of the item to buy (call /content route for available keys)",'string', _.keys(content.gear.flat) - ] - action: user.buy - - "/user/inventory/sell/{type}/{key}": - spec: - method: 'POST' - description: "Sell inventory items back to Alexander" - parameters: [ - #TODO verify these are the correct types - path('type',"The type of object you're selling back.",'string',['eggs','hatchingPotions','food']) - path('key',"The object key you're selling back (call /content route for available keys)",'string') - ] - action: user.sell - - "/user/inventory/purchase/{type}/{key}": - spec: - method: 'POST' - description: "Purchase a gem-purchaseable item from Alexander" - parameters:[ - path('type',"The type of object you're purchasing.",'string',['eggs','hatchingPotions','food','quests','special']) - path('key',"The object key you're purchasing (call /content route for available keys)",'string') - ] - action: user.purchase - - - "/user/inventory/feed/{pet}/{food}": - spec: - method: 'POST' - description: "Feed your pet some food" - parameters: [ - path 'pet',"The key of the pet you're feeding",'string',_.keys(content.pets) - path 'food',"The key of the food to feed your pet",'string',_.keys(content.food) - ] - action: user.feed - - "/user/inventory/equip/{type}/{key}": - spec: - method: 'POST' - description: "Equip an item (either pet, mount, equipped or costume)" - parameters: [ - path 'type',"Type to equip",'string',['pet','mount','equipped', 'costume'] - path 'key',"The object key you're equipping (call /content route for available keys)",'string' - ] - action: user.equip - - "/user/inventory/hatch/{egg}/{hatchingPotion}": - spec: - method: 'POST' - description: "Pour a hatching potion on an egg" - parameters: [ - path 'egg',"The egg key to hatch",'string',_.keys(content.eggs) - path 'hatchingPotion',"The hatching potion to pour",'string',_.keys(content.hatchingPotions) - ] - action: user.hatch - - - # User - "/user:GET": - spec: - path: '/user' - description: "Get the full user object" - action: user.getUser - - "/user:PUT": - spec: - path: '/user' - method: 'PUT' - description: "Update the user object (only certain attributes are supported)" - parameters: [ - body '','The user object (see UserSchema)','object' - ] - action: user.update - - "/user:DELETE": - spec: - path: '/user' - method: 'DELETE' - description: "Delete a user object entirely, USE WITH CAUTION!" - middleware: [auth.auth, i18n.getUserLanguage] - action: user["delete"] - - "/user/revive": - spec: - method: 'POST' - description: "Revive your dead user" - action: user.revive - - "/user/reroll": - spec: - method: 'POST' - description: 'Drink the Fortify Potion (Note, it used to be called re-roll)' - action: user.reroll - - "/user/reset": - spec: - method: 'POST' - description: "Completely reset your account" - action: user.reset - - "/user/sleep": - spec: - method: 'POST' - description: "Toggle whether you're resting in the inn" - action: user.sleep - - "/user/rebirth": - spec: - method: 'POST' - description: "Rebirth your avatar" - action: user.rebirth - - "/user/class/change": - spec: - method: 'POST' - description: "Either remove your avatar's class, or change it to something new" - parameters: [ - query 'class',"The key of the class to change to. If not provided, user's class is removed.",'string',['warrior','healer','rogue','wizard',''] - ] - action: user.changeClass - - "/user/class/allocate": - spec: - method: 'POST' - description: "Allocate one point towards an attribute" - parameters: [ - query 'stat','The stat to allocate towards','string',['str','per','int','con'] - ] - action:user.allocate - - "/user/class/cast/{spell}": - spec: - method: 'POST' - description: "Casts a spell on a target." - parameters: [ - path 'spell',"The key of the spell to cast (see ../../common#content.coffee)",'string' - query 'targetType',"The type of object you're targeting",'string',['party','self','user','task'] - query 'targetId',"The ID of the object you're targeting",'string' - - ] - action: user.cast - - "/user/unlock": - spec: - method: 'POST' - description: "Unlock a certain gem-purchaseable path (or multiple paths)" - parameters: [ - query 'path',"The path to unlock, such as hair.green or shirts.red,shirts.blue",'string' - ] - action: user.unlock - - "/user/batch-update": - spec: - method: 'POST' - description: "This is an advanced route which is useful for apps which might for example need offline support. You can send a whole batch of user-based operations, which allows you to queue them up offline and send them all at once. The format is {op:'nameOfOperation',parameters:{},body:{},query:{}}" - parameters:[ - body '','The array of batch-operations to perform','object' - ] - middleware: [middleware.forceRefresh, auth.auth, i18n.getUserLanguage, cron, user.sessionPartyInvite] - action: user.batchUpdate - - # Tags - "/user/tags": - spec: - method: 'POST' - description: 'Create a new tag' - parameters: [ - body '','New tag (see UserSchema.tags)','object' - ] - action: user.addTag - - "/user/tags/sort": - spec: - method: 'POST' - description: 'Sort tags' - parameters: [ - query("from","Index where you're sorting from (0-based)","integer") - query("to","Index where you're sorting to (0-based)","integer") - ] - action: user.sortTag - - "/user/tags/{id}:PUT": - spec: - path: '/user/tags/{id}' - method: 'PUT' - description: "Edit a tag" - parameters: [ - path 'id','The id of the tag to edit','string' - body '','Tag edits (see UserSchema.tags)','object' - ] - action: user.updateTag - - "/user/tags/{id}:DELETE": - spec: - path: '/user/tags/{id}' - method: 'DELETE' - description: 'Delete a tag' - parameters: [ - path 'id','Id of tag to delete','string' - ] - action: user.deleteTag - - "/user/social/invite-friends": - spec: - method: 'POST' - description: 'Invite friends via email' - parameters: [ - body 'invites','Array of [{name:"Friend\'s Name", email:"friends@email.com"}] to invite to play in your party','object' - ] - action: user.inviteFriends - - # Webhooks - "/user/webhooks": - spec: - method: 'POST' - description: 'Create a new webhook' - parameters: [ - body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' - ] - action: user.addWebhook - - "/user/webhooks/{id}:PUT": - spec: - path: '/user/webhooks/{id}' - method: 'PUT' - description: "Edit a webhook" - parameters: [ - path 'id','The id of the webhook to edit','string' - body '','New Webhook {url:"webhook endpoint (required)", id:"id of webhook (shared.uuid(), optional)", enabled:"whether webhook is enabled (true by default, optional)"}','object' - ] - action: user.updateWebhook - - "/user/webhooks/{id}:DELETE": - spec: - path: '/user/webhooks/{id}' - method: 'DELETE' - description: 'Delete a webhook' - parameters: [ - path 'id','Id of webhook to delete','string' - ] - action: user.deleteWebhook - - # --------------------------------- - # Groups - # --------------------------------- - "/groups:GET": - spec: - path: '/groups' - description: "Get a list of groups" - parameters: [ - query 'type',"Comma-separated types of groups to return, eg 'party,guilds,public,tavern'",'string' - ] - middleware: [auth.auth, i18n.getUserLanguage] - action: groups.list - - - "/groups:POST": - spec: - path: '/groups' - method: 'POST' - description: 'Create a group' - parameters: [ - body '','Group object (see GroupSchema)','object' - ] - middleware: [auth.auth, i18n.getUserLanguage] - action: groups.create - - "/groups/{gid}:GET": - spec: - path: '/groups/{gid}' - description: "Get a group. The party the user currently is in can be accessed with the gid 'party'." - parameters: [path('gid','Group ID','string')] - middleware: [auth.auth, i18n.getUserLanguage] - action: groups.get - - "/groups/{gid}:POST": - spec: - path: '/groups/{gid}' - method: 'POST' - description: "Edit a group" - parameters: [body('','Group object (see GroupSchema)','object')] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.update - - "/groups/{gid}/join": - spec: - method: 'POST' - description: 'Join a group' - parameters: [path('gid','Id of the group to join','string')] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.join - - "/groups/{gid}/leave": - spec: - method: 'POST' - description: 'Leave a group' - parameters: [path('gid','ID of the group to leave','string')] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.leave - - "/groups/{gid}/invite": - spec: - method: 'POST' - description: "Invite a user to a group" - parameters: [ - path 'gid','Group id','string' - query 'uuid','User id to invite','string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action:groups.invite - - "/groups/{gid}/removeMember": - spec: - method: 'POST' - description: "Remove / boot a member from a group" - parameters: [ - path 'gid','Group id','string' - query 'uuid','User id to boot','string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action:groups.removeMember - - "/groups/{gid}/questAccept": - spec: - method: 'POST' - description: "Accept a quest invitation" - parameters: [ - path 'gid',"Group id",'string' - query 'key',"optional. if provided, trigger new invite, if not, accept existing invite",'string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action:groups.questAccept - - "/groups/{gid}/questReject": - spec: - method: 'POST' - description: 'Reject quest invitation' - parameters: [ - path 'gid','Group id','string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.questReject - - "/groups/{gid}/questCancel": - spec: - method: 'POST' - description: 'Cancel quest before it starts (in invitation stage)' - parameters: [path('gid','Group to cancel quest in','string')] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.questCancel - - "/groups/{gid}/questAbort": - spec: - method: 'POST' - description: 'Abort quest after it has started (all progress will be lost)' - parameters: [path('gid','Group to abort quest in','string')] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.questAbort - - #TODO PUT /groups/:gid/chat/:messageId - - "/groups/{gid}/chat:GET": - spec: - path: "/groups/{gid}/chat" - description: "Get all chat messages" - parameters: [path('gid','Group to return the chat from ','string')] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.getChat - - - "/groups/{gid}/chat:POST": - spec: - method: 'POST' - path: "/groups/{gid}/chat" - description: "Send a chat message" - parameters: [ - query 'message', 'Chat message','string' - path 'gid','Group id','string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.postChat - - # placing before route below, so that if !=='seen' it goes to next() - "/groups/{gid}/chat/seen": - spec: - method: 'POST' - description: "Flag chat messages for a particular group as seen" - parameters: [ - path 'gid','Group id','string' - ] - action: groups.seenMessage - - "/groups/{gid}/chat/{messageId}": - spec: - method: 'DELETE' - description: 'Delete a chat message in a given group' - parameters: [ - path 'gid', 'ID of the group containing the message to be deleted', 'string' - path 'messageId', 'ID of message to be deleted', 'string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.deleteChatMessage - - "/groups/{gid}/chat/{mid}/like": - spec: - method: 'POST' - description: "Like a chat message" - parameters: [ - path 'gid','Group id','string' - path 'mid','Message id','string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.likeChatMessage - - "/groups/{gid}/chat/{mid}/flag": - spec: - method: 'POST' - description: "Flag a chat message" - parameters: [ - path 'gid','Group id','string' - path 'mid','Message id','string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.flagChatMessage - - "/groups/{gid}/chat/{mid}/clearflags": - spec: - method: 'POST' - description: "Clear flag count from message and unhide it" - parameters: [ - path 'gid','Group id','string' - path 'mid','Message id','string' - ] - middleware: [auth.auth, i18n.getUserLanguage, groups.attachGroup] - action: groups.clearFlagCount - - # --------------------------------- - # Members - # --------------------------------- - "/members/{uuid}:GET": - spec: - path: '/members/{uuid}' - description: "Get a member." - parameters: [path('uuid','Member ID','string')] - middleware: [i18n.getUserLanguage] # removed auth.auth, so anon users can view shared avatars - action: members.getMember - "/members/{uuid}/message": - spec: - method: 'POST' - description: 'Send a private message to a member' - parameters: [ - path 'uuid', 'The UUID of the member to message', 'string' - body '', '{"message": "The private message to send"}', 'object' - ] - middleware: [auth.auth] - action: members.sendPrivateMessage - "/members/{uuid}/block": - spec: - method: 'POST' - description: 'Block a member from sending private messages' - parameters: [ - path 'uuid', 'The UUID of the member to message', 'string' - ] - middleware: [auth.auth] - action: user.blockUser - "/members/{uuid}/gift": - spec: - method: 'POST' - description: 'Send a gift to a member' - parameters: [ - path 'uuid', 'The UUID of the member', 'string' - body '', '{"type": "gems or subscription", "gems":{"amount":Number, "fromBalance":Boolean}, "subscription":{"months":Number}}', 'object' - ] - middleware: [auth.auth] - action: members.sendGift - - # --------------------------------- - # Hall of Heroes / Patrons - # --------------------------------- - "/hall/heroes": - spec: {} - middleware:[auth.auth, i18n.getUserLanguage] - action: hall.getHeroes - - "/hall/heroes/{uid}:GET": - spec: path: "/hall/heroes/{uid}" - middleware:[auth.auth, i18n.getUserLanguage, hall.ensureAdmin] - action: hall.getHero - - "/hall/heroes/{uid}:POST": - spec: - method: 'POST' - path: "/hall/heroes/{uid}" - middleware: [auth.auth, i18n.getUserLanguage, hall.ensureAdmin] - action: hall.updateHero - - "/hall/patrons": - spec: - parameters: [ - query 'page','Page number to fetch (this list is long)','string' - ] - middleware:[auth.auth, i18n.getUserLanguage] - action: hall.getPatrons - - - # --------------------------------- - # Challenges - # --------------------------------- - - # Note: while challenges belong to groups, and would therefore make sense as a nested resource - # (eg /groups/:gid/challenges/:cid), they will also be referenced by users from the "challenges" tab - # without knowing which group they belong to. So to prevent unecessary lookups, we have them as a top-level resource - "/challenges:GET": - spec: - path: '/challenges' - description: "Get a list of challenges" - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges.list - - - "/challenges:POST": - spec: - path: '/challenges' - method: 'POST' - description: "Create a challenge" - parameters: [body('','Challenge object (see ChallengeSchema)','object')] - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges.create - - "/challenges/{cid}:GET": - spec: - path: '/challenges/{cid}' - description: 'Get a challenge' - parameters: [path('cid','Challenge id','string')] - action: challenges.get - - "/challenges/{cid}/csv": - spec: - description: 'Get a challenge (csv format)' - parameters: [path('cid','Challenge id','string')] - action: challenges.csv - - "/challenges/{cid}:POST": - spec: - path: '/challenges/{cid}' - method: 'POST' - description: "Update a challenge" - parameters: [ - path 'cid','Challenge id','string' - body('','Challenge object (see ChallengeSchema)','object') - ] - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges.update - - "/challenges/{cid}:DELETE": - spec: - path: '/challenges/{cid}' - method: 'DELETE' - description: "Delete a challenge" - parameters: [path('cid','Challenge id','string')] - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges["delete"] - - "/challenges/{cid}/close": - spec: - method: 'POST' - description: 'Close a challenge' - parameters: [ - path 'cid','Challenge id','string' - query 'uid','User ID of the winner','string',true - ] - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges.selectWinner - - "/challenges/{cid}/join": - spec: - method: 'POST' - description: "Join a challenge" - parameters: [path('cid','Challenge id','string')] - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges.join - - "/challenges/{cid}/leave": - spec: - method: 'POST' - description: 'Leave a challenge' - parameters: [path('cid','Challenge id','string')] - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges.leave - - "/challenges/{cid}/member/{uid}": - spec: - description: "Get a member's progress in a particular challenge" - parameters: [ - path 'cid','Challenge id','string' - path 'uid','User id','string' - ] - middleware: [auth.auth, i18n.getUserLanguage] - action: challenges.getMember - - - if nconf.get("NODE_ENV") is "development" - api["/user/addTenGems"] = - spec: method:'POST' - action: user.addTenGems - - _.each api, (route, path) -> - ## Spec format is: - # spec: - # path: "/pet/{petId}" - # description: "Operations about pets" - # notes: "Returns a pet based on ID" - # summary: "Find pet by ID" - # method: "GET" - # parameters: [path("petId", "ID of pet that needs to be fetched", "string")] - # type: "Pet" - # errorResponses: [swagger.errors.invalid("id"), swagger.errors.notFound("pet")] - # nickname: "getPetById" - - route.spec.description ?= '' - _.defaults route.spec, - path: path - nickname: path - notes: route.spec.description - summary: route.spec.description - parameters: [] - #type: 'Pet' - errorResponses: [] - method: 'GET' - route.middleware ?= if path.indexOf('/user') is 0 then [auth.auth, i18n.getUserLanguage, cron] else [i18n.getUserLanguage] - swagger["add#{route.spec.method}"](route);true - - - swagger.configure("#{nconf.get('BASE_URL')}/api/v2", "2") diff --git a/website/src/routes/auth.js b/website/src/routes/auth.js deleted file mode 100644 index 442c919f6b..0000000000 --- a/website/src/routes/auth.js +++ /dev/null @@ -1,21 +0,0 @@ -var auth = require('../controllers/auth'); -var express = require('express'); -var i18n = require('../i18n'); -var router = new express.Router(); - -/* auth.auth*/ -auth.setupPassport(router); //FIXME make this consistent with the others -router.post('/api/v2/register', i18n.getUserLanguage, auth.registerUser); -router.post('/api/v2/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v2/user/auth/social', i18n.getUserLanguage, auth.loginSocial); -router.delete('/api/v2/user/auth/social', i18n.getUserLanguage, auth.auth, auth.deleteSocial); -router.post('/api/v2/user/reset-password', i18n.getUserLanguage, auth.resetPassword); -router.post('/api/v2/user/change-password', i18n.getUserLanguage, auth.auth, auth.changePassword); -router.post('/api/v2/user/change-username', i18n.getUserLanguage, auth.auth, auth.changeUsername); -router.post('/api/v2/user/change-email', i18n.getUserLanguage, auth.auth, auth.changeEmail); - -router.post('/api/v1/register', i18n.getUserLanguage, auth.registerUser); -router.post('/api/v1/user/auth/local', i18n.getUserLanguage, auth.loginLocal); -router.post('/api/v1/user/auth/social', i18n.getUserLanguage, auth.loginSocial); - -module.exports = router; \ No newline at end of file diff --git a/website/src/routes/coupon.js b/website/src/routes/coupon.js deleted file mode 100644 index 57a33866c6..0000000000 --- a/website/src/routes/coupon.js +++ /dev/null @@ -1,12 +0,0 @@ -var nconf = require('nconf'); -var express = require('express'); -var router = new express.Router(); -var auth = require('../controllers/auth'); -var coupon = require('../controllers/coupon'); -var i18n = require('../i18n'); - -router.get('/api/v2/coupons', auth.authWithUrl, i18n.getUserLanguage, coupon.ensureAdmin, coupon.getCoupons); -router.post('/api/v2/coupons/generate/:event', auth.auth, i18n.getUserLanguage, coupon.ensureAdmin, coupon.generateCoupons); -router.post('/api/v2/user/coupon/:code', auth.auth, i18n.getUserLanguage, coupon.enterCode); - -module.exports = router; \ No newline at end of file diff --git a/website/src/routes/dataexport.js b/website/src/routes/dataexport.js deleted file mode 100644 index ba27957124..0000000000 --- a/website/src/routes/dataexport.js +++ /dev/null @@ -1,16 +0,0 @@ -var express = require('express'); -var router = new express.Router(); -var dataexport = require('../controllers/dataexport'); -var auth = require('../controllers/auth'); -var nconf = require('nconf'); -var i18n = require('../i18n'); -var middleware = require('../middleware.js'); - -/* Data export */ -router.get('/history.csv',auth.authWithSession,i18n.getUserLanguage,dataexport.history); //[todo] encode data output options in the data controller and use these to build routes -router.get('/userdata.xml',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.xml); -router.get('/userdata.json',auth.authWithSession,i18n.getUserLanguage,dataexport.leanuser,dataexport.userdata.json); -router.get('/avatar-:uuid.html', i18n.getUserLanguage, middleware.locals, dataexport.avatarPage); -router.get('/avatar-:uuid.png', i18n.getUserLanguage, middleware.locals, dataexport.avatarImage); - -module.exports = router; diff --git a/website/src/routes/pages.js b/website/src/routes/pages.js deleted file mode 100644 index 1f9be4b624..0000000000 --- a/website/src/routes/pages.js +++ /dev/null @@ -1,37 +0,0 @@ -var nconf = require('nconf'); -var express = require('express'); -var router = new express.Router(); -var _ = require('lodash'); -var middleware = require('../middleware'); -var user = require('../controllers/user'); -var auth = require('../controllers/auth'); -var i18n = require('../i18n'); - -// -------- App -------- -router.get('/', i18n.getUserLanguage, middleware.locals, function(req, res) { - if (!req.headers['x-api-user'] && !req.headers['x-api-key'] && !(req.session && req.session.userId)) - return res.redirect('/static/front') - - return res.render('index', { - title: 'HabitRPG | Your Life, The Role Playing Game', - env: res.locals.habitrpg - }); -}); - -// -------- Marketing -------- - -var pages = ['front', 'privacy', 'terms', 'api', 'features', 'videos', 'contact', 'plans', 'new-stuff', 'community-guidelines', 'old-news', 'press-kit']; - -_.each(pages, function(name){ - router.get('/static/' + name, i18n.getUserLanguage, middleware.locals, function(req, res) { - res.render('static/' + name, {env: res.locals.habitrpg}); - }); -}) - -// --------- Redirects -------- - -router.get('/static/extensions', function(req, res) { - res.redirect('http://habitrpg.wikia.com/wiki/App_and_Extension_Integrations'); -}); - -module.exports = router; diff --git a/website/src/routes/payments.js b/website/src/routes/payments.js deleted file mode 100644 index 118f923689..0000000000 --- a/website/src/routes/payments.js +++ /dev/null @@ -1,25 +0,0 @@ -var nconf = require('nconf'); -var express = require('express'); -var router = new express.Router(); -var auth = require('../controllers/auth'); -var payments = require('../controllers/payments'); -var i18n = require('../i18n'); - -router.get('/paypal/checkout', auth.authWithUrl, i18n.getUserLanguage, payments.paypalCheckout); -router.get('/paypal/checkout/success', i18n.getUserLanguage, payments.paypalCheckoutSuccess); -router.get('/paypal/subscribe', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribe); -router.get('/paypal/subscribe/success', i18n.getUserLanguage, payments.paypalSubscribeSuccess); -router.get('/paypal/subscribe/cancel', auth.authWithUrl, i18n.getUserLanguage, payments.paypalSubscribeCancel); -router.post('/paypal/ipn', i18n.getUserLanguage, payments.paypalIPN); // misc ipn handling - -router.post("/stripe/checkout", auth.auth, i18n.getUserLanguage, payments.stripeCheckout); -router.post("/stripe/subscribe/edit", auth.auth, i18n.getUserLanguage, payments.stripeSubscribeEdit) -//router.get("/stripe/subscribe", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead -router.get("/stripe/subscribe/cancel", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribeCancel); - -router.post("/iap/android/verify", auth.authWithUrl, /*i18n.getUserLanguage, */payments.iapAndroidVerify); -router.post("/iap/ios/verify", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.iapIosVerify); - -router.get("/api/v2/coupons/valid-discount/:code", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.validCoupon); - -module.exports = router; \ No newline at end of file diff --git a/website/src/seed.js b/website/src/seed.js deleted file mode 100644 index c54c715168..0000000000 --- a/website/src/seed.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * This script is no longer required due to this code in src/models/group.js: - * // initialize tavern if !exists (fresh installs) - * Group.count({_id:'habitrpg'},function(err,ct){ - * ... - * }) - * - * However we're keeping this script in case future seed updates are needed. - * - * Reference: https://github.com/HabitRPG/habitrpg/issues/3852#issuecomment-55334572 - */ - - -/* - -require('coffee-script') // for habitrpg-shared -var nconf = require('nconf'); -var utils = require('./utils'); -var logging = require('./logging'); -utils.setupConfig(); -var async = require('async'); -var mongoose = require('mongoose'); -User = require('./models/user').model; -Group = require('./models/group').model; - -async.waterfall([ - function(cb){ - mongoose.connect(nconf.get('NODE_DB_URI'), cb); - }, - function(cb){ - Group.findById('habitrpg', cb); - }, - function(tavern, cb){ - logging.info({tavern:tavern,cb:cb}); - if (!tavern) { - tavern = new Group({ - _id: 'habitrpg', - chat: [], - leader: '9', - name: 'HabitRPG', - type: 'guild', - privacy:'public' - }); - tavern.save(cb) - } else { - cb(); - } - } -],function(err){ - if (err) throw err; - logging.info("Done initializing database"); - mongoose.disconnect(); -}) - -*/ diff --git a/website/src/server.js b/website/src/server.js deleted file mode 100644 index f77cb5d7a5..0000000000 --- a/website/src/server.js +++ /dev/null @@ -1,143 +0,0 @@ -// Only do the minimal amount of work before forking just in case of a dyno restart -var cluster = require("cluster"); -var _ = require('lodash'); -var nconf = require('nconf'); -var utils = require('./utils'); -utils.setupConfig(); -var logging = require('./logging'); -var isProd = nconf.get('NODE_ENV') === 'production'; -var isDev = nconf.get('NODE_ENV') === 'development'; -var cores = +nconf.get("CORES"); - -if (cores!==0 && cluster.isMaster && (isDev || isProd)) { - // Fork workers. If config.json has CORES=x, use that - otherwise, use all cpus-1 (production) - _.times(cores || require('os').cpus().length-1, cluster.fork); - - cluster.on('disconnect', function(worker, code, signal) { - var w = cluster.fork(); // replace the dead worker - logging.info('[%s] [master:%s] worker:%s disconnect! new worker:%s fork', new Date(), process.pid, worker.process.pid, w.process.pid); - }); - -} else { - require('coffee-script/register'); // remove this once we've fully converted over - var express = require("express"); - var http = require("http"); - var path = require("path"); - var swagger = require("swagger-node-express"); - var autoinc = require('mongoose-id-autoinc'); - var shared = require('../../common'); - - // Setup translations - var i18n = require('./i18n'); - - var middleware = require('./middleware'); - - var TWO_WEEKS = 1000 * 60 * 60 * 24 * 14; - var app = express(); - var server = http.createServer(); - - // ------------ MongoDB Configuration ------------ - mongoose = require('mongoose'); - var mongooseOptions = !isProd ? {} : { - replset: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } }, - server: { socketOptions: { keepAlive: 1, connectTimeoutMS: 30000 } } - }; - var db = mongoose.connect(nconf.get('NODE_DB_URI'), mongooseOptions, function(err) { - if (err) throw err; - logging.info('Connected with Mongoose'); - }); - autoinc.init(db); - - // load schemas & models - require('./models/challenge'); - require('./models/group'); - require('./models/user'); - - // ------------ Passport Configuration ------------ - var passport = require('passport') - var util = require('util') - var FacebookStrategy = require('passport-facebook').Strategy; - // Passport session setup. - // To support persistent login sessions, Passport needs to be able to - // serialize users into and deserialize users out of the session. Typically, - // this will be as simple as storing the user ID when serializing, and finding - // the user by ID when deserializing. However, since this example does not - // have a database of user records, the complete Facebook profile is serialized - // and deserialized. - passport.serializeUser(function(user, done) { - done(null, user); - }); - - passport.deserializeUser(function(obj, done) { - done(null, obj); - }); - - // FIXME - // This auth strategy is no longer used. It's just kept around for auth.js#loginFacebook() (passport._strategies.facebook.userProfile) - // The proper fix would be to move to a general OAuth module simply to verify accessTokens - passport.use(new FacebookStrategy({ - clientID: nconf.get("FACEBOOK_KEY"), - clientSecret: nconf.get("FACEBOOK_SECRET"), - //callbackURL: nconf.get("BASE_URL") + "/auth/facebook/callback" - }, - function(accessToken, refreshToken, profile, done) { - done(null, profile); - } - )); - - // ------------ Server Configuration ------------ - var publicDir = path.join(__dirname, "/../public"); - - app.set("port", nconf.get('PORT')); - middleware.apiThrottle(app); - app.use(middleware.domainMiddleware(server,mongoose)); - if (!isProd) app.use(express.logger("dev")); - app.use(express.compress()); - app.set("views", __dirname + "/../views"); - app.set("view engine", "jade"); - app.use(express.favicon(publicDir + '/favicon.ico')); - app.use(middleware.cors); - app.use(middleware.forceSSL); - app.use(express.urlencoded()); - app.use(express.json()); - app.use(require('method-override')()); - //app.use(express.cookieParser(nconf.get('SESSION_SECRET'))); - app.use(express.cookieParser()); - app.use(express.cookieSession({ secret: nconf.get('SESSION_SECRET'), httpOnly: false, cookie: { maxAge: TWO_WEEKS }})); - //app.use(express.session()); - - // Initialize Passport! Also use passport.session() middleware, to support - // persistent login sessions (recommended). - app.use(passport.initialize()); - app.use(passport.session()); - - app.use(app.router); - - var maxAge = isProd ? 31536000000 : 0; - // Cache emojis without copying them to build, they are too many - app.use(express['static'](path.join(__dirname, "/../build"), { maxAge: maxAge })); - app.use('/common/dist', express['static'](publicDir + "/../../common/dist", { maxAge: maxAge })); - app.use('/common/audio', express['static'](publicDir + "/../../common/audio", { maxAge: maxAge })); - app.use('/common/script/public', express['static'](publicDir + "/../../common/script/public", { maxAge: maxAge })); - app.use('/common/img/emoji/unicode', express['static'](publicDir + "/../../common/img/emoji/unicode", { maxAge: maxAge })); - app.use(express['static'](publicDir)); - - // Custom Directives - app.use(require('./routes/pages').middleware); - app.use(require('./routes/payments').middleware); - app.use(require('./routes/auth').middleware); - app.use(require('./routes/coupon').middleware); - var v2 = express(); - app.use('/api/v2', v2); - app.use('/api/v1', require('./routes/apiv1').middleware); - app.use('/export', require('./routes/dataexport').middleware); - require('./routes/apiv2.coffee')(swagger, v2); - app.use(middleware.errorHandler); - - server.on('request', app); - server.listen(app.get("port"), function() { - return logging.info("Express server listening on port " + app.get("port")); - }); - - module.exports = server; -} diff --git a/website/src/utils.js b/website/src/utils.js deleted file mode 100644 index 55cab68ede..0000000000 --- a/website/src/utils.js +++ /dev/null @@ -1,145 +0,0 @@ -var nodemailer = require('nodemailer'); -var nconf = require('nconf'); -var crypto = require('crypto'); -var path = require("path"); -var request = require('request'); - -// Set when utils.setupConfig is run -var isProd, baseUrl; - -module.exports.ga = undefined; // set Google Analytics on nconf init - -module.exports.sendEmail = function(mailData) { - var smtpTransport = nodemailer.createTransport("SMTP",{ - service: nconf.get('SMTP_SERVICE'), - auth: { - user: nconf.get('SMTP_USER'), - pass: nconf.get('SMTP_PASS') - } - }); - smtpTransport.sendMail(mailData, function(error, response){ - var logging = require('./logging'); - if(error) logging.error(error); - else logging.info("Message sent: " + response.message); - smtpTransport.close(); // shut down the connection pool, no more messages - }); -} - -function getUserInfo(user, fields) { - var info = {}; - - if(fields.indexOf('name') != -1){ - if(user.auth.local){ - info.name = user.profile.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){ - 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('canSend') != -1){ - info.canSend = user.preferences.emailNotifications.unsubscribeFromAll !== true; - } - - return info; -} - -module.exports.getUserInfo = getUserInfo; - -module.exports.txnEmail = function(mailingInfoArray, emailType, variables){ - var mailingInfoArray = Array.isArray(mailingInfoArray) ? mailingInfoArray : [mailingInfoArray]; - var variables = [ - {name: 'BASE_URL', content: baseUrl}, - {name: 'EMAIL_SETTINGS_URL', content: baseUrl + '/#/options/settings/notifications'} - ].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(function(mailingInfo){ - return mailingInfo._id ? getUserInfo(mailingInfo, ['email', 'name', 'canSend']) : mailingInfo; - }).filter(function(mailingInfo){ - return (mailingInfo.email && mailingInfo.canSend); - }); - - // When only one recipient send his info as variables - if(mailingInfoArray.length === 1 && mailingInfoArray[0].name){ - variables.push({name: 'RECIPIENT_NAME', content: mailingInfoArray[0].name}); - } - - if(isProd && mailingInfoArray.length > 0){ - request({ - url: nconf.get('EMAIL_SERVER:url') + '/job', - method: 'POST', - auth: { - user: nconf.get('EMAIL_SERVER:authUser'), - pass: nconf.get('EMAIL_SERVER:authPassword') - }, - json: { - type: 'email', - data: { - emailType: emailType, - to: mailingInfoArray, - variables: variables - }, - options: { - attemps: 5, - backoff: {delay: 10*60*1000, type: 'fixed'} - } - } - }); - } -} - -// Encryption using http://dailyjs.com/2010/12/06/node-tutorial-5/ -// Note: would use [password-hash](https://github.com/davidwood/node-password-hash), but we need to run -// model.query().equals(), so it's a PITA to work in their verify() function - -module.exports.encryptPassword = function(password, salt) { - return crypto.createHmac('sha1', salt).update(password).digest('hex'); -} - -module.exports.makeSalt = function() { - var len = 10; - return crypto.randomBytes(Math.ceil(len / 2)).toString('hex').substring(0, len); -} - -/** - * Load nconf and define default configuration values if config.json or ENV vars are not found - */ -module.exports.setupConfig = function(){ - nconf.argv() - .env() - //.file('defaults', path.join(path.resolve(__dirname, '../config.json.example'))) - .file('user', path.join(path.resolve(__dirname, '../config.json'))); - - if (nconf.get('NODE_ENV') === "development") - Error.stackTraceLimit = Infinity; - if (nconf.get('NODE_ENV') === 'production') - require('newrelic'); - - isProd = nconf.get('NODE_ENV') === 'production'; - baseUrl = nconf.get('BASE_URL'); - - module.exports.ga = require('universal-analytics')(nconf.get('GA_ID')); -}; - -var algorithm = 'aes-256-ctr'; -module.exports.encrypt = function(text){ - var cipher = crypto.createCipher(algorithm,nconf.get('SESSION_SECRET')) - var crypted = cipher.update(text,'utf8','hex') - crypted += cipher.final('hex'); - return crypted; -} - -module.exports.decrypt = function(text){ - var decipher = crypto.createDecipher(algorithm,nconf.get('SESSION_SECRET')) - var dec = decipher.update(text,'hex','utf8') - dec += decipher.final('utf8'); - return dec; -} \ No newline at end of file